From bb2de855a06da47a0c1f4e5fadb611ac01b5376f Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 11 Sep 2026 10:21:25 +0200 Subject: [PATCH 1/6] feat(surface,sdk): add MCP tools and preflight connections Session-Id: 01a08f7d-c504-7510-8651-0e2ebb694546 Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- docs/SURFACE.md | 13 +- ops/reviews/20260911-spec-C-mcp.md | 418 +++++++++++++++++++++ packages/sdk/src/authored-flow-executor.ts | 19 +- packages/sdk/src/authored-mcp.ts | 141 +++++++ packages/sdk/src/cli.ts | 29 +- packages/sdk/src/cli/check-typescript.ts | 57 +++ packages/sdk/src/cli/check.ts | 24 +- packages/sdk/src/cli/direct-run.ts | 12 + packages/sdk/src/failure-kinds.ts | 2 + packages/sdk/src/index.ts | 2 + packages/sdk/src/journal-client.ts | 7 + packages/sdk/src/mcp-client.ts | 97 +++++ packages/sdk/src/mcp-config.ts | 42 +++ packages/sdk/src/mcp-stdio.ts | 81 ++++ packages/sdk/src/preflight.ts | 44 ++- packages/sdk/src/spec.ts | 12 + packages/sdk/tests/mcp.test.ts | 260 +++++++++++++ packages/sdk/tests/preflight.test.ts | 9 +- packages/sdk/tsconfig.tests.json | 1 + packages/surface/src/context.ts | 1 + testdata/mock-mcp-server/drop.mjs | 15 + testdata/mock-mcp-server/ok.mjs | 20 + testdata/mock-mcp-server/refuse.mjs | 3 + 23 files changed, 1296 insertions(+), 13 deletions(-) create mode 100644 ops/reviews/20260911-spec-C-mcp.md create mode 100644 packages/sdk/src/authored-mcp.ts create mode 100644 packages/sdk/src/cli/check-typescript.ts create mode 100644 packages/sdk/src/mcp-client.ts create mode 100644 packages/sdk/src/mcp-config.ts create mode 100644 packages/sdk/src/mcp-stdio.ts create mode 100644 packages/sdk/tests/mcp.test.ts create mode 100644 testdata/mock-mcp-server/drop.mjs create mode 100644 testdata/mock-mcp-server/ok.mjs create mode 100644 testdata/mock-mcp-server/refuse.mjs diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 804f1f356..184a2e3f7 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -67,6 +67,17 @@ No process runs between events: the handler wakes, executes to its next await, p - `f.memory` — relayhistory: `f.memory.recall(query)`, `f.memory.why(task)`, `f.memory.learn(finding)`. - auth — relayauth: never called directly; `workspace:` / `tools:` declarations compile to path-scoped tokens ("the filesystem paths *are* the permissions"). - `f.mcp` — one line to declare (`tools: { mcp: [stripe] }`), one call to use (`f.mcp.stripe.create_invoice({...})`). Preflight connects to every declared server before the run starts. + The first shipping slice supports stdio subprocess servers and http + endpoints, discovered via a `mcp` map in `flows.json`. Tool inventory is + captured at preflight and cached for the run; a call to an unknown tool + name at runtime is `mcp_unknown_tool`. See flows#302. + Helpers retain the closed kernel vocabulary: the SDK lowers each call to + an agent effect step. Its completed output is an MCP receipt containing + `type: "mcp"`, server, tool, input, output, and the argument-derived + idempotency key. The effect protocol uses the kernel-issued attempt key; + failed calls retain their MCP diagnostic in `trajectory_tail` and complete + with `worker_error`. + 4. **`{{prev}}` / return-value chaining.** Output flows downward implicitly; naming steps is for reaching back, not bookkeeping. 5. **Headers are optional escalation.** identity, memory, budget, tools appear only when used. The empty header is the common case. 6. **Agent definitions escalate by composition** — and a reusable agent *is* a flow: @@ -192,7 +203,7 @@ No process runs between events: the handler wakes, executes to its next await, p deterministic-command preflight gap.” Consequently, `cli_missing` applies to declared `llm` and `agent` CLIs, not deterministic command words. - **Project-config discovery:** starting in the flow file's directory, `flows check` walks parent directories through the filesystem root and selects the first readable `flows.json`. That nearest file is the whole project config; it is not merged with outer files. Its schema is `{ "cli"?: , "executors"?: [], "models"?: [] }`; unknown keys, malformed model entries, and duplicates fail closed as `config_invalid`. A nearer config therefore defines a self-contained nested project boundary and prevents accidental inheritance of outer credentials, executors, or model approvals. The selected path is printed with project-level resolutions and named in refusals; if it declares no `cli` or models, outer configs remain shadowed. At gate 1, a trigger executor is considered registered only when its name is present in this author-written `executors` array; `flows check` does not yet contact a registry, broker, or RelayCron, and absence is `no_executor`. + **Project-config discovery:** starting in the flow file's directory, `flows check` walks parent directories through the filesystem root and selects the first readable `flows.json`. That nearest file is the whole project config; it is not merged with outer files. Its schema is `{ "cli"?: , "executors"?: [], "models"?: [], "mcp"?: }`; unknown keys, malformed model entries, and duplicates fail closed as `config_invalid`. A nearer config therefore defines a self-contained nested project boundary and prevents accidental inheritance of outer credentials, executors, or model approvals. The selected path is printed with project-level resolutions and named in refusals; if it declares no `cli` or models, outer configs remain shadowed. At gate 1, a trigger executor is considered registered only when its name is present in this author-written `executors` array; `flows check` does not yet contact a registry, broker, or RelayCron, and absence is `no_executor`. Implementation status for issue #132: this named-agent contract currently ships in the canonical declarative YAML/JSON compiler. Matching diff --git a/ops/reviews/20260911-spec-C-mcp.md b/ops/reviews/20260911-spec-C-mcp.md new file mode 100644 index 000000000..a93642064 --- /dev/null +++ b/ops/reviews/20260911-spec-C-mcp.md @@ -0,0 +1,418 @@ +# Spec C MCP local evidence — 2026-09-11 + +Implementation base: `9bd8c8095759958bab4fda2e096304256273811d`. +No kernel files changed. GitHub authentication was unavailable during this run; +no PR or CI result is claimed here. + +## Contract interpretation awaiting confirmation + +RFC settled decision 13 keeps the kernel vocabulary closed. The MCP call is an +existing agent effect step whose `step.completed.output` is a receipt with +`type: "mcp"`, server, tool, input, output, and +`mcp:::` as `idempotencyKey`. +`effect.record`/`effect.confirm` use the actual kernel-issued attempt key: +`kernel/relayflowd/src/engine/effects.rs` rejects a different key. +Failures complete with `worker_error`; the kernel drops unsuccessful output, +so the diagnostic is retained in `trajectory_tail` instead. +These are explicit representation differences from the literal fields in #302; +they need acceptance or a separately scoped protocol change. +HTTP follows the official SDK's Streamable HTTP protocol: initialize via POST, +then its optional GET/SSE stream. Inventory is obtained with `tools/list`. + +MCP sessions use official SDK protocol/framing. A small stdio transport owns +spawn and cleanup because the SDK's built-in transport inherits HOME/PATH +and cannot satisfy the required empty environment plus allowlist. +Node/libuv on macOS adds `__CF_USER_TEXT_ENCODING` even for `spawn({env:{}})`; +the environment test allows that platform-generated field only. + +`flows check ` checks declarations without executing the flow body. +The authored run preserves the pre-existing daemon-before-import ordering, +then preflights MCP before any body/step executes. Completed authored bodies +still share the existing executor's documented absence of a durable root; +this change does not claim authored-body crash resume. + +## Dependency preparation and typechecking + +The installed Bun 1.3.14 could not read lockfile version 2. `bun upgrade` +installed 1.4.2, after which the frozen install and surface checks completed. +The literal SDK `npm ci --ignore-scripts` installs published surface 2.0.8, +which lacks both this change's `mcp` and the base commit's `LlmOptions`. +The existing unmodified `scripts/surface-package-gate.sh` packs the local +surface and overrides that registry package. This is the same preparation +used for the SDK checks below; package manifests keep the published version. + +Command (packages/sdk, after local surface pack installation): + +```sh +npm run typecheck && npm run build && npm run typecheck:tests +``` + +Captured output, exit 0: + +```text +> @relayflows/sdk@2.0.8 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +> @relayflows/sdk@2.0.8 build +> tsc && node scripts/make-cli-executable.mjs + +> @relayflows/sdk@2.0.8 typecheck:tests +> tsc -p tsconfig.tests.json +``` + +## SDK suite (not green) + +Command from packages/sdk; PATH included ~/.bun/bin and ~/.cargo/bin: + +```sh +export RELAYFLOWD_BIN=/Users/khaliqgant/flows-spec-C-mcp/kernel/target/release/relayflowd +./node_modules/.bin/vitest run +``` + +Exit 1. The remaining failure is the live Claude analyzer. Its own readiness +probe also exited 1. No analyzer skip override was used. + +
Captured SDK output + +```text + + RUN v2.1.9 /Users/khaliqgant/flows-spec-C-mcp/packages/sdk + + ✓ tests/journal-client.test.ts (14 tests) 81ms +stdout | tests/live-kernel.test.ts +LIVE_KERNEL relayflowd=/Users/khaliqgant/flows-spec-C-mcp/kernel/target/release/relayflowd +LIVE_KERNEL flows=/Users/khaliqgant/flows-spec-C-mcp/packages/sdk/dist/cli.js + + ✓ tests/daemon-lifecycle.test.ts (42 tests) 44ms + ✓ tests/validate.test.ts (68 tests) 16ms + ✓ tests/preflight.test.ts (27 tests) 53ms + ✓ tests/observer-link.test.ts (39 tests) 190ms + ✓ tests/tick-source.test.ts (33 tests) 20ms + ✓ tests/authored-flow.test.ts (24 tests) 664ms + ✓ tests/cloud-run.test.ts (47 tests) 132ms + ✓ tests/verb-field-lint.test.ts (78 tests) 292ms + ✓ tests/authored-flow-lifecycle-executor.test.ts (27 tests) 525ms + ✓ tests/gate-contract.test.ts (20 tests) 80ms + ✓ tests/cli-hn-monitor.test.ts (16 tests) 108ms + ✓ tests/backlog-picker.test.ts (14 tests) 77ms + ✓ tests/tick-runner.test.ts (22 tests) 1362ms + ✓ tests/authored-flow-operation.test.ts (23 tests) 250ms + ✓ tests/backlog-picker-flow.test.ts (6 tests) 458ms + ✓ tests/work-package-consumer.test.ts (13 tests) 207ms + ✓ tests/spec-parity.test.ts (31 tests) 178ms + ✓ tests/worker-lease.test.ts (7 tests) 7ms + ✓ tests/typed-output.test.ts (14 tests) 149ms + ✓ tests/mcp.test.ts (28 tests) 3494ms + ✓ MCP preflight and transports > flows check reports a refusing server and leaves no PID 342ms + ✓ MCP preflight and transports > kills a SIGTERM-resistant silent child after a parent-owned handshake deadline 1307ms + ✓ authored MCP effects against the real kernel > reports a dropped tool connection as a failed CLI run 371ms + ✓ tests/model-selection.test.ts (10 tests) 10ms + ✓ tests/relayflowd-path.test.ts (10 tests) 2ms + ✓ tests/json-schema-bound.test.ts (71 tests) 1436ms + ✓ JSON Schema termination bound > walks a deep schema with an explicit stack rather than recursion 1165ms + ✓ tests/stop-process-group.test.ts (6 tests) 7132ms + ✓ every stop reaches the process group, not just the direct child > exits the run after an execution-timeout stop 1182ms + ✓ every stop reaches the process group, not just the direct child > exits the run after a protocol terminate stop 763ms + ✓ every stop reaches the process group, not just the direct child > kills a SIGTERM-deaf grandchild after a protocol terminate stop 1749ms + ✓ every stop reaches the process group, not just the direct child > kills a SIGTERM-deaf grandchild after an execution-timeout stop 1993ms + ✓ every stop reaches the process group, not just the direct child > holds the loop open long enough for the escalation to run 1151ms + ✓ tests/yaml-local-agent-live.test.ts (7 tests) 2699ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked step CLI and model and journals done 440ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked named CLI and model and journals done 434ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked flow CLI and model and journals done 423ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked project CLI and model and journals done 368ms + ✓ YAML --local-agent through the built CLI and real daemon > still parks without --local-agent 306ms + ✓ YAML --local-agent through the built CLI and real daemon > reports the agent process failure 421ms + ✓ YAML --local-agent through the built CLI and real daemon > preserves declared workspace surfaces that the local worker cannot pin 307ms + ✓ tests/local-dev-ux.test.ts (8 tests) 8ms + ✓ tests/input-binding.test.ts (12 tests) 108ms + ✓ tests/dependency-validation.test.ts (6 tests) 329ms + ✓ tests/deterministic-llm.test.ts (5 tests) 38ms + ✓ tests/hn-poller.test.ts (6 tests) 3ms + ✓ tests/dir-watcher-poller.test.ts (6 tests) 3ms + ✓ tests/hello-deterministic.test.ts (5 tests) 9ms + ✓ tests/work-package-validator.test.ts (7 tests) 3ms + ✓ tests/bin.test.ts (7 tests) 995ms + ↓ tests/real-cli-adapters.test.ts (3 tests | 3 skipped) + ✓ tests/parse-json-output.test.ts (7 tests) 1ms + ✓ tests/cli-adapter.test.ts (3 tests) 2ms + ✓ tests/direct-input.test.ts (4 tests) 7847ms + ✓ direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 2362ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 3831ms + ✓ direct .flow.ts input through the built CLI and live runtime > does not import or execute authored code before daemon availability 761ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses oversized file input before contacting relayflowd 892ms + ✓ tests/journal-client-completion.test.ts (4 tests) 98ms + ✓ tests/direct-run-failure.test.ts (6 tests) 2ms + ✓ tests/flow-executor-chain.test.ts (12 tests) 9569ms + ✓ flow executor LLM and output-binding chain > runs f.llm -> f.agent -> f.run with schema-verified journal output and the exact allowed model 1282ms + ✓ flow executor LLM and output-binding chain > fails invalid LLM output before the next step: not JSON 782ms + ✓ flow executor LLM and output-binding chain > fails invalid LLM output before the next step: {"message":7} 543ms + ✓ flow executor LLM and output-binding chain > retains tagged-template text output 564ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: null 757ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: [1,2] 524ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: "hello" 491ms + ✓ flow executor LLM and output-binding chain > runs the authored LLM path through the built flows CLI 1294ms + ✓ flow executor LLM and output-binding chain > passes a declarative verified value through an agent into a deterministic artifact 664ms + ✓ flow executor LLM and output-binding chain > journals a missing optional field as a failure before the consuming command executes 525ms + ✓ flow executor LLM and output-binding chain > flows run consumes YAML bindings and resume reuses the original journal output 2110ms + ✓ tests/placement.test.ts (54 tests) 7ms + ✓ tests/memory.test.ts (18 tests) 4ms + ✓ tests/worker-platform.test.ts (1 test) 2ms + ✓ tests/classify-outcome.test.ts (2 tests) 2229ms + ✓ classifyOutcome > gives up and reports when a running run never becomes classifiable 2071ms + ✓ tests/cli-progress-wait.test.ts (2 tests) 1241ms + ✓ run starts the wait clock on its first observed lease 471ms + ✓ resume starts the wait clock on its first observed lease 770ms + ✓ tests/worker-cli-abort.test.ts (2 tests) 2547ms + ✓ stops claude and its process group when lease ownership is lost 1297ms + ✓ stops wrapper.mjs and its process group when lease ownership is lost 1250ms + ✓ tests/cli.test.ts (63 tests) 13006ms + ✓ flows check CLI > binds a checked relative wrapper to the flow directory for worker execution 500ms + ✓ flows check CLI > uses the raw Claude adapter model flag instead of accepting auth status as model proof 390ms + ✓ flows check CLI > uses Codex login status and reports a rejected model as unavailable, not unauthenticated 430ms + ✓ flows check CLI > refuses a nonconforming custom wrapper without calling it an authentication failure 492ms + ✓ flows check CLI > accepts an exact allowlisted named-agent model and probes that model 638ms + ✓ flows check CLI > checks the same named-agent contract from declarative JSON 401ms + ✓ flows check CLI > refuses a typo model before probing or contacting relayflowd 595ms + ✓ flows check CLI > maps every input refusal path to its declared kind without raw exceptions 970ms + ✓ flows run/resume CLI over the journal protocol > parses run options, submits the kernel dialect, and exits 0 on success 454ms + ✓ flows run/resume CLI over the journal protocol > exits 1 and emits the declared completionReason for a failed run 1063ms + ✓ flows run/resume CLI over the journal protocol > exits 3 and names the parked llm step 1080ms + ✓ flows run/resume CLI over the journal protocol > classifies a typed hello refusal as a protocol error, not an unreachable daemon 1024ms + ✓ flows run/resume CLI over the journal protocol > follows a dispatched worker step instead of reporting a protocol error 779ms + ✓ flows run/resume CLI over the journal protocol > resumes a parked run from snapshot step types without reading journal sequence one 612ms + ✓ flows run/resume CLI over the journal protocol > maps only run_not_found resumes to exit 2 2384ms + ✓ tests/daemon-lifecycle-live.test.ts (9 tests) 12170ms + ✓ flows run against a data dir with no daemon (§6 test 7) > cold start spawns exactly one daemon, the run succeeds, and the daemon outlives the CLI 918ms + ✓ flows run against a data dir with no daemon (§6 test 7) > polls, bounded, for a daemon that holds the lock before it binds 1675ms + ✓ flows run against a data dir with no daemon (§6 test 7) > attaches to a serving daemon that has not published a connection file 1632ms + ✓ flows run against a data dir with no daemon (§6 test 7) > a second run attaches to the daemon the first one started, spawning nothing 1812ms + ✓ flows run against a data dir with no daemon (§6 test 7) > detects a stale connection file left by a hard kill and starts a fresh daemon 2010ms + ✓ concurrent invocations against one empty data dir (§6 test 15) > ends with exactly one daemon owning the socket, and both runs succeed 1094ms + ✓ refusals from a spawn that cannot produce a daemon > names relayflowd_not_found rather than falling through to PATH 1328ms + ✓ refusals from a spawn that cannot produce a daemon > names daemon_start_failed and quotes the daemon log when startup dies 756ms + ✓ refusals from a spawn that cannot produce a daemon > refuses a daemon speaking another protocol version instead of binding over it 943ms + ✓ tests/worker-cli.test.ts (13 tests) 21059ms + ✓ custom wrapper execution identity > refuses a wrapper symlink retarget before delivering private values 326ms + ✓ custom wrapper execution identity > refuses a duplicate execute protocol frame 360ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a conforming wrapper leaks a stdio pipe to a background helper 2190ms + ✓ custom wrapper execution bounds are reader-owned > resolves when the leaked helper inherits stderr only 1885ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a wrapper leaks a stdio pipe and exits before identifying 3255ms + ✓ custom wrapper execution bounds are reader-owned > journals a completionReason at the default bound when a wrapper leaks a stdio pipe 11260ms + ✓ custom wrapper execution bounds are reader-owned > accepts the same over-8KiB payload whether or not it coalesces with the execute token 487ms + ✓ tests/local-agent-live.test.ts (5 tests) 39274ms + ✓ built CLI local agent against a real daemon > dispatches through the wrapper and keeps --json stdout report-shaped 779ms + ✓ built CLI local agent against a real daemon > runs beyond the initial 30-second lease without a second invocation 35920ms + ✓ built CLI local agent against a real daemon > renders actual agent completion in text output 997ms + ✓ built CLI local agent against a real daemon > returns a failed run when the agent process fails 792ms + ✓ built CLI local agent against a real daemon > refuses a workspace it cannot pin before invoking the agent 785ms +stdout | tests/live-kernel.test.ts > surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once +LIVE_KERNEL kill -9 pid=64515 run=01M27RTDSAG525VW6QRQKREC9W while step=two state=Running + + ❯ tests/live-kernel.test.ts (30 tests | 1 failed) 51639ms + ✓ built flows CLI against live relayflowd > runs rung (a), parks rung (b), and keeps JSON report-shaped 3570ms + ✓ built flows CLI against live relayflowd > allows a deterministic run to exceed the bounded request timeout 32302ms + ✓ built flows CLI against live relayflowd > follows a live worker dispatch through flows run 1005ms + ✓ built flows CLI against live relayflowd > f.agent lowers to a real agent step and dispatches through a live worker 477ms + ✓ built flows CLI against live relayflowd > f.agent's default flowPath anchors on cwd, not cwd's parent 418ms + ✓ built flows CLI against live relayflowd > can always get a parked run to a late-attaching worker 5551ms + ✓ built flows CLI against live relayflowd > reports a real manual-recovery NeedsHuman state as parked 796ms + ✓ built flows CLI against live relayflowd > AgentWorker passes a declared model to an identified wrapper as RELAYFLOW_MODEL 328ms + × built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI 892ms + → LIVE_ANALYZER_UNAVAILABLE: "/Users/khaliqgant/flows-spec-C-mcp/testdata/preflight/analyze-story-claude-cli auth status" exited 1: analyze-story-claude-cli: "claude -p --model claude-haiku-4-5-20251001" exited 1: — failing because gate-2 acceptance requires the real analyzer to execute. Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is not gate evidence. + ✓ built flows CLI against live relayflowd > preflights before journaling and names an unreachable socket 1785ms + ✓ built flows CLI against live relayflowd > starts exactly one daemon when two runs race for one empty data dir 861ms + ✓ surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once 1762ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +Error: LIVE_ANALYZER_UNAVAILABLE: "/Users/khaliqgant/flows-spec-C-mcp/testdata/preflight/analyze-story-claude-cli auth status" exited 1: analyze-story-claude-cli: "claude -p --model claude-haiku-4-5-20251001" exited 1: — failing because gate-2 acceptance requires the real analyzer to execute. Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is not gate evidence. + ❯ tests/live-kernel.test.ts:1186:15 + 1184| const notice = `LIVE_ANALYZER_UNAVAILABLE: ${readiness.detail}`; + 1185| if (process.env['RELAYFLOWS_ALLOW_ANALYZER_SKIP'] !== '1') { + 1186| throw new Error( + | ^ + 1187| `${notice} — failing because gate-2 acceptance requires the … + 1188| + 'Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is … + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + Test Files 1 failed | 51 passed | 1 skipped (53) + Tests 1 failed | 993 passed | 3 skipped (997) + Start at 10:19:00 + Duration 52.60s (transform 1.27s, setup 0ms, collect 7.37s, tests 181.86s, environment 4ms, prepare 2.25s) + +``` +
+ +## Surface, regression types, and packed consumer + +Command from repository root: + +```sh +bash scripts/surface-package-gate.sh +``` + +Exit 0. This includes the requested surface frozen install, test suite, +regression typecheck, and independent packed consumers. + +
Captured package gate output + +```text +bun install v1.4.2 (744846f84) + +Checked 45 installs across 93 packages (no changes) [47.00ms] +$ tsc +$ bun run build && tsc -p tsconfig.test.json && vitest run +$ tsc + + RUN v2.1.9 /Users/khaliqgant/flows-spec-C-mcp/packages/surface + + ✓ tests/flow.test.ts (7 tests) 3ms + + Test Files 1 passed (1) + Tests 7 passed (7) + Start at 10:18:18 + Duration 260ms (transform 30ms, setup 0ms, collect 21ms, tests 3ms, environment 0ms, prepare 51ms) + +$ tsc -p ../../regressions/tsconfig.json +bun pack v1.4.2 (744846f84) + +packed 1.14KB package.json +packed 2.77KB README.md +packed 2.50KB dist/cloud.d.ts +packed 2.30KB dist/cloud.d.ts.map +packed 44B dist/cloud.js +packed 102B dist/cloud.js.map +packed 0.63KB dist/completion.d.ts +packed 346B dist/completion.d.ts.map +packed 0.51KB dist/completion.js +packed 364B dist/completion.js.map +packed 1.43KB dist/context.d.ts +packed 1.36KB dist/context.d.ts.map +packed 46B dist/context.js +packed 106B dist/context.js.map +packed 1.67KB dist/flow.d.ts +packed 1.60KB dist/flow.d.ts.map +packed 6.32KB dist/flow.js +packed 6.38KB dist/flow.js.map +packed 484B dist/index.d.ts +packed 485B dist/index.d.ts.map +packed 147B dist/index.js +packed 200B dist/index.js.map +packed 171B dist/runtime.d.ts +packed 216B dist/runtime.d.ts.map +packed 83B dist/runtime.js +packed 148B dist/runtime.js.map +packed 310B dist/step.d.ts +packed 304B dist/step.d.ts.map +packed 43B dist/step.js +packed 100B dist/step.js.map +packed 2.0KB src/cloud.ts +packed 0.61KB src/completion.ts +packed 1.34KB src/context.ts +packed 8.0KB src/flow.ts +packed 477B src/index.ts +packed 144B src/runtime.ts +packed 272B src/step.ts + +/tmp/relayflows-surface-pack.cguZjJ/relayflows-surface-2.0.8.tgz + +Total files: 37 +Shasum: 5ecd4af88179129e6e71c7240c914f0aec764fc8 +Integrity: sha512-kOWonboi3EuVD[...]VFbvckX+m+b1g== +Unpacked size: 45.20KB +Packed size: 12.66KB + +added 146 packages, and audited 147 packages in 1s + +47 packages are looking for funding + run `npm fund` for details + +6 vulnerabilities (4 moderate, 1 high, 1 critical) + +To address issues that do not require attention, run: + npm audit fix + +To address all issues (including breaking changes), run: + npm audit fix --force + +Run `npm audit` for details. + +changed 1 package, and audited 147 packages in 575ms + +47 packages are looking for funding + run `npm fund` for details + +6 vulnerabilities (4 moderate, 1 high, 1 critical) + +To address issues that do not require attention, run: + npm audit fix + +To address all issues (including breaking changes), run: + npm audit fix --force + +Run `npm audit` for details. + +> @relayflows/sdk@2.0.8 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +bun add v1.4.2 (744846f84) +Resolving dependencies +Resolved, downloaded and extracted [1] +Saved lockfile + +installed @relayflows/surface@/tmp/relayflows-surface-pack.cguZjJ/relayflows-surface-2.0.8.tgz + +1 package installed [9.00ms] +PACKED_RUNTIME_OK name=packed-runtime-consumer completionReason=success +PACKED_RUNTIME_REFUSAL_OK invalidHeaders=9 forgedHandle=refused +PACKED_TYPESCRIPT_OK + + RUN v2.1.9 /Users/khaliqgant/flows-spec-C-mcp/packages/sdk + + ✓ tests/authored-flow.test.ts (24 tests) 664ms + + Test Files 1 passed (1) + Tests 24 passed (24) + Start at 10:18:24 + Duration 1.09s (transform 114ms, setup 0ms, collect 220ms, tests 664ms, environment 0ms, prepare 25ms) + +``` +
+ +## Kernel smoke + +Command from kernel: + +```sh +cargo build --locked --release -p relayflowd && cargo test --workspace +``` + +Exit 0. Captured output excerpts: + +```text + Finished `release` profile [optimized] target(s) in 44.64s + +test result: ok. 60 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.60s + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + +test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s +``` + +## Manual CLI smoke + +The sample flow and flows.json were co-located in a fresh temporary directory +so nearest-config discovery selects the supplied MCP entry. node_modules was +linked to the SDK's dependencies. Config used command `node` and the absolute +path to `testdata/mock-mcp-server/ok.mjs`. RELAYFLOWD_BIN was the release binary +above. The temporary daemon was terminated after this run. + +```text +$ node /Users/khaliqgant/flows-spec-C-mcp/packages/sdk/dist/cli-executable.js run /private/tmp/spec-c-mcp-smoke-yps8m6wh/mcp-smoke.flow.ts --input {} --local-agent --no-observer-link --data-dir /private/tmp/spec-c-mcp-smoke-yps8m6wh/data +{"content":[{"text":"{\"hello\":\"world\"}","type":"text"}],"structuredContent":{"hello":"world"}} +RUN 01M27RPEQAS6AQP8DTSXNJJE8H completed (2 steps) completionReason: success +exit: 0 +``` diff --git a/packages/sdk/src/authored-flow-executor.ts b/packages/sdk/src/authored-flow-executor.ts index 2b1a54bbd..64a5a7005 100644 --- a/packages/sdk/src/authored-flow-executor.ts +++ b/packages/sdk/src/authored-flow-executor.ts @@ -4,6 +4,8 @@ import { assertSlackCredentials, runSlackEffect } from './authored-slack-effect. import { checkSlackHelpers } from './slack-preflight.js'; import { snapshotJsonValue } from './json-value.js'; import type { SlackCall } from './slack-writeback.js'; +import { checkMcpHeader, McpPreflightError } from './cli/check-typescript.js'; +import { buildMcpProxy, runMcpEffect } from './authored-mcp.js'; import { authoredWorkerRunner } from './authored-worker-step.js'; import { readSuccessfulOutput, isSurfaceRunCompletionReason } from './authored-step-output.js'; import { @@ -133,7 +135,8 @@ export async function executeAuthoredFlow( }; const definition = getDefinition(handle); const headerFields = Object.keys(definition.header).filter(key => key !== 'tools'); - if (definition.header.tools && Object.keys(definition.header.tools).some(key => key !== 'slack')) headerFields.push('tools'); + if (definition.header.tools && Object.keys(definition.header.tools).some(key => !['slack', 'mcp'].includes(key))) headerFields.push('tools'); + if (definition.header.tools?.relayfile !== undefined) headerFields.push('tools.relayfile'); const helperPreflight = checkSlackHelpers(definition); if (!helperPreflight.ok) assertSlackCredentials(); if (headerFields.length > 0) { @@ -143,6 +146,9 @@ export async function executeAuthoredFlow( ); } + const checkedMcp = await checkMcpHeader(definition, flowPath); + if (!checkedMcp.report.ok) throw new McpPreflightError(checkedMcp.report); + const journalSteps: AuthoredFlowJournalStep[] = []; const authoredSteps: AuthoredFlowOperation[] = []; const lifecycle = new AuthoredFlowLifecycle(); @@ -211,6 +217,17 @@ export async function executeAuthoredFlow( reply: (channel, threadTs, text) => slackOperation({ type: 'effect', provider: 'slack', verb: 'reply', params: { channel, threadTs, text } }), react: (channel, messageTs, emoji) => slackOperation({ type: 'effect', provider: 'slack', verb: 'react', params: { channel, messageTs, emoji } }), }, + mcp: buildMcpProxy(checkedMcp.inventory, (server, tool, args, known) => { + assertOperationAllowed('mcp', definition.name, requestedCompletion); + const id = `mcp-${nextStep++}`; + return trackStep(authoredSteps, new AuthoredFlowOperation( + id, 'mcp', + () => assertOperationAllowed('mcp', definition.name, requestedCompletion), + () => runMcpEffect(journal, definition.name, id, server, tool, args, + known, checkedMcp.servers[server]!, journalSteps), + lifecycle, + )); + }), run(command) { assertOperationAllowed('run', definition.name, requestedCompletion); const id = `run-${nextStep++}`; diff --git a/packages/sdk/src/authored-mcp.ts b/packages/sdk/src/authored-mcp.ts new file mode 100644 index 000000000..988bd6f8d --- /dev/null +++ b/packages/sdk/src/authored-mcp.ts @@ -0,0 +1,141 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { Ctx, Step } from '@relayflows/surface'; +import { compileSpec, toKernelSpec } from './compile.js'; +import { snapshotJsonValue } from './json-value.js'; +import { McpError, openMcpSession } from './mcp-client.js'; +import type { McpServerConfig } from './spec.js'; +import { SPEC_SCHEMA_VERSION } from './spec.js'; +import type { JournalClient } from './journal-client.js'; +import type { StepDispatchEvent } from './protocol.js'; +import { AuthoredFlowExecutionError } from './authored-flow-error.js'; +import type { AuthoredFlowJournalStep } from './authored-flow-executor.js'; +import { readCompletedStepOutput } from './authored-step-output.js'; + +/** Own keys expose precisely the preflight inventory, including prototype-like names. */ +export function buildMcpProxy( + inventory: Readonly>, + invoke: (server: string, tool: string, args: unknown, known: boolean) => Step, +): Ctx['mcp'] { + const servers: Record Step>> = Object.create(null); + for (const [server, names] of Object.entries(inventory)) { + const tools: Record Step> = Object.create(null); + const call = (tool: string, known: boolean) => (args: unknown) => + invoke(server, tool, snapshotJsonValue(args, `f.mcp.${server}.${tool} arguments`), known); + for (const tool of names) tools[tool] = call(tool, true); + servers[server] = new Proxy(Object.freeze(tools), { + get(target, key) { + if (typeof key !== 'string') return undefined; + if (Object.hasOwn(target, key)) return target[key]; + // Calling a tool outside the inventory still creates an operation, + // so worker_error is journaled like other runtime failures. + return call(key, false); + }, + }); + } + return Object.freeze(servers); +} + +export class McpStepError extends AuthoredFlowExecutionError { + constructor(readonly diagnostic: string, runId: string) { + super('step_failed', diagnostic, 'worker_error', runId); + } +} + +/** Helpers lower to an existing agent effect, never a fourth kernel primitive. + * The MCP receipt lives in step.completed.output: { type, input, output, ... }. + * A private stream routes the step to this short-lived SDK worker exclusively. + */ +export async function runMcpEffect( + journal: JournalClient, flowName: string, id: string, server: string, tool: string, + args: unknown, known: boolean, config: McpServerConfig, + journalSteps: AuthoredFlowJournalStep[], +): Promise { + const idempotencyKey = `mcp:${server}:${tool}:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`; + const surfacePath = `/mcp/${pathPart(server)}/${pathPart(tool)}`; + const stream = `mcp-worker-${randomUUID()}`; + const instruction = JSON.stringify({ type: 'mcp', server, tool, input: args }); + const peer = journal.createPeer(); + let diagnostic: string | undefined; + let settled!: () => void; + let failed!: (error: unknown) => void; + const completed = new Promise((resolve, reject) => { settled = resolve; failed = reject; }); + void completed.catch(() => undefined); + let timer: ReturnType | undefined; + let work: Promise | undefined; + const dispatch = (event: StepDispatchEvent): void => { + const dispatched = event.spec as { instruction?: string; surfaces?: { streams?: { stream: string }[] } }; + if (event.step_id !== id || event.step_type !== 'agent' || work !== undefined + || dispatched?.instruction !== instruction + || !dispatched.surfaces?.streams?.some(pin => pin.stream === stream)) { + failed(new Error('MCP worker received an unexpected dispatch')); + return; + } + work = execute(event); + void work.then(settled, failed); + }; + async function execute(event: StepDispatchEvent): Promise { + const receipt = { type: 'mcp' as const, server, tool, input: args, idempotencyKey }; + let output: unknown; + let confirmed = false; + try { + if (!known) throw new Error('mcp_unknown_tool'); + confirmed = await peer.performEffect({ + runId: event.run_id, stepId: id, attempt: event.attempt, idempotencyKey: event.idempotency_key, + surfacePath, revisionBefore: 'pending', revisionAfter: idempotencyKey, + }, async () => { + const session = await openMcpSession(config, 10_000); + try { + output = await session.callTool(tool, args); + if (typeof output === 'object' && output !== null && 'isError' in output && output.isError === true) { + throw new Error('mcp_tool_error'); + } + } finally { await session.close(); } + }); + // A confirmed election without its receipt is an interrupted writeback, + // not a successful result we may invent or a call we may safely repeat. + if (!confirmed) throw new Error('mcp_result_unavailable'); + } catch (error) { + if (error instanceof McpError) diagnostic = error.code; + else if (error instanceof Error && ['mcp_unknown_tool', 'mcp_tool_error', 'mcp_result_unavailable'].includes(error.message)) diagnostic = error.message; + else throw error; // Journal failures remain fail-closed, never provider errors. + } + await peer.stepComplete(event.run_id, id, event.attempt, event.idempotency_key, + diagnostic === undefined ? 'success' : 'worker_error', { + output: { ...receipt, output: output ?? null, ...(diagnostic ? { diagnostic } : {}) }, + started_pins: event.pins, end_pins: event.pins, + ...(diagnostic ? { trajectory_tail: { ...receipt, diagnostic } } : {}), + effects: confirmed ? [{ surface_path: surfacePath, idempotency_key: event.idempotency_key }] : [], + }); + } + try { + await peer.connect(); + await peer.hello('flows-mcp'); + peer.on('step.dispatch', dispatch); + await peer.workerAttach(stream, ['agent'], { workspace: [], streams: [{ stream, read_offset: 0 }] }, 1); + const spec = toKernelSpec(compileSpec({ version: SPEC_SCHEMA_VERSION, name: `${flowName}/${id}`, + steps: [{ id, type: 'agent', instruction, + surfaces: { streams: [{ stream }], external: [surfacePath] }, maxIterations: 1 }], + })); + timer = setTimeout(() => failed(new Error('MCP worker dispatch deadline exceeded')), 30_000); + const outcome = await journal.runStart(spec); + await completed; + if (diagnostic !== undefined) { + try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); } + catch (error) { + if (!(error instanceof AuthoredFlowExecutionError) || error.code !== 'step_failed') throw error; + throw new McpStepError(diagnostic, outcome.run_id); + } + } + const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps) as { output: unknown }; + return receipt.output; + } finally { + clearTimeout(timer); + peer.off('step.dispatch', dispatch); + peer.close(); + await work?.catch(() => undefined); + } +} + +function pathPart(value: string): string { + return encodeURIComponent(value).replaceAll('.', '%2E'); +} diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 1d14f72a7..7c22f4994 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -18,6 +18,7 @@ import { } from './cli/run.js'; import { runDirectFlow } from './cli/direct-run.js'; import { parseReplayArgs, replayJournal, type ReplayArgs } from './cli/replay.js'; +import { checkTypeScriptFlow } from './cli/check-typescript.js'; import { runCloudCli } from './cli/cloud-run.js'; import { isAuthoredFlowPath } from './direct-input.js'; import { runHnMonitor } from './cli/hn-monitor.js'; @@ -51,7 +52,7 @@ type ParsedArgs = const DEFAULT_DATA_DIR = '.relayflowd'; const USAGE = [ 'Usage:', - 'flows check [--json] ', + 'flows check [--json] ', 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] [--reuse-from ] ', 'flows run --cloud [--json] [--wait] ', 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] --input ', @@ -98,12 +99,12 @@ export async function runCli( if (parsed.command === 'check') { // Deliberately daemon-free (kernel/DAEMON-LIFECYCLE.md §4). `checkFlow` is - // a pure compile-and-preflight that opens no socket, and the parser + // a compile-and-preflight that opens no daemon socket, and the parser // refuses `--data-dir` on `check`, so there is no data dir to attach to. // `flows check` keeps working with no daemon, no relayflowd binary and no // data directory at all -- a property worth keeping, not an omission. - const checked = /\.(?:[cm]?[jt]s)$/.test(parsed.value) - ? await checkHelperBody(parsed.value) : checkFlow(parsed.value); + const checked = isAuthoredFlowPath(parsed.value) + ? await checkAuthoredFlowComposed(parsed.value) : checkFlow(parsed.value); emitCheckReport(checked.report, parsed.json, io); return checked.report.ok ? 0 : 2; } @@ -204,6 +205,26 @@ export async function runCli( return execution.exitCode; } +/** + * Compose helper-body checks (from f.slack aspect) with TS-flow MCP checks + * so an authored .flow.ts surfaces diagnostics from both. Both check + * functions load the authored flow independently — this can be flattened + * later, but composing them keeps the rebase minimal and preserves both + * aspects' coverage. + */ +async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckReport }> { + const helper = await checkHelperBody(path); + if (!helper.report.ok) return helper; + const mcp = await checkTypeScriptFlow(path); + return { + report: { + ...mcp.report, + diagnostics: [...helper.report.diagnostics, ...mcp.report.diagnostics], + ok: helper.report.ok && mcp.report.ok, + }, + }; +} + /** * Start the observer-token mint if the environment says one should happen. * Returns `undefined` when no attempt should be made — no workspace key diff --git a/packages/sdk/src/cli/check-typescript.ts b/packages/sdk/src/cli/check-typescript.ts new file mode 100644 index 000000000..356eb42ed --- /dev/null +++ b/packages/sdk/src/cli/check-typescript.ts @@ -0,0 +1,57 @@ +import { dirname, resolve } from 'node:path'; +import type { AuthoredFlowDefinition } from '../authored-flow.js'; +import { loadAuthoredFlow } from '../authored-flow-loader.js'; +import { preflight } from '../preflight.js'; +import { SPEC_SCHEMA_VERSION, type McpServerConfig } from '../spec.js'; +import { inputFailureReport, readProjectConfig, type CheckReport } from './check.js'; + +export interface CheckedMcp { + report: CheckReport; + servers: Readonly>; + inventory: Readonly>; +} + +export async function checkTypeScriptFlow(path: string): Promise<{ report: CheckReport }> { + try { + const { handle, getDefinition } = await loadAuthoredFlow(path); + return await checkMcpHeader(getDefinition(handle), path); + } catch (error) { + return { report: inputFailureReport({ kind: 'invalid_spec', message: (error as Error).message }, path) }; + } +} + +/** Check declarations without executing the authored body. */ +export async function checkMcpHeader( + definition: Pick, + path: string, +): Promise { + const empty = { servers: Object.freeze({}), inventory: Object.freeze({}) }; + const unsupported = Object.keys(definition.header).filter(key => key !== 'tools'); + if (definition.header.tools?.relayfile !== undefined) unsupported.push('tools.relayfile'); + if (unsupported.length) { + return { ...empty, report: inputFailureReport({ kind: 'invalid_spec', + message: `flow "${definition.name}" uses unsupported header fields: ${unsupported.join(', ')}` }, path) }; + } + try { + const config = readProjectConfig(dirname(resolve(path))); + const result = await preflight({ version: SPEC_SCHEMA_VERSION, name: definition.name, + steps: [{ id: 'header', type: 'deterministic', command: ':' }] }, { + mcpServers: definition.header.tools?.mcp ?? [], mcp: config.mcp, + probes: { command: () => true, cli: () => { throw new Error('no CLI declared'); }, executor: () => false }, + }); + return { + servers: config.mcp ?? empty.servers, + inventory: result.mcpTools ?? empty.inventory, + report: { ...result, path, projectConfigPath: config.path, gates: [], + diagnostics: result.diagnostics.filter(d => d.stepId !== 'header') }, + }; + } catch (error) { + return { ...empty, report: inputFailureReport({ kind: 'config_invalid', message: (error as Error).message }, path) }; + } +} + +export class McpPreflightError extends Error { + constructor(readonly report: CheckReport) { + super(report.diagnostics.map(d => d.message).join('; ')); + } +} diff --git a/packages/sdk/src/cli/check.ts b/packages/sdk/src/cli/check.ts index cb0b1e21b..0838146c9 100644 --- a/packages/sdk/src/cli/check.ts +++ b/packages/sdk/src/cli/check.ts @@ -14,6 +14,8 @@ import { import { MODEL_ENV } from '../worker-cli.js'; import { modelNameError } from '../model-name.js'; import type { FlowSpec } from '../spec.js'; +import type { McpServerConfig } from '../spec.js'; +import { parseMcpConfig } from '../mcp-config.js'; import type { StepGateInspection } from '../gate-contract.js'; import type { CheckFailureKind } from '../failure-kinds.js'; import { @@ -25,7 +27,8 @@ import { type PreflightProbes, } from '../preflight.js'; -interface ProjectConfig { +export interface ProjectConfig { + mcp?: Record; cli?: string; executors: string[]; models: string[]; @@ -34,6 +37,7 @@ interface ProjectConfig { } export interface CheckReport { + mcpTools?: Readonly>; ok: boolean; path?: string; projectConfigPath?: string; @@ -161,7 +165,7 @@ function readFlow(path: string): FlowSpec { } } -function readProjectConfig(start: string): ProjectConfig { +export function readProjectConfig(start: string): ProjectConfig { const configPath = findConfig(start); if (configPath === undefined) return { executors: [], models: [], directory: start }; let value: unknown; @@ -170,8 +174,8 @@ function readProjectConfig(start: string): ProjectConfig { } catch { throw new CheckFailure('config_invalid', `Project config "${configPath}" is not valid JSON.`); } - if (!isObject(value) || Object.keys(value).some((key) => !['cli', 'executors', 'models'].includes(key))) { - throw new CheckFailure('config_invalid', `Project config "${configPath}" expects only cli, executors, and models.`); + if (!isObject(value) || Object.keys(value).some((key) => !['cli', 'executors', 'models', 'mcp'].includes(key))) { + throw new CheckFailure('config_invalid', `Project config "${configPath}" expects only cli, executors, models, and mcp.`); } if (value['cli'] !== undefined && !isNonEmptyString(value['cli'])) { throw new CheckFailure('config_invalid', `Project config "${configPath}" has an invalid cli.`); @@ -193,7 +197,19 @@ function readProjectConfig(start: string): ProjectConfig { throw new CheckFailure('config_invalid', `Project config "${configPath}" has duplicate models.`); } } + let mcp: Record | undefined; + if (value['mcp'] !== undefined) { + try { mcp = parseMcpConfig(value['mcp']); } + catch (error) { + throw new CheckFailure('config_invalid', `Project config "${configPath}": ${(error as Error).message}.`); + } + for (const entry of Object.values(mcp)) { + if ('command' in entry) entry.command = resolveExecutable(entry.command, dirname(configPath)) + ?? canonicalCli(entry.command, dirname(configPath)); + } + } return { + ...(mcp !== undefined ? { mcp } : {}), ...(value['cli'] !== undefined ? { cli: value['cli'] as string } : {}), executors: (value['executors'] as string[] | undefined) ?? [], models: (value['models'] as string[] | undefined) ?? [], diff --git a/packages/sdk/src/cli/direct-run.ts b/packages/sdk/src/cli/direct-run.ts index 3f22d9f12..1987317d3 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -1,3 +1,5 @@ +import { McpStepError } from '../authored-mcp.js'; +import { McpPreflightError } from './check-typescript.js'; import { attachLocalAgent } from '../local-agent.js'; import { LlmWorker } from '../llm-worker.js'; import { @@ -90,6 +92,16 @@ export async function runDirectFlow( }, }; } catch (caught) { + if (caught instanceof McpPreflightError) return { + exitCode: 2, report: fromCheckReport('run', caught.report), + }; + if (caught instanceof McpStepError) return { + exitCode: 1, + report: { ...base, ok: false, runId: caught.runId, socketPath, + status: 'failed', completionReason: 'step_failed', + diagnostics: [...base.diagnostics, { severity: 'failure', kind: 'step_failed', message: caught.message }], + }, + }; // Preserve authored classifications/run IDs; use the worker's cause only // when its connection teardown left a generic transport error. const error = caught instanceof AuthoredFlowExecutionError || caught instanceof AuthoredFlowLoadError diff --git a/packages/sdk/src/failure-kinds.ts b/packages/sdk/src/failure-kinds.ts index 769d717e3..b16a99930 100644 --- a/packages/sdk/src/failure-kinds.ts +++ b/packages/sdk/src/failure-kinds.ts @@ -4,6 +4,8 @@ const SHARED_SPEC_FAILURE_KINDS = ['invalid_spec'] as const; const PREFLIGHT_ENVIRONMENT_FAILURE_KINDS = [ 'helper_slack.credential_missing', 'helper_slack.mount_required', + 'mcp_undeclared_server', + 'mcp_unreachable', 'cli_missing', 'cli_unauthenticated', 'cli_unresolved', diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d29b5324d..102770910 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,6 +12,8 @@ export type { DeterministicStepSpec, ExitCodeGate, FlowSpec, + FlowsJson, + McpServerConfig, JsonSchemaGate, JsonOutputSchema, KernelAgentStep, diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index b861d90e7..5240208de 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -74,6 +74,13 @@ export class JournalClient extends EventEmitter { this.connectTimeoutMs = options.connectTimeoutMs ?? 2_000; } + /** Independent session for an SDK helper worker; preserves the caller's registration. */ + createPeer(): JournalClient { + return new JournalClient(this.socketPath, { + requestTimeoutMs: this.requestTimeoutMs, connectTimeoutMs: this.connectTimeoutMs, + }); + } + /** Open the unix socket connection. Rejects on connect failure (fail-closed). */ connect(): Promise { return new Promise((resolve, reject) => { diff --git a/packages/sdk/src/mcp-client.ts b/packages/sdk/src/mcp-client.ts new file mode 100644 index 000000000..6605db396 --- /dev/null +++ b/packages/sdk/src/mcp-client.ts @@ -0,0 +1,97 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { Agent } from 'undici'; +import type { McpServerConfig } from './spec.js'; +import { McpStdioTransport } from './mcp-stdio.js'; + +export type McpDiagnostic = 'spawn_failed' | 'handshake_timeout' | 'handshake_rejected' | 'mcp_disconnected'; +export class McpError extends Error { + constructor(readonly code: McpDiagnostic) { super(code); } +} +export interface McpSession { + listTools(): Promise; + callTool(name: string, args: unknown): Promise; + close(): Promise; +} + +/** One bounded session, never a reconnect/retry loop. Protocol and framing are + * supplied by the official SDK; this adapter owns resource lifetimes. */ +export async function openMcpSession(config: McpServerConfig, timeoutMs: number): Promise { + const client = new Client({ name: 'relayflows', version: '1.0.0' }); + const controller = new AbortController(); + let dispatcher: Agent | undefined; + let transport: Transport; + if ('command' in config) { + transport = new McpStdioTransport(config); + } else { + dispatcher = new Agent(); + transport = new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: { headers: config.headers }, + reconnectionOptions: { maxRetries: 0, initialReconnectionDelay: 0, maxReconnectionDelay: 0, reconnectionDelayGrowFactor: 1 }, + fetch: (url, init) => fetch(url, { + ...init, redirect: 'error', + signal: AbortSignal.any([controller.signal, ...(init?.signal ? [init.signal] : [])]), + dispatcher, + } as RequestInit), + }); + } + let closing: Promise | undefined; + const close = (): Promise => closing ??= (async () => { + controller.abort(); + try { await client.close(); } finally { + await transport.close(); + await dispatcher?.destroy(); + } + })(); + let phase: 'handshake' | 'call' = 'handshake'; + let failure: McpError | undefined; + let rejectTransport!: (error: McpError) => void; + const broken = new Promise((_, reject) => { rejectTransport = reject; }); + void broken.catch(() => undefined); + const fail = (code: McpDiagnostic): void => { + failure ??= new McpError(code); + rejectTransport(failure); + }; + client.onerror = error => { + const code = (error as NodeJS.ErrnoException).code; + fail(phase === 'call' ? 'mcp_disconnected' + : code === 'ENOENT' || code === 'EACCES' ? 'spawn_failed' : 'handshake_rejected'); + }; + client.onclose = () => { + if (!closing) fail(phase === 'call' ? 'mcp_disconnected' : 'handshake_rejected'); + }; + const bounded = async (work: () => Promise): Promise => { + let timer: ReturnType | undefined; + try { + if (failure) throw failure; + return await Promise.race([work(), broken, new Promise((_, reject) => { + timer = setTimeout(() => reject(new McpError(phase === 'call' ? 'mcp_disconnected' : 'handshake_timeout')), timeoutMs); + })]); + } catch (error) { + await close(); + throw error instanceof McpError ? error : new McpError(phase === 'call' ? 'mcp_disconnected' : 'handshake_rejected'); + } finally { clearTimeout(timer); } + }; + await bounded(() => client.connect(transport)); + return { + listTools: () => bounded(async () => { + const names: string[] = []; + const cursors = new Set(); + let cursor: string | undefined; + do { + const page = await client.listTools(cursor === undefined ? {} : { cursor }); + names.push(...page.tools.map(tool => tool.name)); + cursor = page.nextCursor; + if (cursor !== undefined && cursors.has(cursor)) throw new McpError('handshake_rejected'); + if (cursor !== undefined) cursors.add(cursor); + } while (cursor !== undefined); + return [...new Set(names)]; + }), + callTool: (name, args) => { + phase = 'call'; + return bounded(() => client.callTool({ name, arguments: args as Record })); + }, + close, + }; +} diff --git a/packages/sdk/src/mcp-config.ts b/packages/sdk/src/mcp-config.ts new file mode 100644 index 000000000..2e2a9684a --- /dev/null +++ b/packages/sdk/src/mcp-config.ts @@ -0,0 +1,42 @@ +import type { McpServerConfig } from './spec.js'; + +/** Called on parsed project JSON before any process or connection is opened. */ +export function parseMcpConfig(value: unknown): Record { + if (!record(value)) throw new Error('mcp must be a server map'); + const result: Record = Object.create(null); + for (const [name, config] of Object.entries(value)) { + const invalid = (): never => { throw new Error(`mcp.${name} has an invalid server configuration`); }; + if (!name.trim() || !record(config)) invalid(); + const entry = config as Record; + const stdio = Object.hasOwn(entry, 'command'); + if (stdio === Object.hasOwn(entry, 'url')) invalid(); + const allowed = stdio ? ['command', 'args', 'env'] : ['url', 'headers']; + if (Object.keys(entry).some(key => !allowed.includes(key))) invalid(); + if (stdio) { + if (!nonempty(entry.command)) invalid(); + if (entry.args !== undefined && (!Array.isArray(entry.args) + || !entry.args.every(arg => typeof arg === 'string' && !arg.includes('\0')))) invalid(); + if (entry.env !== undefined && (!Array.isArray(entry.env) + || !entry.env.every(key => typeof key === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) + || new Set(entry.env).size !== entry.env.length)) invalid(); + } else { + if (!nonempty(entry.url)) invalid(); + let url: URL; + try { url = new URL(entry.url as string); } catch { invalid(); } + if (!['http:', 'https:'].includes(url!.protocol) || url!.username || url!.password || url!.hash) invalid(); + if (entry.headers !== undefined) { + if (!record(entry.headers) || !Object.values(entry.headers).every(v => typeof v === 'string')) invalid(); + try { new Headers(entry.headers as Record); } catch { invalid(); } + } + } + result[name] = entry as McpServerConfig; + } + return result; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function nonempty(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && !value.includes('\0'); +} diff --git a/packages/sdk/src/mcp-stdio.ts b/packages/sdk/src/mcp-stdio.ts new file mode 100644 index 000000000..abba4a00f --- /dev/null +++ b/packages/sdk/src/mcp-stdio.ts @@ -0,0 +1,81 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { McpServerConfig } from './spec.js'; + +/** SDK framing with parent-owned spawning/cleanup: SDK's stdio transport + * unconditionally inherits HOME/PATH/etc. and cannot enforce our env contract. */ +export class McpStdioTransport implements Transport { + onclose?: Transport['onclose']; + onerror?: Transport['onerror']; + onmessage?: Transport['onmessage']; + private child?: ChildProcessWithoutNullStreams; + private readonly buffer = new ReadBuffer({ maxBufferSize: 1_048_576 }); + private closed?: Promise; + private closing?: Promise; + constructor(private readonly config: Extract) {} + + async start(): Promise { + const env: NodeJS.ProcessEnv = Object.create(null); + for (const name of this.config.env ?? []) { + if (process.env[name] !== undefined) env[name] = process.env[name]; + } + const child = this.child = spawn(this.config.command, this.config.args ?? [], { + env, stdio: ['pipe', 'pipe', 'pipe'], + }); + this.closed = new Promise(resolve => child.once('close', () => resolve())); + child.stderr.resume(); + child.stdout.on('data', (chunk: Buffer) => { + if (this.closing) return; + try { + this.buffer.append(chunk); + let message: JSONRPCMessage | null; + while ((message = this.buffer.readMessage()) !== null) this.onmessage?.(message); + } catch (error) { this.onerror?.(error as Error); } + }); + child.stdout.once('end', () => this.onclose?.()); + child.stdout.on('error', error => this.onerror?.(error)); + child.stdin.on('error', error => this.onerror?.(error)); + child.on('error', error => this.onerror?.(error)); + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + } + + send(message: JSONRPCMessage): Promise { + return new Promise((resolve, reject) => { + if (!this.child || this.closing) return reject(new Error('MCP transport closed')); + this.child.stdin.write(serializeMessage(message), error => error ? reject(error) : resolve()); + }); + } + + close(): Promise { + return this.closing ??= this.stop(); + } + + private async stop(): Promise { + const child = this.child; + if (!child) return; + child.stdin.end(); + child.kill('SIGTERM'); + // Drain pipes until close or our own deadline; a descendant may keep a + // pipe open even after the direct child has exited. + let timer: ReturnType | undefined; + await Promise.race([this.closed, new Promise(resolve => { + timer = setTimeout(resolve, 1000); + })]); + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null && child.pid !== undefined) { + const exited = new Promise(resolve => child.once('exit', () => resolve())); + child.kill('SIGKILL'); + await exited; + } + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + this.buffer.clear(); + this.onclose?.(); + } +} diff --git a/packages/sdk/src/preflight.ts b/packages/sdk/src/preflight.ts index 8b0b38436..2e8f74258 100644 --- a/packages/sdk/src/preflight.ts +++ b/packages/sdk/src/preflight.ts @@ -1,4 +1,5 @@ -import type { FlowSpec, StepSpec, TriggerSpec } from './spec.js'; +import type { FlowSpec, StepSpec, TriggerSpec, McpServerConfig } from './spec.js'; +import { McpError, openMcpSession, type McpDiagnostic } from './mcp-client.js'; import { acceptsAnyOutput, inspectStepGate, type StepGateInspection } from './gate-contract.js'; import { compileSpec, CompileError } from './compile.js'; import type { @@ -60,6 +61,9 @@ export interface PreflightProbes { } export interface PreflightOptions { + /** Validated tools.mcp header and nearest flows.json connections. */ + mcpServers?: readonly string[]; + mcp?: Readonly>; projectCli?: string; projectConfigPath?: string; projectSearchStart?: string; @@ -70,6 +74,8 @@ export interface PreflightOptions { } export interface PreflightRefusal { + server?: string; + cause?: McpDiagnostic; severity: 'refusal'; kind: PreflightFailureKind; message: string; @@ -94,13 +100,42 @@ export interface PreflightWarning { export type PreflightDiagnostic = PreflightRefusal | PreflightWarning; export interface PreflightResult { + mcpTools?: Readonly>; ok: boolean; gates: StepGateInspection[]; resolutions: CliResolution[]; diagnostics: PreflightDiagnostic[]; } -export function preflight(flow: FlowSpec, options: PreflightOptions): PreflightResult { +// Preserve the synchronous declarative API; an authored MCP declaration opts +// into asynchronous connection probes after the same pure refusal pass. +export function preflight(flow: FlowSpec, options: PreflightOptions & { mcpServers: readonly string[] }): Promise; +export function preflight(flow: FlowSpec, options: PreflightOptions & { mcpServers?: undefined }): PreflightResult; +export function preflight(flow: FlowSpec, options: PreflightOptions): PreflightResult | Promise; +export function preflight(flow: FlowSpec, options: PreflightOptions): PreflightResult | Promise { + const result = preflightSync(flow, options); + if (options.mcpServers === undefined) return result; + return probeMcp(result, options); +} + +async function probeMcp(result: PreflightResult, options: PreflightOptions): Promise { + if (!result.ok) return result; + const inventory: Record = Object.create(null); + for (const server of new Set(options.mcpServers)) { + try { + const session = await openMcpSession(options.mcp![server]!, 10_000); + try { inventory[server] = Object.freeze(await session.listTools()); } + finally { await session.close(); } + } catch (error) { + const cause = error instanceof McpError ? error.code : 'handshake_rejected'; + result.diagnostics.push({ severity: 'refusal', kind: 'mcp_unreachable', server, cause, + message: `MCP server "${server}" is unreachable: ${cause}.` }); + } + } + return { ...result, ok: !result.diagnostics.some(d => d.severity === 'refusal'), mcpTools: Object.freeze(inventory) }; +} + +function preflightSync(flow: FlowSpec, options: PreflightOptions): PreflightResult { // Compile before touching any environment fact. `compileSpec` snapshots raw // input into inert data, validates it against the closed authoring schema, // and lowers `output` sugar into its json_schema gate — so the gate plan @@ -133,6 +168,11 @@ export function preflight(flow: FlowSpec, options: PreflightOptions): PreflightR const cliProbeResults = new Map(); diagnostics.push(...unknownModelDiagnostics(compiled, options)); + for (const server of new Set(options.mcpServers)) { + if (options.mcp !== undefined && Object.hasOwn(options.mcp, server)) continue; + diagnostics.push({ severity: 'refusal', kind: 'mcp_undeclared_server', server, + message: `MCP server "${server}" is not declared in the nearest flows.json mcp map.` }); + } // Resolve the complete flow before touching any environment fact. A later // statically unresolved CLI makes the whole submission impossible, so no // earlier command, provider/model, or trigger probe may run first. diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index b8e67d667..39444b497 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -15,6 +15,18 @@ export type { JsonOutputSchema } from './output-schema.js'; /** The three rungs of the ladder (RFC §1; AGENTS.md rule 7). */ export type StepType = 'deterministic' | 'llm' | 'agent'; +/** Project-owned MCP connections. env contains names, never secret values. */ +export type McpServerConfig = + | { command: string; args?: string[]; env?: string[] } + | { url: string; headers?: Record }; + +export interface FlowsJson { + cli?: string; + executors?: string[]; + models?: string[]; + mcp?: Record; +} + /** * Verification is control flow, not decoration (kernel DESIGN.md §3). * v0 gates are deterministic so verification is kernel-side and replayable. diff --git a/packages/sdk/tests/mcp.test.ts b/packages/sdk/tests/mcp.test.ts new file mode 100644 index 000000000..6809b3952 --- /dev/null +++ b/packages/sdk/tests/mcp.test.ts @@ -0,0 +1,260 @@ +import { createHash } from 'node:crypto'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { flow } from '@relayflows/surface'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { preflight, type PreflightProbes } from '../src/preflight.js'; +import { openMcpSession } from '../src/mcp-client.js'; +import { parseMcpConfig } from '../src/mcp-config.js'; +import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; +import { buildMcpProxy } from '../src/authored-mcp.js'; +import { JournalClient } from '../src/journal-client.js'; +import { socketPathFor } from '../src/daemon-connection.js'; +import type { McpServerConfig } from '../src/spec.js'; + +const root = resolve('../..'); +const mock = (name: string) => join(root, 'testdata/mock-mcp-server', `${name}.mjs`); +const spec = { version: '0.1.0' as const, name: 'mcp-check', steps: [{ id: 'one', type: 'deterministic' as const, command: ':' }] }; +const probes: PreflightProbes = { command: () => true, cli: () => ({ exists: true, authenticated: true }), executor: () => true }; +const roots: string[] = []; +function temp(): string { const path = mkdtempSync(join(tmpdir(), 'flows-mcp-')); roots.push(path); return path; } +function config(name = 'ok', marker?: string): McpServerConfig { + return { command: process.execPath, args: [mock(name), ...(marker ? [marker] : [])] }; +} +function fixture(mcp: unknown, body = "await f.mcp.foo.echo({hello:'world'}); f.done('success');") { + const directory = temp(); + symlinkSync(resolve('node_modules'), join(directory, 'node_modules')); + writeFileSync(join(directory, 'package.json'), '{"type":"module"}'); + writeFileSync(join(directory, 'flows.json'), JSON.stringify({ mcp })); + const path = join(directory, 'example.flow.ts'); + writeFileSync(path, `import {flow} from '@relayflows/surface'; export default flow('mcp-test', {tools:{mcp:['foo']}}, async f => {${body}});`); + return { directory, path }; +} +function gone(marker: string) { + const { pid } = JSON.parse(readFileSync(marker, 'utf8')); + expect(() => process.kill(pid, 0)).toThrow(); +} +afterEach(() => { vi.unstubAllEnvs(); for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }); }); + +describe('MCP preflight and transports', () => { + it('collects undeclared servers and CLI refusals before any probe or spawn', async () => { + const marker = join(temp(), 'started'); + const probe = vi.fn(() => { throw new Error('must not probe'); }); + const result = await preflight({ ...spec, steps: [...spec.steps, { id: 'agent', type: 'agent', instruction: 'hi' }] }, { + mcpServers: ['foo', 'missing'], mcp: { foo: config('ok', marker) }, + probes: { command: probe, cli: probe, executor: probe }, + }); + expect(result.diagnostics.map(d => d.kind)).toEqual(['mcp_undeclared_server', 'cli_unresolved']); + expect(probe).not.toHaveBeenCalled(); + expect(existsSync(marker)).toBe(false); + }); + it('flows check refuses an undeclared server with exit 2 and no daemon', () => { + const f = fixture(undefined); + const dataDir = join(f.directory, 'data'); + const result = spawnSync(process.execPath, ['dist/cli-executable.js', 'check', f.path, '--json'], { + encoding: 'utf8', env: { ...process.env, RELAYFLOWS_DATA_DIR: dataDir }, timeout: 15000, + }); + expect(result.status, result.stderr).toBe(2); + expect(JSON.parse(result.stdout).diagnostics).toContainEqual(expect.objectContaining({ kind: 'mcp_undeclared_server' })); + expect(existsSync(dataDir)).toBe(false); + }); + it('refuses a server that exits, reaping its PID before returning', async () => { + const marker = join(temp(), 'pid'); + const result = await preflight(spec, { mcpServers: ['foo'], mcp: { foo: config('refuse', marker) }, probes }); + expect(result).toMatchObject({ ok: false, diagnostics: expect.arrayContaining([ + expect.objectContaining({ kind: 'mcp_unreachable', cause: 'handshake_rejected' }), + ]) }); + gone(marker); + }); + it('flows check reports a refusing server and leaves no PID', () => { + const marker = join(temp(), 'pid'); + const f = fixture({ foo: config('refuse', marker) }); + const result = spawnSync(process.execPath, ['dist/cli-executable.js', 'check', f.path, '--json'], { encoding: 'utf8', timeout: 15000 }); + expect(result.status).toBe(2); + expect(JSON.parse(result.stdout).diagnostics).toContainEqual(expect.objectContaining({ kind: 'mcp_unreachable', cause: 'handshake_rejected' })); + gone(marker); + }); + it('undeclared Ctx access throws before any journal write or configured server spawn', async () => { + const marker = join(temp(), 'pid'); + const f = fixture({ foo: config('refuse', marker) }); + const client = new JournalClient('/must-not-connect'); + const start = vi.spyOn(client, 'runStart'); + const handle = flow('undeclared', async f => { await f.mcp.notdeclared!.echo!({}); f.done('success'); }); + await expect(executeAuthoredFlow(handle, client, undefined, { flowPath: f.path })).rejects.toBeInstanceOf(TypeError); + expect(start).not.toHaveBeenCalled(); + expect(existsSync(marker)).toBe(false); + }); + it('captures inventory, reaps the PID, and passes only declared env names', async () => { + const marker = join(temp(), 'pid'); + vi.stubEnv('MOCK_MCP_TOKEN', 'allowed'); vi.stubEnv('MOCK_MCP_SECRET', 'hidden'); vi.stubEnv('RELAYFLOW_MODEL', 'hidden'); + const result = await preflight(spec, { mcpServers: ['foo'], mcp: { foo: { ...config('ok', marker), env: ['MOCK_MCP_TOKEN'] } }, probes }); + expect(result).toMatchObject({ ok: true, mcpTools: { foo: ['echo', 'add'] } }); + const env = JSON.parse(readFileSync(marker, 'utf8')).env; + // libuv injects this OS encoding setting on macOS even with env: {}. + if (process.platform === 'darwin') delete env.__CF_USER_TEXT_ENCODING; + expect(env).toEqual({ MOCK_MCP_TOKEN: 'allowed' }); + gone(marker); + }); + it('classifies a missing executable', async () => { + await expect(openMcpSession({ command: '/nonexistent-mcp-executable' }, 100)).rejects.toMatchObject({ code: 'spawn_failed' }); + }); + it('kills a SIGTERM-resistant silent child after a parent-owned handshake deadline', async () => { + const marker = join(temp(), 'pid'); + const source = `require('fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify({pid:process.pid})); process.on('SIGTERM',()=>{}); setInterval(()=>{},100);`; + await expect(openMcpSession({ command: process.execPath, args: ['-e', source] }, 300)).rejects.toMatchObject({ code: 'handshake_timeout' }); + gone(marker); + }); + it('classifies a mid-call stdout drop without retry and closes the child', async () => { + const marker = join(temp(), 'pid'); + const session = await openMcpSession(config('drop', marker), 1000); + try { await expect(session.callTool('echo', {})).rejects.toMatchObject({ code: 'mcp_disconnected' }); } + finally { await session.close(); } + gone(marker); + }); + it.each([ + null, [], { foo: {} }, { foo: { command: 'node', url: 'http://localhost' } }, + { foo: { command: 'node', secret: 'no' } }, { foo: { command: 'node', env: { TOKEN: 'value' } } }, + { foo: { command: 'node', env: ['KEY=value'] } }, { foo: { command: 'node', args: [3] } }, + { foo: { url: 'file:///tmp/mcp' } }, { foo: { url: 'http://localhost', headers: { token: 1 } } }, + ])('fails closed on malformed config %j', value => expect(() => parseMcpConfig(value)).toThrow()); + it('reports malformed connection configuration as config_invalid', () => { + const f = fixture({ foo: { command: 'node', url: 'http://localhost' } }); + const result = spawnSync(process.execPath, ['dist/cli-executable.js', 'check', f.path, '--json'], { encoding: 'utf8', timeout: 15000 }); + expect(result.status).toBe(2); + expect(JSON.parse(result.stdout).diagnostics[0].kind).toBe('config_invalid'); + }); + it('returns undefined for undeclared servers before any journal write', () => { + const invoke = vi.fn(); + const mcp = buildMcpProxy({ foo: ['echo', 'add'] }, invoke); + expect(() => mcp.notdeclared!.echo!({})).toThrow(TypeError); + expect(Object.keys(mcp)).toEqual(['foo']); + expect(Object.keys(mcp.foo!)).toEqual(['echo', 'add']); + expect(mcp.constructor).toBeUndefined(); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe('MCP HTTP transport', () => { + let server: Server; + const sockets = new Set(); + let url: string; + const received: import('node:http').IncomingHttpHeaders[] = []; + let refuse = false; + let hang = false; + beforeAll(async () => { + server = createServer(async (req, res) => { + received.push(req.headers); + if (hang) return; + if (refuse) { res.writeHead(503).end(); return; } + if (req.method === 'GET') { res.writeHead(405).end(); return; } + const chunks = []; for await (const chunk of req) chunks.push(chunk); + const request = JSON.parse(Buffer.concat(chunks).toString()); + if (request.id === undefined) { res.writeHead(202).end(); return; } + const result = request.method === 'initialize' + ? { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'http', version: '1' } } + : request.method === 'tools/list' ? { tools: [{ name: 'echo', inputSchema: { type: 'object' } }] } + : { content: [{ type: 'text', text: JSON.stringify(request.params.arguments) }] }; + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); + }); + server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + url = `http://127.0.0.1:${(server.address() as import('node:net').AddressInfo).port}/`; + }); + afterAll(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); }); + it('initializes and calls HTTP with declared headers and closes all sockets', async () => { + const result = await preflight(spec, { mcpServers: ['foo'], mcp: { foo: { url, headers: { 'x-token': 'declared' } } }, probes }); + expect(result).toMatchObject({ ok: true, mcpTools: { foo: ['echo'] } }); + await vi.waitFor(() => expect(sockets.size).toBe(0)); + expect(received.every(h => h['x-token'] === 'declared' && h.authorization === undefined)).toBe(true); + const session = await openMcpSession({ url }, 1000); + try { expect(await session.callTool('echo', { foo: 1 })).toMatchObject({ content: [{ text: '{"foo":1}' }] }); } + finally { await session.close(); } + await vi.waitFor(() => expect(sockets.size).toBe(0)); + }); + it('aborts a stalled HTTP initialization at the parent deadline', async () => { + hang = true; + try { await expect(openMcpSession({ url }, 100)).rejects.toMatchObject({ code: 'handshake_timeout' }); } + finally { hang = false; } + await vi.waitFor(() => expect(sockets.size).toBe(0)); + }); + it('classifies non-2xx and closes its socket', async () => { + refuse = true; + try { await expect(openMcpSession({ url }, 1000)).rejects.toMatchObject({ code: 'handshake_rejected' }); } + finally { refuse = false; } + await vi.waitFor(() => expect(sockets.size).toBe(0)); + }); +}); + +describe('authored MCP effects against the real kernel', () => { + let daemon: ChildProcess; + let directory: string; + let journal: JournalClient; + beforeAll(async () => { + directory = mkdtempSync(join(tmpdir(), 'flows-mcp-daemon-')); + daemon = spawn(process.env.RELAYFLOWD_BIN ?? join(root, 'kernel/target/release/relayflowd'), ['--data-dir', directory, 'serve'], { stdio: 'ignore' }); + journal = new JournalClient(socketPathFor(directory)); + for (let n = 0; ; n++) { + try { await journal.connect(); await journal.hello('mcp-test'); break; } + catch (error) { if (n > 100) throw error; await delay(25); } + } + }); + afterAll(async () => { + journal?.close(); + if (daemon?.exitCode === null) { const exited = new Promise(resolve => daemon.once('exit', () => resolve())); daemon.kill(); await exited; } + rmSync(directory, { recursive: true, force: true }); + }); + it('journals one MCP receipt per call with args, result, stable logical key, and a confirmed effect', async () => { + const log = join(temp(), 'calls'); + const f = fixture({ foo: { command: process.execPath, args: [mock('ok'), '', log] }, hidden: config('refuse') }); + const handle = flow('mcp-ok', { tools: { mcp: ['foo'] } }, async f => { + expect(await f.mcp.foo!.echo!({ foo: 1 })).toMatchObject({ structuredContent: { foo: 1 } }); + const args = { foo: 2 }; + const call = f.mcp.foo!.echo!(args); + args.foo = 900; + await call; + f.done('success'); + }); + const result = await executeAuthoredFlow(handle, journal, undefined, { flowPath: f.path }); + expect(result.journalSteps).toHaveLength(3); + const calls = readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + expect(calls.filter(c => c.method === 'initialize')).toHaveLength(3); + expect(calls.filter(c => c.method === 'tools/list')).toHaveLength(1); + expect(calls.filter(c => c.method === 'tools/call')).toHaveLength(2); + for (const pid of new Set(calls.map(c => c.pid))) expect(() => process.kill(pid, 0)).toThrow(); + for (const [i, step] of result.journalSteps.slice(0, 2).entries()) { + const entries = (await journal.journalRead(step.runId, 1)).entries as any[]; + const completed = entries.filter(e => e.entry_type === 'step.completed'); + expect(completed).toHaveLength(1); + const args = { foo: i + 1 }; + expect(completed[0].payload).toMatchObject({ completionReason: 'success', output: { + type: 'mcp', input: args, output: { structuredContent: args }, + idempotencyKey: `mcp:foo:echo:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`, + } }); + expect(entries.filter(e => e.entry_type === 'effect.recorded')).toHaveLength(1); + expect(entries.filter(e => e.entry_type === 'effect.confirmed')).toHaveLength(1); + } + }); + it('reports a dropped tool connection as a failed CLI run', () => { + const f = fixture({ foo: config('drop') }); + const result = spawnSync(process.execPath, ['dist/cli-executable.js', 'run', f.path, + '--input', '{}', '--data-dir', directory, '--no-spawn', '--no-observer-link', '--json'], { encoding: 'utf8', timeout: 15000 }); + expect(result.status, result.stderr).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ status: 'failed', completionReason: 'step_failed', diagnostics: [ + { kind: 'step_failed', message: expect.stringContaining('mcp_disconnected') }, + ] }); + }); + it.each([['drop', 'echo', 'mcp_disconnected'], ['ok', 'absent', 'mcp_unknown_tool']])('journals %s/%s failure as worker_error with diagnostic %s', async (variant, tool, diagnostic) => { + const f = fixture({ foo: config(variant) }); + const handle = flow('mcp-failure', { tools: { mcp: ['foo'] } }, async f => { await f.mcp.foo![tool]!({}); f.done('success'); }); + let failure: any; + try { await executeAuthoredFlow(handle, journal, undefined, { flowPath: f.path }); } catch (error) { failure = error; } + expect(failure).toMatchObject({ code: 'step_failed', completionReason: 'worker_error', message: expect.stringContaining(diagnostic) }); + const entries = (await journal.journalRead(failure.runId, 1)).entries as any[]; + expect(entries.find(e => e.entry_type === 'step.completed').payload).toMatchObject({ completionReason: 'worker_error', trajectory_tail: { type: 'mcp', diagnostic } }); + expect(await journal.runGet(failure.runId)).toMatchObject({ status: 'failed' }); + }); +}); diff --git a/packages/sdk/tests/preflight.test.ts b/packages/sdk/tests/preflight.test.ts index 80666bb26..c01150543 100644 --- a/packages/sdk/tests/preflight.test.ts +++ b/packages/sdk/tests/preflight.test.ts @@ -359,10 +359,17 @@ describe('preflight: CLI resolution and refusal predicates', () => { // the compiler, since PreflightRefusal.kind is typed to the declared union // and `tsc --noEmit` runs as part of `npm test`. Recorded so the guarantee is // not read as coming from this test alone. - it('reaches every declared refusal kind, with the converse held by the type', () => { + it('reaches every declared refusal kind, with the converse held by the type', async () => { const scenarios = [ preflightHelpers({ header: { tools: { slack: true } }, body() {} }, { slackMount: false, slackMock: false }), preflightHelpers({ header: { tools: { slack: true } }, body() {} }, { slackToken: 'present', slackMount: false, slackMock: false }), + await preflight(flow({ id: 'a', type: 'deterministic', command: 'x' }), { + probes: probes(), mcpServers: ['absent'], + }), + await preflight(flow({ id: 'a', type: 'deterministic', command: 'x' }), { + probes: probes(), mcpServers: ['missing-binary'], + mcp: { 'missing-binary': { command: '/nonexistent-mcp-test-binary' } }, + }), preflight({ version: '0.1.0', steps: [{ id: 'a', type: 'deterministic', command: 'x', prompt: 'cross-verb' }], diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index bd25fcecb..2cbb0f004 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -11,6 +11,7 @@ "include": [ "src/**/*.ts", "tests/typed-output.test.ts", + "tests/mcp.test.ts", "tests/authored-flow-lifecycle-executor.test.ts", "tests/authored-flow-operation.test.ts", "tests/authored-flow.test.ts", diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index 69d3229a1..0d4d0f7a3 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -29,6 +29,7 @@ export interface LlmOptions { * context or execute a step, so all effects remain behind the journal client. */ export interface Ctx { + readonly mcp: Readonly Step>>>>; run(command: string): Step; llm(strings: TemplateStringsArray, ...values: unknown[]): Step; /** JSON Schema validates the value at runtime; narrow unknown in author code. */ diff --git a/testdata/mock-mcp-server/drop.mjs b/testdata/mock-mcp-server/drop.mjs new file mode 100644 index 000000000..003022da3 --- /dev/null +++ b/testdata/mock-mcp-server/drop.mjs @@ -0,0 +1,15 @@ +import { writeFileSync } from 'node:fs'; +if (process.argv[2]) writeFileSync(process.argv[2], JSON.stringify({ pid: process.pid })); +import { createInterface } from 'node:readline'; +createInterface({ input: process.stdin }).on('line', line => { + const request = JSON.parse(line); + if (request.id === undefined) return; + if (request.method === 'tools/call') { + process.stdout.end('{"jsonrpc":"2.0","id":'); + return; + } + const result = request.method === 'initialize' + ? { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'drop', version: '1.0' } } + : { tools: [{ name: 'echo', inputSchema: { type: 'object' } }] }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n'); +}); diff --git a/testdata/mock-mcp-server/ok.mjs b/testdata/mock-mcp-server/ok.mjs new file mode 100644 index 000000000..d1a1cc35e --- /dev/null +++ b/testdata/mock-mcp-server/ok.mjs @@ -0,0 +1,20 @@ +import { createInterface } from 'node:readline'; +import { appendFileSync, writeFileSync } from 'node:fs'; + +if (process.argv[2]) writeFileSync(process.argv[2], JSON.stringify({ pid: process.pid, env: process.env })); +const tools = ['echo', 'add'].map(name => ({ name, inputSchema: { type: 'object' } })); +createInterface({ input: process.stdin }).on('line', line => { + const request = JSON.parse(line); + if (process.argv[3]) appendFileSync(process.argv[3], JSON.stringify({ pid: process.pid, method: request.method }) + '\n'); + if (request.id === undefined) return; + let result; + if (request.method === 'initialize') result = { + protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'mock', version: '1.0' }, + }; + else if (request.method === 'tools/list') result = { tools }; + else if (request.method === 'tools/call') result = { + content: [{ type: 'text', text: JSON.stringify(request.params.arguments) }], + structuredContent: request.params.arguments, + }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n'); +}); diff --git a/testdata/mock-mcp-server/refuse.mjs b/testdata/mock-mcp-server/refuse.mjs new file mode 100644 index 000000000..80e80ae44 --- /dev/null +++ b/testdata/mock-mcp-server/refuse.mjs @@ -0,0 +1,3 @@ +import { writeFileSync } from 'node:fs'; +if (process.argv[2]) writeFileSync(process.argv[2], JSON.stringify({ pid: process.pid })); +process.exit(1); From 0a4d4ca3d6a9aa34a0a254b83c3523f9bce887b3 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 11 Sep 2026 10:24:27 +0200 Subject: [PATCH 2/6] docs: record approved MCP receipt representation Session-Id: 01a08f7d-c504-7510-8651-0e2ebb694546 Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- ops/reviews/20260911-spec-C-mcp.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ops/reviews/20260911-spec-C-mcp.md b/ops/reviews/20260911-spec-C-mcp.md index a93642064..196217e74 100644 --- a/ops/reviews/20260911-spec-C-mcp.md +++ b/ops/reviews/20260911-spec-C-mcp.md @@ -4,7 +4,7 @@ Implementation base: `9bd8c8095759958bab4fda2e096304256273811d`. No kernel files changed. GitHub authentication was unavailable during this run; no PR or CI result is claimed here. -## Contract interpretation awaiting confirmation +## MCP receipt representation RFC settled decision 13 keeps the kernel vocabulary closed. The MCP call is an existing agent effect step whose `step.completed.output` is a receipt with @@ -14,8 +14,9 @@ existing agent effect step whose `step.completed.output` is a receipt with `kernel/relayflowd/src/engine/effects.rs` rejects a different key. Failures complete with `worker_error`; the kernel drops unsuccessful output, so the diagnostic is retained in `trajectory_tail` instead. -These are explicit representation differences from the literal fields in #302; -they need acceptance or a separately scoped protocol change. +The lead approved this representation on 2026-09-11 (Relay message +224082187757658112): the MCP verb lowers to the closed kernel `agent` +primitive per the closed-vocabulary covenant. HTTP follows the official SDK's Streamable HTTP protocol: initialize via POST, then its optional GET/SSE stream. Inventory is obtained with `tools/list`. From 4c85d45e84e093656751e7f7c680bb7e6982a88f Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 11 Sep 2026 10:36:51 +0200 Subject: [PATCH 3/6] fix(sdk): bound MCP dispatch and terminate server process groups Session-Id: 01a08f7d-c504-7510-8651-0e2ebb694546 Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- ops/reviews/20260911-spec-C-mcp.md | 136 +++++++++++++++++++++++ packages/sdk/src/authored-mcp.ts | 14 ++- packages/sdk/src/mcp-stdio.ts | 19 ++-- packages/sdk/tests/mcp-lifecycle.test.ts | 71 ++++++++++++ packages/sdk/tests/mcp.test.ts | 30 +++++ packages/sdk/tsconfig.tests.json | 1 + 6 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 packages/sdk/tests/mcp-lifecycle.test.ts diff --git a/ops/reviews/20260911-spec-C-mcp.md b/ops/reviews/20260911-spec-C-mcp.md index 196217e74..a7d06b449 100644 --- a/ops/reviews/20260911-spec-C-mcp.md +++ b/ops/reviews/20260911-spec-C-mcp.md @@ -417,3 +417,139 @@ $ node /Users/khaliqgant/flows-spec-C-mcp/packages/sdk/dist/cli-executable.js ru RUN 01M27RPEQAS6AQP8DTSXNJJE8H completed (2 steps) completionReason: success exit: 0 ``` + +## PR #313 review fixes + +Addressed Cursor's High dispatch-deadline finding and Medium descendant-cleanup +finding. A valid dispatch clears the dispatch timer before opening the session; +a stalled run.start is raced against dispatch failure. Stdio owns a POSIX +process group and uses the existing childStop escalation, waiting through its +force deadline when a surviving descendant outlives the wrapper. + +Mutation verification: replaced only authored-mcp.ts and mcp-stdio.ts with their +f642da0 contents, ran the command below, then restored both fixed files +byte-for-byte and ran the identical command again. The three selected cases +failed with the previous implementations and passed after restoration. + +```sh +./node_modules/.bin/vitest run tests/mcp.test.ts tests/mcp-lifecycle.test.ts -t 'clears the dispatch|reaps a SIGTERM-resistant' +``` + +
Previous implementations: exit 1 + +```text + + RUN v2.1.9 /Users/khaliqgant/flows-spec-C-mcp/packages/sdk + + ❯ tests/mcp-lifecycle.test.ts (2 tests | 1 failed | 1 skipped) 7ms + × clears the dispatch deadline before a late valid dispatch starts its provider call 7ms + → expected "spy" to not be called at all, but actually been called 1 times + +Received: + + 1st spy call: + + Array [] + + +Number of calls: 1 + + ❯ tests/mcp.test.ts (30 tests | 2 failed | 28 skipped) 4126ms + × MCP preflight and transports > reaps a SIGTERM-resistant descendant with inherit stdio before cleanup finishes 2116ms + → expected [Function] to throw an error + × MCP preflight and transports > reaps a SIGTERM-resistant descendant with ignore stdio before cleanup finishes 2009ms + → expected [Function] to throw an error + + Test Files 2 failed (2) + Tests 3 failed | 29 skipped (32) + Start at 10:35:35 + Duration 4.54s (transform 151ms, setup 0ms, collect 367ms, tests 4.13s, environment 0ms, prepare 65ms) + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/mcp-lifecycle.test.ts > clears the dispatch deadline before a late valid dispatch starts its provider call +AssertionError: expected "spy" to not be called at all, but actually been called 1 times + +Received: + + 1st spy call: + + Array [] + + +Number of calls: 1 + + ❯ tests/mcp-lifecycle.test.ts:47:30 + 45| await vi.advanceTimersByTimeAsync(2_000); + 46| expect(callTool).toHaveBeenCalledTimes(1); + 47| expect(f.peer.close).not.toHaveBeenCalled(); + | ^ + 48| provider.resolve({ ok: true }); + 49| await expect(running).resolves.toEqual({ ok: true }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL tests/mcp.test.ts > MCP preflight and transports > reaps a SIGTERM-resistant descendant with inherit stdio before cleanup finishes + FAIL tests/mcp.test.ts > MCP preflight and transports > reaps a SIGTERM-resistant descendant with ignore stdio before cleanup finishes +AssertionError: expected [Function] to throw an error + ❯ gone tests/mcp.test.ts:40:38 + 38| function gone(marker: string) { + 39| const { pid } = JSON.parse(readFileSync(marker, 'utf8')); + 40| expect(() => process.kill(pid, 0)).toThrow(); + | ^ + 41| } + 42| afterEach(() => { vi.unstubAllEnvs(); for (const path of roots.splice(… + ❯ tests/mcp.test.ts:133:30 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + +``` +
+ +
Restored fixes: exit 0 + +```text + + RUN v2.1.9 /Users/khaliqgant/flows-spec-C-mcp/packages/sdk + + ✓ tests/mcp-lifecycle.test.ts (2 tests | 1 skipped) 4ms + ✓ tests/mcp.test.ts (30 tests | 28 skipped) 3230ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with inherit stdio before cleanup finishes 1165ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with ignore stdio before cleanup finishes 2064ms + + Test Files 2 passed (2) + Tests 3 passed | 29 skipped (32) + Start at 10:36:00 + Duration 3.62s (transform 143ms, setup 0ms, collect 353ms, tests 3.23s, environment 0ms, prepare 63ms) + +``` +
+ +Typecheck, build, and test typecheck also exited 0. Focused suite command: + +```sh +./node_modules/.bin/vitest run tests/mcp.test.ts tests/mcp-lifecycle.test.ts tests/worker-cli-abort.test.ts +``` + +Captured output, exit 0: + +```text + + RUN v2.1.9 /Users/khaliqgant/flows-spec-C-mcp/packages/sdk + + ✓ tests/mcp-lifecycle.test.ts (2 tests) 5ms + ✓ tests/worker-cli-abort.test.ts (2 tests) 2668ms + ✓ stops claude and its process group when lease ownership is lost 1407ms + ✓ stops wrapper.mjs and its process group when lease ownership is lost 1260ms + ✓ tests/mcp.test.ts (30 tests) 6432ms + ✓ MCP preflight and transports > kills a SIGTERM-resistant silent child after a parent-owned handshake deadline 1307ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with inherit stdio before cleanup finishes 1167ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with ignore stdio before cleanup finishes 2061ms + ✓ authored MCP effects against the real kernel > reports a dropped tool connection as a failed CLI run 320ms + + Test Files 3 passed (3) + Tests 34 passed (34) + Start at 10:36:05 + Duration 6.82s (transform 154ms, setup 0ms, collect 413ms, tests 9.11s, environment 0ms, prepare 98ms) + +``` diff --git a/packages/sdk/src/authored-mcp.ts b/packages/sdk/src/authored-mcp.ts index 988bd6f8d..06aa0f559 100644 --- a/packages/sdk/src/authored-mcp.ts +++ b/packages/sdk/src/authored-mcp.ts @@ -62,7 +62,9 @@ export async function runMcpEffect( void completed.catch(() => undefined); let timer: ReturnType | undefined; let work: Promise | undefined; + let dispatchExpired = false; const dispatch = (event: StepDispatchEvent): void => { + if (dispatchExpired) return; const dispatched = event.spec as { instruction?: string; surfaces?: { streams?: { stream: string }[] } }; if (event.step_id !== id || event.step_type !== 'agent' || work !== undefined || dispatched?.instruction !== instruction @@ -70,6 +72,8 @@ export async function runMcpEffect( failed(new Error('MCP worker received an unexpected dispatch')); return; } + clearTimeout(timer); + timer = undefined; work = execute(event); void work.then(settled, failed); }; @@ -116,8 +120,14 @@ export async function runMcpEffect( steps: [{ id, type: 'agent', instruction, surfaces: { streams: [{ stream }], external: [surfacePath] }, maxIterations: 1 }], })); - timer = setTimeout(() => failed(new Error('MCP worker dispatch deadline exceeded')), 30_000); - const outcome = await journal.runStart(spec); + timer = setTimeout(() => { + dispatchExpired = true; + failed(new Error('MCP worker dispatch deadline exceeded')); + }, 30_000); + // Propagate a dispatch failure even if run.start itself is still pending. + const starting = journal.runStart(spec); + await Promise.race([starting, completed]); + const outcome = await starting; await completed; if (diagnostic !== undefined) { try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); } diff --git a/packages/sdk/src/mcp-stdio.ts b/packages/sdk/src/mcp-stdio.ts index abba4a00f..6b77e9a7c 100644 --- a/packages/sdk/src/mcp-stdio.ts +++ b/packages/sdk/src/mcp-stdio.ts @@ -1,3 +1,4 @@ +import { childStop, type ChildStop } from './child-stop.js'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; @@ -11,6 +12,7 @@ export class McpStdioTransport implements Transport { onerror?: Transport['onerror']; onmessage?: Transport['onmessage']; private child?: ChildProcessWithoutNullStreams; + private stopTree?: ChildStop; private readonly buffer = new ReadBuffer({ maxBufferSize: 1_048_576 }); private closed?: Promise; private closing?: Promise; @@ -22,8 +24,9 @@ export class McpStdioTransport implements Transport { if (process.env[name] !== undefined) env[name] = process.env[name]; } const child = this.child = spawn(this.config.command, this.config.args ?? [], { - env, stdio: ['pipe', 'pipe', 'pipe'], + env, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32', }); + this.stopTree = childStop(child, process.platform !== 'win32'); this.closed = new Promise(resolve => child.once('close', () => resolve())); child.stderr.resume(); child.stdout.on('data', (chunk: Buffer) => { @@ -59,17 +62,17 @@ export class McpStdioTransport implements Transport { const child = this.child; if (!child) return; child.stdin.end(); - child.kill('SIGTERM'); - // Drain pipes until close or our own deadline; a descendant may keep a - // pipe open even after the direct child has exited. + this.stopTree!.terminate(); + // A direct-child close is insufficient: wrappers can leave descendants + // alive with either inherited pipes or completely detached stdio. let timer: ReturnType | undefined; - await Promise.race([this.closed, new Promise(resolve => { - timer = setTimeout(resolve, 1000); - })]); + const deadline = new Promise(resolve => { timer = setTimeout(resolve, 1000); }); + await Promise.race([this.closed, deadline]); + if (!this.stopTree!.maySettleOnChildExit()) await deadline; clearTimeout(timer); if (child.exitCode === null && child.signalCode === null && child.pid !== undefined) { const exited = new Promise(resolve => child.once('exit', () => resolve())); - child.kill('SIGKILL'); + this.stopTree!.kill(); await exited; } child.stdin.destroy(); diff --git a/packages/sdk/tests/mcp-lifecycle.test.ts b/packages/sdk/tests/mcp-lifecycle.test.ts new file mode 100644 index 000000000..ca8ea2250 --- /dev/null +++ b/packages/sdk/tests/mcp-lifecycle.test.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; +import { runMcpEffect } from '../src/authored-mcp.js'; +import { openMcpSession } from '../src/mcp-client.js'; +import type { JournalClient } from '../src/journal-client.js'; + +vi.mock('../src/mcp-client.js', async importOriginal => ({ + ...await importOriginal(), openMcpSession: vi.fn(), +})); +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.clearAllMocks(); }); + +function fixture() { + const peer = Object.assign(new EventEmitter(), { + connect: vi.fn(async () => {}), hello: vi.fn(async () => {}), workerAttach: vi.fn(async () => {}), + close: vi.fn(), + performEffect: vi.fn(async (_effect, perform: () => Promise) => { await perform(); return true; }), + stepComplete: vi.fn(async () => {}), + }); + const started = Promise.withResolvers(); + const journal = { + createPeer: () => peer, + runStart: vi.fn(async (spec: any) => { started.resolve(spec); return { run_id: 'mcp-run' }; }), + journalRead: vi.fn(async () => ({ entries: [{ entry_type: 'step.completed', step_id: 'mcp-1', + payload: { completionReason: 'success', output: { type: 'mcp', output: { ok: true } } } }] })), + }; + const run = () => runMcpEffect(journal as unknown as JournalClient, 'test', 'mcp-1', 'foo', 'echo', {}, true, { command: 'unused' }, []); + return { peer, journal, started, run }; +} + +it('clears the dispatch deadline before a late valid dispatch starts its provider call', async () => { + vi.useFakeTimers(); + const provider = Promise.withResolvers(); + const callTool = vi.fn(() => provider.promise); + const close = vi.fn(async () => {}); + vi.mocked(openMcpSession).mockResolvedValue({ callTool, close, listTools: vi.fn() }); + const f = fixture(); + const running = f.run(); + const spec = await f.started.promise; + await vi.advanceTimersByTimeAsync(29_000); + f.peer.emit('step.dispatch', { + run_id: 'mcp-run', step_id: 'mcp-1', step_type: 'agent', spec: spec.steps[0], + attempt: 1, idempotency_key: 'kernel-key', pins: {}, + }); + try { + await vi.advanceTimersByTimeAsync(2_000); + expect(callTool).toHaveBeenCalledTimes(1); + expect(f.peer.close).not.toHaveBeenCalled(); + provider.resolve({ ok: true }); + await expect(running).resolves.toEqual({ ok: true }); + expect(f.peer.stepComplete).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + } finally { + provider.resolve({ ok: true }); + await running.catch(() => undefined); + } +}); + +it('bounds dispatch even when run.start never answers and performs no provider effect', async () => { + vi.useFakeTimers(); + const f = fixture(); + f.journal.runStart.mockImplementation(async (spec: any) => { + f.started.resolve(spec); return new Promise(() => {}); + }); + const running = f.run(); + const rejected = expect(running).rejects.toThrow('dispatch deadline exceeded'); + await f.started.promise; + await vi.advanceTimersByTimeAsync(30_001); + await rejected; + expect(f.peer.close).toHaveBeenCalledTimes(1); + expect(openMcpSession).not.toHaveBeenCalled(); +}); diff --git a/packages/sdk/tests/mcp.test.ts b/packages/sdk/tests/mcp.test.ts index 6809b3952..7ee780d3e 100644 --- a/packages/sdk/tests/mcp.test.ts +++ b/packages/sdk/tests/mcp.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSy import { createServer, type Server } from 'node:http'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { setTimeout as delay } from 'node:timers/promises'; import { flow } from '@relayflows/surface'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -108,6 +109,35 @@ describe('MCP preflight and transports', () => { await expect(openMcpSession({ command: process.execPath, args: ['-e', source] }, 300)).rejects.toMatchObject({ code: 'handshake_timeout' }); gone(marker); }); + it.skipIf(process.platform === 'win32').each(['inherit', 'ignore'] as const)('reaps a SIGTERM-resistant descendant with %s stdio before cleanup finishes', async stdio => { + const directory = temp(); + const parent = join(directory, 'parent'); + const child = join(directory, 'child'); + const wrapper = join(directory, 'wrapper.mjs'); + const source = `process.on('SIGTERM',()=>{}); require('node:fs').writeFileSync(${JSON.stringify(child)},JSON.stringify({pid:process.pid})); setInterval(()=>{},100);`; + writeFileSync(wrapper, `import {spawn} from 'node:child_process'; +import {writeFileSync,existsSync} from 'node:fs'; +writeFileSync(${JSON.stringify(parent)},JSON.stringify({pid:process.pid})); +spawn(process.execPath,['-e',${JSON.stringify(source)}],{stdio:${JSON.stringify(stdio)}}); +while(!existsSync(${JSON.stringify(child)})) await new Promise(r=>setTimeout(r,10)); +${stdio === 'inherit' ? `await import(${JSON.stringify(pathToFileURL(mock('ok')).href)});` : 'setInterval(()=>{},100);'} +`); + try { + if (stdio === 'inherit') { + const session = await openMcpSession({ command: process.execPath, args: [wrapper] }, 1000); + await session.close(); + } else { + await expect(openMcpSession({ command: process.execPath, args: [wrapper] }, 1000)).rejects.toMatchObject({ code: 'handshake_timeout' }); + } + gone(parent); + await vi.waitFor(() => gone(child)); + } finally { + for (const marker of [parent, child]) { + if (!existsSync(marker)) continue; + try { process.kill(JSON.parse(readFileSync(marker, 'utf8')).pid, 'SIGKILL'); } catch {} + } + } + }); it('classifies a mid-call stdout drop without retry and closes the child', async () => { const marker = join(temp(), 'pid'); const session = await openMcpSession(config('drop', marker), 1000); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 2cbb0f004..9293212bf 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -12,6 +12,7 @@ "src/**/*.ts", "tests/typed-output.test.ts", "tests/mcp.test.ts", + "tests/mcp-lifecycle.test.ts", "tests/authored-flow-lifecycle-executor.test.ts", "tests/authored-flow-operation.test.ts", "tests/authored-flow.test.ts", From cdf85c1c6e665556c6247030fd98f351a422ca32 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 11 Sep 2026 11:57:09 +0200 Subject: [PATCH 4/6] chore(sdk): add @modelcontextprotocol/sdk dep after rebase Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/package-lock.json | 1126 +++++++++++++++++++++++++++++++- packages/sdk/package.json | 1 + 2 files changed, 1122 insertions(+), 5 deletions(-) diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index 1a6e52500..5a48c963d 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.8", "license": "Apache-2.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "@relayfile/adapter-core": "0.5.24", "@relayfile/relay-helpers": "0.4.11", "@relayflows/surface": "2.0.8", @@ -418,6 +419,18 @@ "node": ">=12" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -438,6 +451,46 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1130,6 +1183,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -1160,6 +1226,23 @@ } } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1185,6 +1268,59 @@ "node": "18 || 20 || >=22" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1203,6 +1339,15 @@ "node": "20 || >=22" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1213,6 +1358,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1292,6 +1466,77 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -1324,7 +1569,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1348,6 +1592,15 @@ "node": ">=6" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -1403,6 +1656,35 @@ "url": "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/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -1428,6 +1710,24 @@ "url": "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/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1435,6 +1735,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1474,6 +1786,12 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1484,6 +1802,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1494,6 +1842,68 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "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/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1516,6 +1926,45 @@ ], "license": "BSD-3-Clause" }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1531,6 +1980,97 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -1562,9 +2102,29 @@ "url": "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/fb55/entities?sponsor=1" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { @@ -1584,6 +2144,51 @@ "node": ">= 4" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "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/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", @@ -1612,6 +2217,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1629,6 +2240,65 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "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/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -1671,7 +2341,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1693,6 +2362,35 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -1705,6 +2403,48 @@ "url": "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/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -1754,6 +2494,34 @@ "url": "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/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1778,6 +2546,15 @@ "dev": true, "license": "ISC" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -1807,6 +2584,79 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1862,12 +2712,172 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1892,6 +2902,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -1960,6 +2979,46 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1990,6 +3049,24 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -2161,6 +3238,21 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2178,6 +3270,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -2202,6 +3300,24 @@ "funding": { "url": "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/sponsors/eemeli" } + }, + "node_modules/zod": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", + "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", + "license": "MIT", + "funding": { + "url": "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/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 4d4759075..e7a8e0b8b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -42,6 +42,7 @@ }, "license": "Apache-2.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "@relayfile/adapter-core": "0.5.24", "@relayfile/relay-helpers": "0.4.11", "@relayflows/surface": "2.0.8", From c1c808bbbfa445c63f4425feee8a50ce27efb8ee Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 11 Sep 2026 12:20:31 +0200 Subject: [PATCH 5/6] fix(cli): checkAuthoredFlowComposed matches broader TS/JS regex The composed helper-body + trigger check should run against the same file extensions the pre-rebase helper-body check accepted (any .ts / .tsx / .mts / .mjs / .js). isAuthoredFlowPath only matches paths with the .flow. prefix, so a plain slack.mjs fixture was mis-routed to the YAML checkFlow path and refused with a spurious 'spec: expected an object' before the slack credential preflight could run. Fixes tests/authored-flow-slack.test.ts:106 refusing at the wrong layer. Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 7c22f4994..b51382628 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -103,7 +103,7 @@ export async function runCli( // refuses `--data-dir` on `check`, so there is no data dir to attach to. // `flows check` keeps working with no daemon, no relayflowd binary and no // data directory at all -- a property worth keeping, not an omission. - const checked = isAuthoredFlowPath(parsed.value) + const checked = /\.(?:[cm]?[jt]s)$/.test(parsed.value) ? await checkAuthoredFlowComposed(parsed.value) : checkFlow(parsed.value); emitCheckReport(checked.report, parsed.json, io); return checked.report.ok ? 0 : 2; From e3043b4e8b3347ee73e0f7090f05aea80a89f90c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 11 Sep 2026 12:42:43 +0200 Subject: [PATCH 6/6] fix(mcp): renew worker lease during MCP tool calls; cancel orphaned child run on deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Bugbot findings on PR#313: HIGH — MCP worker skipped lease renewal. The perform-effect body ran the MCP session's callTool without wrapping in withWorkerLease, so any tool that took longer than the initial lease-deadline lost ownership and the write-back failed. Wrap the perform-effect body in withWorkerLease so the existing renewal path (see worker-lease.ts) fires while the tool is in flight. MED — Nested MCP run left indeterminate when the dispatch deadline fires. When run.start returned a run_id but no dispatch arrived in 30s, the child run was orphaned. Capture the run_id and, on dispatch-expiry, call journal.runCancel best-effort in the finally block. Cancel errors do not mask the deadline diagnostic the parent flow already sees. Adds two regression tests: - Cancel-on-deadline: run.start resolves, dispatch never fires, journal.runCancel called with the child run_id. - Lease-renewed-under-load: dispatch delivered with a 30s lease, callTool hangs 45s, stepHeartbeat fires at least twice (initial + one renewal). Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/src/authored-mcp.ts | 37 ++++++++++++------ packages/sdk/tests/mcp-lifecycle.test.ts | 50 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/authored-mcp.ts b/packages/sdk/src/authored-mcp.ts index 06aa0f559..9a2081267 100644 --- a/packages/sdk/src/authored-mcp.ts +++ b/packages/sdk/src/authored-mcp.ts @@ -10,6 +10,7 @@ import type { StepDispatchEvent } from './protocol.js'; import { AuthoredFlowExecutionError } from './authored-flow-error.js'; import type { AuthoredFlowJournalStep } from './authored-flow-executor.js'; import { readCompletedStepOutput } from './authored-step-output.js'; +import { withWorkerLease } from './worker-lease.js'; /** Own keys expose precisely the preflight inventory, including prototype-like names. */ export function buildMcpProxy( @@ -83,17 +84,22 @@ export async function runMcpEffect( let confirmed = false; try { if (!known) throw new Error('mcp_unknown_tool'); - confirmed = await peer.performEffect({ - runId: event.run_id, stepId: id, attempt: event.attempt, idempotencyKey: event.idempotency_key, - surfacePath, revisionBefore: 'pending', revisionAfter: idempotencyKey, - }, async () => { - const session = await openMcpSession(config, 10_000); - try { - output = await session.callTool(tool, args); - if (typeof output === 'object' && output !== null && 'isError' in output && output.isError === true) { - throw new Error('mcp_tool_error'); - } - } finally { await session.close(); } + // Renew the worker lease while the MCP tool is in flight. Without this, + // a tool that runs longer than the initial lease loses ownership and + // the write-back path fails — see worker-lease.ts for the renewal contract. + confirmed = await withWorkerLease(peer, event, async () => { + return peer.performEffect({ + runId: event.run_id, stepId: id, attempt: event.attempt, idempotencyKey: event.idempotency_key, + surfacePath, revisionBefore: 'pending', revisionAfter: idempotencyKey, + }, async () => { + const session = await openMcpSession(config, 10_000); + try { + output = await session.callTool(tool, args); + if (typeof output === 'object' && output !== null && 'isError' in output && output.isError === true) { + throw new Error('mcp_tool_error'); + } + } finally { await session.close(); } + }); }); // A confirmed election without its receipt is an interrupted writeback, // not a successful result we may invent or a call we may safely repeat. @@ -111,6 +117,7 @@ export async function runMcpEffect( effects: confirmed ? [{ surface_path: surfacePath, idempotency_key: event.idempotency_key }] : [], }); } + let childRunId: string | undefined; try { await peer.connect(); await peer.hello('flows-mcp'); @@ -128,6 +135,7 @@ export async function runMcpEffect( const starting = journal.runStart(spec); await Promise.race([starting, completed]); const outcome = await starting; + childRunId = outcome.run_id; await completed; if (diagnostic !== undefined) { try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); } @@ -141,6 +149,13 @@ export async function runMcpEffect( } finally { clearTimeout(timer); peer.off('step.dispatch', dispatch); + // If the dispatch deadline fired we started a child run that will never + // be worked -- cancel it so its journal doesn't stay indeterminate. + // Best-effort: the parent flow already surfaced the deadline via the + // rejected `completed` promise, so a cancel error here must not mask it. + if (dispatchExpired && childRunId !== undefined) { + try { await journal.runCancel(childRunId); } catch { /* fail-open on cleanup */ } + } peer.close(); await work?.catch(() => undefined); } diff --git a/packages/sdk/tests/mcp-lifecycle.test.ts b/packages/sdk/tests/mcp-lifecycle.test.ts index ca8ea2250..f85f5d4f0 100644 --- a/packages/sdk/tests/mcp-lifecycle.test.ts +++ b/packages/sdk/tests/mcp-lifecycle.test.ts @@ -15,11 +15,15 @@ function fixture() { close: vi.fn(), performEffect: vi.fn(async (_effect, perform: () => Promise) => { await perform(); return true; }), stepComplete: vi.fn(async () => {}), + // withWorkerLease renews the lease periodically; give a moving deadline far + // enough ahead that the fake-timer tests never trip it on their own. + stepHeartbeat: vi.fn(async () => ({ lease_deadline_ms: Date.now() + 60_000 })), }); const started = Promise.withResolvers(); const journal = { createPeer: () => peer, runStart: vi.fn(async (spec: any) => { started.resolve(spec); return { run_id: 'mcp-run' }; }), + runCancel: vi.fn(async () => ({ run_id: 'mcp-run', state: 'canceled' as const })), journalRead: vi.fn(async () => ({ entries: [{ entry_type: 'step.completed', step_id: 'mcp-1', payload: { completionReason: 'success', output: { type: 'mcp', output: { ok: true } } } }] })), }; @@ -40,6 +44,7 @@ it('clears the dispatch deadline before a late valid dispatch starts its provide f.peer.emit('step.dispatch', { run_id: 'mcp-run', step_id: 'mcp-1', step_type: 'agent', spec: spec.steps[0], attempt: 1, idempotency_key: 'kernel-key', pins: {}, + lease_deadline_ms: Date.now() + 45_000, lease_id: 'lease-mcp-1', }); try { await vi.advanceTimersByTimeAsync(2_000); @@ -69,3 +74,48 @@ it('bounds dispatch even when run.start never answers and performs no provider e expect(f.peer.close).toHaveBeenCalledTimes(1); expect(openMcpSession).not.toHaveBeenCalled(); }); + +it('cancels the orphaned child run when the dispatch deadline fires after run.start resolved', async () => { + vi.useFakeTimers(); + const f = fixture(); + const running = f.run(); + const rejected = expect(running).rejects.toThrow('dispatch deadline exceeded'); + await f.started.promise; // run.start already returned run_id 'mcp-run' + await vi.advanceTimersByTimeAsync(30_001); // dispatch never arrives -> deadline fires + await rejected; + expect(f.journal.runCancel).toHaveBeenCalledTimes(1); + expect(f.journal.runCancel).toHaveBeenCalledWith('mcp-run'); + expect(f.peer.close).toHaveBeenCalledTimes(1); +}); + +it('renews the worker lease while a long-running MCP tool is in flight', async () => { + vi.useFakeTimers(); + const provider = Promise.withResolvers(); + const callTool = vi.fn(() => provider.promise); + const close = vi.fn(async () => {}); + vi.mocked(openMcpSession).mockResolvedValue({ callTool, close, listTools: vi.fn() }); + const f = fixture(); + const running = f.run(); + const spec = await f.started.promise; + // Deliver dispatch with a 30s lease so renewal must fire at least once for a 90s tool call. + f.peer.emit('step.dispatch', { + run_id: 'mcp-run', step_id: 'mcp-1', step_type: 'agent', spec: spec.steps[0], + attempt: 1, idempotency_key: 'kernel-key', pins: {}, + lease_deadline_ms: Date.now() + 30_000, lease_id: 'lease-mcp-1', + }); + try { + // The initial armExpiry + first renew fires immediately in withWorkerLease. + // withWorkerLease schedules the next renewal at ~1/3 of the remaining deadline. + // Let one full round pass, then confirm heartbeat was invoked at least once + // beyond the initial establishment renew. + await vi.advanceTimersByTimeAsync(45_000); + expect(f.peer.stepHeartbeat).toHaveBeenCalled(); + // Multiple heartbeats within 45s proves lease is being actively renewed. + expect(f.peer.stepHeartbeat.mock.calls.length).toBeGreaterThanOrEqual(2); + provider.resolve({ ok: true }); + await expect(running).resolves.toEqual({ ok: true }); + } finally { + provider.resolve({ ok: true }); + await running.catch(() => undefined); + } +});