From 7df45f0ab78b9542a1cea2de8e85aa524630250d Mon Sep 17 00:00:00 2001 From: Zachary Lyon Date: Mon, 3 Aug 2026 11:10:20 -0700 Subject: [PATCH 1/4] Add @tiny-fish/mcp: local MCP proxy to agent.tinyfish.ai/mcp Co-Authored-By: Claude Fable 5 --- .editorconfig | 9 + .github/workflows/ci.yml | 68 + .github/workflows/release.yml | 50 + .gitignore | 7 + .prettierrc | 7 + CHANGELOG.md | 39 + LICENSE | 21 + README.md | 233 +- eslint.config.js | 32 + package-lock.json | 4099 +++++++++++++++++++++++++++++++ package.json | 65 + src/config.ts | 80 + src/core/errors.ts | 256 ++ src/core/proxy-core.ts | 408 +++ src/core/session.ts | 109 + src/core/sse.ts | 137 ++ src/core/upstream.ts | 246 ++ src/http/adapter.ts | 353 +++ src/http/index.ts | 112 + src/http/origin.ts | 25 + src/index.ts | 70 + src/log.ts | 22 + src/shutdown.ts | 2 + src/version.ts | 9 + tests/adapter.test.ts | 368 +++ tests/config.test.ts | 119 + tests/errors.test.ts | 480 ++++ tests/helpers/http.ts | 98 + tests/helpers/mock-upstream.ts | 520 ++++ tests/origin.test.ts | 46 + tests/proxy.integration.test.ts | 231 ++ tests/relay.test.ts | 422 ++++ tests/session.test.ts | 648 +++++ tests/sse.test.ts | 179 ++ tests/upstream.test.ts | 244 ++ tsconfig.all.json | 8 + tsconfig.json | 14 + vitest.config.ts | 7 + vitest.integration.config.ts | 12 + 39 files changed, 9854 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/config.ts create mode 100644 src/core/errors.ts create mode 100644 src/core/proxy-core.ts create mode 100644 src/core/session.ts create mode 100644 src/core/sse.ts create mode 100644 src/core/upstream.ts create mode 100644 src/http/adapter.ts create mode 100644 src/http/index.ts create mode 100644 src/http/origin.ts create mode 100644 src/index.ts create mode 100644 src/log.ts create mode 100644 src/shutdown.ts create mode 100644 src/version.ts create mode 100644 tests/adapter.test.ts create mode 100644 tests/config.test.ts create mode 100644 tests/errors.test.ts create mode 100644 tests/helpers/http.ts create mode 100644 tests/helpers/mock-upstream.ts create mode 100644 tests/origin.test.ts create mode 100644 tests/proxy.integration.test.ts create mode 100644 tests/relay.test.ts create mode 100644 tests/session.test.ts create mode 100644 tests/sse.test.ts create mode 100644 tests/upstream.test.ts create mode 100644 tsconfig.all.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 vitest.integration.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..86a63dc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9531a69 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +--- +name: CI +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint + - name: Type-check + run: npm run type-check + - name: Build + run: npm run build + - name: Unit tests + run: npm test + + # Gated integration leg: runs the real-upstream suite only when the + # TINYFISH_API_KEY secret exists. GitHub does not allow `secrets.*` in a + # job-level `if:`, so the documented pattern is used instead — export the + # secret into the job env and branch on it inside a step. Fork PRs never + # receive secrets (the job-level `if:` also skips them outright), and the + # test suite itself skips with a notice when the key is empty, so this job + # is green-but-inert until the secret is configured. + integration: + runs-on: ubuntu-latest + needs: ci + if: github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository + env: + TINYFISH_API_KEY: ${{ secrets.TINYFISH_API_KEY }} + steps: + - name: Detect API key secret + id: key + run: | + if [ -n "$TINYFISH_API_KEY" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "TINYFISH_API_KEY secret not configured - skipping integration tests" + fi + - name: Checkout code + if: steps.key.outputs.present == 'true' + uses: actions/checkout@v4 + - name: Setup Node.js + if: steps.key.outputs.present == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install dependencies + if: steps.key.outputs.present == 'true' + run: npm ci + - name: Integration tests (real hosted upstream) + if: steps.key.outputs.present == 'true' + run: npm run test:integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..de79479 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +--- +name: Release +# Publish @tiny-fish/mcp to npm when a v* tag is pushed (e.g. v0.1.0). +# The tag itself is created by a human per docs/phases/release-runbook.md +# (npm version + git push --follow-tags). Requires the NPM_TOKEN secret +# (npm automation token for the @tiny-fish org) and id-token permission +# for --provenance attestation. +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + # Required for npm --provenance (Sigstore attestation via OIDC). + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + registry-url: https://registry.npmjs.org + - name: Install dependencies + run: npm ci + - name: Assert tag matches package.json version + # A mistyped tag (v0.2.0 on a 0.1.0 tree) must fail before publish. + run: | + pkg_version="$(node -p "require('./package.json').version")" + if [ "${GITHUB_REF_NAME}" != "v${pkg_version}" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match package.json version ${pkg_version}" >&2 + exit 1 + fi + - name: Lint + run: npm run lint + - name: Type-check + run: npm run type-check + - name: Build + run: npm run build + - name: Unit tests + run: npm test + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fba404 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ + +docs/ +.DS_Store +/node_modules +dist/ +.env +coverage/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a4a2d60 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..05b9338 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-08-03 + +### Added + +- Initial release of `@tiny-fish/mcp`: a local Streamable-HTTP MCP server at + `http://127.0.0.1:3711/mcp` that transparently reverse-proxies the hosted + TinyFish MCP server (`https://agent.tinyfish.ai/mcp`). +- API-key auth: reads `TINYFISH_API_KEY` from the environment and sends it + upstream as `X-API-Key`, with `X-TF-Request-Origin` / `X-TF-Client-Name` / + `X-TF-Client-Version` attribution headers on every call. +- Transparent pass-through of `initialize`, `ping`, `tools/list`, + `tools/call`, `resources/list`, and `resources/read`, including verbatim + forwarding of upstream JSON-RPC errors and byte-verbatim relay of the + `run_web_automation` SSE progress stream. +- Session bridging: one upstream `Mcp-Session-Id` per local session; local + teardown aborts in-flight upstream requests (upstream has no DELETE). +- Security guardrails: loopback-only bind (`127.0.0.1`, not configurable), + Origin-header allowlist (403 otherwise), and the API key never logged or + echoed. +- Configuration via `PORT` (default `3711`) and `TINYFISH_UPSTREAM_URL` + (default hosted; `http:` allowed only for loopback hosts). +- Locally shaped errors for upstream-leg failures: `-32001` (auth rejected, + HTTP 502), `-32000` (unreachable / stream failed, HTTP 502), with recovery + guidance and `runId` on mid-stream failures. +- `tinyfish-mcp` bin, Node >= 22, ESM, published files limited to `dist/`, + `README.md`, `LICENSE`. + +[Unreleased]: https://github.com/tinyfish-io/tinyfish-mcp-server/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/tinyfish-io/tinyfish-mcp-server/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cede851 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TinyFish + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index adfe7d8..7b0fb7f 100644 --- a/README.md +++ b/README.md @@ -1 +1,232 @@ -# empty-repo +# @tiny-fish/mcp + +TinyFish local MCP server — a transparent reverse proxy that exposes a local +Streamable-HTTP MCP endpoint at `http://127.0.0.1:3711/mcp` and forwards every +request to the hosted TinyFish MCP server at `https://agent.tinyfish.ai/mcp`. + +The proxy defines no tools and no schemas of its own. `tools/list`, +`tools/call`, `resources/*`, errors, and the `run_web_automation` SSE progress +stream all come from the hosted server: streaming (SSE) responses are relayed +byte-verbatim, and non-streaming JSON responses are relayed content-identical +(parsed and re-serialized, deep-equal to upstream). What the hosted server +says is what your client sees. + +## Use the hosted server first + +If your MCP client supports remote Streamable-HTTP servers (Claude Code, +Claude Desktop connectors, Cursor, VS Code, and most modern clients do), +connect it **directly** to the hosted server — no install, no local process: + +``` +https://agent.tinyfish.ai/mcp +``` + +Use this package instead when: + +- your client only talks to local MCP servers, or +- you want to authenticate with a **TinyFish API key** from your environment + instead of the hosted server's OAuth flow (CI, headless machines, scripts). + +## Install + +```sh +npm install -g @tiny-fish/mcp +``` + +Or run it without installing: + +```sh +npx @tiny-fish/mcp +``` + +Requires **Node.js >= 22**. (This is a deliberate deviation from the TinyFish +CLI's `>=24` requirement — nothing here needs Node 24.) + +## API key + +The server reads `TINYFISH_API_KEY` from its environment at startup and sends +it upstream as `X-API-Key` on every call. Get a key at +[https://agent.tinyfish.ai](https://agent.tinyfish.ai). + +```sh +export TINYFISH_API_KEY=tf_... +tinyfish-mcp +``` + +On success it prints one line to stderr: + +``` +tinyfish-mcp [info] listening on http://127.0.0.1:3711 — upstream https://agent.tinyfish.ai/mcp — v0.1.0 +``` + +The key is never logged and never echoed back to clients. + +## Client configuration + +Start `tinyfish-mcp` (e.g. in a terminal, or under your process manager of +choice), then point your client at `http://127.0.0.1:3711/mcp`. + +### Claude Code + +```sh +claude mcp add --transport http tinyfish http://127.0.0.1:3711/mcp +``` + +### Claude Desktop + +Settings → Connectors → Add custom connector, with URL +`http://127.0.0.1:3711/mcp`. On versions whose +`claude_desktop_config.json` supports URL-based servers: + +```json +{ + "mcpServers": { + "tinyfish": { + "url": "http://127.0.0.1:3711/mcp" + } + } +} +``` + +Note: Claude Desktop can also use the hosted `https://agent.tinyfish.ai/mcp` +directly as a custom connector — prefer that unless you need API-key auth. + +### Cursor + +`~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project): + +```json +{ + "mcpServers": { + "tinyfish": { + "url": "http://127.0.0.1:3711/mcp" + } + } +} +``` + +### VS Code + +`.vscode/mcp.json`: + +```json +{ + "servers": { + "tinyfish": { + "type": "http", + "url": "http://127.0.0.1:3711/mcp" + } + } +} +``` + +### Any other client + +Configure a Streamable-HTTP (remote/URL) MCP server with: + +```json +{ "url": "http://127.0.0.1:3711/mcp" } +``` + +The endpoint accepts `POST /mcp` only (matching the hosted server, which has +no GET SSE channel and no DELETE session teardown). `GET /healthz` returns +`200 ok` for debugging. + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `TINYFISH_API_KEY` | (required) | TinyFish API key, sent upstream as `X-API-Key`. The server refuses to start without it. | +| `PORT` | `3711` | Local listen port (integer 1-65535). No auto-increment: if the port is busy the server exits with an error. | +| `TINYFISH_UPSTREAM_URL` | `https://agent.tinyfish.ai/mcp` | Upstream MCP URL. Must be `https:`; `http:` is allowed only for `127.0.0.1`/`localhost` (local testing). | + +The proxy also sends attribution headers on every upstream call: +`X-TF-Request-Origin: tinyfish-mcp`, `X-TF-Client-Name: tinyfish-mcp`, and +`X-TF-Client-Version: `. + +## Security & trust model + +This is a loopback-only server with a deliberate, documented trust boundary: + +- **Loopback bind, always.** The server binds the `127.0.0.1` literal and this + is not configurable — it can never listen on `0.0.0.0` or a LAN interface. +- **Origin validation.** Requests carrying an `Origin` header are rejected + with `403` unless the origin is `http(s)://127.0.0.1` or + `http(s)://localhost` (any port). This blocks the DNS-rebinding attack the + MCP spec calls out for local HTTP servers. Requests without an `Origin` + header (curl, MCP SDKs, inspectors) are allowed. +- **Server-holds-key.** The process reads `TINYFISH_API_KEY` from its env; + clients send no credential on the local hop. This is the simplest model, + but it means **any local process that can reach `127.0.0.1:` can use + your key and drive automations** (which can spend TinyFish credits). The + loopback bind and Origin check are the mitigations; treat the port as a + local trust boundary on a machine you trust. + +## Troubleshooting + +**"Port 3711 is already in use"** — another process holds the port. Stop it, +or set `PORT` to a free port and update your client config to match. + +**HTTP 502 with JSON-RPC error `-32001`** ("Upstream rejected the request … +check that TINYFISH_API_KEY is set to a valid TinyFish API key") — the hosted +server rejected your key. Verify `TINYFISH_API_KEY` is set in the environment +of the `tinyfish-mcp` process (not just your shell) and that the key is valid +at [https://agent.tinyfish.ai](https://agent.tinyfish.ai). The error's `data` +carries the upstream status and (truncated) body for diagnosis. + +**HTTP 502 with JSON-RPC error `-32000`** ("cannot reach …") — the upstream +server is unreachable: check your network/proxy/VPN; the hosted server may +also be temporarily down. If you overrode `TINYFISH_UPSTREAM_URL`, check it. +If a streamed `run_web_automation` call fails mid-stream you get the same +`-32000` as the final SSE frame, with a "the run may still be executing" +warning and the `runId` when known — check the run's status instead of +retrying blindly. + +**Batch requests don't work** — MCP forbids JSON-RPC batching and the hosted +server does not support batch arrays. The proxy forwards a batch as-is and +the upstream answers its own error; send one JSON-RPC message per request. + +**Claude/other client can't connect** — make sure `tinyfish-mcp` is actually +running (it's a standalone server; clients do not launch it) and that +`GET http://127.0.0.1:3711/healthz` answers `ok`. + +## Development + +```sh +npm ci +npm run build # tsc → dist/, marks dist/index.js executable +npm test # unit tests (offline, mock upstream) +npm run test:watch # unit tests in watch mode +npm run lint # eslint over src/ tests/ +npm run format # prettier +npm run type-check # tsc --noEmit over the whole tree +``` + +Layout: `src/core/` is the transport-agnostic proxy core (upstream client, +session bridge, SSE relay, error shaping); `src/http/` is the thin HTTP +adapter (routing, Origin check); `tests/` holds the unit suites plus +`tests/helpers/mock-upstream.ts`, a mock of the hosted endpoint that the unit +tests run against entirely offline. + +Integration tests hit the **real** hosted upstream and are gated: without +`TINYFISH_API_KEY` they skip with a printed notice. + +```sh +TINYFISH_API_KEY=... npm run test:integration +``` + +Set `TINYFISH_UPSTREAM_URL` to point them at a sandbox deployment instead of +production. Note the `run_web_automation` integration test executes a real +automation and spends credits. + +## Maintenance + +- **Owner:** TBD (to be named before the first public release — see the + maintenance gate in the internal release runbook, + `docs/phases/release-runbook.md`). +- **Support policy:** TBD — issue-triage and release cadence are defined in + the release runbook alongside the owner. + +## License + +MIT diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..2a0f481 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +import js from "@eslint/js"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import globals from "globals"; + +export default [ + js.configs.recommended, + { + files: ["src/**/*.ts", "tests/**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { project: "./tsconfig.all.json" }, + globals: { ...globals.node }, + }, + plugins: { "@typescript-eslint": tsPlugin }, + rules: { + ...tsPlugin.configs.recommended.rules, + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + // TypeScript handles same-name const + type natively (value/type namespaces are separate) + "no-redeclare": "off", + "@typescript-eslint/no-redeclare": "off", + "no-console": "error", + }, + }, + { + files: ["tests/**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..06a41af --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4099 @@ +{ + "name": "@tiny-fish/mcp", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@tiny-fish/mcp", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^4.4.3" + }, + "bin": { + "tinyfish-mcp": "dist/index.js" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^10.1.0", + "globals": "^17.4.0", + "prettier": "^3.0.0", + "tsx": "^4.23.1", + "typescript": "^5.0.0", + "vitest": "^4.1.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@hono/node-server": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "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/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "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/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": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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/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": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "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.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.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.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "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", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "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.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/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", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "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", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "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": "https://github.com/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": "https://github.com/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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "https://github.com/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": "https://github.com/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.12.33", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.33.tgz", + "integrity": "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "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.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/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "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-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.6.tgz", + "integrity": "sha512-HwMtbJjMw8rC8dUTwCNilHJD+fxTeKM3JV1eprSmTjS41qwXSSt6exJXgyPK1QOu0jB9eDYLESRDkB3qaT3jnw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "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/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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": "https://github.com/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", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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": "https://github.com/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": "https://github.com/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": "https://github.com/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": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "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": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "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", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/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/package.json b/package.json new file mode 100644 index 0000000..601d47b --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "@tiny-fish/mcp", + "version": "0.1.0", + "description": "TinyFish local MCP server — reverse proxy to agent.tinyfish.ai/mcp", + "mcpName": "io.github.tinyfish-io/tinyfish-mcp-server", + "repository": { + "type": "git", + "url": "git+https://github.com/tinyfish-io/tinyfish-mcp-server.git" + }, + "homepage": "https://github.com/tinyfish-io/tinyfish-mcp-server#readme", + "bugs": { + "url": "https://github.com/tinyfish-io/tinyfish-mcp-server/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "tinyfish", + "web-automation", + "browser-automation", + "proxy" + ], + "author": "TinyFish (https://tinyfish.ai)", + "type": "module", + "license": "MIT", + "bin": { + "tinyfish-mcp": "./dist/index.js" + }, + "files": [ + "dist/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc && chmod +x dist/index.js", + "test": "vitest --run", + "test:watch": "vitest", + "test:integration": "vitest --run --config vitest.integration.config.ts", + "lint": "eslint src tests", + "format": "prettier --write src tests", + "type-check": "tsc --noEmit --project tsconfig.all.json", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^10.1.0", + "globals": "^17.4.0", + "prettier": "^3.0.0", + "tsx": "^4.23.1", + "typescript": "^5.0.0", + "vitest": "^4.1.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c3285df --- /dev/null +++ b/src/config.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +export const DEFAULT_PORT = 3711; +export const DEFAULT_UPSTREAM_URL = "https://agent.tinyfish.ai/mcp"; + +export const API_KEY_GUIDANCE = "Set TINYFISH_API_KEY — get a key at https://agent.tinyfish.ai"; + +export interface Config { + /** Never log this field. */ + apiKey: string; + port: number; + upstreamUrl: string; +} + +/** Thrown by parseConfig; `message` is the actionable stderr line(s) for the user. */ +export class ConfigError extends Error {} + +const envSchema = z.object({ + TINYFISH_API_KEY: z + .string({ error: API_KEY_GUIDANCE }) + .min(1, { error: API_KEY_GUIDANCE }), + PORT: z + .string() + .optional() + .transform((value, ctx) => { + if (value === undefined) return DEFAULT_PORT; + const port = /^\d+$/.test(value) ? Number(value) : NaN; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + ctx.addIssue({ + code: "custom", + message: `Invalid PORT "${value}" — must be an integer between 1 and 65535`, + }); + return z.NEVER; + } + return port; + }), + TINYFISH_UPSTREAM_URL: z + .string() + .optional() + .transform((value, ctx) => { + const raw = value ?? DEFAULT_UPSTREAM_URL; + let url: URL; + try { + url = new URL(raw); + } catch { + ctx.addIssue({ + code: "custom", + message: `Invalid TINYFISH_UPSTREAM_URL "${raw}" — must be an absolute URL`, + }); + return z.NEVER; + } + const isLoopback = url.hostname === "127.0.0.1" || url.hostname === "localhost"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) { + ctx.addIssue({ + code: "custom", + message: + `Invalid TINYFISH_UPSTREAM_URL "${raw}" — scheme must be https ` + + `(http is allowed only for 127.0.0.1/localhost)`, + }); + return z.NEVER; + } + return raw; + }), +}); + +/** + * Pure env → Config parser. Throws ConfigError with an actionable message on + * invalid input; performs no I/O and never touches process state. + */ +export function parseConfig(env: Record): Config { + const parsed = envSchema.safeParse(env); + if (!parsed.success) { + throw new ConfigError(parsed.error.issues.map((issue) => issue.message).join("\n")); + } + return { + apiKey: parsed.data.TINYFISH_API_KEY, + port: parsed.data.PORT, + upstreamUrl: parsed.data.TINYFISH_UPSTREAM_URL, + }; +} diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..c87d665 --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,256 @@ +/** + * Typed transport-level errors thrown by the proxy core, plus the one + * client-facing shaping function (Phase 6): `toJsonRpcError` for pre-stream + * failures and `toStreamErrorFrame` for failures after an SSE relay started. + * Every adapter catch path routes through these two functions — no ad-hoc + * error bodies anywhere else, with exactly two deliberate exceptions (pinned, + * Phase 7): the adapter's ParseError reply (http/adapter.ts — built where the + * unparseable body is caught, since there is nothing to route), and the + * last-resort -32603 backstop in http/index.ts's invokeSafely. Messages must + * never contain the API key (they describe network/protocol conditions only). + * + * HTTP status decision for locally shaped errors (pinned here, Phase 6): + * upstream's own convention (shared/json-rpc.ts) maps client-error JSON-RPC + * codes to HTTP 400 and everything else to 500. The proxy mirrors the 400 for + * client errors (-32700 ParseError) and picks **502 Bad Gateway** for the + * upstream-leg failures it shapes itself (-32000 unreachable/stream-failed, + * -32001 auth rejection): the proxy is healthy, the upstream hop failed — + * distinguishing these from a genuine local proxy bug, which stays **500** + * with -32603 InternalError. Upstream-originated JSON-RPC errors are never + * shaped at all: they forward verbatim under upstream's own HTTP status. + */ + +/** JSON-RPC error codes used by locally shaped errors. */ +export const JsonRpcErrorCodes = { + /** Upstream unreachable / upstream stream failed (server-side, HTTP 502). */ + UpstreamUnavailable: -32000, + /** Upstream rejected auth — check TINYFISH_API_KEY (HTTP 502). */ + UpstreamAuth: -32001, + /** Local proxy bug (HTTP 500). */ + InternalError: -32603, + /** Malformed client JSON (HTTP 400, id -1 — mirrors upstream). */ + ParseError: -32700, +} as const; + +/** Base class for all proxy-core errors (transport level, not JSON-RPC). */ +export class ProxyCoreError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + } +} + +/** The upstream server could not be reached (DNS, TLS, refused, reset). */ +export class UpstreamUnreachableError extends ProxyCoreError { + /** Upstream "host[:port]" when known — used in the client-facing message. */ + host?: string; +} + +/** + * Upstream answered 401/403 with a body that is NOT a JSON-RPC message (a + * JSON-RPC error body, whatever its HTTP status, forwards verbatim instead — + * rules-table row 1). Carries the upstream status and body text so the shaped + * client error can include them as diagnostics. The body text is truncated to + * ~2KB at construction; the Error message itself never includes it. + */ +export class UpstreamAuthError extends ProxyCoreError { + readonly status: number; + /** Upstream response body text, truncated to AUTH_BODY_LIMIT chars. */ + readonly bodyText: string; + + constructor(status: number, bodyText: string, options?: ErrorOptions) { + super(`Upstream rejected the request as unauthorized (HTTP ${status})`, options); + this.status = status; + this.bodyText = bodyText.slice(0, AUTH_BODY_LIMIT); + } +} + +/** + * ~2KB cap on the upstream auth-failure body relayed in error data. + * + * Measured in UTF-16 code units (String.prototype.slice), not bytes: for + * multi-byte scripts the UTF-8 wire size can reach ~3× (≤ ~6KB) — bounded + * either way, which is all the "~2KB" contract promises. A slice boundary can + * split a surrogate pair; harmless, since Node's well-formed JSON.stringify + * escapes the lone surrogate and the response stays valid JSON. + */ +export const AUTH_BODY_LIMIT = 2048; + +/** + * Delivering a relayed SSE frame to the LOCAL client failed (the transport's + * onEvent callback rejected — e.g. the client socket died mid-write). This is + * a client-side condition, never an upstream one: it must not be logged or + * classified as "Upstream unreachable" (Phase 4 review gap 2 / Phase 5). + */ +export class LocalWriteError extends ProxyCoreError {} + +/** An in-flight upstream request was aborted locally (session close / shutdown). */ +export class UpstreamAbortedError extends ProxyCoreError {} + +/** + * Upstream answered with something the core cannot interpret (non-JSON body, + * SSE stream that ends without a final response frame, unexpected empty body). + */ +export class UpstreamProtocolError extends ProxyCoreError { + /** Upstream HTTP status when one was received before the failure. */ + readonly status?: number; + + constructor(message: string, status?: number, options?: ErrorOptions) { + super(message, options); + this.status = status; + } +} + +/** True for the AbortError DOMException fetch throws when its signal fires. */ +export function isAbortError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "name" in err && + (err as { name: unknown }).name === "AbortError" + ); +} + +// --------------------------------------------------------------------------- +// Client-facing shaping (Phase 6) — the only place error bodies are built +// --------------------------------------------------------------------------- + +export interface JsonRpcErrorBody { + jsonrpc: "2.0"; + error: { code: number; message: string; data?: unknown }; + id: unknown; +} + +/** A shaped failure: the HTTP status to answer with plus the JSON-RPC body. */ +export interface ShapedJsonRpcError { + httpStatus: number; + body: JsonRpcErrorBody; +} + +/** + * Map a failure the upstream never answered (or answered unusably) to the + * client-facing JSON-RPC error + local HTTP status, per the Phase 6 rules + * table. Only failures upstream never saw as JSON-RPC get shaped here — + * upstream JSON-RPC errors forward verbatim and never reach this function. + * Never includes the API key: transport-error messages describe network and + * protocol conditions only, and unexpected local errors get a generic message + * (their stack goes to stderr at the catch site, not to the client). + */ +export function toJsonRpcError(failure: unknown, requestId: unknown): ShapedJsonRpcError { + const id = requestId ?? null; + + if (failure instanceof UpstreamAuthError) { + return { + httpStatus: 502, + body: { + jsonrpc: "2.0", + error: { + code: JsonRpcErrorCodes.UpstreamAuth, + message: + `Upstream rejected the request (HTTP ${failure.status}) — ` + + `check that TINYFISH_API_KEY is set to a valid TinyFish API key`, + data: { upstreamStatus: failure.status, upstreamBody: failure.bodyText }, + }, + id, + }, + }; + } + + if (failure instanceof UpstreamUnreachableError) { + const host = failure.host ?? "the upstream server"; + return { + httpStatus: 502, + body: { + jsonrpc: "2.0", + error: { + code: JsonRpcErrorCodes.UpstreamUnavailable, + message: + `cannot reach ${host} — check your network; ` + + `the hosted MCP server may also be temporarily unavailable`, + }, + id, + }, + }; + } + + if (failure instanceof ProxyCoreError) { + // Remaining core classifications (protocol violation, local abort): the + // upstream leg failed but the proxy is healthy — same 502 / -32000 shape, + // with the classified message (never contains the key or body internals). + return { + httpStatus: 502, + body: { + jsonrpc: "2.0", + error: { code: JsonRpcErrorCodes.UpstreamUnavailable, message: failure.message }, + id, + }, + }; + } + + // Local proxy bug: generic message only; the stack goes to stderr. + return { + httpStatus: 500, + body: { + jsonrpc: "2.0", + error: { code: JsonRpcErrorCodes.InternalError, message: "Internal error" }, + id, + }, + }; +} + +/** + * Build the final SSE-framed JSON-RPC error for a failure AFTER the local SSE + * relay started (rules-table row "mid-stream upstream disconnect"). A + * tools/call may have side effects, so the message warns that the run may + * still be executing and is never retried silently; when a run id was already + * seen in a progress frame's `_meta.runId` it is included in `data.runId` + * (camelCase, matching upstream's `_meta.runId` convention) and named in the + * message, else `data` is omitted entirely. + * + * The -32000 message differentiates the failure kind: a locally aborted + * upstream request (session close / shutdown mid-stream) reads differently + * from an upstream that died or broke protocol — but both keep the "run may + * still be executing" guidance, because in either case a live run could be + * left behind upstream. + */ +export function toStreamErrorFrame( + failure: unknown, + requestId: unknown, + runId?: string +): JsonRpcErrorBody { + const id = requestId ?? null; + if (failure instanceof ProxyCoreError) { + // Upstream died / broke protocol / was aborted mid-stream. Not -32603: + // this is an upstream-leg failure, mirrored to the pre-stream -32000. + const runHint = + runId !== undefined + ? ` Check the run status with get_run id "${runId}" instead of retrying.` + : ` Check the run status before retrying.`; + const condition = + failure instanceof UpstreamAbortedError + ? "The proxy aborted the upstream request mid-stream (local session closed or shutting down); " + : "Upstream stream ended unexpectedly before the final response; "; + return { + jsonrpc: "2.0", + error: { + code: JsonRpcErrorCodes.UpstreamUnavailable, + message: + condition + "the run may still be executing — do not retry blindly." + runHint, + ...(runId !== undefined ? { data: { runId } } : {}), + }, + id, + }; + } + // Local proxy bug mid-stream: generic message (stack to stderr at the catch + // site); still include the run id when known — it is upstream-issued data + // the client already saw, and it is the only recovery handle left. + return { + jsonrpc: "2.0", + error: { + code: JsonRpcErrorCodes.InternalError, + message: "Internal error while relaying the upstream stream", + ...(runId !== undefined ? { data: { runId } } : {}), + }, + id, + }; +} diff --git a/src/core/proxy-core.ts b/src/core/proxy-core.ts new file mode 100644 index 0000000..f62eb20 --- /dev/null +++ b/src/core/proxy-core.ts @@ -0,0 +1,408 @@ +/** + * Transport-agnostic proxy core — the one module every transport wraps. + * + * Composes the upstream client (single fetch call site) with the session + * store (abort tracking + session/protocol-version bridging). Routes on + * JSON-RPC shape and response content type only; never inspects tool names or + * schemas. All signatures use web-standard types and plain callbacks — no + * node:http anywhere. + * + * SSE frames are parsed by the incremental parser in core/sse.ts and carry + * both the parsed JSON and the raw data payload string, so a relaying + * transport can pipe upstream's bytes through verbatim. + */ +import { log } from "../log.js"; +import { shutdownHooks } from "../shutdown.js"; +import { LocalWriteError, ProxyCoreError, UpstreamProtocolError } from "./errors.js"; +import { SessionStore } from "./session.js"; +import { parseSseStream } from "./sse.js"; +import { toTransportError, UpstreamClient, type FetchLike } from "./upstream.js"; + +/** + * Plain callback invoked per intermediate SSE frame (progress notifications). + * May return a promise; forwardStream awaits each emission before reading the + * next frame, so a relaying transport can propagate write backpressure + * (Phase 5 contract). `rawData` is the frame's original `data:` payload string + * (multi-line values joined with "\n") — relay it verbatim when possible; the + * parsed `message` is a fallback for transports that must re-serialize. A + * rejection from onEvent surfaces as LocalWriteError (client-side condition), + * never as an upstream transport error. + * + * DELIBERATE DROP (Phase 6 decision, Phase 5 review gap 3): SSE `event:` and + * `id:` fields are parsed by core/sse.ts but NOT carried through this + * callback — only the `data:` payload is relayed. The verified upstream sends + * bare `data:` frames exclusively, so threading them through would be dead + * plumbing today; if upstream ever starts emitting these fields, extend + * OnEvent (SseEvent already surfaces them) and the adapter's writeSseFrame. + */ +export type OnEvent = (message: unknown, rawData?: string) => void | Promise; + +/** + * A completed upstream exchange. `body` is the raw JSON-RPC response object, + * verbatim — success or error, never unwrapped. `status` is upstream's HTTP + * status (it must survive to the client — spike finding). `sessionId` is the + * Mcp-Session-Id upstream echoed on JSON responses (null on SSE — upstream's + * SSE path sets no session header). + */ +export interface ProxyResponse { + status: number; + body: unknown; + sessionId: string | null; + /** + * Upstream's Content-Type header for JSON responses (adapter copies it + * through rather than hardcoding its own). Null when synthesized locally + * (e.g. the final frame of an SSE stream). + */ + contentType: string | null; + /** + * SSE path only: the final frame's original `data:` payload string, so a + * relaying transport can emit upstream's bytes verbatim. Absent on plain + * JSON responses. + */ + rawBody?: string; +} + +export interface ProxyCore { + /** + * POST the client's initialize request upstream verbatim; capture + * Mcp-Session-Id from the response headers into the session map; return + * upstream's raw JSON-RPC response (upstream always answers + * protocolVersion 2025-11-25). + */ + initialize( + localKey: string, + initializeRequest: unknown, + clientProtocolVersion?: string, + /** + * Mcp-Session-Id the CLIENT sent on this initialize, if any. Real upstream + * adopts a client-sent header id instead of minting one, so a re-initialize + * after a proxy restart must replay it (raw-pipe transparency). Never an + * adapter-invented key. + */ + clientSessionId?: string + ): Promise; + /** + * Forward a JSON-RPC notification; upstream answers 204; resolves void. + * `clientProtocolVersion` is the MCP-Protocol-Version the client sent on + * THIS call (headers are per-request on the wire); falls back to the value + * captured at initialize when omitted. + */ + notify(localKey: string, notification: unknown, clientProtocolVersion?: string): Promise; + /** Non-streaming forward with the stored session id; response verbatim. */ + forward( + localKey: string, + request: unknown, + clientProtocolVersion?: string + ): Promise; + /** + * Forward a request that may stream. If upstream answers text/event-stream, + * onEvent is called per SSE frame that is a notification and the promise + * resolves with the frame that is the final JSON-RPC response (matching the + * request id). If upstream answers plain JSON, resolves with it directly. + * + * `signal` (optional) additionally aborts THIS request's upstream fetch — + * the transport fires it on local client disconnect so no upstream stream + * is orphaned. Distinct from close(), which tears down the whole session. + */ + forwardStream( + localKey: string, + request: unknown, + onEvent: OnEvent, + clientProtocolVersion?: string, + signal?: AbortSignal + ): Promise; + /** Abort in-flight upstream fetches for the session, drop the mapping. Local-only. */ + close(localKey: string): void; + /** Close every session (shutdown). */ + closeAll(): void; +} + +export interface ProxyCoreOptions { + upstreamUrl: string; + /** Never logged. */ + apiKey: string; + /** X-TF-Client-Version override; defaults to the package version. */ + clientVersion?: string; + /** Injectable fetch so unit tests need no network. */ + fetchFn?: FetchLike; + /** + * Where to register session cleanup for process shutdown. Defaults to the + * process-wide shutdownHooks array; pass null to skip registration (tests). + */ + hooks?: Array<() => void | Promise> | null; +} + +export function createProxyCore(options: ProxyCoreOptions): ProxyCore { + const upstream = new UpstreamClient({ + url: options.upstreamUrl, + apiKey: options.apiKey, + clientVersion: options.clientVersion, + fetchFn: options.fetchFn, + }); + const sessions = new SessionStore(); + + /** + * Run an upstream exchange with an abort controller tracked on the session. + * An optional external signal (per-request, e.g. local client disconnect) + * also fires the tracked controller, so session close and client disconnect + * abort through the same path. + */ + async function withInflight( + localKey: string, + fn: (signal: AbortSignal) => Promise, + externalSignal?: AbortSignal + ): Promise { + const controller = sessions.beginRequest(localKey); + const onExternalAbort = (): void => controller.abort(); + if (externalSignal !== undefined) { + if (externalSignal.aborted) controller.abort(); + else externalSignal.addEventListener("abort", onExternalAbort, { once: true }); + } + try { + return await fn(controller.signal); + } finally { + externalSignal?.removeEventListener("abort", onExternalAbort); + sessions.endRequest(localKey, controller); + } + } + + const core: ProxyCore = { + async initialize(localKey, initializeRequest, clientProtocolVersion, clientSessionId) { + const entry = sessions.getOrCreate(localKey); + if (clientProtocolVersion !== undefined) { + entry.protocolVersion = clientProtocolVersion; + } + const response = await withInflight(localKey, (signal) => + upstream.post(initializeRequest, { + // No localKey fallback here: upstream adopts any header id verbatim, + // so sending an adapter-invented key on initialize would prevent + // upstream from minting the session id. A CLIENT-sent id, however, + // must replay (upstream honors it — proxy-restart transparency). + sessionId: entry.upstreamSessionId ?? clientSessionId, + protocolVersion: entry.protocolVersion, + signal, + }) + ); + if (response.kind !== "json") { + throw new UpstreamProtocolError( + `Unexpected ${response.kind} response to initialize (HTTP ${response.status})`, + response.status + ); + } + // Re-fetch: the session may have been closed while the fetch was in flight. + const live = sessions.get(localKey); + if (live !== undefined && response.sessionId !== null) { + live.upstreamSessionId = response.sessionId; + // Alias the entry under the upstream-issued id: with raw-pipe bridging + // the client re-sends upstream's id, so later calls arrive keyed by it. + sessions.alias(response.sessionId, live); + } + return { + status: response.status, + body: response.body, + sessionId: response.sessionId, + contentType: response.contentType, + }; + }, + + async notify(localKey, notification, clientProtocolVersion) { + const entry = sessions.getOrCreate(localKey); + const response = await withInflight(localKey, (signal) => + upstream.post(notification, { + // No captured id means the client is re-sending upstream's own id as + // the local key (raw-pipe bridging) — replay the key itself. + sessionId: entry.upstreamSessionId ?? localKey, + protocolVersion: clientProtocolVersion ?? entry.protocolVersion, + signal, + }) + ); + // Upstream's contract for notifications is HTTP 204, empty body. + // PINNED DECISION (Phase 6): any other successful-fetch answer — + // including a JSON-RPC error body — is deliberately swallowed after a + // stderr warning, because a JSON-RPC notification has no response + // channel to relay it on (the local client still gets its 204). + // Transport/auth failures that THROW (unreachable, 401 with a + // non-JSON-RPC body) still propagate and are shaped by the adapter. + // Unreachable against today's real upstream, which 204s notifications + // before auth runs — revisit if the API-key branch changes that. + if (response.kind !== "empty") { + const errorCode = + response.kind === "json" ? jsonRpcErrorCodeOf(response.body) : undefined; + const codeSuffix = errorCode !== undefined ? `, JSON-RPC error ${errorCode}` : ""; + log.warn( + `notification got unexpected ${response.kind} response ` + + `(HTTP ${response.status}${codeSuffix}) — dropped; ` + + `notifications have no response channel` + ); + } + }, + + async forward(localKey, request, clientProtocolVersion) { + const entry = sessions.getOrCreate(localKey); + const response = await withInflight(localKey, (signal) => + upstream.post(request, { + sessionId: entry.upstreamSessionId ?? localKey, + protocolVersion: clientProtocolVersion ?? entry.protocolVersion, + signal, + }) + ); + if (response.kind !== "json") { + throw new UpstreamProtocolError( + `Unexpected ${response.kind} response to non-streaming request (HTTP ${response.status})`, + response.status + ); + } + return { + status: response.status, + body: response.body, + sessionId: response.sessionId, + contentType: response.contentType, + }; + }, + + async forwardStream(localKey, request, onEvent, clientProtocolVersion, abortSignal) { + const entry = sessions.getOrCreate(localKey); + return withInflight( + localKey, + async (signal) => { + const response = await upstream.post(request, { + sessionId: entry.upstreamSessionId ?? localKey, + protocolVersion: clientProtocolVersion ?? entry.protocolVersion, + signal, + }); + if (response.kind === "json") { + return { + status: response.status, + body: response.body, + sessionId: response.sessionId, + contentType: response.contentType, + }; + } + if (response.kind === "sse") { + const finalFrame = await readSseUntilFinal( + response.stream, + requestIdOf(request), + onEvent + ); + // Upstream SSE responses never carry Mcp-Session-Id; the final frame + // is synthesized from the stream, so no upstream Content-Type applies. + return { + status: response.status, + body: finalFrame.message, + sessionId: null, + contentType: null, + rawBody: finalFrame.rawData, + }; + } + throw new UpstreamProtocolError( + `Unexpected empty response to a request (HTTP ${response.status})`, + response.status + ); + }, + abortSignal + ); + }, + + close(localKey) { + sessions.close(localKey); + }, + + closeAll() { + sessions.closeAll(); + }, + }; + + const hooks = options.hooks === undefined ? shutdownHooks : options.hooks; + if (hooks !== null) { + hooks.push(() => core.closeAll()); + } + + return core; +} + +// --------------------------------------------------------------------------- +// SSE stream consumption (parser lives in core/sse.ts) +// --------------------------------------------------------------------------- + +/** The JSON-RPC error code of a response body, if it is an error response. */ +function jsonRpcErrorCodeOf(body: unknown): number | undefined { + if (typeof body !== "object" || body === null) return undefined; + const error = (body as { error?: unknown }).error; + if (typeof error !== "object" || error === null) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "number" ? code : undefined; +} + +/** + * The JSON-RPC id of a request message, undefined when absent (Phase 7: the + * single copy — the HTTP adapter imports this too; the shaping functions in + * core/errors.ts normalize undefined to null themselves). + */ +export function requestIdOf(request: unknown): unknown { + if (typeof request === "object" && request !== null && "id" in request) { + return (request as { id: unknown }).id; + } + return undefined; +} + +/** A JSON-RPC response frame: has result/error, no method; id must match. */ +function isFinalResponse(message: unknown, requestId: unknown): boolean { + if (typeof message !== "object" || message === null) return false; + const frame = message as Record; + if ("method" in frame) return false; + if (!("result" in frame) && !("error" in frame)) return false; + return requestId === undefined || frame.id === requestId; +} + +/** + * Consume the SSE stream via parseSseStream, emitting notification frames via + * onEvent (each emission awaited — backpressure) and returning the final + * JSON-RPC response frame with its raw payload string. Reading stops at the + * final frame: breaking out of the for-await runs the generator's return + * path, which cancels the upstream stream — no waiting on upstream to close, + * and any spec-violating post-final frames never reach onEvent. + * + * Error classification: onEvent rejections (local client write failures) are + * wrapped as LocalWriteError; stream/read failures map to transport errors + * (abort → UpstreamAbortedError, network → UpstreamUnreachableError); parser + * failures are UpstreamProtocolError. Only genuinely unclassified errors go + * through toTransportError. + */ +async function readSseUntilFinal( + stream: ReadableStream, + requestId: unknown, + onEvent: OnEvent +): Promise<{ message: unknown; rawData: string }> { + let finalFrame: { message: unknown; rawData: string } | undefined; + + try { + for await (const event of parseSseStream(stream)) { + if (isFinalResponse(event.message, requestId)) { + finalFrame = { message: event.message, rawData: event.rawData }; + break; + } + // Tolerate and relay any notification method verbatim (future-proofing); + // a rejected write is a LOCAL failure, never an upstream one. + try { + await onEvent(event.message, event.rawData); + } catch (err) { + throw new LocalWriteError( + `Relaying SSE frame to the local client failed: ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err } + ); + } + } + } catch (err) { + // Already-classified errors (LocalWriteError, UpstreamProtocolError from + // the parser, typed transport errors) pass through untouched. + if (err instanceof ProxyCoreError) throw err; + throw toTransportError(err); + } + + if (finalFrame === undefined) { + throw new UpstreamProtocolError("Upstream SSE stream ended without a final response frame"); + } + return finalFrame; +} diff --git a/src/core/session.ts b/src/core/session.ts new file mode 100644 index 0000000..2cec351 --- /dev/null +++ b/src/core/session.ts @@ -0,0 +1,109 @@ +/** + * Session bridging state. + * + * With the raw-pipe architecture (spike decision B) the client re-sends + * upstream's own Mcp-Session-Id, so localKey is normally the upstream-issued + * id itself and the map's main job is abort tracking for in-flight upstream + * fetches. The upstreamSessionId / protocolVersion fields are kept per the + * Phase 3 contract regardless — they cost nothing and keep the door open for + * a future stdio transport that needs real id bridging. + * + * Close is local-only cleanup: upstream has no DELETE handler + * (00-shared-context §1), so teardown just aborts in-flight fetches and drops + * the entry. + * + * Growth characteristic (deliberate): entries are created on first use and + * removed only by close()/closeAll(). The raw-pipe HTTP surface has no + * client-driven teardown signal (clients cannot DELETE), so a long-running + * proxy accumulates one small entry per distinct session key until process + * shutdown runs closeAll() via the shutdown hook. That is acceptable for a + * local single-user proxy — entries are a few strings plus an empty Set — and + * is the consciously chosen steady state. Phase 4 guidance: call + * core.close(localKey) wherever the transport does learn of a session's end + * (e.g. a future stdio transport's disconnect); an idle TTL can be added + * later if a real leak ever materializes. + */ + +export interface SessionEntry { + /** Captured from the initialize response's Mcp-Session-Id header. */ + upstreamSessionId?: string; + /** The client's MCP-Protocol-Version, replayed on subsequent calls. */ + protocolVersion?: string; + /** AbortControllers for upstream fetches currently in flight. */ + readonly inflight: Set; +} + +export class SessionStore { + private readonly sessions = new Map(); + + get size(): number { + return this.sessions.size; + } + + get(localKey: string): SessionEntry | undefined { + return this.sessions.get(localKey); + } + + has(localKey: string): boolean { + return this.sessions.has(localKey); + } + + /** Create-on-first-use lookup. */ + getOrCreate(localKey: string): SessionEntry { + let entry = this.sessions.get(localKey); + if (entry === undefined) { + entry = { inflight: new Set() }; + this.sessions.set(localKey, entry); + } + return entry; + } + + /** + * Map an additional key to an existing entry. Used by initialize() to make + * the upstream-issued Mcp-Session-Id resolve to the same session as the + * initialize-time local key: with raw-pipe bridging the client re-sends + * upstream's id, so later calls arrive keyed by that id. + */ + alias(aliasKey: string, entry: SessionEntry): void { + this.sessions.set(aliasKey, entry); + } + + /** + * Register a new in-flight upstream request for the session, creating the + * session on first use. Pair with endRequest once the request settles. + */ + beginRequest(localKey: string): AbortController { + const controller = new AbortController(); + this.getOrCreate(localKey).inflight.add(controller); + return controller; + } + + /** Forget a settled request's controller (no-op if the session was closed). */ + endRequest(localKey: string, controller: AbortController): void { + this.sessions.get(localKey)?.inflight.delete(controller); + } + + /** + * Local-only teardown: abort every in-flight upstream fetch for the session + * and drop the entry — including every alias key that maps to the same + * entry. No upstream DELETE — upstream has no DELETE handler. + */ + close(localKey: string): void { + const entry = this.sessions.get(localKey); + if (entry === undefined) return; + for (const [key, value] of this.sessions) { + if (value === entry) this.sessions.delete(key); + } + for (const controller of entry.inflight) { + controller.abort(); + } + entry.inflight.clear(); + } + + /** Close every session (shutdown path). */ + closeAll(): void { + for (const localKey of [...this.sessions.keys()]) { + this.close(localKey); + } + } +} diff --git a/src/core/sse.ts b/src/core/sse.ts new file mode 100644 index 0000000..aa82933 --- /dev/null +++ b/src/core/sse.ts @@ -0,0 +1,137 @@ +/** + * Incremental SSE parser over a ReadableStream (Phase 5). + * + * Replaces proxy-core's inline blank-line splitter. Handles chunk boundaries + * anywhere — mid-line, mid-event, mid-CRLF, even mid-UTF-8-codepoint (the + * streaming TextDecoder holds partial sequences) — per the SSE processing + * model: + * + * - Lines end with LF or CRLF (upstream sends LF; CRLF tolerated). + * - `data:` field values accumulate; multi-line data is joined with "\n". + * - `event:` / `id:` fields are tolerated and surfaced (currently unused + * upstream — every real frame is a bare `data:` line). + * - `:` comment lines are consumed, never forwarded. + * - `retry:` and unknown fields are ignored. + * - A blank line dispatches the pending event; blocks without any `data:` + * field dispatch nothing (spec behavior — comments/ids alone are dropped). + * - A stream that ends without a trailing blank line still dispatches its + * pending event (tolerance kept from the Phase 3 inline parser). + * + * Each yielded event carries BOTH the parsed JSON and the raw joined data + * payload string, so a relaying transport can pipe upstream's original bytes + * through untouched (byte-verbatim relay — spike guidance) instead of + * re-serializing. + * + * Teardown: breaking out of (or throwing from) a for-await over this + * generator runs its return path, which ends the inner for-await over the + * stream and cancels the ReadableStream — no orphaned upstream reads. + */ +import { UpstreamProtocolError } from "./errors.js"; + +export interface SseEvent { + /** The data payload parsed as JSON (every upstream frame is one JSON-RPC message). */ + message: unknown; + /** + * The exact data payload string: field values of every `data:` line in the + * block, joined with "\n". Relay this verbatim (re-split on "\n" into + * `data:` lines when re-framing) to preserve upstream's bytes. + */ + rawData: string; + /** `event:` field value, if the block carried one (unused upstream). */ + event?: string; + /** `id:` field value, if the block carried one (unused upstream). */ + id?: string; +} + +/** + * Parse an SSE byte stream into events. Throws UpstreamProtocolError when a + * data payload is not valid JSON; stream read failures propagate as-is (the + * consumer maps them to transport errors). + */ +export async function* parseSseStream( + stream: ReadableStream +): AsyncGenerator { + const decoder = new TextDecoder(); + let buffer = ""; + let dataLines: string[] | null = null; + let eventField: string | undefined; + let idField: string | undefined; + + /** Fold one complete line into the pending event; return it when dispatched. */ + const processLine = (line: string): SseEvent | undefined => { + if (line === "") { + // Blank line: dispatch the pending event, if it carried any data. + const pending = dataLines; + const event = eventField; + const id = idField; + dataLines = null; + eventField = undefined; + idField = undefined; + if (pending === null) return undefined; + const rawData = pending.join("\n"); + return { message: parseJsonPayload(rawData), rawData, event, id }; + } + if (line.startsWith(":")) return undefined; // comment — consumed + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); // spec: strip one leading space + switch (field) { + case "data": + (dataLines ??= []).push(value); + break; + case "event": + eventField = value; + break; + case "id": + idField = value; + break; + default: + // retry: and unknown fields — ignored. + break; + } + return undefined; + }; + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }); + let newline: number; + while ((newline = buffer.indexOf("\n")) !== -1) { + let line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + // CRLF: the CR waits in the buffer until its LF arrives, so a CRLF pair + // split across chunks needs no special casing — just strip it here. + if (line.endsWith("\r")) line = line.slice(0, -1); + const event = processLine(line); + if (event !== undefined) yield event; + } + } + + // Stream ended: flush the decoder's partial UTF-8 state and any final line + // without a trailing newline, then dispatch a pending event (tolerate a + // stream that ends without the final blank line). + buffer += decoder.decode(); + if (buffer.length > 0) { + let line = buffer; + if (line.endsWith("\r")) line = line.slice(0, -1); + const event = processLine(line); + if (event !== undefined) yield event; + } + const flushed = processLine(""); + if (flushed !== undefined) yield flushed; +} + +/** + * Every upstream frame must be a JSON-RPC message. An empty `data:` payload is + * therefore a protocol violation too (JSON.parse("") throws) — pinned behavior: + * it surfaces as UpstreamProtocolError, same as any other non-JSON payload. + */ +function parseJsonPayload(rawData: string): unknown { + try { + return JSON.parse(rawData); + } catch (err) { + throw new UpstreamProtocolError("Upstream SSE frame is not valid JSON", undefined, { + cause: err, + }); + } +} diff --git a/src/core/upstream.ts b/src/core/upstream.ts new file mode 100644 index 0000000..f80b60c --- /dev/null +++ b/src/core/upstream.ts @@ -0,0 +1,246 @@ +/** + * Single fetch call site for the upstream MCP endpoint. + * + * POSTs one JSON-RPC message per call with the auth + attribution headers and + * classifies the response by HTTP status and content type only — it never + * inspects methods, tool names, or result schemas. The upstream HTTP status is + * preserved on every variant so the transport adapter can pass it through + * (spike finding: json-rpc client errors arrive as 400, server errors as 500, + * notifications as 204). + * + * Web-standard types only (fetch / Response / ReadableStream) — no node:http. + */ +import { VERSION } from "../version.js"; +import { + isAbortError, + UpstreamAbortedError, + UpstreamAuthError, + UpstreamProtocolError, + UpstreamUnreachableError, +} from "./errors.js"; + +export type FetchLike = typeof globalThis.fetch; + +export interface UpstreamClientOptions { + /** Full upstream MCP URL, e.g. https://agent.tinyfish.ai/mcp */ + url: string; + /** Sent as X-API-Key. Never logged. */ + apiKey: string; + /** Sent as X-TF-Client-Version; defaults to the package version. */ + clientVersion?: string; + /** Injectable fetch for tests; defaults to globalThis.fetch. */ + fetchFn?: FetchLike; +} + +export interface UpstreamCallOptions { + /** Replayed as Mcp-Session-Id when known. */ + sessionId?: string; + /** Client's MCP-Protocol-Version, passed through when the client sent one. */ + protocolVersion?: string; + /** Aborts the request and any in-progress body read. */ + signal?: AbortSignal; +} + +/** + * Response classification. Transport failures are not a variant — they are + * thrown as typed errors (UpstreamUnreachableError / UpstreamAbortedError). + */ +export type UpstreamResponse = + | { + kind: "json"; + status: number; + /** Mcp-Session-Id echoed by upstream (JSON responses only). */ + sessionId: string | null; + /** The parsed JSON-RPC response, verbatim — success or error object. */ + body: unknown; + /** Upstream's Content-Type header, verbatim (null if absent). */ + contentType: string | null; + } + | { + kind: "sse"; + status: number; + /** Raw upstream byte stream. Upstream SSE responses carry no session header. */ + stream: ReadableStream; + } + | { + /** 204/empty body — upstream's answer to notifications. */ + kind: "empty"; + status: number; + }; + +export class UpstreamClient { + private readonly url: string; + private readonly apiKey: string; + private readonly clientVersion: string; + private readonly fetchFn: FetchLike; + + constructor(options: UpstreamClientOptions) { + this.url = options.url; + this.apiKey = options.apiKey; + this.clientVersion = options.clientVersion ?? VERSION; + this.fetchFn = options.fetchFn ?? globalThis.fetch; + } + + /** POST one JSON-RPC message upstream and classify the response. */ + async post(message: unknown, options: UpstreamCallOptions = {}): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "X-API-Key": this.apiKey, + "X-TF-Request-Origin": "tinyfish-mcp", + "X-TF-Client-Name": "tinyfish-mcp", + "X-TF-Client-Version": this.clientVersion, + }; + if (options.sessionId !== undefined && options.sessionId !== "") { + headers["Mcp-Session-Id"] = options.sessionId; + } + if (options.protocolVersion !== undefined && options.protocolVersion !== "") { + headers["MCP-Protocol-Version"] = options.protocolVersion; + } + + let response: Response; + try { + response = await this.fetchFn(this.url, { + method: "POST", + headers, + body: JSON.stringify(message), + signal: options.signal, + }); + } catch (err) { + throw toTransportError(err, this.url); + } + + const status = response.status; + const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); + + if (status === 204 || status === 205) { + return { kind: "empty", status }; + } + + if (contentType.startsWith("text/event-stream")) { + if (response.body === null) { + throw new UpstreamProtocolError("Upstream SSE response has no body", status); + } + return { kind: "sse", status, stream: response.body }; + } + + let text: string; + try { + text = await response.text(); + } catch (err) { + throw toTransportError(err, this.url); + } + if (text.length === 0) { + // A body-less 401/403 (e.g. a gateway/LB that strips bodies) is still an + // auth rejection — classify it BEFORE the generic empty return so the + // client gets the check-your-TINYFISH_API_KEY error, not a protocol one + // (Phase 6 review gap 1). + if (status === 401 || status === 403) { + throw new UpstreamAuthError(status, ""); + } + return { kind: "empty", status }; + } + let body: unknown; + try { + body = JSON.parse(text); + } catch (err) { + // Phase 6: a 401/403 whose body is not JSON at all is an auth rejection + // from an intermediary or a non-MCP error page — classified so the + // adapter can shape the check-your-TINYFISH_API_KEY error. Any other + // status with a non-JSON body stays a protocol violation. + if (status === 401 || status === 403) { + throw new UpstreamAuthError(status, text, { cause: err }); + } + throw new UpstreamProtocolError( + `Upstream returned non-JSON body (HTTP ${status}, content-type "${contentType}")`, + status, + { cause: err } + ); + } + // A 401/403 with a JSON body that is NOT a JSON-RPC message (e.g. + // {"error":"unauthorized"}) is also an auth rejection — only a genuine + // JSON-RPC error body forwards verbatim (rules-table row 1, preserving + // upstream's HTTP status). + if ((status === 401 || status === 403) && !isJsonRpcMessage(body)) { + throw new UpstreamAuthError(status, text); + } + return { + kind: "json", + status, + sessionId: response.headers.get("mcp-session-id"), + body, + contentType: response.headers.get("content-type"), + }; + } +} + +/** + * A parsed body that is a JSON-RPC 2.0 message (forwardable verbatim): the + * version marker plus at least one of the members every JSON-RPC message + * carries (`result`/`error` for responses, `method` for requests and + * notifications). Quasi-JSON-RPC junk like {"jsonrpc":"2.0","message":"no"} + * fails the gate, so at 401/403 it shapes as the -32001 auth error instead of + * forwarding (Phase 6 review gap 2). + * + * BATCH ARRAYS are deliberately outside this gate: MCP forbids JSON-RPC + * batching and upstream does not support it, so a 401/403 whose body is a + * batch(-error) array shapes as -32001 with the raw body preserved in + * `data.upstreamBody` rather than forwarding verbatim (Phase 6 review note, + * pinned in Phase 7). + */ +function isJsonRpcMessage(body: unknown): boolean { + return ( + typeof body === "object" && + body !== null && + !Array.isArray(body) && + (body as { jsonrpc?: unknown }).jsonrpc === "2.0" && + ("error" in body || "result" in body || "method" in body) + ); +} + +/** + * Map a fetch/stream failure to a typed transport error. Never includes the + * key. When `url` is given (the fetch call site knows it), the upstream host + * is attached so the Phase 6 shaping can name it in the client-facing message. + */ +export function toTransportError( + err: unknown, + url?: string +): UpstreamAbortedError | UpstreamUnreachableError { + if (isAbortError(err)) { + return new UpstreamAbortedError("Upstream request aborted", { cause: err }); + } + // Undici wraps network failures as "TypeError: fetch failed" with the real + // error (ECONNREFUSED, ENOTFOUND, TLS) on err.cause — surface that detail. + let detail = err instanceof Error ? err.message : String(err); + const cause = causeDetail(err); + if (cause !== undefined) { + detail += ` (${cause})`; + } + const error = new UpstreamUnreachableError(`Upstream unreachable: ${detail}`, { cause: err }); + if (url !== undefined) { + try { + error.host = new URL(url).host; + } catch { + // Malformed URL: leave host unset; the shaping falls back to a generic name. + } + } + return error; +} + +/** Extract the errno code (or message) from an error's cause chain, if any. */ +function causeDetail(err: unknown): string | undefined { + if (typeof err !== "object" || err === null || !("cause" in err)) return undefined; + const cause = (err as { cause: unknown }).cause; + if (typeof cause !== "object" || cause === null) return undefined; + const code = (cause as { code?: unknown }).code; + if (typeof code === "string" && code.length > 0) return code; + if (cause instanceof AggregateError) { + for (const inner of cause.errors) { + const innerCode = (inner as { code?: unknown } | null)?.code; + if (typeof innerCode === "string" && innerCode.length > 0) return innerCode; + } + } + if (cause instanceof Error && cause.message.length > 0) return cause.message; + return undefined; +} diff --git a/src/http/adapter.ts b/src/http/adapter.ts new file mode 100644 index 0000000..2076276 --- /dev/null +++ b/src/http/adapter.ts @@ -0,0 +1,353 @@ +/** + * Per-request MCP wiring over the transport-agnostic proxy core. + * + * Session bridging (raw-pipe, spike decision B): the local client re-sends + * whatever Mcp-Session-Id upstream issued, so the client-sent header IS the + * core localKey. For `initialize` — where no upstream id exists yet — a + * locally generated key seeds the session entry, and the core aliases that + * entry under the upstream-issued id once the response arrives. `Mcp-Session-Id` + * is echoed on JSON responses exactly when upstream echoed it (the core hands + * back the echoed value; null on SSE — upstream's SSE path sets no session + * header, and neither do we). + * + * Local-hop auth is server-holds-key: inbound Authorization headers are + * ignored — the only client headers that influence the upstream call are + * Mcp-Session-Id and MCP-Protocol-Version; the core builds every outbound + * header itself. + * + * Streaming (Phase 5): when upstream answers SSE, frames are relayed through + * the core's onEvent into an SSE response using the ORIGINAL `data:` payload + * string (byte-verbatim relay — no re-serialization). Each write is awaited + * (backpressure), a local client disconnect aborts the upstream fetch via a + * per-request AbortSignal (no orphaned upstream streams), and mid-stream + * failures are classified: local write failures are never labeled upstream. + */ +import { randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + JsonRpcErrorCodes, + LocalWriteError, + ProxyCoreError, + toJsonRpcError, + toStreamErrorFrame, +} from "../core/errors.js"; +import { requestIdOf, type OnEvent, type ProxyCore, type ProxyResponse } from "../core/proxy-core.js"; +import { log } from "../log.js"; +import type { RequestHandler } from "./index.js"; + +export function createMcpAdapter(core: ProxyCore): RequestHandler { + return async (req, res) => { + const sessionId = headerValue(req, "mcp-session-id"); + const protocolVersion = headerValue(req, "mcp-protocol-version"); + + const raw = await readBody(req); + let message: unknown; + try { + message = JSON.parse(raw.toString("utf8")); + } catch { + // Local ParseError mirroring upstream's shape (shared/json-rpc.ts: + // client-error codes → HTTP 400; id -1 when no request id is known). + // The one case the proxy answers without forwarding — it cannot route + // what it cannot parse. + sendJson(res, 400, { + jsonrpc: "2.0", + error: { code: JsonRpcErrorCodes.ParseError, message: "Parse error: Invalid JSON" }, + id: -1, + }); + return; + } + + try { + await route(core, res, message, sessionId, protocolVersion); + } catch (err) { + // Phase 6: every failure upstream never answered as JSON-RPC is shaped + // through the one function in core/errors.ts. Streamed requests handle + // their own mid-stream failures and only rethrow pre-stream ones, so + // headers are normally unsent here; the guard covers a write that died + // halfway through sending a response. + logFailure(err); + if (res.headersSent) { + res.end(); + return; + } + const shaped = toJsonRpcError(err, requestIdOf(message)); + sendJson(res, shaped.httpStatus, shaped.body); + } + }; +} + +async function route( + core: ProxyCore, + res: ServerResponse, + message: unknown, + sessionId: string | undefined, + protocolVersion: string | undefined +): Promise { + // Notification (has method, no id): forward, answer 204 empty like upstream. + if (isNotification(message)) { + // localKey "" ⇒ the core sends no Mcp-Session-Id upstream (transparent + // for session-less notifications); a real client id replays verbatim. + await core.notify(sessionId ?? "", message, protocolVersion); + res.writeHead(204); + res.end(); + return; + } + + const method = methodOf(message); + + if (method === "initialize") { + // No upstream session exists yet: a generated localKey seeds the entry + // unless the client is re-initializing with a session id it already + // holds. The client-sent id (if any) is passed separately so the core + // replays it upstream (upstream adopts client-sent header ids) without + // ever sending an adapter-invented key. + const localKey = sessionId ?? randomUUID(); + const response = await core.initialize(localKey, message, protocolVersion, sessionId); + sendProxyResponse(res, response); + return; + } + + if (method === "tools/call") { + await relayPossiblyStreaming(core, sessionId ?? "", message, protocolVersion, res); + return; + } + + // Everything else — ping, tools/list, resources/*, unknown methods, and + // shapeless bodies — forwards generically; upstream's status and body + // (including MethodNotFound / InvalidRequest errors) pass through verbatim. + // JSON-RPC BATCH ARRAYS land here too (methodOf/isNotification treat an + // array as shapeless): MCP forbids batching and upstream does not support + // it, so a batch is not special-cased anywhere — it forwards as-is and + // upstream answers its own InvalidRequest. Same story on the response side: + // upstream.ts's isJsonRpcMessage gate excludes arrays by design. + const response = await core.forward(sessionId ?? "", message, protocolVersion); + sendProxyResponse(res, response); +} + +/** + * Log a shaped failure to stderr. Classified core errors log their (key-free) + * message; anything else is a local proxy bug and logs its full stack — the + * client only ever sees the generic InternalError message. + */ +function logFailure(err: unknown): void { + if (err instanceof ProxyCoreError) { + log.warn(err.message); + return; + } + log.error( + `proxy bug (client got a generic InternalError): ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }` + ); +} + +/** + * tools/call may answer JSON or SSE; the adapter cannot know which until the + * core either emits an event (⇒ SSE) or resolves. The SSE response is opened + * lazily on the first relayed frame; the resolved final frame is appended and + * the stream closed. Each write is awaited (OnEvent may return a promise), so + * socket backpressure propagates into the core's frame loop. + * + * Client-disconnect abort: if the local socket closes before the response is + * finished ('close' with writableEnded false — 'close' alone also fires on + * normal completion), the per-request AbortSignal fires and the core aborts + * the upstream fetch. That surfaces as an UpstreamAbortedError rejection, + * absorbed here as an ordinary disconnect (nobody is left to answer). + * + * Note: an SSE stream that carried ONLY the final frame still relays as a + * plain JSON response (streaming never turned true). Real upstream always + * emits progress first; if it ever mattered, the final frame's rawBody is the + * verbatim payload either way. + */ +async function relayPossiblyStreaming( + core: ProxyCore, + localKey: string, + message: unknown, + protocolVersion: string | undefined, + res: ServerResponse +): Promise { + let streaming = false; + // Last _meta.runId seen in a relayed progress frame — upstream names the + // run there so a mid-stream failure can hand the client a recovery handle + // (rules table: include run_id in the error frame's data when seen). + let lastRunId: string | undefined; + const clientAbort = new AbortController(); + const onClose = (): void => { + if (!res.writableEnded) clientAbort.abort(); + }; + res.on("close", onClose); + + const onEvent: OnEvent = async (event, rawData) => { + if (!streaming) { + streaming = true; + // Mirror upstream's SSE headers. Deliberately no Mcp-Session-Id: + // upstream's SSE path never sets one (Phase 5 invariant). + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + } + lastRunId = runIdOf(event) ?? lastRunId; + await writeSseFrame(res, event, rawData); + }; + + try { + const response = await core.forwardStream( + localKey, + message, + onEvent, + protocolVersion, + clientAbort.signal + ); + if (streaming) { + try { + await writeSseFrame(res, response.body, response.rawBody); + } catch (err) { + // A final-frame write failure is a LOCAL socket condition, exactly + // like a progress-frame write failure inside onEvent — classify it the + // same way so it can never be mislabeled as an upstream stream failure + // (Phase 5 review gap 2). + throw new LocalWriteError( + `Relaying the final SSE frame to the local client failed: ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err } + ); + } + res.end(); + return; + } + // Plain JSON answer: written with upstream's status, body verbatim. + sendProxyResponse(res, response); + } catch (err) { + if (clientAbort.signal.aborted) { + // The local client went away; the upstream fetch was aborted through the + // per-request signal. Not an upstream failure — log it as what it is. + log.warn("local client disconnected mid-tools/call; upstream request aborted"); + res.destroy(); + return; + } + if (err instanceof LocalWriteError) { + // Writing to the local client socket failed (client dying but 'close' + // not yet observed). Teardown already happened in the core (the throw + // exits the frame loop, canceling the upstream stream). Never labeled + // "Upstream unreachable" (Phase 4 review gap 2). + log.warn(err.message); + res.destroy(); + return; + } + if (streaming) { + // The stream broke after the local SSE response already started. Emit + // the final SSE-framed JSON-RPC error per the Phase 6 rules table + // (-32000, "the run may still be executing", runId in data when seen) + // — never an unframed body into a started SSE stream. The log prefix + // names the actual culprit: a classified core error is an upstream-leg + // failure; anything else is a LOCAL proxy bug and must not be logged + // with an upstream-blaming label (same mislabel class as gap 2). + if (err instanceof ProxyCoreError) { + log.error(`upstream stream failed mid-relay: ${err.message}`); + } else { + log.error( + `proxy bug mid-stream (client got a framed InternalError): ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }` + ); + } + const errorFrame = toStreamErrorFrame(err, requestIdOf(message), lastRunId); + await writeSseFrame(res, errorFrame).catch(() => undefined); + res.end(); + return; + } + // Pre-stream failures (headers not sent) rethrow to the shaping catch in + // the handler (toJsonRpcError → -32000/-32001/-32603 with an HTTP status). + throw err; + } finally { + res.removeListener("close", onClose); + } +} + +/** Extract `params._meta.runId` from a relayed progress notification, if present. */ +function runIdOf(event: unknown): string | undefined { + if (typeof event !== "object" || event === null) return undefined; + const params = (event as { params?: unknown }).params; + if (typeof params !== "object" || params === null) return undefined; + const meta = (params as { _meta?: unknown })._meta; + if (typeof meta !== "object" || meta === null) return undefined; + const runId = (meta as { runId?: unknown }).runId; + return typeof runId === "string" && runId.length > 0 ? runId : undefined; +} + +/** Write upstream's status + JSON-RPC body verbatim; echo the session header. */ +function sendProxyResponse(res: ServerResponse, response: ProxyResponse): void { + // Copy upstream's Content-Type through (spike guidance); fall back for + // locally synthesized responses (e.g. an SSE final frame answered as JSON). + const headers: Record = { + "Content-Type": response.contentType ?? "application/json", + }; + if (response.sessionId !== null) { + headers["Mcp-Session-Id"] = response.sessionId; + } + sendJson(res, response.status, response.body, headers); +} + +function sendJson( + res: ServerResponse, + status: number, + body: unknown, + headers: Record = { "Content-Type": "application/json" } +): void { + res.writeHead(status, headers); + res.end(JSON.stringify(body)); +} + +/** + * One SSE frame; resolves when the chunk is flushed. Relays the ORIGINAL + * upstream payload string when available (byte-verbatim — spike guidance); + * falls back to JSON.stringify for locally synthesized frames. A payload + * containing newlines (multi-line `data:` field) is re-split into one + * `data:` line per payload line, which reconstructs to identical bytes on the + * receiving parser. + */ +function writeSseFrame(res: ServerResponse, message: unknown, rawData?: string): Promise { + const payload = rawData ?? JSON.stringify(message); + const frame = payload + .split("\n") + .map((line) => `data: ${line}`) + .join("\n"); + return new Promise((resolve, reject) => { + res.write(`${frame}\n\n`, (err) => (err ? reject(err) : resolve())); + }); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +/** Node folds duplicate headers into one comma-joined string; empty ⇒ absent. */ +function headerValue(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** JSON-RPC notification: an object with a method and no id key (mock/upstream rule). */ +function isNotification(message: unknown): boolean { + return ( + typeof message === "object" && + message !== null && + !Array.isArray(message) && + typeof (message as { method?: unknown }).method === "string" && + !("id" in message) + ); +} + +function methodOf(message: unknown): string | undefined { + if (typeof message !== "object" || message === null) return undefined; + const method = (message as { method?: unknown }).method; + return typeof method === "string" ? method : undefined; +} diff --git a/src/http/index.ts b/src/http/index.ts new file mode 100644 index 0000000..0645c58 --- /dev/null +++ b/src/http/index.ts @@ -0,0 +1,112 @@ +import { createServer } from "node:http"; +import type { IncomingMessage, Server, ServerResponse } from "node:http"; +import { log } from "../log.js"; +import { checkOrigin } from "./origin.js"; + +/** + * Request handler contract for the HTTP layer. Handlers may be async: the + * server awaits the returned promise and converts a rejection into a 500 + * JSON-RPC InternalError response (or just ends the response when headers are + * already out, e.g. mid-SSE), so async failures are never silently swallowed + * (Phase 2 review note). Phase 6 refines the error shaping. + */ +export type RequestHandler = ( + req: IncomingMessage, + res: ServerResponse +) => void | Promise; + +/** JSON-RPC InternalError, used for unhandled handler failures. */ +const INTERNAL_ERROR = -32603; + +const notFoundHandler: RequestHandler = (_req, res) => { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found\n"); +}; + +/** + * Routing shell around the MCP adapter: + * - Origin allowlist first, before the body is touched: deny → 403 plain text. + * - GET /healthz → 200 "ok" (client debugging; kept trivial). + * - POST /mcp → the MCP handler; other methods on /mcp → 405 with Allow: POST + * (mirrors upstream, where Next.js 405s methods the route does not export). + * - Everything else → 404. + */ +export function createAppHandler(mcpHandler: RequestHandler): RequestHandler { + return async (req, res) => { + if (!checkOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Forbidden: Origin not allowed\n"); + return; + } + const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname; + if (pathname === "/mcp") { + if (req.method !== "POST") { + res.writeHead(405, { Allow: "POST" }); + res.end(); + return; + } + await mcpHandler(req, res); + return; + } + if (pathname === "/healthz" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + return; + } + notFoundHandler(req, res); + }; +} + +/** + * Starts an HTTP server bound to 127.0.0.1 (loopback only, never configurable). + * Resolves once listening; rejects with the bind error (e.g. EADDRINUSE) otherwise. + */ +export function startHttpServer( + port: number, + handler: RequestHandler = notFoundHandler +): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + void invokeSafely(handler, req, res); + }); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(server); + }); + }); +} + +/** + * Await the handler; turn sync throws and async rejections into a 500. With + * Phase 6 the MCP adapter shapes every classified failure itself, so anything + * that reaches this catch is a local proxy bug: full stack to stderr, generic + * -32603 InternalError to the client. + */ +async function invokeSafely( + handler: RequestHandler, + req: IncomingMessage, + res: ServerResponse +): Promise { + try { + await handler(req, res); + } catch (err) { + // Core error messages never contain the API key (Phase 3 invariant), and + // the client-facing body is generic regardless — the key cannot leak. + log.error( + `request failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}` + ); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: INTERNAL_ERROR, message: "Internal error" }, + id: null, + }) + ); + } else { + res.end(); + } + } +} diff --git a/src/http/origin.ts b/src/http/origin.ts new file mode 100644 index 0000000..5f80b97 --- /dev/null +++ b/src/http/origin.ts @@ -0,0 +1,25 @@ +/** + * Pure Origin-header allowlist check — the anti-DNS-rebinding / anti-CSRF + * control for the loopback server. Evaluated before the request body is read; + * on deny the caller answers 403 without touching the body. + * + * Policy (00-shared-context §Security): + * - Absent Origin → allow (non-browser clients: curl, MCP SDKs, inspectors). + * - http/https origins on `127.0.0.1` or `localhost` → allow, any port + * (per phase doc: "any port variant of loopback also fine" — so the phase + * doc's `port` parameter is dead and deliberately omitted here). + * - Everything else → deny, including the literal "null" Origin (sandboxed + * iframes, file://), IPv6 `[::1]` (the server binds IPv4 loopback only), + * and anything that does not parse as a URL. + */ +export function checkOrigin(originHeader: string | undefined): boolean { + if (originHeader === undefined) return true; + let origin: URL; + try { + origin = new URL(originHeader); + } catch { + return false; + } + if (origin.protocol !== "http:" && origin.protocol !== "https:") return false; + return origin.hostname === "127.0.0.1" || origin.hostname === "localhost"; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2a092b3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { ConfigError, parseConfig } from "./config.js"; +import { createProxyCore } from "./core/proxy-core.js"; +import { createMcpAdapter } from "./http/adapter.js"; +import { createAppHandler, startHttpServer } from "./http/index.js"; +import { log } from "./log.js"; +import { shutdownHooks } from "./shutdown.js"; +import { VERSION } from "./version.js"; + +function isErrnoException(err: unknown): err is Error & { code?: string } { + return err instanceof Error && "code" in err; +} + +async function main(): Promise { + let config; + try { + config = parseConfig(process.env); + } catch (err) { + if (err instanceof ConfigError) { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + + // The real MCP handler: origin/routing shell → adapter → proxy core. + // createProxyCore registers session teardown into shutdownHooks itself. + const core = createProxyCore({ upstreamUrl: config.upstreamUrl, apiKey: config.apiKey }); + const handler = createAppHandler(createMcpAdapter(core)); + + let server; + try { + server = await startHttpServer(config.port, handler); + } catch (err) { + if (isErrnoException(err) && err.code === "EADDRINUSE") { + log.error( + `Port ${config.port} is already in use — stop the other process or set PORT to a free port` + ); + process.exit(1); + } + throw err; + } + + // The one startup line. Never include config.apiKey here or in any other log call. + log.info( + `listening on http://127.0.0.1:${config.port} — upstream ${config.upstreamUrl} — v${VERSION}` + ); + + let shuttingDown = false; + const shutdown = async (): Promise => { + if (shuttingDown) return; + shuttingDown = true; + server.close(); + for (const hook of shutdownHooks) { + try { + await hook(); + } catch (err) { + log.error(`shutdown hook failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + process.exit(0); + }; + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); +} + +main().catch((err: unknown) => { + log.error(`startup failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..d3072b7 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,22 @@ +/** + * Minimal leveled logger. Writes to stderr only — stdout stays clean for a + * future stdio transport. Callers must never pass the API key into a message. + */ + +export type LogLevel = "info" | "warn" | "error"; + +function write(level: LogLevel, message: string): void { + process.stderr.write(`tinyfish-mcp [${level}] ${message}\n`); +} + +export const log = { + info(message: string): void { + write("info", message); + }, + warn(message: string): void { + write("warn", message); + }, + error(message: string): void { + write("error", message); + }, +}; diff --git a/src/shutdown.ts b/src/shutdown.ts new file mode 100644 index 0000000..7e736f7 --- /dev/null +++ b/src/shutdown.ts @@ -0,0 +1,2 @@ +/** Phase 3 registers session cleanup here; hooks run on SIGINT/SIGTERM before exit. */ +export const shutdownHooks: Array<() => void | Promise> = []; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..832a4a4 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,9 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +// Resolves to the package root's package.json from both src/ (dev) and dist/ (published). +const pkg = require("../package.json") as { version: string }; + +/** Package version, read once at startup. Feeds X-TF-Client-Version upstream. */ +export const VERSION: string = pkg.version; diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts new file mode 100644 index 0000000..c1b12e5 --- /dev/null +++ b/tests/adapter.test.ts @@ -0,0 +1,368 @@ +/** + * Full-flow tests for the HTTP adapter over real local HTTP against the spike + * mock upstream: client → 127.0.0.1 server → proxy core → mock upstream. + * + * Tests inside the main describe run sequentially and share one session + * (initialize captures the upstream-issued Mcp-Session-Id; later requests + * re-send it, exercising the raw-pipe session bridging end to end). + */ +import type { Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { listeningPort, postJson, send } from "./helpers/http.js"; +import { + buildEchoResult, + buildResourceReadResult, + MOCK_INITIALIZE_RESULT, + MOCK_RESOURCES_RESULT, + MOCK_TOOLS_RESULT, + startMockUpstream, + type MockUpstream, +} from "./helpers/mock-upstream.js"; +import { createProxyCore } from "../src/core/proxy-core.js"; +import { createMcpAdapter } from "../src/http/adapter.js"; +import { createAppHandler, startHttpServer, type RequestHandler } from "../src/http/index.js"; + +const API_KEY = "sk-adapter-secret-0000"; + +describe("HTTP adapter full flow (real local HTTP → mock upstream)", () => { + let mock: MockUpstream; + let server: Server; + let base: string; + let mcpUrl: string; + // Captured at initialize; re-sent by the "client" afterwards (raw-pipe bridging). + let sessionId: string; + const sessionHeaders = () => ({ + "Mcp-Session-Id": sessionId, + "MCP-Protocol-Version": "2025-11-25", + }); + + beforeAll(async () => { + mock = await startMockUpstream(); + const core = createProxyCore({ + upstreamUrl: mock.url, + apiKey: API_KEY, + hooks: null, + }); + server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + base = `http://127.0.0.1:${listeningPort(server)}`; + mcpUrl = `${base}/mcp`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + await mock.close(); + }); + + it("initialize: upstream body verbatim, status 200, Mcp-Session-Id echoed", async () => { + const result = await postJson(mcpUrl, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "adapter-test", version: "0.0.0" }, + }, + }); + expect(result.status).toBe(200); + expect(result.contentType).toContain("application/json"); + // Byte-verbatim: the proxy re-serializes JSON.parse output, which is + // byte-stable (key order preserved) — compare raw text. + expect(result.text).toBe( + JSON.stringify({ jsonrpc: "2.0", result: MOCK_INITIALIZE_RESULT, id: 1 }) + ); + expect(result.sessionId).toBeTruthy(); + // The echoed id is the upstream-issued one, not something local. + expect(result.sessionId).toBe(mock.seen.at(-1)?.sessionId); + sessionId = result.sessionId as string; + }); + + it("re-initialize with a client-sent session id replays it upstream (proxy-restart case)", async () => { + // A client re-initializing with an id the proxy never saw (e.g. after a + // proxy restart) must have that id reach upstream — real upstream adopts + // client-sent header ids on initialize instead of minting a fresh one. + const restoredId = "restored-after-proxy-restart"; + const result = await postJson( + mcpUrl, + { + jsonrpc: "2.0", + id: 90, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t" } }, + }, + { "Mcp-Session-Id": restoredId } + ); + expect(result.status).toBe(200); + expect(mock.seen.at(-1)).toMatchObject({ method: "initialize", sessionId: restoredId }); + // Upstream (and therefore the proxy) echoes the adopted id back. + expect(result.sessionId).toBe(restoredId); + }); + + it("notifications/initialized: 204 empty; session id reaches upstream", async () => { + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", method: "notifications/initialized" }, + sessionHeaders() + ); + expect(result.status).toBe(204); + expect(result.text).toBe(""); + expect(mock.seen.at(-1)).toMatchObject({ + method: "notifications/initialized", + sessionId, + }); + }); + + it("tools/list: body verbatim vs fixture; upstream session id echoed back", async () => { + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + sessionHeaders() + ); + expect(result.status).toBe(200); + expect(result.text).toBe(JSON.stringify({ jsonrpc: "2.0", result: MOCK_TOOLS_RESULT, id: 2 })); + expect(result.sessionId).toBe(sessionId); + expect(mock.seen.at(-1)).toMatchObject({ method: "tools/list", sessionId }); + }); + + it("tools/call (non-streaming echo): JSON body verbatim with upstream status", async () => { + const args = { text: "hello adapter", n: 42 }; + const request = { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "echo", arguments: args }, + }; + const result = await postJson(mcpUrl, request, sessionHeaders()); + expect(result.status).toBe(200); + expect(result.contentType).toContain("application/json"); + expect(result.text).toBe( + JSON.stringify({ jsonrpc: "2.0", result: buildEchoResult("echo", args), id: 3 }) + ); + expect(result.sessionId).toBe(sessionId); + // The request body reached upstream verbatim over the real socket — the + // mock records the parsed body per request (Phase 7 review gap 3). + expect(mock.seen.at(-1)?.body).toEqual(request); + }); + + it("resources/list forwards generically: raw body verbatim vs fixture (Phase 7 — spike never exercised resources/*)", async () => { + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 40, method: "resources/list", params: {} }, + sessionHeaders() + ); + expect(result.status).toBe(200); + expect(result.contentType).toContain("application/json"); + // Raw body string comparison — the wire-bytes bar, not a deep-equal. + expect(result.text).toBe( + JSON.stringify({ jsonrpc: "2.0", result: MOCK_RESOURCES_RESULT, id: 40 }) + ); + expect(mock.seen.at(-1)).toMatchObject({ method: "resources/list", sessionId }); + }); + + it("resources/read forwards the uri param and relays the contents verbatim", async () => { + const uri = "tinyfish://mock/readme"; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 41, method: "resources/read", params: { uri } }, + sessionHeaders() + ); + expect(result.status).toBe(200); + expect(result.text).toBe( + JSON.stringify({ jsonrpc: "2.0", result: buildResourceReadResult(uri), id: 41 }) + ); + expect(mock.seen.at(-1)).toMatchObject({ method: "resources/read", sessionId }); + }); + + // The SSE streaming test moved to tests/relay.test.ts (Phase 5) — the relay + // suite owns all streaming coverage; no duplicate here. + + it("unknown method: upstream MethodNotFound relayed verbatim with upstream 400", async () => { + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 5, method: "prompts/list", params: {} }, + sessionHeaders() + ); + expect(result.status).toBe(400); + expect(result.text).toBe( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32601, message: "Method not found: prompts/list" }, + id: 5, + }) + ); + }); + + it("ping without a session works (no invented Mcp-Session-Id sent upstream)", async () => { + const result = await postJson(mcpUrl, { jsonrpc: "2.0", id: 6, method: "ping" }); + expect(result.status).toBe(200); + expect(JSON.parse(result.text)).toEqual({ jsonrpc: "2.0", result: {}, id: 6 }); + expect(mock.seen.at(-1)).toMatchObject({ method: "ping", sessionId: null }); + }); + + it("inbound Authorization header is ignored and never reaches upstream", async () => { + const before = mock.seen.length; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 7, method: "tools/list", params: {} }, + { ...sessionHeaders(), Authorization: "Bearer stolen-credential" } + ); + expect(result.status).toBe(200); + expect(mock.seen.length).toBe(before + 1); + expect(mock.seen.at(-1)).toMatchObject({ method: "tools/list", authorization: null }); + }); + + it("malformed JSON body: local ParseError -32700, HTTP 400, id -1; nothing forwarded", async () => { + const before = mock.seen.length; + const result = await send(mcpUrl, { + body: "{ not json", + headers: { "Content-Type": "application/json" }, + }); + expect(result.status).toBe(400); + expect(JSON.parse(result.text)).toEqual({ + jsonrpc: "2.0", + error: { code: -32700, message: "Parse error: Invalid JSON" }, + id: -1, + }); + expect(mock.seen.length).toBe(before); + }); + + it("non-/mcp paths are 404", async () => { + const getRoot = await send(`${base}/`, { method: "GET" }); + expect(getRoot.status).toBe(404); + const postOther = await postJson(`${base}/other`, { jsonrpc: "2.0", id: 8, method: "ping" }); + expect(postOther.status).toBe(404); + }); + + it("non-POST /mcp mirrors upstream: 405 with Allow: POST", async () => { + for (const method of ["GET", "DELETE"]) { + const result = await send(mcpUrl, { method }); + expect(result.status).toBe(405); + expect(result.headers.get("allow")).toBe("POST"); + } + }); + + it("GET /healthz answers 200 ok", async () => { + const result = await send(`${base}/healthz`, { method: "GET" }); + expect(result.status).toBe(200); + expect(result.text).toBe("ok"); + }); + + it("evil Origin: 403 plain text before proxying (mock sees nothing)", async () => { + const before = mock.seen.length; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 9, method: "ping" }, + { Origin: "https://evil.example.com" } + ); + expect(result.status).toBe(403); + expect(result.contentType).toContain("text/plain"); + expect(mock.seen.length).toBe(before); + }); + + it("loopback Origin is allowed through", async () => { + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 10, method: "ping" }, + { Origin: "http://localhost:6274" } // MCP Inspector's default origin + ); + expect(result.status).toBe(200); + }); + + it("client MCP-Protocol-Version header is forwarded per request", async () => { + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 11, method: "ping" }, + { "MCP-Protocol-Version": "2025-11-25" } + ); + expect(result.status).toBe(200); + expect(mock.seen.at(-1)).toMatchObject({ method: "ping", protocolVersion: "2025-11-25" }); + }); + + it("MCP-Protocol-Version is absent upstream when the client did not send one", async () => { + const result = await postJson(mcpUrl, { jsonrpc: "2.0", id: 12, method: "ping" }); + expect(result.status).toBe(200); + expect(mock.seen.at(-1)).toMatchObject({ method: "ping", protocolVersion: null }); + }); +}); + +describe("ping before initialize (fresh server, no session anywhere)", () => { + it("forwards a header-less ping before any initialize happened (upstream allows it)", async () => { + // Phase 7 gap-fill: the main suite pings AFTER its initialize ran; this + // proves the very first request a client ever sends can be a ping — no + // session header, no prior state — and it round-trips. + const mock = await startMockUpstream(); + const core = createProxyCore({ upstreamUrl: mock.url, apiKey: API_KEY, hooks: null }); + const server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + try { + const result = await postJson(`http://127.0.0.1:${listeningPort(server)}/mcp`, { + jsonrpc: "2.0", + id: 1, + method: "ping", + }); + expect(result.status).toBe(200); + // Raw body string — upstream's answer verbatim. + expect(result.text).toBe(JSON.stringify({ jsonrpc: "2.0", result: {}, id: 1 })); + expect(mock.seen).toHaveLength(1); + expect(mock.seen[0]).toMatchObject({ method: "ping", sessionId: null }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await mock.close(); + } + }); +}); + +describe("unhandled adapter failures (async-safe RequestHandler seam)", () => { + it("async handler rejection becomes a 500 JSON-RPC InternalError, not a swallow", async () => { + const rejecting: RequestHandler = async () => { + await Promise.resolve(); + throw new Error("boom (contains no secrets)"); + }; + const server = await startHttpServer(0, rejecting); + try { + const result = await send(`http://127.0.0.1:${listeningPort(server)}/anything`, { + method: "POST", + body: "{}", + }); + expect(result.status).toBe(500); + expect(JSON.parse(result.text)).toEqual({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal error" }, + id: null, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("unreachable upstream: shaped -32000 'cannot reach' error (Phase 6), no key leak", async () => { + const core = createProxyCore({ + // A loopback port with nothing listening — fetch fails fast. + upstreamUrl: "http://127.0.0.1:9/mcp", + apiKey: "sk-never-leaked-1234", + hooks: null, + }); + const server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + try { + const result = await postJson(`http://127.0.0.1:${listeningPort(server)}/mcp`, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "x" } }, + }); + expect(result.status).toBe(502); + expect(JSON.parse(result.text)).toEqual({ + jsonrpc: "2.0", + error: { + code: -32000, + message: + "cannot reach 127.0.0.1:9 — check your network; " + + "the hosted MCP server may also be temporarily unavailable", + }, + id: 1, + }); + expect(result.text).not.toContain("sk-never-leaked-1234"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..9c42c93 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + API_KEY_GUIDANCE, + ConfigError, + DEFAULT_PORT, + DEFAULT_UPSTREAM_URL, + parseConfig, +} from "../src/config.js"; + +const validEnv = { TINYFISH_API_KEY: "sk-test-123" }; + +describe("parseConfig", () => { + it("parses a fully specified env", () => { + const config = parseConfig({ + TINYFISH_API_KEY: "sk-test-123", + PORT: "8080", + TINYFISH_UPSTREAM_URL: "https://example.com/mcp", + }); + expect(config).toEqual({ + apiKey: "sk-test-123", + port: 8080, + upstreamUrl: "https://example.com/mcp", + }); + }); + + it("applies defaults for PORT and upstream URL", () => { + const config = parseConfig(validEnv); + expect(config.port).toBe(DEFAULT_PORT); + expect(config.port).toBe(3711); + expect(config.upstreamUrl).toBe(DEFAULT_UPSTREAM_URL); + expect(config.upstreamUrl).toBe("https://agent.tinyfish.ai/mcp"); + }); + + it("ignores unrelated env vars", () => { + const config = parseConfig({ ...validEnv, HOME: "/home/user", PATH: "/usr/bin" }); + expect(config.apiKey).toBe("sk-test-123"); + }); + + describe("TINYFISH_API_KEY", () => { + it("rejects a missing key with the actionable guidance", () => { + expect(() => parseConfig({})).toThrowError(ConfigError); + expect(() => parseConfig({})).toThrowError(API_KEY_GUIDANCE); + expect(() => parseConfig({})).toThrowError( + "Set TINYFISH_API_KEY — get a key at https://agent.tinyfish.ai" + ); + }); + + it("rejects an empty key with the same guidance", () => { + expect(() => parseConfig({ TINYFISH_API_KEY: "" })).toThrowError(API_KEY_GUIDANCE); + }); + + it("never includes the key value in error messages", () => { + try { + parseConfig({ TINYFISH_API_KEY: "sk-super-secret", PORT: "not-a-port" }); + expect.unreachable("parseConfig should have thrown"); + } catch (err) { + expect((err as Error).message).not.toContain("sk-super-secret"); + } + }); + }); + + describe("PORT", () => { + it.each(["0", "65536", "-1", "abc", "37.11", "3711abc", ""])( + "rejects invalid PORT %j", + (port) => { + expect(() => parseConfig({ ...validEnv, PORT: port })).toThrowError(ConfigError); + expect(() => parseConfig({ ...validEnv, PORT: port })).toThrowError( + /must be an integer between 1 and 65535/ + ); + } + ); + + it.each([ + ["1", 1], + ["65535", 65535], + ["3711", 3711], + ])("accepts boundary/typical PORT %j", (raw, expected) => { + expect(parseConfig({ ...validEnv, PORT: raw }).port).toBe(expected); + }); + }); + + describe("TINYFISH_UPSTREAM_URL", () => { + it("rejects a non-URL value", () => { + expect(() => + parseConfig({ ...validEnv, TINYFISH_UPSTREAM_URL: "not a url" }) + ).toThrowError(/must be an absolute URL/); + }); + + it("rejects http for non-loopback hosts", () => { + expect(() => + parseConfig({ ...validEnv, TINYFISH_UPSTREAM_URL: "http://example.com/mcp" }) + ).toThrowError(ConfigError); + expect(() => + parseConfig({ ...validEnv, TINYFISH_UPSTREAM_URL: "http://192.168.1.10:3000/mcp" }) + ).toThrowError(/scheme must be https/); + }); + + it("rejects non-http(s) schemes", () => { + expect(() => + parseConfig({ ...validEnv, TINYFISH_UPSTREAM_URL: "ftp://127.0.0.1/mcp" }) + ).toThrowError(ConfigError); + }); + + it.each([ + "http://127.0.0.1:9999/mcp", + "http://localhost:9999/mcp", + "http://localhost/mcp", + ])("accepts http loopback URL %s", (url) => { + expect(parseConfig({ ...validEnv, TINYFISH_UPSTREAM_URL: url }).upstreamUrl).toBe(url); + }); + + it("accepts https for any host", () => { + expect( + parseConfig({ ...validEnv, TINYFISH_UPSTREAM_URL: "https://sandbox.tinyfish.ai/mcp" }) + .upstreamUrl + ).toBe("https://sandbox.tinyfish.ai/mcp"); + }); + }); +}); diff --git a/tests/errors.test.ts b/tests/errors.test.ts new file mode 100644 index 0000000..9168b2b --- /dev/null +++ b/tests/errors.test.ts @@ -0,0 +1,480 @@ +/** + * Phase 6 error-handling tests — one test per rules-table row in + * docs/phases/phase-6-errors.md, exercised through the FULL local HTTP hop + * (client → 127.0.0.1 proxy → mock upstream) unless the row is unreachable + * over real sockets (the final-frame write race uses a stub ServerResponse). + * + * Rows: + * 1. Upstream JSON-RPC error (any code) → forwarded byte-verbatim. + * 2. Upstream 401/403, non-JSON-RPC body → -32001 + TINYFISH_API_KEY hint. + * 3. Upstream 4xx/5xx with JSON-RPC body → row 1 (verbatim, status kept). + * 4. Upstream unreachable → -32000 "cannot reach ", + * never silently retried. + * 5. Mid-stream SSE disconnect → framed -32000, "run may still + * be executing", runId in data + * when seen in progress _meta. + * 6. Local proxy bug → -32603 generic, stack to stderr. + * + Malformed client JSON → -32700, id -1, HTTP 400. + * + Phase-5 carry-over: final-frame write failure classifies as LOCAL. + * + * Locally shaped upstream-leg errors (-32000/-32001) answer HTTP 502; local + * bugs answer 500; ParseError answers 400 (decision documented in + * src/core/errors.ts). + */ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, Server, ServerResponse } from "node:http"; +import { createServer as createNetServer, type AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { automationCall, listeningPort, postJson, sseDataPayloads } from "./helpers/http.js"; +import { MOCK_RUN_ID, startMockUpstream, type MockUpstream } from "./helpers/mock-upstream.js"; +import { + AUTH_BODY_LIMIT, + toStreamErrorFrame, + UpstreamAbortedError, + UpstreamUnreachableError, +} from "../src/core/errors.js"; +import { createProxyCore, type ProxyCore } from "../src/core/proxy-core.js"; +import { createMcpAdapter } from "../src/http/adapter.js"; +import { createAppHandler, startHttpServer } from "../src/http/index.js"; +import { log } from "../src/log.js"; + +const API_KEY = "sk-errors-secret-7777"; + +interface JsonRpcErrorShape { + jsonrpc: string; + error: { code: number; message: string; data?: Record }; + id: unknown; +} + +describe("Phase 6 rules table (full local HTTP hop → mock upstream)", () => { + let mock: MockUpstream; + let server: Server; + let mcpUrl: string; + let sessionId: string; + + beforeAll(async () => { + mock = await startMockUpstream(); + const core = createProxyCore({ upstreamUrl: mock.url, apiKey: API_KEY, hooks: null }); + server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + mcpUrl = `http://127.0.0.1:${listeningPort(server)}/mcp`; + const init = await fetch(mcpUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t" } }, + }), + }); + sessionId = init.headers.get("mcp-session-id") as string; + await init.text(); + expect(sessionId).toBeTruthy(); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + await mock.close(); + }); + + afterEach(() => { + mock.authReject = null; + vi.restoreAllMocks(); + }); + + const sessionHeaders = () => ({ "Mcp-Session-Id": sessionId }); + + it("row 1: upstream JSON-RPC error forwards byte-verbatim under upstream's HTTP status", async () => { + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 3, method: "prompts/list", params: {} }, + sessionHeaders() + ); + // Upstream maps MethodNotFound to HTTP 400 — preserved, body raw-text-equal. + expect(result.status).toBe(400); + expect(result.text).toBe( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32601, message: "Method not found: prompts/list" }, + id: 3, + }) + ); + }); + + it("row 3: upstream 401 with a JSON-RPC error body forwards verbatim, 401 preserved (never -32001)", async () => { + // Error body includes data — every field must survive untouched. + const upstreamBody = JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Invalid API key. Generate a new one at https://app.tinyfish.ai", + data: { docs: "https://docs.tinyfish.ai/auth", run_hint: null }, + }, + id: 4, + }); + mock.authReject = { status: 401, contentType: "application/json", body: upstreamBody }; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 4, method: "tools/list", params: {} }, + sessionHeaders() + ); + expect(result.status).toBe(401); + expect(result.text).toBe(upstreamBody); + }); + + it("row 2: upstream 401 with a text body shapes -32001, TINYFISH_API_KEY hint, HTTP 502", async () => { + mock.authReject = { + status: 401, + contentType: "text/plain", + body: "Unauthorized: no valid credential presented (mock gateway)", + }; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 5, method: "tools/list", params: {} }, + sessionHeaders() + ); + expect(result.status).toBe(502); + const parsed = JSON.parse(result.text) as JsonRpcErrorShape; + expect(parsed.error.code).toBe(-32001); + expect(parsed.error.message).toContain("TINYFISH_API_KEY"); + expect(parsed.error.data).toEqual({ + upstreamStatus: 401, + upstreamBody: "Unauthorized: no valid credential presented (mock gateway)", + }); + expect(parsed.id).toBe(5); + expect(result.text).not.toContain(API_KEY); + }); + + it("row 2: upstream 403 with a huge HTML body truncates upstreamBody to ~2KB", async () => { + const hugeBody = `Forbidden${"x".repeat(5000)}`; + mock.authReject = { status: 403, contentType: "text/html", body: hugeBody }; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 6, method: "tools/list", params: {} }, + sessionHeaders() + ); + expect(result.status).toBe(502); + const parsed = JSON.parse(result.text) as JsonRpcErrorShape; + expect(parsed.error.code).toBe(-32001); + expect(parsed.error.data?.upstreamStatus).toBe(403); + expect(parsed.error.data?.upstreamBody).toBe(hugeBody.slice(0, AUTH_BODY_LIMIT)); + expect((parsed.error.data?.upstreamBody as string).length).toBe(AUTH_BODY_LIMIT); + }); + + it("row 2: upstream 401 with a JSON but non-JSON-RPC body also shapes -32001", async () => { + mock.authReject = { + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "unauthorized", hint: "not a JSON-RPC message" }), + }; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 7, method: "tools/list", params: {} }, + sessionHeaders() + ); + expect(result.status).toBe(502); + const parsed = JSON.parse(result.text) as JsonRpcErrorShape; + expect(parsed.error.code).toBe(-32001); + expect(parsed.error.data?.upstreamStatus).toBe(401); + }); + + it("row 2: upstream 401 with an EMPTY body still shapes -32001 (not a generic empty/protocol error)", async () => { + // Gateways and LBs strip bodies; the check-your-key hint must survive. + mock.authReject = { status: 401, contentType: "text/plain", body: "" }; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 12, method: "tools/list", params: {} }, + sessionHeaders() + ); + expect(result.status).toBe(502); + const parsed = JSON.parse(result.text) as JsonRpcErrorShape; + expect(parsed.error.code).toBe(-32001); + expect(parsed.error.message).toContain("TINYFISH_API_KEY"); + expect(parsed.error.data).toEqual({ upstreamStatus: 401, upstreamBody: "" }); + expect(parsed.id).toBe(12); + }); + + it("row 2: 401 with quasi-JSON-RPC junk (jsonrpc marker, no error/result/method) shapes -32001", async () => { + const junk = JSON.stringify({ jsonrpc: "2.0", message: "unauthorized" }); + mock.authReject = { status: 401, contentType: "application/json", body: junk }; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 13, method: "tools/list", params: {} }, + sessionHeaders() + ); + // Not a JSON-RPC message → NOT forwarded verbatim; shaped as auth error. + expect(result.status).toBe(502); + const parsed = JSON.parse(result.text) as JsonRpcErrorShape; + expect(parsed.error.code).toBe(-32001); + expect(parsed.error.data).toEqual({ upstreamStatus: 401, upstreamBody: junk }); + }); + + it("notification hitting a 401 non-JSON-RPC body: HTTP 502, -32001, id null (never a silent 204)", async () => { + // Phase 7 review gap 2 (Phase 6 probe E): the THROWING notification error + // path through the wire. The auth failure throws before the adapter's + // writeHead(204), so the shaped -32001 replaces the 204 wholesale, with + // id null (notifications carry no id). Contrast: a 401 whose body IS a + // JSON-RPC error is swallowed after a warn (pinned decision, covered at + // core level in tests/session.test.ts). + mock.authReject = { + status: 401, + contentType: "text/plain", + body: "Unauthorized (mock gateway, non-JSON-RPC body)", + }; + const result = await postJson( + mcpUrl, + { jsonrpc: "2.0", method: "notifications/initialized" }, + sessionHeaders() + ); + expect(result.status).toBe(502); + const parsed = JSON.parse(result.text) as JsonRpcErrorShape; + expect(parsed.error.code).toBe(-32001); + expect(parsed.error.message).toContain("TINYFISH_API_KEY"); + expect(parsed.error.data).toEqual({ + upstreamStatus: 401, + upstreamBody: "Unauthorized (mock gateway, non-JSON-RPC body)", + }); + expect(parsed.id).toBeNull(); + expect(result.text).not.toContain(API_KEY); + }); + + it("row 5: mid-stream disconnect emits framed -32000 with runId from progress _meta", async () => { + // Frames 0-1 carry _meta.runId; the mock kills the socket after 2 frames. + const result = await postJson(mcpUrl, automationCall(20, { crashAfterFrames: 2 }, "tok-20"), { + ...sessionHeaders(), + }); + expect(result.status).toBe(200); + expect(result.contentType).toContain("text/event-stream"); + const payloads = sseDataPayloads(result.text); + expect(payloads).toHaveLength(3); + const frame = JSON.parse(payloads[2]) as JsonRpcErrorShape; + expect(frame.jsonrpc).toBe("2.0"); + expect(frame.error.code).toBe(-32000); + expect(frame.error.message).toContain("the run may still be executing"); + expect(frame.error.data).toEqual({ runId: MOCK_RUN_ID }); + // Recovery guidance names the run id, mirroring upstream's convention. + expect(frame.error.message).toContain(MOCK_RUN_ID); + expect(frame.id).toBe(20); + }); + + it("row 5: mid-stream disconnect with no runId seen omits data entirely", async () => { + const result = await postJson( + mcpUrl, + automationCall(21, { crashAfterFrames: 2, omitRunMeta: true }, "tok-21"), + sessionHeaders() + ); + expect(result.status).toBe(200); + const payloads = sseDataPayloads(result.text); + expect(payloads).toHaveLength(3); + const frame = JSON.parse(payloads[2]) as JsonRpcErrorShape; + expect(frame.error.code).toBe(-32000); + expect(frame.error.message).toContain("the run may still be executing"); + expect("data" in frame.error).toBe(false); + expect(frame.id).toBe(21); + }); + + it("malformed client JSON: local ParseError -32700, id -1, HTTP 400", async () => { + const before = mock.seen.length; + const response = await fetch(mcpUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: '{"jsonrpc": "2.0", "id": 8, "method": ', // truncated JSON + }); + expect(response.status).toBe(400); + expect(JSON.parse(await response.text())).toEqual({ + jsonrpc: "2.0", + error: { code: -32700, message: "Parse error: Invalid JSON" }, + id: -1, + }); + // The proxy answered without forwarding — it cannot route what it cannot parse. + expect(mock.seen.length).toBe(before); + }); +}); + +describe("row 4: upstream unreachable", () => { + it("shapes -32000 'cannot reach ' at HTTP 502 and never retries silently", async () => { + // A TCP server that destroys every accepted socket: fetch fails after + // connecting, and the connection count proves there was no silent retry + // (a tools/call may have side effects). + let connections = 0; + const deadUpstream = createNetServer((socket) => { + connections += 1; + socket.destroy(); + }); + await new Promise((resolve) => deadUpstream.listen(0, "127.0.0.1", resolve)); + const port = (deadUpstream.address() as AddressInfo).port; + + const core = createProxyCore({ + upstreamUrl: `http://127.0.0.1:${port}/mcp`, + apiKey: API_KEY, + hooks: null, + }); + const server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + try { + const result = await postJson(`http://127.0.0.1:${listeningPort(server)}/mcp`, { + jsonrpc: "2.0", + id: 9, + method: "tools/call", + params: { name: "echo", arguments: { text: "side-effectful" } }, + }); + expect(result.status).toBe(502); + expect(JSON.parse(result.text)).toEqual({ + jsonrpc: "2.0", + error: { + code: -32000, + message: + `cannot reach 127.0.0.1:${port} — check your network; ` + + `the hosted MCP server may also be temporarily unavailable`, + }, + id: 9, + }); + expect(result.text).not.toContain(API_KEY); + // Exactly one upstream attempt — never retried silently. + expect(connections).toBe(1); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => deadUpstream.close(() => resolve())); + } + }); +}); + +describe("row 6: local proxy bug", () => { + it("answers generic -32603 at HTTP 500 with the stack on stderr, never the detail", async () => { + const boom = new TypeError("boom: simulated proxy bug (internal detail)"); + const buggyCore: ProxyCore = { + initialize: () => Promise.reject(boom), + notify: () => Promise.reject(boom), + forward: () => Promise.reject(boom), + forwardStream: () => Promise.reject(boom), + close: () => undefined, + closeAll: () => undefined, + }; + const errorSpy = vi.spyOn(log, "error").mockImplementation(() => undefined); + const server = await startHttpServer(0, createAppHandler(createMcpAdapter(buggyCore))); + try { + const result = await postJson(`http://127.0.0.1:${listeningPort(server)}/mcp`, { + jsonrpc: "2.0", + id: 42, + method: "tools/list", + params: {}, + }); + expect(result.status).toBe(500); + expect(JSON.parse(result.text)).toEqual({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal error" }, + id: 42, + }); + // The client never sees the internal detail... + expect(result.text).not.toContain("boom"); + // ...but stderr gets the full stack. + const logged = errorSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(logged).toContain("boom: simulated proxy bug"); + expect(logged).toContain("at "); // stack frames present + } finally { + vi.restoreAllMocks(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + +describe("toStreamErrorFrame differentiates failure kinds (Phase 6 review gap 4)", () => { + it("gives a session-close abort its own message, distinct from an upstream death", () => { + const aborted = toStreamErrorFrame(new UpstreamAbortedError("aborted"), 1, MOCK_RUN_ID); + const died = toStreamErrorFrame(new UpstreamUnreachableError("terminated"), 1, MOCK_RUN_ID); + expect(aborted.error.code).toBe(-32000); + expect(died.error.code).toBe(-32000); + expect(aborted.error.message).not.toBe(died.error.message); + expect(aborted.error.message).toContain("proxy aborted the upstream request"); + expect(died.error.message).toContain("Upstream stream ended unexpectedly"); + // Both keep the recovery guidance — a live run could be left behind. + expect(aborted.error.message).toContain("the run may still be executing"); + expect(died.error.message).toContain("the run may still be executing"); + expect(aborted.error.data).toEqual({ runId: MOCK_RUN_ID }); + expect(died.error.data).toEqual({ runId: MOCK_RUN_ID }); + }); + + it("shapes a local bug mid-stream as generic -32603, keeping the runId handle", () => { + const frame = toStreamErrorFrame(new TypeError("boom internals"), 2, MOCK_RUN_ID); + expect(frame.error.code).toBe(-32603); + expect(frame.error.message).not.toContain("boom"); + expect(frame.error.data).toEqual({ runId: MOCK_RUN_ID }); + expect(frame.id).toBe(2); + }); +}); + +describe("carry-over: final-frame write failure classifies as LOCAL (Phase 5 review gap 2)", () => { + /** + * Stub ServerResponse whose write() succeeds for the progress frames and + * fails on the final frame, WITHOUT emitting 'close' first — the narrow + * race where the socket dies between the last progress write and the final + * write. The failure must be classified as a local write failure (logged as + * such, socket destroyed), never as "upstream stream failed mid-relay". + */ + class FinalWriteFailingResponse extends EventEmitter { + headersSent = false; + writableEnded = false; + destroyed = false; + writes = 0; + constructor(private readonly failAfterWrites: number) { + super(); + } + writeHead(): this { + this.headersSent = true; + return this; + } + write(_chunk: unknown, cb?: (err?: Error | null) => void): boolean { + this.writes += 1; + if (this.writes > this.failAfterWrites) { + cb?.(new Error("EPIPE: simulated local socket death on the final frame")); + return false; + } + cb?.(); + return true; + } + end(): this { + this.writableEnded = true; + return this; + } + destroy(): this { + this.destroyed = true; + return this; + } + } + + function fakeRequest(body: unknown): IncomingMessage { + const req = Readable.from([ + Buffer.from(JSON.stringify(body)), + ]) as unknown as IncomingMessage; + (req as { headers: Record }).headers = { + "mcp-session-id": "gap2-session", + }; + return req; + } + + it("logs the LocalWriteError and destroys the socket; never 'upstream failed mid-relay'", async () => { + const mock = await startMockUpstream(); + const core = createProxyCore({ upstreamUrl: mock.url, apiKey: API_KEY, hooks: null }); + const handler = createMcpAdapter(core); + const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined); + const errorSpy = vi.spyOn(log, "error").mockImplementation(() => undefined); + try { + // 3 progress frames succeed; write #4 (the final frame) fails. + const res = new FinalWriteFailingResponse(3); + await handler(fakeRequest(automationCall(30, {}, "tok-30")), res as unknown as ServerResponse); + + expect(res.writes).toBe(4); // 3 progress + the failed final write + expect(res.destroyed).toBe(true); + expect(res.writableEnded).toBe(false); + + const warned = warnSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(warned).toContain("Relaying the final SSE frame to the local client failed"); + const errored = errorSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(errored).not.toContain("upstream stream failed mid-relay"); + expect(errored).not.toContain("Upstream unreachable"); + } finally { + vi.restoreAllMocks(); + await mock.close(); + } + }); +}); diff --git a/tests/helpers/http.ts b/tests/helpers/http.ts new file mode 100644 index 0000000..d35e4e5 --- /dev/null +++ b/tests/helpers/http.ts @@ -0,0 +1,98 @@ +/** + * Shared test helpers for suites that drive the FULL local proxy over real + * HTTP (client → 127.0.0.1 server → proxy core → mock upstream). Deduplicated + * from adapter/relay/errors suites in Phase 7 — fixture builders live in + * ./mock-upstream.ts; scripted-fetch helpers stay local to the suites that + * shape them differently. + */ +import type { Server } from "node:http"; + +/** The bound port of a listening server (tests always listen on port 0). */ +export function listeningPort(server: Server): number { + const address = server.address(); + if (typeof address === "object" && address !== null) return address.port; + throw new Error("server has no address"); +} + +export interface PostResult { + status: number; + contentType: string; + /** Mcp-Session-Id response header, null when absent. */ + sessionId: string | null; + /** Raw response body text (for byte-verbatim assertions). */ + text: string; + headers: Headers; +} + +/** Raw request sender — method/body/headers exactly as given. */ +export async function send( + url: string, + init: { method?: string; body?: string; headers?: Record } = {} +): Promise { + const response = await fetch(url, { + method: init.method ?? "POST", + headers: init.headers ?? {}, + body: init.body, + }); + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + sessionId: response.headers.get("mcp-session-id"), + text: await response.text(), + headers: response.headers, + }; +} + +/** POST a JSON body and read the whole response (raw text preserved). */ +export function postJson( + url: string, + body: unknown, + headers: Record = {} +): Promise { + return send(url, { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +/** Parse the `data:` payload strings out of an SSE body, in order, one per frame. */ +export function sseDataPayloads(text: string): string[] { + const payloads: string[] = []; + for (const frame of text.split("\n\n")) { + const dataLines: string[] = []; + for (const line of frame.split("\n")) { + if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, "")); + } + if (dataLines.length > 0) payloads.push(dataLines.join("\n")); + } + return payloads; +} + +/** A tools/call of run_web_automation with optional mock knobs in arguments. */ +export function automationCall( + id: number, + args: Record, + progressToken?: string +): Record { + return { + jsonrpc: "2.0", + id, + method: "tools/call", + params: { + name: "run_web_automation", + arguments: { goal: "extract mock data", ...args }, + ...(progressToken !== undefined ? { _meta: { progressToken } } : {}), + }, + }; +} + +/** A Response with a JSON body (scripted/injected-fetch suites). */ +export function jsonResponse( + body: unknown, + { status = 200, headers = {} }: { status?: number; headers?: Record } = {} +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} diff --git a/tests/helpers/mock-upstream.ts b/tests/helpers/mock-upstream.ts new file mode 100644 index 0000000..5e8d844 --- /dev/null +++ b/tests/helpers/mock-upstream.ts @@ -0,0 +1,520 @@ +/** + * Mock of the hosted https://agent.tinyfish.ai/mcp endpoint. + * + * Faithful to docs/phases/00-shared-context.md §Verified upstream behavior + * (verified against ux-labs/frontend/app/mcp/{route.ts,lib/http-handler.ts, + * mcp-sse-event-formatter.ts,shared/json-rpc.ts}): + * + * - POST-only (anything else gets 405, like Next.js's missing-export handling). + * - initialize with no Mcp-Session-Id header generates a UUID; successful JSON + * responses echo Mcp-Session-Id but the SSE path does not (sse-event-handling.ts: + * 396-403 sets only stream headers). Session ids are NOT validated (any non-empty + * string is accepted). + * - Non-ping requests without the header get JSON-RPC -32600 + * "Missing required Mcp-Session-Id header" (HTTP 400, per shared/json-rpc.ts + * which maps client-error codes to HTTP 400 and the rest to 500). + * - Notifications return HTTP 204 with an empty body. + * - Supported methods: ping, initialize, tools/list, tools/call, resources/list, + * resources/read. Anything else -> -32601 "Method not found: ". + * - tools/call of run_web_automation streams text/event-stream: `data:` frames + * only (no `event:` lines, no `:` comments — heartbeats are ordinary progress + * notifications), 3 progress notifications then the final JSON-RPC response. + * - Other tool names echo their arguments back as a CallToolResult. + * - Asserts the proxy sent X-API-Key and the X-TF-* attribution headers. + * DIVERGENCE from real upstream (deliberately stricter, and an invented 401 + * shape): the real server 204s notifications before auth runs; this mock + * rejects notifications missing the headers too, so tests catch header + * regressions on every call type. + * + * Phase 7: promoted from spike/mock-upstream.ts — this is now the single + * fixture shared by every test suite (and the spike scripts, which remain + * type-checked). Script knobs: `authReject` (canned auth-layer rejection), + * plus per-call tool arguments `frameDelayMs`, `crashAfterFrames`, + * `omitRunMeta`, and `noProgress` on run_web_automation; `seen` records + * method / sessionId / authorization / protocolVersion / aborted / parsed + * request body per request. + */ +import { randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +export const ErrorCodes = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, +} as const; + +type JsonRpcId = string | number; + +const SESSION_HEADER = "Mcp-Session-Id"; + +const REQUIRED_PROXY_HEADERS = [ + "x-api-key", + "x-tf-request-origin", + "x-tf-client-name", + "x-tf-client-version", +] as const; + +// --------------------------------------------------------------------------- +// Fixtures (exported so the scripted client can byte-compare against them) +// --------------------------------------------------------------------------- + +export const MOCK_RUN_ID = "run_mock_0001"; + +export const MOCK_INSTRUCTIONS = + "TinyFish Search and Fetch are free and the most token-efficient way to retrieve current " + + "web context. (Mock stand-in for the hosted server's long instructions string — the proxy " + + "must pass it through verbatim, byte for byte, including this parenthetical.)"; + +/** Shape mirrors route.ts handleInitialize (protocolVersion always 2025-11-25). */ +export const MOCK_INITIALIZE_RESULT = { + protocolVersion: "2025-11-25", + capabilities: { + tools: { listChanged: false }, + resources: { listChanged: false }, + }, + serverInfo: { name: "tinyfish", version: "9.9.9-mock" }, + instructions: MOCK_INSTRUCTIONS, +}; + +export const MOCK_TOOLS_RESULT = { + tools: [ + { + name: "echo", + description: "Mock echo tool: returns its arguments as text.", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + additionalProperties: true, + }, + }, + { + name: "run_web_automation", + description: + "Mock of the hosted automation tool. Streams progress notifications over SSE, " + + "then a final CallToolResult. Include _meta.progressToken for progress notifications.", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + goal: { type: "string" }, + }, + required: ["goal"], + }, + }, + ], +}; + +export const MOCK_RESOURCES_RESULT = { + resources: [ + { + uri: "tinyfish://mock/readme", + name: "mock-readme", + mimeType: "text/plain", + description: "A mock resource served by the fake upstream.", + }, + ], +}; + +export function buildResourceReadResult(uri: string) { + return { + contents: [{ uri, mimeType: "text/plain", text: `mock contents of ${uri}` }], + }; +} + +/** Mirrors json-rpc.ts createWrapResult / the echo path: a plain CallToolResult. */ +export function buildEchoResult(name: string, args: unknown) { + return { + content: [ + { + type: "text", + text: `Echo from mock upstream tool "${name}": ${JSON.stringify(args ?? {})}`, + }, + ], + isError: false, + }; +} + +/** + * The scripted SSE sequence for run_web_automation, mirroring MCPSSEFormatter: + * params key order progressToken, progress, total, message, _meta; heartbeat is + * an ordinary progress notification; final response result key order + * content, isError, _meta, structuredContent (formatComplete sets _meta before + * structuredContent). + */ +export function buildAutomationSseMessages( + progressToken: string | number | undefined, + requestId: JsonRpcId, +): Record[] { + const token = progressToken ?? "unknown"; + const resultJson = { headline: "Mock automation extracted this", items: [1, 2, 3] }; + return [ + { + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: token, + progress: 0, + total: 100, + message: + `Run ${MOCK_RUN_ID} started. IMPORTANT: If this tool errors or times out, do NOT ` + + `retry. The run is still executing. Call get_run with id "${MOCK_RUN_ID}" to check ` + + `status instead.`, + _meta: { runId: MOCK_RUN_ID }, + }, + }, + { + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: token, + progress: 1, + total: 100, + message: "Navigating to the target page", + _meta: { + runId: MOCK_RUN_ID, + screenshotUrl: `https://mock.tinyfish.ai/screenshots/${MOCK_RUN_ID}/latest.png`, + }, + }, + }, + { + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: token, + progress: 2, + total: 100, + message: "Heartbeat: run is still ongoing", + }, + }, + { + jsonrpc: "2.0", + id: requestId, + result: { + content: [{ type: "text", text: JSON.stringify(resultJson, null, 2) }], + isError: false, + _meta: { profile_hint: "Mock profile hint: pass use_profile=true to reuse saved sessions." }, + structuredContent: { + runId: MOCK_RUN_ID, + status: "completed", + runUrl: `https://mock.tinyfish.ai/runs/${MOCK_RUN_ID}`, + result: resultJson, + }, + }, + }, + ]; +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +export interface MockUpstream { + url: string; + port: number; + server: Server; + /** + * Requests seen, in order (method + session id + inbound Authorization + * header, which the proxy must never forward), for client-side assertions. + * `aborted` flips to true when the proxy tears the connection down before + * the response finished (Phase 5: client-disconnect must propagate as an + * upstream abort — the mock observes it here). + */ + seen: Array<{ + method: string | undefined; + sessionId: string | null; + authorization: string | null; + protocolVersion: string | null; + aborted: boolean; + /** The parsed JSON request body, so tests can assert the request reached upstream verbatim. */ + body: unknown; + }>; + /** + * Phase 6 knob (mutable): when set, EVERY request is answered with this + * canned rejection before any routing — simulating an auth layer or + * intermediary answering with an arbitrary status/content-type/body (e.g. a + * 401 text page, or a 401 whose body IS a JSON-RPC error). The body string + * is sent verbatim so tests can byte-compare. Reset to null when done. + */ + authReject: { status: number; contentType: string; body: string } | null; + close(): Promise; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function sendJson( + res: ServerResponse, + status: number, + payload: unknown, + extraHeaders: Record = {}, +): void { + res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders }); + res.end(JSON.stringify(payload)); +} + +function jsonRpcError( + res: ServerResponse, + code: number, + message: string, + id: JsonRpcId, + extraHeaders: Record = {}, +): void { + // shared/json-rpc.ts: client-error codes -> HTTP 400, everything else -> 500. + const isClientError = + code === ErrorCodes.ParseError || + code === ErrorCodes.InvalidRequest || + code === ErrorCodes.InvalidParams || + code === ErrorCodes.MethodNotFound; + sendJson( + res, + isClientError ? 400 : 500, + { jsonrpc: "2.0", error: { code, message }, id }, + extraHeaders, + ); +} + +function jsonRpcSuccess( + res: ServerResponse, + result: unknown, + id: JsonRpcId, + sessionHeaders: Record, +): void { + sendJson(res, 200, { jsonrpc: "2.0", result, id }, sessionHeaders); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Stream the scripted SSE sequence. Test-only knobs read from the tool + * arguments (the real upstream ignores unknown arguments, so these are safe + * mock divergences): + * - `frameDelayMs` (default 15): delay between frames — slow it down so tests + * and manual checks can abort mid-stream deterministically. + * - `crashAfterFrames`: destroy the socket after N frames (mid-stream + * upstream-disconnect simulation; the proxy must not relay it as a clean + * end). + * - `omitRunMeta` (Phase 6): strip `params._meta` from progress frames, so a + * crash test can exercise the no-runId-seen branch of the mid-stream error + * frame. + * - `noProgress` (Phase 7): degenerate stream — the FIRST frame is the final + * JSON-RPC response, no progress notifications precede it (still served as + * text/event-stream, like an upstream whose run finishes instantly). + * A premature client (= proxy) disconnect marks the seen entry aborted and + * stops the frame loop — no writes into a dead socket. + */ +async function streamAutomation( + res: ServerResponse, + scriptedMessages: Record[], + entry: MockUpstream["seen"][number], + args: Record, +): Promise { + const frameDelayMs = typeof args.frameDelayMs === "number" ? args.frameDelayMs : 15; + const crashAfterFrames = + typeof args.crashAfterFrames === "number" ? args.crashAfterFrames : undefined; + let messages = + args.omitRunMeta === true + ? scriptedMessages.map((message) => { + const params = message.params as Record | undefined; + if (params === undefined || !("_meta" in params)) return message; + const rest = Object.fromEntries( + Object.entries(params).filter(([key]) => key !== "_meta"), + ); + return { ...message, params: rest }; + }) + : scriptedMessages; + if (args.noProgress === true) { + // Only the final JSON-RPC response frame — no progress notifications. + messages = messages.slice(-1); + } + let clientGone = false; + res.on("close", () => { + // 'close' also fires after a normal end(); only a close before the + // response finished is a premature teardown (= the proxy aborted). + if (!res.writableEnded) { + clientGone = true; + entry.aborted = true; + } + }); + // Real upstream SSE responses do NOT echo Mcp-Session-Id — sse-event-handling.ts:396-403 + // sets only the stream headers below (verified in phase-0 review). + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + let written = 0; + for (const message of messages) { + if (clientGone) return; + if (crashAfterFrames !== undefined && written >= crashAfterFrames) { + res.destroy(); + return; + } + // Upstream frames are bare `data:` lines (sse-event-handling.ts:273). + res.write(`data: ${JSON.stringify(message)}\n\n`); + written += 1; + await sleep(frameDelayMs); + } + res.end(); +} + +export function startMockUpstream(): Promise { + const seen: MockUpstream["seen"] = []; + + const server = createServer((req, res) => { + void handle(req, res).catch((err: unknown) => { + jsonRpcError(res, ErrorCodes.InternalError, `Internal server error: ${String(err)}`, -1); + }); + }); + + const mock: MockUpstream = { + url: "", + port: 0, + server, + seen, + authReject: null, + close: () => new Promise((r) => server.close(() => r())), + }; + + async function handle(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== "POST") { + // Next.js returns 405 for methods the route does not export (incl. DELETE). + res.writeHead(405, { Allow: "POST" }); + res.end(); + return; + } + + // Phase 6 knob: canned auth-layer rejection, body verbatim, before routing. + if (mock.authReject !== null) { + res.writeHead(mock.authReject.status, { "Content-Type": mock.authReject.contentType }); + res.end(mock.authReject.body); + return; + } + + for (const header of REQUIRED_PROXY_HEADERS) { + if (!req.headers[header]) { + sendJson(res, 401, { + jsonrpc: "2.0", + error: { code: -32000, message: `Mock upstream: missing required header ${header}` }, + id: -1, + }); + return; + } + } + + const raw = await readBody(req); + let body: Record; + try { + body = JSON.parse(raw) as Record; + } catch { + jsonRpcError(res, ErrorCodes.ParseError, "Parse error: Invalid JSON", -1); + return; + } + + const method = typeof body.method === "string" ? body.method : undefined; + const isNotification = method !== undefined && !("id" in body); + + // Notifications are handled before session resolution (http-handler.ts:147-153). + if (isNotification) { + seen.push({ + method, + sessionId: (req.headers["mcp-session-id"] as string) ?? null, + authorization: req.headers.authorization ?? null, + protocolVersion: (req.headers["mcp-protocol-version"] as string) ?? null, + aborted: false, + body, + }); + res.writeHead(204); + res.end(); + return; + } + + if (body.jsonrpc !== "2.0" || method === undefined || body.id === undefined) { + jsonRpcError( + res, + ErrorCodes.InvalidRequest, + "Invalid JSON-RPC 2.0 request format", + (body.id as JsonRpcId) ?? -1, + ); + return; + } + const id = body.id as JsonRpcId; + const params = (body.params ?? {}) as Record; + + // Session model (http-handler.ts:218-223): header wins; initialize mints a + // UUID; ids are never validated beyond non-emptiness. + const headerSession = (req.headers["mcp-session-id"] as string | undefined) || null; + const sessionId = headerSession ?? (method === "initialize" ? randomUUID() : null); + if (sessionId === null && method !== "ping") { + jsonRpcError(res, ErrorCodes.InvalidRequest, "Missing required Mcp-Session-Id header", id); + return; + } + const sessionHeaders: Record = sessionId + ? { [SESSION_HEADER]: sessionId } + : {}; + const entry: MockUpstream["seen"][number] = { + method, + sessionId, + authorization: req.headers.authorization ?? null, + protocolVersion: (req.headers["mcp-protocol-version"] as string) ?? null, + aborted: false, + body, + }; + seen.push(entry); + + switch (method) { + case "ping": + jsonRpcSuccess(res, {}, id, sessionHeaders); + return; + case "initialize": + jsonRpcSuccess(res, MOCK_INITIALIZE_RESULT, id, sessionHeaders); + return; + case "tools/list": + jsonRpcSuccess(res, MOCK_TOOLS_RESULT, id, sessionHeaders); + return; + case "tools/call": { + const name = typeof params.name === "string" ? params.name : ""; + if (name === "run_web_automation") { + const meta = (params._meta ?? {}) as Record; + const token = meta.progressToken as string | number | undefined; + const args = (params.arguments ?? {}) as Record; + await streamAutomation(res, buildAutomationSseMessages(token, id), entry, args); + return; + } + jsonRpcSuccess(res, buildEchoResult(name, params.arguments), id, sessionHeaders); + return; + } + case "resources/list": + jsonRpcSuccess(res, MOCK_RESOURCES_RESULT, id, sessionHeaders); + return; + case "resources/read": + jsonRpcSuccess( + res, + buildResourceReadResult(typeof params.uri === "string" ? params.uri : "mock://unknown"), + id, + sessionHeaders, + ); + return; + default: + jsonRpcError(res, ErrorCodes.MethodNotFound, `Method not found: ${method}`, id); + return; + } + } + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + mock.port = port; + mock.url = `http://127.0.0.1:${port}/mcp`; + resolve(mock); + }); + }); +} diff --git a/tests/origin.test.ts b/tests/origin.test.ts new file mode 100644 index 0000000..7181a2b --- /dev/null +++ b/tests/origin.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { checkOrigin } from "../src/http/origin.js"; + +describe("checkOrigin", () => { + const allowed: Array = [ + undefined, // non-browser clients send no Origin + "http://127.0.0.1:3711", + "http://localhost:3711", + // any loopback port variant is fine (phase doc) + "http://127.0.0.1:8080", + "http://localhost:1234", + "http://localhost", // default port + "http://127.0.0.1", + // https loopback (e.g. a locally-served https dev page) + "https://127.0.0.1:3711", + "https://localhost:3711", + ]; + + const denied: string[] = [ + "https://evil.example.com", + "http://evil.example.com:3711", + "https://agent.tinyfish.ai", + "http://127.0.0.2:3711", // other loopback addresses are not allowlisted + "http://127.0.0.1.evil.com:3711", // prefix-spoofed hostname + "http://localhost.evil.com:3711", + "http://[::1]:3711", // IPv6 loopback — server binds IPv4 loopback only + "null", // sandboxed iframe / file:// pages + "file:///etc/passwd", + "chrome-extension://abcdefghijklmnop", + "ws://127.0.0.1:3711", // non-http(s) scheme + "not a url", + "", + ]; + + for (const origin of allowed) { + it(`allows ${origin === undefined ? "" : `"${origin}"`}`, () => { + expect(checkOrigin(origin)).toBe(true); + }); + } + + for (const origin of denied) { + it(`denies "${origin}"`, () => { + expect(checkOrigin(origin)).toBe(false); + }); + } +}); diff --git a/tests/proxy.integration.test.ts b/tests/proxy.integration.test.ts new file mode 100644 index 0000000..489fb59 --- /dev/null +++ b/tests/proxy.integration.test.ts @@ -0,0 +1,231 @@ +/** + * Gated integration tests — the real local proxy against the REAL hosted + * upstream (default https://agent.tinyfish.ai/mcp, override with + * TINYFISH_UPSTREAM_URL, e.g. a sandbox deployment). + * + * Run: TINYFISH_API_KEY=... npm run test:integration + * Without the key the suite skips with a printed notice (CLI skip pattern — + * ux-labs/sdk/cli/tests/api.integration.test.ts). + * + * BLOCKED ON BACKEND: the hosted server's auth chain (shared/resolve-user-id.ts) + * accepts Bearer / HMAC widget token / Clerk OAuth today; the X-API-Key → + * validateApiKey branch is an external ux-labs PR that has NOT yet reached + * sandbox. Until that branch deploys, no API key can authenticate this proxy's + * upstream calls, so this suite effectively never runs — it exists so CI turns + * green the day the backend lands (set the TINYFISH_API_KEY secret). + * + * Coverage when the key is set: + * - tools/list via the proxy deep-equals a direct upstream tools/list (the + * parity guarantee made executable); + * - one cheap tools/call (fetch_content) round-trips; + * - one run_web_automation yields ≥1 progress notification then a final result. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { listeningPort, postJson, sseDataPayloads } from "./helpers/http.js"; +import { createProxyCore } from "../src/core/proxy-core.js"; +import { createMcpAdapter } from "../src/http/adapter.js"; +import { createAppHandler, startHttpServer } from "../src/http/index.js"; +import { VERSION } from "../src/version.js"; + +const API_KEY = process.env.TINYFISH_API_KEY; +const UPSTREAM_URL = process.env.TINYFISH_UPSTREAM_URL ?? "https://agent.tinyfish.ai/mcp"; + +if (!API_KEY) { + process.stderr.write( + "\nproxy.integration: TINYFISH_API_KEY is not set — skipping the integration suite " + + "(unit coverage runs offline via `npm test`).\n" + + "Run with: TINYFISH_API_KEY=... npm run test:integration\n\n" + ); +} + +const INITIALIZE_PARAMS = { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "tinyfish-mcp-integration", version: VERSION }, +}; + +/** Direct upstream POST, bypassing the proxy — the parity baseline. */ +async function postUpstream( + body: unknown, + sessionId?: string +): Promise<{ status: number; sessionId: string | null; json: unknown }> { + const response = await fetch(UPSTREAM_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "X-API-Key": API_KEY as string, + "X-TF-Request-Origin": "tinyfish-mcp", + "X-TF-Client-Name": "tinyfish-mcp", + "X-TF-Client-Version": VERSION, + ...(sessionId !== undefined ? { "Mcp-Session-Id": sessionId } : {}), + }, + body: JSON.stringify(body), + }); + return { + status: response.status, + sessionId: response.headers.get("mcp-session-id"), + json: JSON.parse(await response.text()), + }; +} + +interface JsonRpcResponse { + jsonrpc: string; + result?: Record; + error?: { code: number; message: string }; + id: unknown; +} + +const describeWithApiKey = API_KEY ? describe.sequential : describe.skip; + +describeWithApiKey("proxy integration (real hosted upstream)", () => { + let server: Awaited>; + let mcpUrl: string; + let sessionId: string; + + beforeAll(async () => { + const core = createProxyCore({ + upstreamUrl: UPSTREAM_URL, + apiKey: API_KEY as string, + hooks: null, + }); + server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + mcpUrl = `http://127.0.0.1:${listeningPort(server)}/mcp`; + + const init = await postJson(mcpUrl, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: INITIALIZE_PARAMS, + }); + expect(init.status).toBe(200); + const body = JSON.parse(init.text) as JsonRpcResponse; + expect(body.error).toBeUndefined(); + // Upstream always answers 2025-11-25 regardless of the requested version. + expect(body.result?.protocolVersion).toBe("2025-11-25"); + expect(init.sessionId).toBeTruthy(); + sessionId = init.sessionId as string; + + const notified = await postJson( + mcpUrl, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { "Mcp-Session-Id": sessionId } + ); + expect(notified.status).toBe(204); + }, 60_000); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it( + "tools/list via the proxy deep-equals a direct upstream tools/list", + async () => { + const viaProxy = await postJson( + mcpUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + { "Mcp-Session-Id": sessionId } + ); + expect(viaProxy.status).toBe(200); + const proxied = JSON.parse(viaProxy.text) as JsonRpcResponse; + expect(proxied.error).toBeUndefined(); + + // Direct baseline: its own upstream session, same request. + const directInit = await postUpstream({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: INITIALIZE_PARAMS, + }); + expect(directInit.status).toBe(200); + const direct = await postUpstream( + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + directInit.sessionId ?? undefined + ); + expect(direct.status).toBe(200); + + // The parity guarantee: byte-order aside, the proxied answer IS the + // upstream answer. + expect(proxied.result).toEqual((direct.json as JsonRpcResponse).result); + const tools = proxied.result?.tools as Array<{ name: string }>; + expect(tools.length).toBeGreaterThan(0); + }, + 60_000 + ); + + it( + "a cheap tools/call (fetch_content) round-trips through the proxy", + async () => { + const result = await postJson( + mcpUrl, + { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "fetch_content", + arguments: { url: "https://example.com" }, + }, + }, + { "Mcp-Session-Id": sessionId } + ); + expect(result.status).toBe(200); + const body = JSON.parse(result.text) as JsonRpcResponse; + expect(body.error).toBeUndefined(); + expect(body.id).toBe(3); + const content = body.result?.content as Array<{ type: string; text?: string }>; + expect(Array.isArray(content)).toBe(true); + expect(content.length).toBeGreaterThan(0); + }, + 120_000 + ); + + it( + "run_web_automation streams ≥1 progress notification then a final result", + async () => { + const response = await fetch(mcpUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Mcp-Session-Id": sessionId }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "run_web_automation", + arguments: { + url: "https://example.com", + goal: "Read the page heading and report it.", + }, + _meta: { progressToken: "integ-tok-4" }, + }, + }), + }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const payloads = sseDataPayloads(await response.text()); + expect(payloads.length).toBeGreaterThanOrEqual(2); + + const messages = payloads.map((p) => JSON.parse(p) as Record); + const progress = messages.filter((m) => m.method === "notifications/progress"); + expect(progress.length).toBeGreaterThanOrEqual(1); + + const final = messages[messages.length - 1] as unknown as JsonRpcResponse; + expect(final.id).toBe(4); + expect(final.result ?? final.error).toBeDefined(); + // A finished automation answers a CallToolResult (content array), even + // for a failed run (isError true) — the stream must end with it. + if (final.result !== undefined) { + expect(Array.isArray(final.result.content)).toBe(true); + } + }, + 600_000 + ); +}); + +// Mirror of the CLI pattern: keep a visible, always-collected marker of the +// skip so a keyless run reports 1 passed test instead of "no tests found". +describe.skipIf(Boolean(API_KEY))("proxy integration (real hosted upstream)", () => { + it("skips real upstream coverage when TINYFISH_API_KEY is not set", () => { + expect(API_KEY).toBeFalsy(); + }); +}); diff --git a/tests/relay.test.ts b/tests/relay.test.ts new file mode 100644 index 0000000..9aa3a0a --- /dev/null +++ b/tests/relay.test.ts @@ -0,0 +1,422 @@ +/** + * Phase 5 SSE relay tests over real local HTTP: client → 127.0.0.1 server → + * proxy core → mock upstream. Covers ordered byte-verbatim relay (raw `data:` + * payload strings — and the full SSE body — compared on the wire), + * progressToken preservation (incl. upstream's 'unknown' fill-in), + * client-abort-mid-stream canceling the upstream request (the mock observes + * the abort), independent concurrent session streams, mid-stream upstream + * crash (Phase 6 in-stream error frame), local-write vs upstream error + * classification at the core level, >64KB SSE frames through the full local + * server (Phase 7), and shutdown (closeAll via shutdown hooks) aborting an + * in-flight stream with the framed abort error (Phase 7). + * + * The frame-verbatim SSE test formerly in adapter.test.ts moved here. + */ +import { createServer, type Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { automationCall, listeningPort, postJson, sseDataPayloads } from "./helpers/http.js"; +import { + buildAutomationSseMessages, + MOCK_RUN_ID, + startMockUpstream, + type MockUpstream, +} from "./helpers/mock-upstream.js"; +import { LocalWriteError, UpstreamUnreachableError } from "../src/core/errors.js"; +import { createProxyCore, type ProxyCore } from "../src/core/proxy-core.js"; +import { createMcpAdapter } from "../src/http/adapter.js"; +import { createAppHandler, startHttpServer } from "../src/http/index.js"; + +const API_KEY = "sk-relay-secret-5555"; + +describe("SSE relay (real local HTTP → mock upstream)", () => { + let mock: MockUpstream; + let server: Server; + let mcpUrl: string; + let sessionId: string; + + const sessionHeaders = () => ({ "Mcp-Session-Id": sessionId }); + + async function initializeSession(id: number): Promise { + const result = await postJson(mcpUrl, { + jsonrpc: "2.0", + id, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "relay-test", version: "0.0.0" }, + }, + }); + expect(result.status).toBe(200); + expect(result.sessionId).toBeTruthy(); + return result.sessionId as string; + } + + beforeAll(async () => { + mock = await startMockUpstream(); + const core = createProxyCore({ upstreamUrl: mock.url, apiKey: API_KEY, hooks: null }); + server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + mcpUrl = `http://127.0.0.1:${listeningPort(server)}/mcp`; + sessionId = await initializeSession(1); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + await mock.close(); + }); + + it("relays the ordered sequence with byte-identical raw data payloads; no session header", async () => { + const result = await postJson(mcpUrl, automationCall(4, {}, "tok-relay"), sessionHeaders()); + expect(result.status).toBe(200); + expect(result.contentType).toContain("text/event-stream"); + // Upstream's SSE path sets no Mcp-Session-Id; neither may the proxy. + expect(result.sessionId).toBeNull(); + const expected = buildAutomationSseMessages("tok-relay", 4); + // Raw data: payload strings on the wire, in order, final frame last. + expect(sseDataPayloads(result.text)).toEqual(expected.map((m) => JSON.stringify(m))); + // Stronger (Phase 7 wire-bytes bar): the ENTIRE raw SSE body received on + // the socket is byte-identical to what the mock wrote — framing included, + // no re-parse anywhere in this comparison. + expect(result.text).toBe(expected.map((m) => `data: ${JSON.stringify(m)}\n\n`).join("")); + }); + + it("preserves upstream's 'unknown' progressToken when the client sent none", async () => { + const result = await postJson(mcpUrl, automationCall(5, {}), sessionHeaders()); + expect(result.status).toBe(200); + const payloads = sseDataPayloads(result.text); + const expected = buildAutomationSseMessages(undefined, 5); + expect(payloads).toEqual(expected.map((m) => JSON.stringify(m))); + // The fill-in token really is the literal string 'unknown', relayed untouched. + const first = JSON.parse(payloads[0]) as { params: { progressToken: unknown } }; + expect(first.params.progressToken).toBe("unknown"); + }); + + it("aborts the upstream request when the local client disconnects mid-stream", async () => { + const seenBefore = mock.seen.length; + const abort = new AbortController(); + const response = await fetch(mcpUrl, { + method: "POST", + headers: { "Content-Type": "application/json", ...sessionHeaders() }, + body: JSON.stringify(automationCall(6, { frameDelayMs: 400 }, "tok-abort")), + signal: abort.signal, + }); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + // Read the first relayed frame, then drop the connection mid-stream. + const reader = (response.body as ReadableStream).getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + abort.abort(); + + // The mock must OBSERVE the abort — the proxy tore the upstream request + // down; nothing is orphaned waiting on the remaining scripted frames. + await vi.waitFor( + () => { + const call = mock.seen[seenBefore]; + expect(call?.method).toBe("tools/call"); + expect(call?.aborted).toBe(true); + }, + { timeout: 3000 } + ); + + // The proxy is still healthy afterwards. + const ping = await postJson(mcpUrl, { jsonrpc: "2.0", id: 7, method: "ping" }); + expect(ping.status).toBe(200); + }); + + it("streams two concurrent sessions independently", async () => { + const otherSession = await initializeSession(10); + expect(otherSession).not.toBe(sessionId); + + const [a, b] = await Promise.all([ + postJson(mcpUrl, automationCall(11, {}, "tok-A"), { "Mcp-Session-Id": sessionId }), + postJson(mcpUrl, automationCall(12, {}, "tok-B"), { "Mcp-Session-Id": otherSession }), + ]); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + expect(sseDataPayloads(a.text)).toEqual( + buildAutomationSseMessages("tok-A", 11).map((m) => JSON.stringify(m)) + ); + expect(sseDataPayloads(b.text)).toEqual( + buildAutomationSseMessages("tok-B", 12).map((m) => JSON.stringify(m)) + ); + }); + + it("PINNED: a no-progress SSE stream (first frame is final) downgrades to a plain JSON response", async () => { + // Phase 7 review gap 1: when upstream's stream carries ONLY the final + // JSON-RPC response (no progress frames first), `streaming` never flips in + // relayPossiblyStreaming, so the adapter answers the tools/call as an + // ordinary application/json response instead of opening an SSE stream — + // the documented degenerate-stream behavior in src/http/adapter.ts. + // Real upstream always emits progress first; this test pins the fallback. + const result = await postJson( + mcpUrl, + automationCall(15, { noProgress: true }, "tok-noprog"), + sessionHeaders() + ); + expect(result.status).toBe(200); + expect(result.contentType).toContain("application/json"); + expect(result.contentType).not.toContain("text/event-stream"); + // No session header either: the core's SSE path reports sessionId null. + expect(result.sessionId).toBeNull(); + const finalMessage = buildAutomationSseMessages("tok-noprog", 15).at(-1); + // Raw body string: the re-serialized final frame, byte-stable vs fixture. + expect(result.text).toBe(JSON.stringify(finalMessage)); + }); + + it("emits the Phase 6 in-stream error frame when upstream dies mid-stream", async () => { + // The mock destroys the socket after 2 frames; the local client must see + // the 2 relayed frames plus an SSE-framed error — never an unframed body, + // never a silent clean end. Phase 6 shape: -32000, "run may still be + // executing", runId (seen in progress _meta) in data. Full payload + // assertions live in tests/errors.test.ts. + const result = await postJson( + mcpUrl, + automationCall(20, { crashAfterFrames: 2 }, "tok-crash"), + sessionHeaders() + ); + expect(result.status).toBe(200); + expect(result.contentType).toContain("text/event-stream"); + const payloads = sseDataPayloads(result.text); + const expected = buildAutomationSseMessages("tok-crash", 20); + expect(payloads).toHaveLength(3); + expect(payloads.slice(0, 2)).toEqual(expected.slice(0, 2).map((m) => JSON.stringify(m))); + const errorFrame = JSON.parse(payloads[2]) as { + jsonrpc: string; + error: { code: number; message: string; data?: { runId?: string } }; + id: unknown; + }; + expect(errorFrame.jsonrpc).toBe("2.0"); + expect(errorFrame.error.code).toBe(-32000); + expect(errorFrame.error.message).toContain("the run may still be executing"); + expect(errorFrame.error.data?.runId).toBe(MOCK_RUN_ID); + expect(errorFrame.id).toBe(20); + + // The proxy survives the upstream crash. + const ping = await postJson(mcpUrl, { jsonrpc: "2.0", id: 21, method: "ping" }); + expect(ping.status).toBe(200); + }); +}); + +describe("raw byte relay fidelity (odd-bytes upstream)", () => { + it("relays non-canonical upstream payload bytes verbatim (no re-serialization)", async () => { + // JSON.stringify(JSON.parse(x)) would normalize this spacing away — only + // a true raw relay reproduces it on the local wire. + const oddProgress = + '{ "jsonrpc" : "2.0" , "method" : "notifications/progress" , ' + + '"params" : { "progressToken" : "tok-raw" , "progress" : 1 , "total" : 100 , "message" : "odd spacing" } }'; + const oddFinal = '{ "jsonrpc" : "2.0" , "id" : 7 , "result" : { "ok" : true } }'; + const upstream = createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write(`data: ${oddProgress}\n\n`); + res.write(`data: ${oddFinal}\n\n`); + res.end(); + }); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const core = createProxyCore({ + upstreamUrl: `http://127.0.0.1:${listeningPort(upstream)}/mcp`, + apiKey: API_KEY, + hooks: null, + }); + const server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + try { + const result = await postJson( + `http://127.0.0.1:${listeningPort(server)}/mcp`, + automationCall(7, {}, "tok-raw"), + { "Mcp-Session-Id": "raw-session" } + ); + expect(result.status).toBe(200); + expect(result.contentType).toContain("text/event-stream"); + expect(sseDataPayloads(result.text)).toEqual([oddProgress, oddFinal]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => upstream.close(() => resolve())); + } + }); + + it("relays a >64KB SSE frame through the FULL local server, byte-identical", async () => { + // Phase 7 gap-fill: tests/sse.test.ts proves the PARSER survives huge + // frames; this proves the whole hop does — real sockets on both legs, + // upstream writing the frame in small chunks so it arrives fragmented. + const bigPayload = JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: "tok-big", + progress: 1, + total: 100, + message: "B".repeat(96 * 1024), // ~96KB payload > the 64KB frame bar + }, + }); + const finalPayload = JSON.stringify({ + jsonrpc: "2.0", + id: 8, + result: { content: [{ type: "text", text: "done" }], isError: false }, + }); + const upstream = createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + const frame = `data: ${bigPayload}\n\n`; + // 4KB chunks: the big frame crosses many socket writes. + for (let i = 0; i < frame.length; i += 4096) { + res.write(frame.slice(i, i + 4096)); + } + res.write(`data: ${finalPayload}\n\n`); + res.end(); + }); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const core = createProxyCore({ + upstreamUrl: `http://127.0.0.1:${listeningPort(upstream)}/mcp`, + apiKey: API_KEY, + hooks: null, + }); + const server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + try { + const result = await postJson( + `http://127.0.0.1:${listeningPort(server)}/mcp`, + automationCall(8, {}, "tok-big"), + { "Mcp-Session-Id": "big-session" } + ); + expect(result.status).toBe(200); + expect(result.contentType).toContain("text/event-stream"); + const payloads = sseDataPayloads(result.text); + expect(payloads).toHaveLength(2); + expect(payloads[0].length).toBeGreaterThan(64 * 1024); + expect(payloads[0]).toBe(bigPayload); + expect(payloads[1]).toBe(finalPayload); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => upstream.close(() => resolve())); + } + }); +}); + +describe("shutdown with an in-flight stream (closeAll via shutdown hooks)", () => { + it("aborts the upstream fetch and ends the client stream with the framed abort error", async () => { + // Phase 7 gap-fill for "SIGTERM closes cleanly": src/index.ts wires + // SIGINT/SIGTERM to the shutdownHooks array and createProxyCore registers + // closeAll() there — running the registered hook IS the signal path minus + // process.exit. Asserted: the in-flight upstream fetch aborts (the mock + // observes it) and the local client's stream ends — no hang — with the + // Phase 6 framed -32000 "proxy aborted the upstream request" error. + const mock = await startMockUpstream(); + const hooks: Array<() => void | Promise> = []; + const core = createProxyCore({ upstreamUrl: mock.url, apiKey: API_KEY, hooks }); + expect(hooks).toHaveLength(1); // closeAll registered exactly like production + const server = await startHttpServer(0, createAppHandler(createMcpAdapter(core))); + const mcpUrl = `http://127.0.0.1:${listeningPort(server)}/mcp`; + try { + const seenBefore = mock.seen.length; + // Slow frames keep the stream in flight while shutdown runs. + const response = await fetch(mcpUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Mcp-Session-Id": "shutdown-session" }, + body: JSON.stringify(automationCall(50, { frameDelayMs: 500 }, "tok-shutdown")), + }); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + // Wait for the first relayed frame so the stream is genuinely mid-flight. + const reader = (response.body as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let text = ""; + const first = await reader.read(); + expect(first.done).toBe(false); + text += decoder.decode(first.value, { stream: true }); + + // Simulate SIGTERM: run the registered shutdown hooks (session closeAll). + for (const hook of hooks) await hook(); + + // The upstream request was aborted — the mock observed the teardown. + await vi.waitFor( + () => { + const call = mock.seen[seenBefore]; + expect(call?.method).toBe("tools/call"); + expect(call?.aborted).toBe(true); + }, + { timeout: 3000 } + ); + + // The client stream ENDS with the framed abort error as its last frame. + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + const payloads = sseDataPayloads(text); + expect(payloads.length).toBeGreaterThanOrEqual(2); // ≥1 progress + the error frame + const last = JSON.parse(payloads[payloads.length - 1]) as { + jsonrpc: string; + error: { code: number; message: string; data?: { runId?: string } }; + id: unknown; + }; + expect(last.jsonrpc).toBe("2.0"); + expect(last.error.code).toBe(-32000); + expect(last.error.message).toContain("proxy aborted the upstream request"); + expect(last.error.message).toContain("the run may still be executing"); + expect(last.error.data?.runId).toBe(MOCK_RUN_ID); + expect(last.id).toBe(50); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await mock.close(); + } + }); +}); + +describe("relay error classification (core level)", () => { + let mock: MockUpstream; + let core: ProxyCore; + + beforeAll(async () => { + mock = await startMockUpstream(); + core = createProxyCore({ upstreamUrl: mock.url, apiKey: API_KEY, hooks: null }); + }); + + afterAll(async () => { + await mock.close(); + }); + + it("surfaces onEvent failures as LocalWriteError, never UpstreamUnreachableError", async () => { + const seenBefore = mock.seen.length; + const failure = core.forwardStream( + "local-write-fail", + automationCall(30, { frameDelayMs: 200 }, "tok-fail"), + () => { + throw new Error("EPIPE: broken pipe (simulated local client socket)"); + } + ); + await expect(failure).rejects.toBeInstanceOf(LocalWriteError); + await expect(failure).rejects.not.toBeInstanceOf(UpstreamUnreachableError); + await failure.catch((err: unknown) => { + expect((err as Error).message).toContain("Relaying SSE frame to the local client failed"); + }); + // Teardown was clean: the upstream stream was canceled, and the mock saw it. + await vi.waitFor( + () => { + expect(mock.seen[seenBefore]?.aborted).toBe(true); + }, + { timeout: 3000 } + ); + }); + + it("passes the raw payload string to onEvent alongside the parsed message", async () => { + const raws: Array = []; + const result = await core.forwardStream( + "raw-arg", + automationCall(31, {}, "tok-raw-arg"), + (message, rawData) => { + expect(rawData).toBe(JSON.stringify(message)); + raws.push(rawData); + } + ); + expect(raws).toHaveLength(3); + const expected = buildAutomationSseMessages("tok-raw-arg", 31); + expect(raws).toEqual(expected.slice(0, 3).map((m) => JSON.stringify(m))); + // The final frame's raw payload rides on the ProxyResponse for the adapter. + expect(result.rawBody).toBe(JSON.stringify(expected[3])); + expect(result.body).toEqual(expected[3]); + }); +}); diff --git a/tests/session.test.ts b/tests/session.test.ts new file mode 100644 index 0000000..b3f987b --- /dev/null +++ b/tests/session.test.ts @@ -0,0 +1,648 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { jsonResponse } from "./helpers/http.js"; +import { + buildAutomationSseMessages, + MOCK_INITIALIZE_RESULT, + MOCK_TOOLS_RESULT, + startMockUpstream, + type MockUpstream, +} from "./helpers/mock-upstream.js"; +import { UpstreamAbortedError, UpstreamProtocolError } from "../src/core/errors.js"; +import { createProxyCore, type ProxyCoreOptions } from "../src/core/proxy-core.js"; +import { SessionStore } from "../src/core/session.js"; +import type { FetchLike } from "../src/core/upstream.js"; + +const API_KEY = "sk-session-secret-9876"; +const UPSTREAM_URL = "https://upstream.test/mcp"; + +const INITIALIZE_REQUEST = { + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.1" }, + }, +}; + +const INITIALIZED_NOTIFICATION = { jsonrpc: "2.0", method: "notifications/initialized" }; +const TOOLS_LIST_REQUEST = { jsonrpc: "2.0", id: 1, method: "tools/list" }; + +interface RecordedCall { + headers: Record; + body: unknown; + signal: AbortSignal | undefined; +} + +/** Injected fetch recording each call; responses come from a queue of factories. */ +function scriptedFetch(...responders: Array<(call: RecordedCall) => Response | Promise>): { + fetchFn: FetchLike; + calls: RecordedCall[]; +} { + const calls: RecordedCall[] = []; + const fetchFn = ((_input: string | URL | Request, init?: RequestInit) => { + const call: RecordedCall = { + headers: Object.fromEntries( + Object.entries((init?.headers ?? {}) as Record).map(([k, v]) => [ + k.toLowerCase(), + v, + ]) + ), + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + signal: init?.signal ?? undefined, + }; + calls.push(call); + const responder = responders.shift(); + if (responder === undefined) throw new Error("scriptedFetch: no responder left"); + return Promise.resolve(responder(call)); + }) as FetchLike; + return { fetchFn, calls }; +} + +function initializeResponse(sessionId: string): Response { + return jsonResponse( + { jsonrpc: "2.0", result: MOCK_INITIALIZE_RESULT, id: 0 }, + { headers: { "mcp-session-id": sessionId } } + ); +} + +function sseResponse(text: string): Response { + return new Response(text, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +/** A fetch that never resolves until its signal aborts (for close/abort tests). */ +function hangingResponder(): (call: RecordedCall) => Promise { + return (call) => + new Promise((_resolve, reject) => { + call.signal?.addEventListener("abort", () => + reject(new DOMException("This operation was aborted", "AbortError")) + ); + }); +} + +function makeCore(fetchFn: FetchLike, extra: Partial = {}) { + return createProxyCore({ + upstreamUrl: UPSTREAM_URL, + apiKey: API_KEY, + fetchFn, + hooks: null, + ...extra, + }); +} + +describe("SessionStore", () => { + it("creates on first use and returns the same entry after", () => { + const store = new SessionStore(); + const entry = store.getOrCreate("a"); + expect(store.getOrCreate("a")).toBe(entry); + expect(store.get("a")).toBe(entry); + expect(store.size).toBe(1); + }); + + it("tracks in-flight controllers and forgets them on endRequest", () => { + const store = new SessionStore(); + const controller = store.beginRequest("a"); + expect(store.get("a")?.inflight.has(controller)).toBe(true); + store.endRequest("a", controller); + expect(store.get("a")?.inflight.size).toBe(0); + expect(controller.signal.aborted).toBe(false); + }); + + it("close aborts every in-flight controller and drops the entry", () => { + const store = new SessionStore(); + const c1 = store.beginRequest("a"); + const c2 = store.beginRequest("a"); + const other = store.beginRequest("b"); + store.close("a"); + expect(c1.signal.aborted).toBe(true); + expect(c2.signal.aborted).toBe(true); + expect(other.signal.aborted).toBe(false); + expect(store.has("a")).toBe(false); + expect(store.has("b")).toBe(true); + }); + + it("close on an unknown key is a no-op", () => { + expect(() => new SessionStore().close("nope")).not.toThrow(); + }); + + it("alias maps a second key to the same entry", () => { + const store = new SessionStore(); + const entry = store.getOrCreate("a"); + store.alias("upstream-id", entry); + expect(store.get("upstream-id")).toBe(entry); + expect(store.size).toBe(2); + }); + + it("close drops every alias key pointing at the same entry", () => { + const store = new SessionStore(); + const entry = store.getOrCreate("a"); + store.alias("upstream-id", entry); + const controller = store.beginRequest("upstream-id"); + store.close("a"); + expect(store.has("a")).toBe(false); + expect(store.has("upstream-id")).toBe(false); + expect(controller.signal.aborted).toBe(true); + expect(store.size).toBe(0); + }); + + it("closeAll aborts and drops every session", () => { + const store = new SessionStore(); + const c1 = store.beginRequest("a"); + const c2 = store.beginRequest("b"); + store.closeAll(); + expect(c1.signal.aborted).toBe(true); + expect(c2.signal.aborted).toBe(true); + expect(store.size).toBe(0); + }); +}); + +describe("proxyCore session bridging (injected fetch)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("captures Mcp-Session-Id from initialize and replays it on later calls", async () => { + const { fetchFn, calls } = scriptedFetch( + () => initializeResponse("sess-abc"), + () => jsonResponse({ jsonrpc: "2.0", result: MOCK_TOOLS_RESULT, id: 1 }) + ); + const core = makeCore(fetchFn); + + const init = await core.initialize("local-1", INITIALIZE_REQUEST); + expect(init.sessionId).toBe("sess-abc"); + expect(init.status).toBe(200); + expect(init.body).toEqual({ jsonrpc: "2.0", result: MOCK_INITIALIZE_RESULT, id: 0 }); + expect(calls[0].headers).not.toHaveProperty("mcp-session-id"); + + await core.forward("local-1", TOOLS_LIST_REQUEST); + expect(calls[1].headers["mcp-session-id"]).toBe("sess-abc"); + }); + + it("passes the client protocol version through on initialize and later calls", async () => { + const { fetchFn, calls } = scriptedFetch( + () => initializeResponse("sess-pv"), + () => new Response(null, { status: 204 }), + () => jsonResponse({ jsonrpc: "2.0", result: {}, id: 2 }) + ); + const core = makeCore(fetchFn); + + await core.initialize("local-1", INITIALIZE_REQUEST, "2025-06-18"); + await core.notify("local-1", INITIALIZED_NOTIFICATION); + await core.forward("local-1", { jsonrpc: "2.0", id: 2, method: "ping" }); + + for (const call of calls) { + expect(call.headers["mcp-protocol-version"]).toBe("2025-06-18"); + } + }); + + it("aliases the session under the upstream id: calls keyed by it reuse the captured entry", async () => { + const { fetchFn, calls } = scriptedFetch( + () => initializeResponse("sess-upstream"), + () => jsonResponse({ jsonrpc: "2.0", result: MOCK_TOOLS_RESULT, id: 1 }) + ); + const core = makeCore(fetchFn); + await core.initialize("init-key", INITIALIZE_REQUEST, "2025-06-18"); + + // Phase 4 raw-pipe bridging: the client re-sends upstream's id and the + // adapter keys calls by it — which must resolve to the same session entry. + await core.forward("sess-upstream", TOOLS_LIST_REQUEST); + expect(calls[1].headers["mcp-session-id"]).toBe("sess-upstream"); + // The protocol version proves it's the shared entry, not just the localKey fallback. + expect(calls[1].headers["mcp-protocol-version"]).toBe("2025-06-18"); + }); + + it("falls back to sending localKey as Mcp-Session-Id when no id was captured", async () => { + const { fetchFn, calls } = scriptedFetch( + () => jsonResponse({ jsonrpc: "2.0", result: MOCK_TOOLS_RESULT, id: 1 }), + () => new Response(null, { status: 204 }) + ); + const core = makeCore(fetchFn); + await core.forward("upstream-id-123", TOOLS_LIST_REQUEST); + await core.notify("upstream-id-123", INITIALIZED_NOTIFICATION); + expect(calls[0].headers["mcp-session-id"]).toBe("upstream-id-123"); + expect(calls[1].headers["mcp-session-id"]).toBe("upstream-id-123"); + }); + + it("initialize never falls back to localKey (upstream must mint the id)", async () => { + const { fetchFn, calls } = scriptedFetch(() => initializeResponse("sess-minted")); + await makeCore(fetchFn).initialize("adapter-invented-key", INITIALIZE_REQUEST); + expect(calls[0].headers).not.toHaveProperty("mcp-session-id"); + }); + + it("does not resurrect a session closed while initialize was in flight", async () => { + let release: ((response: Response) => void) | undefined; + const { fetchFn, calls } = scriptedFetch( + () => new Promise((resolve) => (release = resolve)), + () => jsonResponse({ jsonrpc: "2.0", result: {}, id: 1 }) + ); + const core = makeCore(fetchFn); + const pending = core.initialize("local-1", INITIALIZE_REQUEST); + await vi.waitFor(() => { + expect(release).toBeDefined(); + }); + core.close("local-1"); + release?.(initializeResponse("sess-late")); + + // The response still flows back to the caller... + const result = await pending; + expect(result.sessionId).toBe("sess-late"); + // ...but no entry was recreated or aliased: the next call for the same key + // falls back to the key itself rather than replaying "sess-late". + await core.forward("local-1", TOOLS_LIST_REQUEST); + expect(calls[1].headers["mcp-session-id"]).toBe("local-1"); + }); + + it("omits the protocol version header when the client never sent one", async () => { + const { fetchFn, calls } = scriptedFetch(() => initializeResponse("sess-npv")); + await makeCore(fetchFn).initialize("local-1", INITIALIZE_REQUEST); + expect(calls[0].headers).not.toHaveProperty("mcp-protocol-version"); + }); + + it("forwards the initialize request body verbatim", async () => { + const { fetchFn, calls } = scriptedFetch(() => initializeResponse("sess-vb")); + await makeCore(fetchFn).initialize("local-1", INITIALIZE_REQUEST); + expect(calls[0].body).toEqual(INITIALIZE_REQUEST); + }); + + it("notify resolves void on 204 and carries the stored session id", async () => { + const { fetchFn, calls } = scriptedFetch( + () => initializeResponse("sess-n"), + () => new Response(null, { status: 204 }) + ); + const core = makeCore(fetchFn); + await core.initialize("local-1", INITIALIZE_REQUEST); + await expect(core.notify("local-1", INITIALIZED_NOTIFICATION)).resolves.toBeUndefined(); + expect(calls[1].body).toEqual(INITIALIZED_NOTIFICATION); + expect(calls[1].headers["mcp-session-id"]).toBe("sess-n"); + }); + + it("returns upstream JSON-RPC error objects untouched with their HTTP status", async () => { + const errorFixture = { + jsonrpc: "2.0", + error: { code: -32601, message: "Method not found: prompts/list" }, + id: 9, + }; + const { fetchFn } = scriptedFetch(() => jsonResponse(errorFixture, { status: 400 })); + const result = await makeCore(fetchFn).forward("local-1", { + jsonrpc: "2.0", + id: 9, + method: "prompts/list", + }); + expect(result.status).toBe(400); + expect(result.body).toEqual(errorFixture); + }); + + it("forward throws UpstreamProtocolError on an unexpected SSE response", async () => { + const { fetchFn } = scriptedFetch(() => sseResponse(`data: {"jsonrpc":"2.0"}\n\n`)); + await expect(makeCore(fetchFn).forward("local-1", TOOLS_LIST_REQUEST)).rejects.toBeInstanceOf( + UpstreamProtocolError + ); + }); + + it("initialize throws UpstreamProtocolError on an unexpected SSE response", async () => { + const { fetchFn } = scriptedFetch(() => sseResponse(`data: {"jsonrpc":"2.0"}\n\n`)); + await expect( + makeCore(fetchFn).initialize("local-1", INITIALIZE_REQUEST) + ).rejects.toBeInstanceOf(UpstreamProtocolError); + }); + + it("initialize throws UpstreamProtocolError on an unexpected empty response", async () => { + const { fetchFn } = scriptedFetch(() => new Response(null, { status: 204 })); + await expect( + makeCore(fetchFn).initialize("local-1", INITIALIZE_REQUEST) + ).rejects.toBeInstanceOf(UpstreamProtocolError); + }); + + it("close aborts an in-flight fetch and forgets the session", async () => { + const { fetchFn, calls } = scriptedFetch( + () => initializeResponse("sess-x"), + hangingResponder(), + () => jsonResponse({ jsonrpc: "2.0", result: {}, id: 3 }) + ); + const core = makeCore(fetchFn); + await core.initialize("local-1", INITIALIZE_REQUEST); + + const pending = core.forward("local-1", TOOLS_LIST_REQUEST); + // Let the fetch start before closing. + await new Promise((resolve) => setImmediate(resolve)); + core.close("local-1"); + + await expect(pending).rejects.toBeInstanceOf(UpstreamAbortedError); + expect(calls[1].signal?.aborted).toBe(true); + + // The mapping is gone: the next call for the same key no longer carries the + // captured "sess-x" — it falls back to replaying the key itself. + await core.forward("local-1", { jsonrpc: "2.0", id: 3, method: "ping" }); + expect(calls[2].headers["mcp-session-id"]).toBe("local-1"); + }); + + it("registers session cleanup in the provided shutdown hooks array", async () => { + const hooks: Array<() => void | Promise> = []; + const { fetchFn, calls } = scriptedFetch(hangingResponder()); + const core = makeCore(fetchFn, { hooks }); + expect(hooks).toHaveLength(1); + + const pending = core.forward("local-1", TOOLS_LIST_REQUEST); + await new Promise((resolve) => setImmediate(resolve)); + await hooks[0](); + + await expect(pending).rejects.toBeInstanceOf(UpstreamAbortedError); + expect(calls[0].signal?.aborted).toBe(true); + }); + + it("never writes the API key to stderr (including the notify warning path)", async () => { + const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + const { fetchFn } = scriptedFetch( + () => initializeResponse("sess-log"), + // Non-204 answer to a notification triggers the core's only log line. + () => jsonResponse({ jsonrpc: "2.0", error: { code: -32000, message: "nope" }, id: -1 }, { status: 401 }) + ); + const core = makeCore(fetchFn); + await core.initialize("local-1", INITIALIZE_REQUEST); + await core.notify("local-1", INITIALIZED_NOTIFICATION); + core.close("local-1"); + + const written = stderrSpy.mock.calls.map((args) => String(args[0])).join(""); + // Phase 6 pinned decision: the JSON answer is dropped (notifications have + // no response channel) but the warn names both status and error code. + expect(written).toContain("unexpected json response (HTTP 401, JSON-RPC error -32000)"); + expect(written).not.toContain(API_KEY); + }); +}); + +describe("proxyCore forwardStream", () => { + const CALL_REQUEST = { + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { + name: "run_web_automation", + arguments: { goal: "test" }, + _meta: { progressToken: "tok-1" }, + }, + }; + + it("resolves plain JSON responses directly without calling onEvent", async () => { + const body = { jsonrpc: "2.0", result: { content: [], isError: false }, id: 5 }; + const { fetchFn } = scriptedFetch(() => + jsonResponse(body, { headers: { "mcp-session-id": "sess-j" } }) + ); + const onEvent = vi.fn(); + const result = await makeCore(fetchFn).forwardStream("local-1", CALL_REQUEST, onEvent); + expect(result).toEqual({ + status: 200, + body, + sessionId: "sess-j", + contentType: "application/json", + }); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("emits SSE notification frames via onEvent and resolves the final response", async () => { + const messages = buildAutomationSseMessages("tok-1", 5); + const text = messages.map((m) => `data: ${JSON.stringify(m)}\n\n`).join(""); + const { fetchFn } = scriptedFetch(() => sseResponse(text)); + + const events: unknown[] = []; + const result = await makeCore(fetchFn).forwardStream("local-1", CALL_REQUEST, (m) => { + events.push(m); + }); + + expect(events).toEqual(messages.slice(0, 3)); + expect(result.body).toEqual(messages[3]); + expect(result.status).toBe(200); + // Upstream SSE responses carry no Mcp-Session-Id header. + expect(result.sessionId).toBeNull(); + }); + + it("handles SSE frames split across arbitrary chunk boundaries", async () => { + const messages = buildAutomationSseMessages(undefined, 5); + const text = messages.map((m) => `data: ${JSON.stringify(m)}\n\n`).join(""); + // Slice into awkward 7-byte chunks so frames straddle chunk boundaries. + const bytes = new TextEncoder().encode(text); + const stream = new ReadableStream({ + start(controller) { + for (let i = 0; i < bytes.length; i += 7) { + controller.enqueue(bytes.slice(i, i + 7)); + } + controller.close(); + }, + }); + const { fetchFn } = scriptedFetch( + () => + new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + ); + + const events: unknown[] = []; + const result = await makeCore(fetchFn).forwardStream("local-1", CALL_REQUEST, (m) => { + events.push(m); + }); + expect(events).toEqual(messages.slice(0, 3)); + expect(result.body).toEqual(messages[3]); + }); + + it("awaits each async onEvent emission before processing the next frame", async () => { + const messages = buildAutomationSseMessages("tok-1", 5); + const text = messages.map((m) => `data: ${JSON.stringify(m)}\n\n`).join(""); + const { fetchFn } = scriptedFetch(() => sseResponse(text)); + + const order: string[] = []; + let n = 0; + const result = await makeCore(fetchFn).forwardStream("local-1", CALL_REQUEST, async () => { + const i = ++n; + order.push(`start-${i}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + order.push(`end-${i}`); + }); + + expect(order).toEqual(["start-1", "end-1", "start-2", "end-2", "start-3", "end-3"]); + expect(result.body).toEqual(messages[3]); + }); + + it("stops reading at the final frame: post-final frames never reach onEvent, stream canceled", async () => { + const messages = buildAutomationSseMessages("tok-1", 5); + const postFinal = { + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: "tok-1", + progress: 99, + total: 100, + message: "spec-violating frame after the final response", + }, + }; + let canceled = false; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const message of [...messages, postFinal]) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(message)}\n\n`)); + } + // Never closes — resolution proves the reader stops at the final frame + // instead of waiting for upstream to end the stream. + }, + cancel() { + canceled = true; + }, + }); + const { fetchFn } = scriptedFetch( + () => + new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + ); + + const events: unknown[] = []; + const result = await makeCore(fetchFn).forwardStream("local-1", CALL_REQUEST, (m) => { + events.push(m); + }); + expect(events).toEqual(messages.slice(0, 3)); + expect(result.body).toEqual(messages[3]); + expect(canceled).toBe(true); + }); + + it("throws UpstreamProtocolError when the stream ends without a final response", async () => { + const notification = { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progressToken: "tok-1", progress: 0, total: 100, message: "started" }, + }; + const { fetchFn } = scriptedFetch(() => + sseResponse(`data: ${JSON.stringify(notification)}\n\n`) + ); + await expect( + makeCore(fetchFn).forwardStream("local-1", CALL_REQUEST, () => {}) + ).rejects.toBeInstanceOf(UpstreamProtocolError); + }); + + it("aborts an in-flight SSE stream on close", async () => { + let streamController: ReadableStreamDefaultController | undefined; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue( + new TextEncoder().encode( + `data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"tok-1","progress":0,"total":100,"message":"started"}}\n\n` + ) + ); + // Never closes — the stream hangs until the fetch signal aborts. + }, + }); + const { fetchFn, calls } = scriptedFetch((call) => { + // Mimic fetch: aborting the signal errors the response body stream. + call.signal?.addEventListener("abort", () => { + streamController?.error(new DOMException("This operation was aborted", "AbortError")); + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + const core = makeCore(fetchFn); + const events: unknown[] = []; + const pending = core.forwardStream("local-1", CALL_REQUEST, (m) => { + events.push(m); + }); + // Wait until the first frame arrived, then tear the session down. + await vi.waitFor(() => { + expect(events).toHaveLength(1); + }); + core.close("local-1"); + + await expect(pending).rejects.toBeInstanceOf(UpstreamAbortedError); + expect(calls[0].signal?.aborted).toBe(true); + }); +}); + +describe("proxyCore against the real mock upstream (HTTP hop)", () => { + let mock: MockUpstream | undefined; + + afterEach(async () => { + await mock?.close(); + mock = undefined; + }); + + it("runs a full conversation: initialize, notify, list, error, streaming call", async () => { + mock = await startMockUpstream(); + const core = createProxyCore({ + upstreamUrl: mock.url, + apiKey: "sk-e2e-key", + hooks: null, + }); + + // initialize — upstream mints the session id; core captures it. + const init = await core.initialize("local-e2e", INITIALIZE_REQUEST, "2025-06-18"); + expect(init.status).toBe(200); + expect(init.sessionId).toBeTruthy(); + expect(init.body).toEqual({ jsonrpc: "2.0", result: MOCK_INITIALIZE_RESULT, id: 0 }); + const sessionId = init.sessionId; + + // notification → 204 → resolves; session id was replayed. + await expect(core.notify("local-e2e", INITIALIZED_NOTIFICATION)).resolves.toBeUndefined(); + + // tools/list replays the captured session id (mock records what it saw). + const list = await core.forward("local-e2e", TOOLS_LIST_REQUEST); + expect(list.status).toBe(200); + expect(list.body).toEqual({ jsonrpc: "2.0", result: MOCK_TOOLS_RESULT, id: 1 }); + + // unknown method → HTTP 400 with the error object verbatim. + const unknown = await core.forward("local-e2e", { + jsonrpc: "2.0", + id: 2, + method: "prompts/list", + }); + expect(unknown.status).toBe(400); + expect(unknown.body).toEqual({ + jsonrpc: "2.0", + error: { code: -32601, message: "Method not found: prompts/list" }, + id: 2, + }); + + // streaming tools/call: 3 progress notifications in order, then the final frame. + const events: unknown[] = []; + const call = await core.forwardStream( + "local-e2e", + { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "run_web_automation", + arguments: { goal: "extract" }, + _meta: { progressToken: "tok-e2e" }, + }, + }, + (m) => { + events.push(m); + } + ); + const expected = buildAutomationSseMessages("tok-e2e", 3); + expect(events).toEqual(expected.slice(0, 3)); + expect(call.body).toEqual(expected[3]); + expect(call.status).toBe(200); + expect(call.sessionId).toBeNull(); + + // Every non-notification request after initialize carried the same session id. + expect(mock.seen.map((s) => s.method)).toEqual([ + "initialize", + "notifications/initialized", + "tools/list", + "prompts/list", + "tools/call", + ]); + for (const entry of mock.seen.slice(1)) { + expect(entry.sessionId).toBe(sessionId); + } + + core.close("local-e2e"); + }); +}); diff --git a/tests/sse.test.ts b/tests/sse.test.ts new file mode 100644 index 0000000..84fcd40 --- /dev/null +++ b/tests/sse.test.ts @@ -0,0 +1,179 @@ +/** + * Unit tests for the incremental SSE parser (src/core/sse.ts): chunk + * boundaries anywhere, multi-line data, comments, CRLF, event:/id: fields, + * huge frames, raw-payload fidelity. + */ +import { describe, expect, it } from "vitest"; +import { UpstreamProtocolError } from "../src/core/errors.js"; +import { parseSseStream, type SseEvent } from "../src/core/sse.js"; + +const encoder = new TextEncoder(); + +/** A ReadableStream that enqueues each given chunk (string or bytes) as-is. */ +function streamOf(...chunks: Array): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(typeof chunk === "string" ? encoder.encode(chunk) : chunk); + } + controller.close(); + }, + }); +} + +/** Split a string's UTF-8 bytes into chunks of `size` bytes (may split codepoints). */ +function byteChunks(text: string, size: number): Uint8Array[] { + const bytes = encoder.encode(text); + const chunks: Uint8Array[] = []; + for (let i = 0; i < bytes.length; i += size) { + chunks.push(bytes.slice(i, i + size)); + } + return chunks; +} + +async function collect(stream: ReadableStream): Promise { + const events: SseEvent[] = []; + for await (const event of parseSseStream(stream)) { + events.push(event); + } + return events; +} + +describe("parseSseStream", () => { + it("parses a simple single-frame stream and preserves the raw payload", async () => { + const events = await collect(streamOf('data: {"a":1}\n\n')); + expect(events).toHaveLength(1); + expect(events[0].message).toEqual({ a: 1 }); + expect(events[0].rawData).toBe('{"a":1}'); + expect(events[0].event).toBeUndefined(); + expect(events[0].id).toBeUndefined(); + }); + + it("parses multiple frames in order", async () => { + const events = await collect(streamOf('data: {"n":1}\n\ndata: {"n":2}\n\ndata: {"n":3}\n\n')); + expect(events.map((e) => e.message)).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); + }); + + it("handles an event split across chunks mid-line and mid-event", async () => { + const events = await collect( + streamOf('da', 'ta: {"sp', 'lit":tr', "ue}\n", "\ndata: ", '{"next":2}\n\n') + ); + expect(events.map((e) => e.message)).toEqual([{ split: true }, { next: 2 }]); + expect(events[0].rawData).toBe('{"split":true}'); + }); + + it("joins multi-line data with \\n and preserves the joined raw payload", async () => { + // JSON tolerates embedded newlines between tokens, so the joined payload + // still parses while proving multi-line reassembly. + const events = await collect(streamOf('data: {"a":\ndata: 1}\n\n')); + expect(events).toHaveLength(1); + expect(events[0].rawData).toBe('{"a":\n1}'); + expect(events[0].message).toEqual({ a: 1 }); + }); + + it("consumes comment lines without forwarding them", async () => { + const events = await collect( + streamOf(': heartbeat comment\n\ndata: {"a":1,\n: mid-event comment\ndata: "b":2}\n\n') + ); + // The comment-only block dispatches nothing; the data block survives + // intact with the comment line dropped from between its data lines. + expect(events).toHaveLength(1); + expect(events[0].rawData).toBe('{"a":1,\n"b":2}'); + expect(events[0].message).toEqual({ a: 1, b: 2 }); + }); + + it("handles CRLF line endings, including a CRLF pair split across chunks", async () => { + const events = await collect(streamOf('data: {"crlf":1}\r\n\r', '\ndata: {"crlf":2}\r\n\r\n')); + expect(events.map((e) => e.message)).toEqual([{ crlf: 1 }, { crlf: 2 }]); + expect(events[0].rawData).toBe('{"crlf":1}'); + }); + + it("tolerates event: and id: fields and surfaces them on the yielded item", async () => { + const events = await collect(streamOf('event: message\nid: 42\ndata: {"a":1}\n\n')); + expect(events).toHaveLength(1); + expect(events[0].event).toBe("message"); + expect(events[0].id).toBe("42"); + expect(events[0].message).toEqual({ a: 1 }); + }); + + it("ignores retry: and unknown fields and field-name-only lines", async () => { + const events = await collect( + streamOf('retry: 3000\nunknown: x\nnocolonline\ndata: {"a":1}\n\n') + ); + expect(events).toHaveLength(1); + expect(events[0].rawData).toBe('{"a":1}'); + }); + + it("strips exactly one leading space from field values", async () => { + // "data: x" → value " x" (only the first space is field-syntax). + const events = await collect(streamOf('data: " padded"\n\ndata:"tight"\n\n')); + expect(events[0].rawData).toBe(' " padded"'); + expect(events[0].message).toBe(" padded"); + expect(events[1].rawData).toBe('"tight"'); + expect(events[1].message).toBe("tight"); + }); + + it("reassembles a huge frame split over many small chunks", async () => { + const big = { blob: "x".repeat(256 * 1024), tail: [1, 2, 3] }; + const payload = JSON.stringify(big); + const text = `data: ${payload}\n\ndata: {"after":true}\n\n`; + const events = await collect(streamOf(...byteChunks(text, 1024))); + expect(events).toHaveLength(2); + expect(events[0].rawData).toBe(payload); + expect(events[0].message).toEqual(big); + expect(events[1].message).toEqual({ after: true }); + }); + + it("decodes multi-byte UTF-8 characters split across chunk boundaries", async () => { + const text = 'data: {"emoji":"🐟🐟🐟"}\n\n'; + // 3-byte chunks guarantee the 4-byte emoji codepoints straddle boundaries. + const events = await collect(streamOf(...byteChunks(text, 3))); + expect(events).toHaveLength(1); + expect(events[0].message).toEqual({ emoji: "🐟🐟🐟" }); + }); + + it("dispatches a trailing event when the stream ends without a blank line", async () => { + const events = await collect(streamOf('data: {"a":1}\n\ndata: {"tail":true}')); + expect(events.map((e) => e.message)).toEqual([{ a: 1 }, { tail: true }]); + }); + + it("dispatches nothing for blocks without a data field", async () => { + const events = await collect(streamOf("event: ping\nid: 7\n\n: comment only\n\n")); + expect(events).toHaveLength(0); + }); + + it("preserves non-canonical JSON payload bytes verbatim in rawData", async () => { + const raw = '{ "spaced" : true , "arr" : [ 1 , 2 ] }'; + const events = await collect(streamOf(`data: ${raw}\n\n`)); + expect(events[0].rawData).toBe(raw); + expect(events[0].message).toEqual({ spaced: true, arr: [1, 2] }); + }); + + it("throws UpstreamProtocolError on a non-JSON data payload", async () => { + await expect(collect(streamOf("data: not json\n\n"))).rejects.toBeInstanceOf( + UpstreamProtocolError + ); + }); + + it("throws UpstreamProtocolError on an empty data payload (pinned: every frame must be JSON-RPC)", async () => { + await expect(collect(streamOf("data:\n\n"))).rejects.toBeInstanceOf(UpstreamProtocolError); + }); + + it("cancels the underlying stream when the consumer breaks early", async () => { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"n":1}\n\ndata: {"n":2}\n\n')); + // Never closes — an un-canceled reader would hang forever. + }, + cancel() { + canceled = true; + }, + }); + for await (const event of parseSseStream(stream)) { + expect(event.message).toEqual({ n: 1 }); + break; + } + expect(canceled).toBe(true); + }); +}); diff --git a/tests/upstream.test.ts b/tests/upstream.test.ts new file mode 100644 index 0000000..84cd13c --- /dev/null +++ b/tests/upstream.test.ts @@ -0,0 +1,244 @@ +import { createServer } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { jsonResponse } from "./helpers/http.js"; +import { + UpstreamAbortedError, + UpstreamProtocolError, + UpstreamUnreachableError, +} from "../src/core/errors.js"; +import { UpstreamClient, type FetchLike } from "../src/core/upstream.js"; +import { VERSION } from "../src/version.js"; + +const API_KEY = "sk-unit-secret-0123"; +const URL_UNDER_TEST = "https://upstream.test/mcp"; + +const PING = { jsonrpc: "2.0", id: 1, method: "ping" }; + +interface RecordedCall { + url: string; + headers: Record; + body: string; + signal: AbortSignal | undefined; +} + +/** Injected fetch that records every call and replies from a response factory. */ +function recordingFetch(respond: (call: RecordedCall) => Response | Promise): { + fetchFn: FetchLike; + calls: RecordedCall[]; +} { + const calls: RecordedCall[] = []; + const fetchFn = ((input: string | URL | Request, init?: RequestInit) => { + const call: RecordedCall = { + url: String(input), + headers: Object.fromEntries( + Object.entries((init?.headers ?? {}) as Record).map(([k, v]) => [ + k.toLowerCase(), + v, + ]) + ), + body: typeof init?.body === "string" ? init.body : "", + signal: init?.signal ?? undefined, + }; + calls.push(call); + return Promise.resolve(respond(call)); + }) as FetchLike; + return { fetchFn, calls }; +} + +function makeClient(fetchFn: FetchLike, clientVersion?: string): UpstreamClient { + return new UpstreamClient({ url: URL_UNDER_TEST, apiKey: API_KEY, clientVersion, fetchFn }); +} + +describe("UpstreamClient", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("outbound headers", () => { + it("sends the full header set on every call", async () => { + const { fetchFn, calls } = recordingFetch(() => jsonResponse({ jsonrpc: "2.0", result: {}, id: 1 })); + const client = makeClient(fetchFn, "1.2.3"); + + await client.post(PING); + await client.post(PING, { sessionId: "sess-1" }); + + expect(calls).toHaveLength(2); + for (const call of calls) { + expect(call.url).toBe(URL_UNDER_TEST); + expect(call.headers["content-type"]).toBe("application/json"); + expect(call.headers["accept"]).toBe("application/json, text/event-stream"); + expect(call.headers["x-api-key"]).toBe(API_KEY); + expect(call.headers["x-tf-request-origin"]).toBe("tinyfish-mcp"); + expect(call.headers["x-tf-client-name"]).toBe("tinyfish-mcp"); + expect(call.headers["x-tf-client-version"]).toBe("1.2.3"); + } + }); + + it("defaults X-TF-Client-Version to the package version", async () => { + const { fetchFn, calls } = recordingFetch(() => jsonResponse({ jsonrpc: "2.0", result: {}, id: 1 })); + await makeClient(fetchFn).post(PING); + expect(calls[0].headers["x-tf-client-version"]).toBe(VERSION); + }); + + it("omits Mcp-Session-Id and MCP-Protocol-Version when not provided", async () => { + const { fetchFn, calls } = recordingFetch(() => jsonResponse({ jsonrpc: "2.0", result: {}, id: 1 })); + await makeClient(fetchFn).post(PING); + expect(calls[0].headers).not.toHaveProperty("mcp-session-id"); + expect(calls[0].headers).not.toHaveProperty("mcp-protocol-version"); + }); + + it("sends Mcp-Session-Id and MCP-Protocol-Version when provided", async () => { + const { fetchFn, calls } = recordingFetch(() => jsonResponse({ jsonrpc: "2.0", result: {}, id: 1 })); + await makeClient(fetchFn).post(PING, { + sessionId: "sess-42", + protocolVersion: "2025-06-18", + }); + expect(calls[0].headers["mcp-session-id"]).toBe("sess-42"); + expect(calls[0].headers["mcp-protocol-version"]).toBe("2025-06-18"); + }); + + it("serializes the JSON-RPC message as the POST body", async () => { + const { fetchFn, calls } = recordingFetch(() => jsonResponse({ jsonrpc: "2.0", result: {}, id: 1 })); + await makeClient(fetchFn).post(PING); + expect(JSON.parse(calls[0].body)).toEqual(PING); + }); + }); + + describe("response classification", () => { + it("classifies a JSON response with session header", async () => { + const body = { jsonrpc: "2.0", result: { ok: true }, id: 7 }; + const { fetchFn } = recordingFetch(() => + jsonResponse(body, { headers: { "mcp-session-id": "sess-json" } }) + ); + const response = await makeClient(fetchFn).post(PING); + expect(response).toEqual({ + kind: "json", + status: 200, + sessionId: "sess-json", + body, + contentType: "application/json", + }); + }); + + it("classifies a JSON response without session header (sessionId null)", async () => { + const { fetchFn } = recordingFetch(() => jsonResponse({ jsonrpc: "2.0", result: {}, id: 1 })); + const response = await makeClient(fetchFn).post(PING); + expect(response.kind).toBe("json"); + if (response.kind === "json") expect(response.sessionId).toBeNull(); + }); + + it("preserves upstream HTTP status on JSON-RPC error responses", async () => { + const errorBody = { + jsonrpc: "2.0", + error: { code: -32601, message: "Method not found: prompts/list" }, + id: 3, + }; + const { fetchFn } = recordingFetch(() => jsonResponse(errorBody, { status: 400 })); + const response = await makeClient(fetchFn).post(PING); + expect(response).toEqual({ + kind: "json", + status: 400, + sessionId: null, + body: errorBody, + contentType: "application/json", + }); + }); + + it("classifies 204 as empty (notification path)", async () => { + const { fetchFn } = recordingFetch(() => new Response(null, { status: 204 })); + const response = await makeClient(fetchFn).post({ + jsonrpc: "2.0", + method: "notifications/initialized", + }); + expect(response).toEqual({ kind: "empty", status: 204 }); + }); + + it("classifies an empty 200 body as empty", async () => { + const { fetchFn } = recordingFetch(() => new Response("", { status: 200 })); + const response = await makeClient(fetchFn).post(PING); + expect(response).toEqual({ kind: "empty", status: 200 }); + }); + + it("classifies text/event-stream as sse with the raw stream", async () => { + const frame = `data: {"jsonrpc":"2.0","result":{},"id":1}\n\n`; + const { fetchFn } = recordingFetch( + () => + new Response(frame, { + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + }) + ); + const response = await makeClient(fetchFn).post(PING); + expect(response.kind).toBe("sse"); + if (response.kind === "sse") { + expect(response.status).toBe(200); + expect(await new Response(response.stream).text()).toBe(frame); + } + }); + + it("throws UpstreamProtocolError on a non-JSON body, carrying the status", async () => { + const { fetchFn } = recordingFetch( + () => + new Response("gateway error", { + status: 502, + headers: { "content-type": "text/html" }, + }) + ); + const promise = makeClient(fetchFn).post(PING); + await expect(promise).rejects.toBeInstanceOf(UpstreamProtocolError); + await promise.catch((err: UpstreamProtocolError) => { + expect(err.status).toBe(502); + expect(err.message).not.toContain(API_KEY); + }); + }); + }); + + describe("transport failures", () => { + it("throws UpstreamUnreachableError on connection refused (real fetch, closed port)", async () => { + // Grab a loopback port and close it again so nothing is listening. + const port = await new Promise((resolve) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + const p = typeof address === "object" && address !== null ? address.port : 0; + srv.close(() => resolve(p)); + }); + }); + const client = new UpstreamClient({ + url: `http://127.0.0.1:${port}/mcp`, + apiKey: API_KEY, + }); + const promise = client.post(PING); + await expect(promise).rejects.toBeInstanceOf(UpstreamUnreachableError); + await promise.catch((err: Error) => { + // The actionable errno from err.cause is surfaced in the message. + expect(err.message).toContain("ECONNREFUSED"); + expect(err.message).not.toContain(API_KEY); + expect(String(err)).not.toContain(API_KEY); + }); + }); + + it("throws UpstreamAbortedError when the signal aborts the fetch", async () => { + const fetchFn = ((_input: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("This operation was aborted", "AbortError")) + ); + })) as FetchLike; + const controller = new AbortController(); + const promise = makeClient(fetchFn).post(PING, { signal: controller.signal }); + controller.abort(); + await expect(promise).rejects.toBeInstanceOf(UpstreamAbortedError); + }); + }); + + it("never writes the API key to stderr", async () => { + const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + const { fetchFn } = recordingFetch(() => jsonResponse({ jsonrpc: "2.0", result: {}, id: 1 })); + const client = makeClient(fetchFn); + await client.post(PING); + await client.post(PING, { sessionId: "sess-1", protocolVersion: "2025-06-18" }); + const written = stderrSpy.mock.calls.map((args) => String(args[0])).join(""); + expect(written).not.toContain(API_KEY); + }); +}); diff --git a/tsconfig.all.json b/tsconfig.all.json new file mode 100644 index 0000000..efc81b3 --- /dev/null +++ b/tsconfig.all.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..388ff9b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e67e3bf --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/node_modules/**", "**/*.integration.test.ts"], + }, +}); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..2e859a1 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.integration.test.ts"], + // Integration tests start the local proxy server in-process and hit the real + // hosted upstream, so keep the runner on a single forked process (serial) for + // deterministic behavior. + pool: "forks", + fileParallelism: false, + }, +}); From 0c82347222bf87bd897933649989b8a9fbb859c1 Mon Sep 17 00:00:00 2001 From: Zachary Lyon Date: Mon, 3 Aug 2026 11:40:29 -0700 Subject: [PATCH 2/4] Address review feedback - Never echo TINYFISH_UPSTREAM_URL values in config errors; reject URLs with embedded credentials - Remove internal doc/process references from code comments; trim comments - Name maintainer in README; drop TBD support-policy section - Keep 0.1.0 changes under Unreleased until first publish; mark README pre-release - Add language identifiers to README fenced blocks Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 +-- README.md | 14 ++++---- src/config.ts | 15 +++++++-- src/core/errors.ts | 50 +++++++++++++-------------- src/core/proxy-core.ts | 28 +++++++-------- src/core/session.ts | 35 +++++++++---------- src/core/sse.ts | 13 ++++--- src/core/upstream.ts | 20 +++++------ src/http/adapter.ts | 42 +++++++++++------------ src/http/index.ts | 13 ++++--- src/http/origin.ts | 5 ++- src/shutdown.ts | 2 +- tests/adapter.test.ts | 19 +++++------ tests/config.test.ts | 21 ++++++++++++ tests/errors.test.ts | 24 ++++++------- tests/helpers/http.ts | 7 ++-- tests/helpers/mock-upstream.ts | 60 +++++++++++++++------------------ tests/origin.test.ts | 2 +- tests/proxy.integration.test.ts | 15 ++++----- tests/relay.test.ts | 26 +++++++------- tests/session.test.ts | 4 +-- tests/sse.test.ts | 2 +- 22 files changed, 213 insertions(+), 209 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b9338..3d1d68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,6 @@ and this project adheres to ## [Unreleased] -## [0.1.0] - 2026-08-03 - ### Added - Initial release of `@tiny-fish/mcp`: a local Streamable-HTTP MCP server at @@ -35,5 +33,4 @@ and this project adheres to - `tinyfish-mcp` bin, Node >= 22, ESM, published files limited to `dist/`, `README.md`, `LICENSE`. -[Unreleased]: https://github.com/tinyfish-io/tinyfish-mcp-server/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/tinyfish-io/tinyfish-mcp-server/releases/tag/v0.1.0 +[Unreleased]: https://github.com/tinyfish-io/tinyfish-mcp-server/commits/main diff --git a/README.md b/README.md index 7b0fb7f..4629c33 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # @tiny-fish/mcp +> **Status: pre-release.** Not yet published to npm — API-key authentication +> on the hosted server is still rolling out. + TinyFish local MCP server — a transparent reverse proxy that exposes a local Streamable-HTTP MCP endpoint at `http://127.0.0.1:3711/mcp` and forwards every request to the hosted TinyFish MCP server at `https://agent.tinyfish.ai/mcp`. @@ -17,7 +20,7 @@ If your MCP client supports remote Streamable-HTTP servers (Claude Code, Claude Desktop connectors, Cursor, VS Code, and most modern clients do), connect it **directly** to the hosted server — no install, no local process: -``` +```text https://agent.tinyfish.ai/mcp ``` @@ -55,7 +58,7 @@ tinyfish-mcp On success it prints one line to stderr: -``` +```text tinyfish-mcp [info] listening on http://127.0.0.1:3711 — upstream https://agent.tinyfish.ai/mcp — v0.1.0 ``` @@ -221,11 +224,8 @@ automation and spends credits. ## Maintenance -- **Owner:** TBD (to be named before the first public release — see the - maintenance gate in the internal release runbook, - `docs/phases/release-runbook.md`). -- **Support policy:** TBD — issue-triage and release cadence are defined in - the release runbook alongside the owner. +Maintained by [@Zechereh](https://github.com/Zechereh). Bugs and feature +requests: [GitHub issues](https://github.com/tinyfish-io/tinyfish-mcp-server/issues). ## License diff --git a/src/config.ts b/src/config.ts index c3285df..e8a4c54 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,12 +40,21 @@ const envSchema = z.object({ .transform((value, ctx) => { const raw = value ?? DEFAULT_UPSTREAM_URL; let url: URL; + // Error messages never echo the raw value: it may embed credentials or + // query-string secrets, and these messages go to stderr. try { url = new URL(raw); } catch { ctx.addIssue({ code: "custom", - message: `Invalid TINYFISH_UPSTREAM_URL "${raw}" — must be an absolute URL`, + message: "Invalid TINYFISH_UPSTREAM_URL — must be an absolute URL", + }); + return z.NEVER; + } + if (url.username !== "" || url.password !== "") { + ctx.addIssue({ + code: "custom", + message: "Invalid TINYFISH_UPSTREAM_URL — URL credentials are not allowed", }); return z.NEVER; } @@ -54,8 +63,8 @@ const envSchema = z.object({ ctx.addIssue({ code: "custom", message: - `Invalid TINYFISH_UPSTREAM_URL "${raw}" — scheme must be https ` + - `(http is allowed only for 127.0.0.1/localhost)`, + "Invalid TINYFISH_UPSTREAM_URL — scheme must be https " + + "(http is allowed only for 127.0.0.1/localhost)", }); return z.NEVER; } diff --git a/src/core/errors.ts b/src/core/errors.ts index c87d665..46e998c 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,23 +1,23 @@ /** - * Typed transport-level errors thrown by the proxy core, plus the one - * client-facing shaping function (Phase 6): `toJsonRpcError` for pre-stream - * failures and `toStreamErrorFrame` for failures after an SSE relay started. - * Every adapter catch path routes through these two functions — no ad-hoc - * error bodies anywhere else, with exactly two deliberate exceptions (pinned, - * Phase 7): the adapter's ParseError reply (http/adapter.ts — built where the - * unparseable body is caught, since there is nothing to route), and the - * last-resort -32603 backstop in http/index.ts's invokeSafely. Messages must - * never contain the API key (they describe network/protocol conditions only). + * Typed transport-level errors thrown by the proxy core, plus the only + * client-facing shaping functions: `toJsonRpcError` for pre-stream failures + * and `toStreamErrorFrame` for failures after an SSE relay started. Every + * adapter catch path routes through these two functions — no ad-hoc error + * bodies anywhere else, with exactly two deliberate exceptions: the adapter's + * ParseError reply (http/adapter.ts — built where the unparseable body is + * caught, since there is nothing to route), and the last-resort -32603 + * backstop in http/index.ts's invokeSafely. Messages must never contain the + * API key (they describe network/protocol conditions only). * - * HTTP status decision for locally shaped errors (pinned here, Phase 6): - * upstream's own convention (shared/json-rpc.ts) maps client-error JSON-RPC - * codes to HTTP 400 and everything else to 500. The proxy mirrors the 400 for - * client errors (-32700 ParseError) and picks **502 Bad Gateway** for the - * upstream-leg failures it shapes itself (-32000 unreachable/stream-failed, - * -32001 auth rejection): the proxy is healthy, the upstream hop failed — - * distinguishing these from a genuine local proxy bug, which stays **500** - * with -32603 InternalError. Upstream-originated JSON-RPC errors are never - * shaped at all: they forward verbatim under upstream's own HTTP status. + * HTTP status decision for locally shaped errors: the hosted server maps + * client-error JSON-RPC codes to HTTP 400 and everything else to 500. The + * proxy mirrors the 400 for client errors (-32700 ParseError) and picks + * **502 Bad Gateway** for the upstream-leg failures it shapes itself (-32000 + * unreachable/stream-failed, -32001 auth rejection): the proxy is healthy, + * the upstream hop failed — distinguishing these from a genuine local proxy + * bug, which stays **500** with -32603 InternalError. Upstream-originated + * JSON-RPC errors are never shaped at all: they forward verbatim under + * upstream's own HTTP status. */ /** JSON-RPC error codes used by locally shaped errors. */ @@ -48,8 +48,8 @@ export class UpstreamUnreachableError extends ProxyCoreError { /** * Upstream answered 401/403 with a body that is NOT a JSON-RPC message (a - * JSON-RPC error body, whatever its HTTP status, forwards verbatim instead — - * rules-table row 1). Carries the upstream status and body text so the shaped + * JSON-RPC error body, whatever its HTTP status, forwards verbatim instead). + * Carries the upstream status and body text so the shaped * client error can include them as diagnostics. The body text is truncated to * ~2KB at construction; the Error message itself never includes it. */ @@ -80,7 +80,7 @@ export const AUTH_BODY_LIMIT = 2048; * Delivering a relayed SSE frame to the LOCAL client failed (the transport's * onEvent callback rejected — e.g. the client socket died mid-write). This is * a client-side condition, never an upstream one: it must not be logged or - * classified as "Upstream unreachable" (Phase 4 review gap 2 / Phase 5). + * classified as "Upstream unreachable". */ export class LocalWriteError extends ProxyCoreError {} @@ -112,7 +112,7 @@ export function isAbortError(err: unknown): boolean { } // --------------------------------------------------------------------------- -// Client-facing shaping (Phase 6) — the only place error bodies are built +// Client-facing shaping — the only place error bodies are built // --------------------------------------------------------------------------- export interface JsonRpcErrorBody { @@ -129,8 +129,8 @@ export interface ShapedJsonRpcError { /** * Map a failure the upstream never answered (or answered unusably) to the - * client-facing JSON-RPC error + local HTTP status, per the Phase 6 rules - * table. Only failures upstream never saw as JSON-RPC get shaped here — + * client-facing JSON-RPC error + local HTTP status. + * Only failures upstream never saw as JSON-RPC get shaped here — * upstream JSON-RPC errors forward verbatim and never reach this function. * Never includes the API key: transport-error messages describe network and * protocol conditions only, and unexpected local errors get a generic message @@ -200,7 +200,7 @@ export function toJsonRpcError(failure: unknown, requestId: unknown): ShapedJson /** * Build the final SSE-framed JSON-RPC error for a failure AFTER the local SSE - * relay started (rules-table row "mid-stream upstream disconnect"). A + * relay started (mid-stream upstream disconnect). A * tools/call may have side effects, so the message warns that the run may * still be executing and is never retried silently; when a run id was already * seen in a progress frame's `_meta.runId` it is included in `data.runId` diff --git a/src/core/proxy-core.ts b/src/core/proxy-core.ts index f62eb20..b1e6212 100644 --- a/src/core/proxy-core.ts +++ b/src/core/proxy-core.ts @@ -21,14 +21,14 @@ import { toTransportError, UpstreamClient, type FetchLike } from "./upstream.js" /** * Plain callback invoked per intermediate SSE frame (progress notifications). * May return a promise; forwardStream awaits each emission before reading the - * next frame, so a relaying transport can propagate write backpressure - * (Phase 5 contract). `rawData` is the frame's original `data:` payload string + * next frame, so a relaying transport can propagate write backpressure. + * `rawData` is the frame's original `data:` payload string * (multi-line values joined with "\n") — relay it verbatim when possible; the * parsed `message` is a fallback for transports that must re-serialize. A * rejection from onEvent surfaces as LocalWriteError (client-side condition), * never as an upstream transport error. * - * DELIBERATE DROP (Phase 6 decision, Phase 5 review gap 3): SSE `event:` and + * DELIBERATE DROP: SSE `event:` and * `id:` fields are parsed by core/sse.ts but NOT carried through this * callback — only the `data:` payload is relayed. The verified upstream sends * bare `data:` frames exclusively, so threading them through would be dead @@ -40,7 +40,7 @@ export type OnEvent = (message: unknown, rawData?: string) => void | Promise upstream.post(notification, { // No captured id means the client is re-sending upstream's own id as - // the local key (raw-pipe bridging) — replay the key itself. + // the local key — replay the key itself. sessionId: entry.upstreamSessionId ?? localKey, protocolVersion: clientProtocolVersion ?? entry.protocolVersion, signal, }) ); // Upstream's contract for notifications is HTTP 204, empty body. - // PINNED DECISION (Phase 6): any other successful-fetch answer — + // Deliberate: any other successful-fetch answer — // including a JSON-RPC error body — is deliberately swallowed after a // stderr warning, because a JSON-RPC notification has no response // channel to relay it on (the local client still gets its 204). @@ -334,9 +334,9 @@ function jsonRpcErrorCodeOf(body: unknown): number | undefined { } /** - * The JSON-RPC id of a request message, undefined when absent (Phase 7: the - * single copy — the HTTP adapter imports this too; the shaping functions in - * core/errors.ts normalize undefined to null themselves). + * The JSON-RPC id of a request message, undefined when absent. Single copy — + * the HTTP adapter imports this too; the shaping functions in core/errors.ts + * normalize undefined to null themselves. */ export function requestIdOf(request: unknown): unknown { if (typeof request === "object" && request !== null && "id" in request) { diff --git a/src/core/session.ts b/src/core/session.ts index 2cec351..eef0528 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -1,25 +1,22 @@ /** * Session bridging state. * - * With the raw-pipe architecture (spike decision B) the client re-sends - * upstream's own Mcp-Session-Id, so localKey is normally the upstream-issued - * id itself and the map's main job is abort tracking for in-flight upstream - * fetches. The upstreamSessionId / protocolVersion fields are kept per the - * Phase 3 contract regardless — they cost nothing and keep the door open for - * a future stdio transport that needs real id bridging. + * The proxy relays session headers verbatim: the client re-sends upstream's + * own Mcp-Session-Id, so localKey is normally the upstream-issued id itself + * and the map's main job is abort tracking for in-flight upstream fetches. + * The upstreamSessionId / protocolVersion fields cost nothing and keep the + * door open for a future stdio transport that needs real id bridging. * - * Close is local-only cleanup: upstream has no DELETE handler - * (00-shared-context §1), so teardown just aborts in-flight fetches and drops - * the entry. + * Close is local-only cleanup: the hosted server has no DELETE endpoint, so + * teardown just aborts in-flight fetches and drops the entry. * * Growth characteristic (deliberate): entries are created on first use and - * removed only by close()/closeAll(). The raw-pipe HTTP surface has no - * client-driven teardown signal (clients cannot DELETE), so a long-running - * proxy accumulates one small entry per distinct session key until process - * shutdown runs closeAll() via the shutdown hook. That is acceptable for a - * local single-user proxy — entries are a few strings plus an empty Set — and - * is the consciously chosen steady state. Phase 4 guidance: call - * core.close(localKey) wherever the transport does learn of a session's end + * removed only by close()/closeAll(). The HTTP surface has no client-driven + * teardown signal (clients cannot DELETE), so a long-running proxy + * accumulates one small entry per distinct session key until process shutdown + * runs closeAll() via the shutdown hook. That is acceptable for a local + * single-user proxy — entries are a few strings plus an empty Set. Call + * core.close(localKey) wherever a transport does learn of a session's end * (e.g. a future stdio transport's disconnect); an idle TTL can be added * later if a real leak ever materializes. */ @@ -61,8 +58,8 @@ export class SessionStore { /** * Map an additional key to an existing entry. Used by initialize() to make * the upstream-issued Mcp-Session-Id resolve to the same session as the - * initialize-time local key: with raw-pipe bridging the client re-sends - * upstream's id, so later calls arrive keyed by that id. + * initialize-time local key: the client re-sends upstream's id, so later + * calls arrive keyed by that id. */ alias(aliasKey: string, entry: SessionEntry): void { this.sessions.set(aliasKey, entry); @@ -86,7 +83,7 @@ export class SessionStore { /** * Local-only teardown: abort every in-flight upstream fetch for the session * and drop the entry — including every alias key that maps to the same - * entry. No upstream DELETE — upstream has no DELETE handler. + * entry. No upstream DELETE — the hosted server has no DELETE endpoint. */ close(localKey: string): void { const entry = this.sessions.get(localKey); diff --git a/src/core/sse.ts b/src/core/sse.ts index aa82933..053f1f9 100644 --- a/src/core/sse.ts +++ b/src/core/sse.ts @@ -1,7 +1,7 @@ /** - * Incremental SSE parser over a ReadableStream (Phase 5). + * Incremental SSE parser over a ReadableStream. * - * Replaces proxy-core's inline blank-line splitter. Handles chunk boundaries + * Handles chunk boundaries * anywhere — mid-line, mid-event, mid-CRLF, even mid-UTF-8-codepoint (the * streaming TextDecoder holds partial sequences) — per the SSE processing * model: @@ -15,12 +15,11 @@ * - A blank line dispatches the pending event; blocks without any `data:` * field dispatch nothing (spec behavior — comments/ids alone are dropped). * - A stream that ends without a trailing blank line still dispatches its - * pending event (tolerance kept from the Phase 3 inline parser). + * pending event. * * Each yielded event carries BOTH the parsed JSON and the raw joined data * payload string, so a relaying transport can pipe upstream's original bytes - * through untouched (byte-verbatim relay — spike guidance) instead of - * re-serializing. + * through untouched instead of re-serializing. * * Teardown: breaking out of (or throwing from) a for-await over this * generator runs its return path, which ends the inner for-await over the @@ -123,8 +122,8 @@ export async function* parseSseStream( /** * Every upstream frame must be a JSON-RPC message. An empty `data:` payload is - * therefore a protocol violation too (JSON.parse("") throws) — pinned behavior: - * it surfaces as UpstreamProtocolError, same as any other non-JSON payload. + * therefore a protocol violation too (JSON.parse("") throws): it surfaces as + * UpstreamProtocolError, same as any other non-JSON payload. */ function parseJsonPayload(rawData: string): unknown { try { diff --git a/src/core/upstream.ts b/src/core/upstream.ts index f80b60c..2bcb948 100644 --- a/src/core/upstream.ts +++ b/src/core/upstream.ts @@ -5,8 +5,8 @@ * classifies the response by HTTP status and content type only — it never * inspects methods, tool names, or result schemas. The upstream HTTP status is * preserved on every variant so the transport adapter can pass it through - * (spike finding: json-rpc client errors arrive as 400, server errors as 500, - * notifications as 204). + * (the hosted server answers JSON-RPC client errors as 400, server errors as + * 500, notifications as 204). * * Web-standard types only (fetch / Response / ReadableStream) — no node:http. */ @@ -133,8 +133,7 @@ export class UpstreamClient { if (text.length === 0) { // A body-less 401/403 (e.g. a gateway/LB that strips bodies) is still an // auth rejection — classify it BEFORE the generic empty return so the - // client gets the check-your-TINYFISH_API_KEY error, not a protocol one - // (Phase 6 review gap 1). + // client gets the check-your-TINYFISH_API_KEY error, not a protocol one. if (status === 401 || status === 403) { throw new UpstreamAuthError(status, ""); } @@ -144,7 +143,7 @@ export class UpstreamClient { try { body = JSON.parse(text); } catch (err) { - // Phase 6: a 401/403 whose body is not JSON at all is an auth rejection + // A 401/403 whose body is not JSON at all is an auth rejection // from an intermediary or a non-MCP error page — classified so the // adapter can shape the check-your-TINYFISH_API_KEY error. Any other // status with a non-JSON body stays a protocol violation. @@ -159,8 +158,8 @@ export class UpstreamClient { } // A 401/403 with a JSON body that is NOT a JSON-RPC message (e.g. // {"error":"unauthorized"}) is also an auth rejection — only a genuine - // JSON-RPC error body forwards verbatim (rules-table row 1, preserving - // upstream's HTTP status). + // JSON-RPC error body forwards verbatim, preserving upstream's HTTP + // status. if ((status === 401 || status === 403) && !isJsonRpcMessage(body)) { throw new UpstreamAuthError(status, text); } @@ -180,13 +179,12 @@ export class UpstreamClient { * carries (`result`/`error` for responses, `method` for requests and * notifications). Quasi-JSON-RPC junk like {"jsonrpc":"2.0","message":"no"} * fails the gate, so at 401/403 it shapes as the -32001 auth error instead of - * forwarding (Phase 6 review gap 2). + * forwarding. * * BATCH ARRAYS are deliberately outside this gate: MCP forbids JSON-RPC * batching and upstream does not support it, so a 401/403 whose body is a * batch(-error) array shapes as -32001 with the raw body preserved in - * `data.upstreamBody` rather than forwarding verbatim (Phase 6 review note, - * pinned in Phase 7). + * `data.upstreamBody` rather than forwarding verbatim. */ function isJsonRpcMessage(body: unknown): boolean { return ( @@ -201,7 +199,7 @@ function isJsonRpcMessage(body: unknown): boolean { /** * Map a fetch/stream failure to a typed transport error. Never includes the * key. When `url` is given (the fetch call site knows it), the upstream host - * is attached so the Phase 6 shaping can name it in the client-facing message. + * is attached so the error shaping can name it in the client-facing message. */ export function toTransportError( err: unknown, diff --git a/src/http/adapter.ts b/src/http/adapter.ts index 2076276..22c25c4 100644 --- a/src/http/adapter.ts +++ b/src/http/adapter.ts @@ -1,7 +1,7 @@ /** * Per-request MCP wiring over the transport-agnostic proxy core. * - * Session bridging (raw-pipe, spike decision B): the local client re-sends + * Session bridging: the local client re-sends * whatever Mcp-Session-Id upstream issued, so the client-sent header IS the * core localKey. For `initialize` — where no upstream id exists yet — a * locally generated key seeds the session entry, and the core aliases that @@ -15,9 +15,9 @@ * Mcp-Session-Id and MCP-Protocol-Version; the core builds every outbound * header itself. * - * Streaming (Phase 5): when upstream answers SSE, frames are relayed through + * Streaming: when upstream answers SSE, frames are relayed through * the core's onEvent into an SSE response using the ORIGINAL `data:` payload - * string (byte-verbatim relay — no re-serialization). Each write is awaited + * string (byte-verbatim, no re-serialization). Each write is awaited * (backpressure), a local client disconnect aborts the upstream fetch via a * per-request AbortSignal (no orphaned upstream streams), and mid-stream * failures are classified: local write failures are never labeled upstream. @@ -45,10 +45,9 @@ export function createMcpAdapter(core: ProxyCore): RequestHandler { try { message = JSON.parse(raw.toString("utf8")); } catch { - // Local ParseError mirroring upstream's shape (shared/json-rpc.ts: - // client-error codes → HTTP 400; id -1 when no request id is known). - // The one case the proxy answers without forwarding — it cannot route - // what it cannot parse. + // Local ParseError mirroring upstream's shape (client-error codes → + // HTTP 400; id -1 when no request id is known). The one case the proxy + // answers without forwarding — it cannot route what it cannot parse. sendJson(res, 400, { jsonrpc: "2.0", error: { code: JsonRpcErrorCodes.ParseError, message: "Parse error: Invalid JSON" }, @@ -60,7 +59,7 @@ export function createMcpAdapter(core: ProxyCore): RequestHandler { try { await route(core, res, message, sessionId, protocolVersion); } catch (err) { - // Phase 6: every failure upstream never answered as JSON-RPC is shaped + // Every failure upstream never answered as JSON-RPC is shaped // through the one function in core/errors.ts. Streamed requests handle // their own mid-stream failures and only rethrow pre-stream ones, so // headers are normally unsent here; the guard covers a write that died @@ -168,8 +167,7 @@ async function relayPossiblyStreaming( ): Promise { let streaming = false; // Last _meta.runId seen in a relayed progress frame — upstream names the - // run there so a mid-stream failure can hand the client a recovery handle - // (rules table: include run_id in the error frame's data when seen). + // run there so a mid-stream failure can hand the client a recovery handle. let lastRunId: string | undefined; const clientAbort = new AbortController(); const onClose = (): void => { @@ -181,7 +179,7 @@ async function relayPossiblyStreaming( if (!streaming) { streaming = true; // Mirror upstream's SSE headers. Deliberately no Mcp-Session-Id: - // upstream's SSE path never sets one (Phase 5 invariant). + // upstream's SSE path never sets one. res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", @@ -206,8 +204,7 @@ async function relayPossiblyStreaming( } catch (err) { // A final-frame write failure is a LOCAL socket condition, exactly // like a progress-frame write failure inside onEvent — classify it the - // same way so it can never be mislabeled as an upstream stream failure - // (Phase 5 review gap 2). + // same way so it can never be mislabeled as an upstream stream failure. throw new LocalWriteError( `Relaying the final SSE frame to the local client failed: ${ err instanceof Error ? err.message : String(err) @@ -232,19 +229,18 @@ async function relayPossiblyStreaming( // Writing to the local client socket failed (client dying but 'close' // not yet observed). Teardown already happened in the core (the throw // exits the frame loop, canceling the upstream stream). Never labeled - // "Upstream unreachable" (Phase 4 review gap 2). + // "Upstream unreachable". log.warn(err.message); res.destroy(); return; } if (streaming) { // The stream broke after the local SSE response already started. Emit - // the final SSE-framed JSON-RPC error per the Phase 6 rules table - // (-32000, "the run may still be executing", runId in data when seen) - // — never an unframed body into a started SSE stream. The log prefix - // names the actual culprit: a classified core error is an upstream-leg - // failure; anything else is a LOCAL proxy bug and must not be logged - // with an upstream-blaming label (same mislabel class as gap 2). + // the final SSE-framed JSON-RPC error (-32000, "the run may still be + // executing", runId in data when seen) — never an unframed body into a + // started SSE stream. The log prefix names the actual culprit: a + // classified core error is an upstream-leg failure; anything else is a + // LOCAL proxy bug and must not be logged with an upstream-blaming label. if (err instanceof ProxyCoreError) { log.error(`upstream stream failed mid-relay: ${err.message}`); } else { @@ -280,7 +276,7 @@ function runIdOf(event: unknown): string | undefined { /** Write upstream's status + JSON-RPC body verbatim; echo the session header. */ function sendProxyResponse(res: ServerResponse, response: ProxyResponse): void { - // Copy upstream's Content-Type through (spike guidance); fall back for + // Copy upstream's Content-Type through; fall back for // locally synthesized responses (e.g. an SSE final frame answered as JSON). const headers: Record = { "Content-Type": response.contentType ?? "application/json", @@ -303,7 +299,7 @@ function sendJson( /** * One SSE frame; resolves when the chunk is flushed. Relays the ORIGINAL - * upstream payload string when available (byte-verbatim — spike guidance); + * upstream payload string when available (byte-verbatim); * falls back to JSON.stringify for locally synthesized frames. A payload * containing newlines (multi-line `data:` field) is re-split into one * `data:` line per payload line, which reconstructs to identical bytes on the @@ -335,7 +331,7 @@ function headerValue(req: IncomingMessage, name: string): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -/** JSON-RPC notification: an object with a method and no id key (mock/upstream rule). */ +/** JSON-RPC notification: an object with a method and no id key (upstream's rule). */ function isNotification(message: unknown): boolean { return ( typeof message === "object" && diff --git a/src/http/index.ts b/src/http/index.ts index 0645c58..a65bee8 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -7,8 +7,7 @@ import { checkOrigin } from "./origin.js"; * Request handler contract for the HTTP layer. Handlers may be async: the * server awaits the returned promise and converts a rejection into a 500 * JSON-RPC InternalError response (or just ends the response when headers are - * already out, e.g. mid-SSE), so async failures are never silently swallowed - * (Phase 2 review note). Phase 6 refines the error shaping. + * already out, e.g. mid-SSE), so async failures are never silently swallowed. */ export type RequestHandler = ( req: IncomingMessage, @@ -78,9 +77,9 @@ export function startHttpServer( } /** - * Await the handler; turn sync throws and async rejections into a 500. With - * Phase 6 the MCP adapter shapes every classified failure itself, so anything - * that reaches this catch is a local proxy bug: full stack to stderr, generic + * Await the handler; turn sync throws and async rejections into a 500. The + * MCP adapter shapes every classified failure itself, so anything that + * reaches this catch is a local proxy bug: full stack to stderr, generic * -32603 InternalError to the client. */ async function invokeSafely( @@ -91,8 +90,8 @@ async function invokeSafely( try { await handler(req, res); } catch (err) { - // Core error messages never contain the API key (Phase 3 invariant), and - // the client-facing body is generic regardless — the key cannot leak. + // Core error messages never contain the API key, and the client-facing + // body is generic regardless — the key cannot leak. log.error( `request failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}` ); diff --git a/src/http/origin.ts b/src/http/origin.ts index 5f80b97..bc2dc01 100644 --- a/src/http/origin.ts +++ b/src/http/origin.ts @@ -3,11 +3,10 @@ * control for the loopback server. Evaluated before the request body is read; * on deny the caller answers 403 without touching the body. * - * Policy (00-shared-context §Security): + * Policy: * - Absent Origin → allow (non-browser clients: curl, MCP SDKs, inspectors). * - http/https origins on `127.0.0.1` or `localhost` → allow, any port - * (per phase doc: "any port variant of loopback also fine" — so the phase - * doc's `port` parameter is dead and deliberately omitted here). + * (any port variant of loopback is fine, so no `port` parameter exists). * - Everything else → deny, including the literal "null" Origin (sandboxed * iframes, file://), IPv6 `[::1]` (the server binds IPv4 loopback only), * and anything that does not parse as a URL. diff --git a/src/shutdown.ts b/src/shutdown.ts index 7e736f7..010546d 100644 --- a/src/shutdown.ts +++ b/src/shutdown.ts @@ -1,2 +1,2 @@ -/** Phase 3 registers session cleanup here; hooks run on SIGINT/SIGTERM before exit. */ +/** Cleanup hooks (e.g. session teardown) run on SIGINT/SIGTERM before exit. */ export const shutdownHooks: Array<() => void | Promise> = []; diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index c1b12e5..894a1c2 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -1,10 +1,10 @@ /** - * Full-flow tests for the HTTP adapter over real local HTTP against the spike - * mock upstream: client → 127.0.0.1 server → proxy core → mock upstream. + * Full-flow tests for the HTTP adapter over real local HTTP: + * client → 127.0.0.1 server → proxy core → mock upstream. * * Tests inside the main describe run sequentially and share one session * (initialize captures the upstream-issued Mcp-Session-Id; later requests - * re-send it, exercising the raw-pipe session bridging end to end). + * re-send it, exercising the session bridging end to end). */ import type { Server } from "node:http"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -29,7 +29,7 @@ describe("HTTP adapter full flow (real local HTTP → mock upstream)", () => { let server: Server; let base: string; let mcpUrl: string; - // Captured at initialize; re-sent by the "client" afterwards (raw-pipe bridging). + // Captured at initialize; re-sent by the "client" afterwards. let sessionId: string; const sessionHeaders = () => ({ "Mcp-Session-Id": sessionId, @@ -140,11 +140,11 @@ describe("HTTP adapter full flow (real local HTTP → mock upstream)", () => { ); expect(result.sessionId).toBe(sessionId); // The request body reached upstream verbatim over the real socket — the - // mock records the parsed body per request (Phase 7 review gap 3). + // mock records the parsed body per request. expect(mock.seen.at(-1)?.body).toEqual(request); }); - it("resources/list forwards generically: raw body verbatim vs fixture (Phase 7 — spike never exercised resources/*)", async () => { + it("resources/list forwards generically: raw body verbatim vs fixture", async () => { const result = await postJson( mcpUrl, { jsonrpc: "2.0", id: 40, method: "resources/list", params: {} }, @@ -173,8 +173,7 @@ describe("HTTP adapter full flow (real local HTTP → mock upstream)", () => { expect(mock.seen.at(-1)).toMatchObject({ method: "resources/read", sessionId }); }); - // The SSE streaming test moved to tests/relay.test.ts (Phase 5) — the relay - // suite owns all streaming coverage; no duplicate here. + // SSE streaming coverage lives in tests/relay.test.ts — no duplicate here. it("unknown method: upstream MethodNotFound relayed verbatim with upstream 400", async () => { const result = await postJson( @@ -287,7 +286,7 @@ describe("HTTP adapter full flow (real local HTTP → mock upstream)", () => { describe("ping before initialize (fresh server, no session anywhere)", () => { it("forwards a header-less ping before any initialize happened (upstream allows it)", async () => { - // Phase 7 gap-fill: the main suite pings AFTER its initialize ran; this + // The main suite pings AFTER its initialize ran; this // proves the very first request a client ever sends can be a ping — no // session header, no prior state — and it round-trips. const mock = await startMockUpstream(); @@ -334,7 +333,7 @@ describe("unhandled adapter failures (async-safe RequestHandler seam)", () => { } }); - it("unreachable upstream: shaped -32000 'cannot reach' error (Phase 6), no key leak", async () => { + it("unreachable upstream: shaped -32000 'cannot reach' error, no key leak", async () => { const core = createProxyCore({ // A loopback port with nothing listening — fetch fails fast. upstreamUrl: "http://127.0.0.1:9/mcp", diff --git a/tests/config.test.ts b/tests/config.test.ts index 9c42c93..17699a7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -115,5 +115,26 @@ describe("parseConfig", () => { .upstreamUrl ).toBe("https://sandbox.tinyfish.ai/mcp"); }); + + it("rejects URLs with embedded credentials", () => { + expect(() => + parseConfig({ ...validEnv, TINYFISH_UPSTREAM_URL: "https://user:secret@example.com/mcp" }) + ).toThrowError(/URL credentials are not allowed/); + }); + + it("never echoes the URL value in error messages", () => { + for (const url of [ + "http://user:hunter2@example.com/mcp", + "https://user:hunter2@example.com/mcp", + "hunter2 not a url", + ]) { + try { + parseConfig({ ...validEnv, TINYFISH_UPSTREAM_URL: url }); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as Error).message).not.toContain("hunter2"); + } + } + }); }); }); diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 9168b2b..17a6b62 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -1,13 +1,13 @@ /** - * Phase 6 error-handling tests — one test per rules-table row in - * docs/phases/phase-6-errors.md, exercised through the FULL local HTTP hop - * (client → 127.0.0.1 proxy → mock upstream) unless the row is unreachable - * over real sockets (the final-frame write race uses a stub ServerResponse). + * Error-handling tests — one test per error-shaping rule, exercised through + * the FULL local HTTP hop (client → 127.0.0.1 proxy → mock upstream) unless + * the rule is unreachable over real sockets (the final-frame write race uses + * a stub ServerResponse). * - * Rows: + * Rules: * 1. Upstream JSON-RPC error (any code) → forwarded byte-verbatim. * 2. Upstream 401/403, non-JSON-RPC body → -32001 + TINYFISH_API_KEY hint. - * 3. Upstream 4xx/5xx with JSON-RPC body → row 1 (verbatim, status kept). + * 3. Upstream 4xx/5xx with JSON-RPC body → rule 1 (verbatim, status kept). * 4. Upstream unreachable → -32000 "cannot reach ", * never silently retried. * 5. Mid-stream SSE disconnect → framed -32000, "run may still @@ -15,7 +15,7 @@ * when seen in progress _meta. * 6. Local proxy bug → -32603 generic, stack to stderr. * + Malformed client JSON → -32700, id -1, HTTP 400. - * + Phase-5 carry-over: final-frame write failure classifies as LOCAL. + * + Final-frame write failure classifies as LOCAL. * * Locally shaped upstream-leg errors (-32000/-32001) answer HTTP 502; local * bugs answer 500; ParseError answers 400 (decision documented in @@ -47,7 +47,7 @@ interface JsonRpcErrorShape { id: unknown; } -describe("Phase 6 rules table (full local HTTP hop → mock upstream)", () => { +describe("error-shaping rules (full local HTTP hop → mock upstream)", () => { let mock: MockUpstream; let server: Server; let mcpUrl: string; @@ -211,11 +211,11 @@ describe("Phase 6 rules table (full local HTTP hop → mock upstream)", () => { }); it("notification hitting a 401 non-JSON-RPC body: HTTP 502, -32001, id null (never a silent 204)", async () => { - // Phase 7 review gap 2 (Phase 6 probe E): the THROWING notification error + // The THROWING notification error // path through the wire. The auth failure throws before the adapter's // writeHead(204), so the shaped -32001 replaces the 204 wholesale, with // id null (notifications carry no id). Contrast: a 401 whose body IS a - // JSON-RPC error is swallowed after a warn (pinned decision, covered at + // JSON-RPC error is swallowed after a warn (deliberate, covered at // core level in tests/session.test.ts). mock.authReject = { status: 401, @@ -378,7 +378,7 @@ describe("row 6: local proxy bug", () => { }); }); -describe("toStreamErrorFrame differentiates failure kinds (Phase 6 review gap 4)", () => { +describe("toStreamErrorFrame differentiates failure kinds", () => { it("gives a session-close abort its own message, distinct from an upstream death", () => { const aborted = toStreamErrorFrame(new UpstreamAbortedError("aborted"), 1, MOCK_RUN_ID); const died = toStreamErrorFrame(new UpstreamUnreachableError("terminated"), 1, MOCK_RUN_ID); @@ -403,7 +403,7 @@ describe("toStreamErrorFrame differentiates failure kinds (Phase 6 review gap 4) }); }); -describe("carry-over: final-frame write failure classifies as LOCAL (Phase 5 review gap 2)", () => { +describe("final-frame write failure classifies as LOCAL", () => { /** * Stub ServerResponse whose write() succeeds for the progress frames and * fails on the final frame, WITHOUT emitting 'close' first — the narrow diff --git a/tests/helpers/http.ts b/tests/helpers/http.ts index d35e4e5..2082a54 100644 --- a/tests/helpers/http.ts +++ b/tests/helpers/http.ts @@ -1,9 +1,8 @@ /** * Shared test helpers for suites that drive the FULL local proxy over real - * HTTP (client → 127.0.0.1 server → proxy core → mock upstream). Deduplicated - * from adapter/relay/errors suites in Phase 7 — fixture builders live in - * ./mock-upstream.ts; scripted-fetch helpers stay local to the suites that - * shape them differently. + * HTTP (client → 127.0.0.1 server → proxy core → mock upstream). Fixture + * builders live in ./mock-upstream.ts; scripted-fetch helpers stay local to + * the suites that shape them differently. */ import type { Server } from "node:http"; diff --git a/tests/helpers/mock-upstream.ts b/tests/helpers/mock-upstream.ts index 5e8d844..1b51bf5 100644 --- a/tests/helpers/mock-upstream.ts +++ b/tests/helpers/mock-upstream.ts @@ -1,18 +1,16 @@ /** - * Mock of the hosted https://agent.tinyfish.ai/mcp endpoint. - * - * Faithful to docs/phases/00-shared-context.md §Verified upstream behavior - * (verified against ux-labs/frontend/app/mcp/{route.ts,lib/http-handler.ts, - * mcp-sse-event-formatter.ts,shared/json-rpc.ts}): + * Mock of the hosted https://agent.tinyfish.ai/mcp endpoint — the single + * fixture shared by every test suite. Faithful to the hosted server's + * verified behavior: * * - POST-only (anything else gets 405, like Next.js's missing-export handling). * - initialize with no Mcp-Session-Id header generates a UUID; successful JSON - * responses echo Mcp-Session-Id but the SSE path does not (sse-event-handling.ts: - * 396-403 sets only stream headers). Session ids are NOT validated (any non-empty - * string is accepted). + * responses echo Mcp-Session-Id but the SSE path does not (it sets only + * stream headers). Session ids are NOT validated (any non-empty string is + * accepted). * - Non-ping requests without the header get JSON-RPC -32600 - * "Missing required Mcp-Session-Id header" (HTTP 400, per shared/json-rpc.ts - * which maps client-error codes to HTTP 400 and the rest to 500). + * "Missing required Mcp-Session-Id header" (HTTP 400 — the hosted server + * maps client-error codes to HTTP 400 and the rest to 500). * - Notifications return HTTP 204 with an empty body. * - Supported methods: ping, initialize, tools/list, tools/call, resources/list, * resources/read. Anything else -> -32601 "Method not found: ". @@ -26,13 +24,10 @@ * rejects notifications missing the headers too, so tests catch header * regressions on every call type. * - * Phase 7: promoted from spike/mock-upstream.ts — this is now the single - * fixture shared by every test suite (and the spike scripts, which remain - * type-checked). Script knobs: `authReject` (canned auth-layer rejection), - * plus per-call tool arguments `frameDelayMs`, `crashAfterFrames`, - * `omitRunMeta`, and `noProgress` on run_web_automation; `seen` records - * method / sessionId / authorization / protocolVersion / aborted / parsed - * request body per request. + * Script knobs: `authReject` (canned auth-layer rejection), plus per-call + * tool arguments `frameDelayMs`, `crashAfterFrames`, `omitRunMeta`, and + * `noProgress` on run_web_automation; `seen` records method / sessionId / + * authorization / protocolVersion / aborted / parsed request body per request. */ import { randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; @@ -67,7 +62,7 @@ export const MOCK_INSTRUCTIONS = "web context. (Mock stand-in for the hosted server's long instructions string — the proxy " + "must pass it through verbatim, byte for byte, including this parenthetical.)"; -/** Shape mirrors route.ts handleInitialize (protocolVersion always 2025-11-25). */ +/** Shape mirrors the hosted server's initialize result (protocolVersion always 2025-11-25). */ export const MOCK_INITIALIZE_RESULT = { protocolVersion: "2025-11-25", capabilities: { @@ -123,7 +118,7 @@ export function buildResourceReadResult(uri: string) { }; } -/** Mirrors json-rpc.ts createWrapResult / the echo path: a plain CallToolResult. */ +/** Mirrors the hosted server's echo path: a plain CallToolResult. */ export function buildEchoResult(name: string, args: unknown) { return { content: [ @@ -137,7 +132,8 @@ export function buildEchoResult(name: string, args: unknown) { } /** - * The scripted SSE sequence for run_web_automation, mirroring MCPSSEFormatter: + * The scripted SSE sequence for run_web_automation, mirroring the hosted + * server's frame shapes: * params key order progressToken, progress, total, message, _meta; heartbeat is * an ordinary progress notification; final response result key order * content, isError, _meta, structuredContent (formatComplete sets _meta before @@ -218,8 +214,8 @@ export interface MockUpstream { * Requests seen, in order (method + session id + inbound Authorization * header, which the proxy must never forward), for client-side assertions. * `aborted` flips to true when the proxy tears the connection down before - * the response finished (Phase 5: client-disconnect must propagate as an - * upstream abort — the mock observes it here). + * the response finished (client-disconnect must propagate as an upstream + * abort — the mock observes it here). */ seen: Array<{ method: string | undefined; @@ -231,7 +227,7 @@ export interface MockUpstream { body: unknown; }>; /** - * Phase 6 knob (mutable): when set, EVERY request is answered with this + * Mutable knob: when set, EVERY request is answered with this * canned rejection before any routing — simulating an auth layer or * intermediary answering with an arbitrary status/content-type/body (e.g. a * 401 text page, or a 401 whose body IS a JSON-RPC error). The body string @@ -267,7 +263,7 @@ function jsonRpcError( id: JsonRpcId, extraHeaders: Record = {}, ): void { - // shared/json-rpc.ts: client-error codes -> HTTP 400, everything else -> 500. + // Hosted-server convention: client-error codes -> HTTP 400, everything else -> 500. const isClientError = code === ErrorCodes.ParseError || code === ErrorCodes.InvalidRequest || @@ -301,10 +297,10 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); * - `crashAfterFrames`: destroy the socket after N frames (mid-stream * upstream-disconnect simulation; the proxy must not relay it as a clean * end). - * - `omitRunMeta` (Phase 6): strip `params._meta` from progress frames, so a + * - `omitRunMeta`: strip `params._meta` from progress frames, so a * crash test can exercise the no-runId-seen branch of the mid-stream error * frame. - * - `noProgress` (Phase 7): degenerate stream — the FIRST frame is the final + * - `noProgress`: degenerate stream — the FIRST frame is the final * JSON-RPC response, no progress notifications precede it (still served as * text/event-stream, like an upstream whose run finishes instantly). * A premature client (= proxy) disconnect marks the seen entry aborted and @@ -343,8 +339,8 @@ async function streamAutomation( entry.aborted = true; } }); - // Real upstream SSE responses do NOT echo Mcp-Session-Id — sse-event-handling.ts:396-403 - // sets only the stream headers below (verified in phase-0 review). + // The hosted server's SSE responses do NOT echo Mcp-Session-Id — they set + // only the stream headers below. res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", @@ -357,7 +353,7 @@ async function streamAutomation( res.destroy(); return; } - // Upstream frames are bare `data:` lines (sse-event-handling.ts:273). + // The hosted server's frames are bare `data:` lines. res.write(`data: ${JSON.stringify(message)}\n\n`); written += 1; await sleep(frameDelayMs); @@ -391,7 +387,7 @@ export function startMockUpstream(): Promise { return; } - // Phase 6 knob: canned auth-layer rejection, body verbatim, before routing. + // Canned auth-layer rejection knob: body verbatim, before routing. if (mock.authReject !== null) { res.writeHead(mock.authReject.status, { "Content-Type": mock.authReject.contentType }); res.end(mock.authReject.body); @@ -421,7 +417,7 @@ export function startMockUpstream(): Promise { const method = typeof body.method === "string" ? body.method : undefined; const isNotification = method !== undefined && !("id" in body); - // Notifications are handled before session resolution (http-handler.ts:147-153). + // The hosted server handles notifications before session resolution. if (isNotification) { seen.push({ method, @@ -448,7 +444,7 @@ export function startMockUpstream(): Promise { const id = body.id as JsonRpcId; const params = (body.params ?? {}) as Record; - // Session model (http-handler.ts:218-223): header wins; initialize mints a + // Hosted server's session model: header wins; initialize mints a // UUID; ids are never validated beyond non-emptiness. const headerSession = (req.headers["mcp-session-id"] as string | undefined) || null; const sessionId = headerSession ?? (method === "initialize" ? randomUUID() : null); diff --git a/tests/origin.test.ts b/tests/origin.test.ts index 7181a2b..4537503 100644 --- a/tests/origin.test.ts +++ b/tests/origin.test.ts @@ -6,7 +6,7 @@ describe("checkOrigin", () => { undefined, // non-browser clients send no Origin "http://127.0.0.1:3711", "http://localhost:3711", - // any loopback port variant is fine (phase doc) + // any loopback port variant is fine "http://127.0.0.1:8080", "http://localhost:1234", "http://localhost", // default port diff --git a/tests/proxy.integration.test.ts b/tests/proxy.integration.test.ts index 489fb59..34cd74b 100644 --- a/tests/proxy.integration.test.ts +++ b/tests/proxy.integration.test.ts @@ -4,15 +4,12 @@ * TINYFISH_UPSTREAM_URL, e.g. a sandbox deployment). * * Run: TINYFISH_API_KEY=... npm run test:integration - * Without the key the suite skips with a printed notice (CLI skip pattern — - * ux-labs/sdk/cli/tests/api.integration.test.ts). + * Without the key the suite skips with a printed notice. * - * BLOCKED ON BACKEND: the hosted server's auth chain (shared/resolve-user-id.ts) - * accepts Bearer / HMAC widget token / Clerk OAuth today; the X-API-Key → - * validateApiKey branch is an external ux-labs PR that has NOT yet reached - * sandbox. Until that branch deploys, no API key can authenticate this proxy's - * upstream calls, so this suite effectively never runs — it exists so CI turns - * green the day the backend lands (set the TINYFISH_API_KEY secret). + * BLOCKED ON BACKEND: the hosted server does not yet accept X-API-Key + * authentication for this proxy's upstream calls, so this suite effectively + * never runs — it exists so CI turns green the day the backend support lands + * (set the TINYFISH_API_KEY secret). * * Coverage when the key is set: * - tools/list via the proxy deep-equals a direct upstream tools/list (the @@ -222,7 +219,7 @@ describeWithApiKey("proxy integration (real hosted upstream)", () => { ); }); -// Mirror of the CLI pattern: keep a visible, always-collected marker of the +// Keep a visible, always-collected marker of the // skip so a keyless run reports 1 passed test instead of "no tests found". describe.skipIf(Boolean(API_KEY))("proxy integration (real hosted upstream)", () => { it("skips real upstream coverage when TINYFISH_API_KEY is not set", () => { diff --git a/tests/relay.test.ts b/tests/relay.test.ts index 9aa3a0a..0e944c0 100644 --- a/tests/relay.test.ts +++ b/tests/relay.test.ts @@ -1,16 +1,14 @@ /** - * Phase 5 SSE relay tests over real local HTTP: client → 127.0.0.1 server → + * SSE relay tests over real local HTTP: client → 127.0.0.1 server → * proxy core → mock upstream. Covers ordered byte-verbatim relay (raw `data:` * payload strings — and the full SSE body — compared on the wire), * progressToken preservation (incl. upstream's 'unknown' fill-in), * client-abort-mid-stream canceling the upstream request (the mock observes * the abort), independent concurrent session streams, mid-stream upstream - * crash (Phase 6 in-stream error frame), local-write vs upstream error + * crash (in-stream error frame), local-write vs upstream error * classification at the core level, >64KB SSE frames through the full local - * server (Phase 7), and shutdown (closeAll via shutdown hooks) aborting an - * in-flight stream with the framed abort error (Phase 7). - * - * The frame-verbatim SSE test formerly in adapter.test.ts moved here. + * server, and shutdown (closeAll via shutdown hooks) aborting an + * in-flight stream with the framed abort error. */ import { createServer, type Server } from "node:http"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -74,7 +72,7 @@ describe("SSE relay (real local HTTP → mock upstream)", () => { const expected = buildAutomationSseMessages("tok-relay", 4); // Raw data: payload strings on the wire, in order, final frame last. expect(sseDataPayloads(result.text)).toEqual(expected.map((m) => JSON.stringify(m))); - // Stronger (Phase 7 wire-bytes bar): the ENTIRE raw SSE body received on + // Stronger (wire-bytes bar): the ENTIRE raw SSE body received on // the socket is byte-identical to what the mock wrote — framing included, // no re-parse anywhere in this comparison. expect(result.text).toBe(expected.map((m) => `data: ${JSON.stringify(m)}\n\n`).join("")); @@ -141,8 +139,8 @@ describe("SSE relay (real local HTTP → mock upstream)", () => { ); }); - it("PINNED: a no-progress SSE stream (first frame is final) downgrades to a plain JSON response", async () => { - // Phase 7 review gap 1: when upstream's stream carries ONLY the final + it("a no-progress SSE stream (first frame is final) downgrades to a plain JSON response", async () => { + // When upstream's stream carries ONLY the final // JSON-RPC response (no progress frames first), `streaming` never flips in // relayPossiblyStreaming, so the adapter answers the tools/call as an // ordinary application/json response instead of opening an SSE stream — @@ -163,10 +161,10 @@ describe("SSE relay (real local HTTP → mock upstream)", () => { expect(result.text).toBe(JSON.stringify(finalMessage)); }); - it("emits the Phase 6 in-stream error frame when upstream dies mid-stream", async () => { + it("emits the in-stream error frame when upstream dies mid-stream", async () => { // The mock destroys the socket after 2 frames; the local client must see // the 2 relayed frames plus an SSE-framed error — never an unframed body, - // never a silent clean end. Phase 6 shape: -32000, "run may still be + // never a silent clean end. Shape: -32000, "run may still be // executing", runId (seen in progress _meta) in data. Full payload // assertions live in tests/errors.test.ts. const result = await postJson( @@ -237,7 +235,7 @@ describe("raw byte relay fidelity (odd-bytes upstream)", () => { }); it("relays a >64KB SSE frame through the FULL local server, byte-identical", async () => { - // Phase 7 gap-fill: tests/sse.test.ts proves the PARSER survives huge + // tests/sse.test.ts proves the PARSER survives huge // frames; this proves the whole hop does — real sockets on both legs, // upstream writing the frame in small chunks so it arrives fragmented. const bigPayload = JSON.stringify({ @@ -297,12 +295,12 @@ describe("raw byte relay fidelity (odd-bytes upstream)", () => { describe("shutdown with an in-flight stream (closeAll via shutdown hooks)", () => { it("aborts the upstream fetch and ends the client stream with the framed abort error", async () => { - // Phase 7 gap-fill for "SIGTERM closes cleanly": src/index.ts wires + // "SIGTERM closes cleanly": src/index.ts wires // SIGINT/SIGTERM to the shutdownHooks array and createProxyCore registers // closeAll() there — running the registered hook IS the signal path minus // process.exit. Asserted: the in-flight upstream fetch aborts (the mock // observes it) and the local client's stream ends — no hang — with the - // Phase 6 framed -32000 "proxy aborted the upstream request" error. + // framed -32000 "proxy aborted the upstream request" error. const mock = await startMockUpstream(); const hooks: Array<() => void | Promise> = []; const core = createProxyCore({ upstreamUrl: mock.url, apiKey: API_KEY, hooks }); diff --git a/tests/session.test.ts b/tests/session.test.ts index b3f987b..8972fef 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -207,7 +207,7 @@ describe("proxyCore session bridging (injected fetch)", () => { const core = makeCore(fetchFn); await core.initialize("init-key", INITIALIZE_REQUEST, "2025-06-18"); - // Phase 4 raw-pipe bridging: the client re-sends upstream's id and the + // The client re-sends upstream's id and the // adapter keys calls by it — which must resolve to the same session entry. await core.forward("sess-upstream", TOOLS_LIST_REQUEST); expect(calls[1].headers["mcp-session-id"]).toBe("sess-upstream"); @@ -367,7 +367,7 @@ describe("proxyCore session bridging (injected fetch)", () => { core.close("local-1"); const written = stderrSpy.mock.calls.map((args) => String(args[0])).join(""); - // Phase 6 pinned decision: the JSON answer is dropped (notifications have + // Deliberate: the JSON answer is dropped (notifications have // no response channel) but the warn names both status and error code. expect(written).toContain("unexpected json response (HTTP 401, JSON-RPC error -32000)"); expect(written).not.toContain(API_KEY); diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 84fcd40..388ae44 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -155,7 +155,7 @@ describe("parseSseStream", () => { ); }); - it("throws UpstreamProtocolError on an empty data payload (pinned: every frame must be JSON-RPC)", async () => { + it("throws UpstreamProtocolError on an empty data payload (every frame must be JSON-RPC)", async () => { await expect(collect(streamOf("data:\n\n"))).rejects.toBeInstanceOf(UpstreamProtocolError); }); From b6babc2153b463d09bf645980fba609d77ec69f5 Mon Sep 17 00:00:00 2001 From: Zachary Lyon Date: Mon, 3 Aug 2026 16:39:34 -0700 Subject: [PATCH 3/4] Fix integration test tool arguments against real upstream schemas fetch_content requires urls/format/links/image_links; run_web_automation requires a client-minted session_id argument. Verified green against sandbox now that /mcp accepts X-API-Key. Co-Authored-By: Claude Fable 5 --- tests/proxy.integration.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/proxy.integration.test.ts b/tests/proxy.integration.test.ts index 34cd74b..87d8e0d 100644 --- a/tests/proxy.integration.test.ts +++ b/tests/proxy.integration.test.ts @@ -6,10 +6,8 @@ * Run: TINYFISH_API_KEY=... npm run test:integration * Without the key the suite skips with a printed notice. * - * BLOCKED ON BACKEND: the hosted server does not yet accept X-API-Key - * authentication for this proxy's upstream calls, so this suite effectively - * never runs — it exists so CI turns green the day the backend support lands - * (set the TINYFISH_API_KEY secret). + * X-API-Key auth is live on sandbox; production pending. Point + * TINYFISH_UPSTREAM_URL at sandbox until the backend reaches production. * * Coverage when the key is set: * - tools/list via the proxy deep-equals a direct upstream tools/list (the @@ -17,6 +15,7 @@ * - one cheap tools/call (fetch_content) round-trips; * - one run_web_automation yields ≥1 progress notification then a final result. */ +import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { listeningPort, postJson, sseDataPayloads } from "./helpers/http.js"; import { createProxyCore } from "../src/core/proxy-core.js"; @@ -161,7 +160,13 @@ describeWithApiKey("proxy integration (real hosted upstream)", () => { method: "tools/call", params: { name: "fetch_content", - arguments: { url: "https://example.com" }, + // urls/format/links/image_links are all required by the tool schema. + arguments: { + urls: ["https://example.com"], + format: "markdown", + links: false, + image_links: false, + }, }, }, { "Mcp-Session-Id": sessionId } @@ -192,6 +197,9 @@ describeWithApiKey("proxy integration (real hosted upstream)", () => { arguments: { url: "https://example.com", goal: "Read the page heading and report it.", + // Required client-minted correlation id (a tool argument, not + // the Mcp-Session-Id header). + session_id: randomUUID(), }, _meta: { progressToken: "integ-tok-4" }, }, From 5706ce80f4c79786c6b840958ba43ff95ed81171 Mon Sep 17 00:00:00 2001 From: Zachary Lyon Date: Mon, 3 Aug 2026 16:42:48 -0700 Subject: [PATCH 4/4] Drop sandbox references from integration test header Co-Authored-By: Claude Fable 5 --- tests/proxy.integration.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/proxy.integration.test.ts b/tests/proxy.integration.test.ts index 87d8e0d..c8a663c 100644 --- a/tests/proxy.integration.test.ts +++ b/tests/proxy.integration.test.ts @@ -1,14 +1,11 @@ /** - * Gated integration tests — the real local proxy against the REAL hosted + * Gated integration tests — the real local proxy against the real hosted * upstream (default https://agent.tinyfish.ai/mcp, override with - * TINYFISH_UPSTREAM_URL, e.g. a sandbox deployment). + * TINYFISH_UPSTREAM_URL). * * Run: TINYFISH_API_KEY=... npm run test:integration * Without the key the suite skips with a printed notice. * - * X-API-Key auth is live on sandbox; production pending. Point - * TINYFISH_UPSTREAM_URL at sandbox until the backend reaches production. - * * Coverage when the key is set: * - tools/list via the proxy deep-equals a direct upstream tools/list (the * parity guarantee made executable);