From d471c9b6957987f9c02471748df9d71d7bbaba4e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Mon, 9 Feb 2026 17:26:23 +0100 Subject: [PATCH] feat: initial release of torrentclaw-mcp server MCP (Model Context Protocol) server that wraps the TorrentClaw REST API, enabling LLMs (Claude Desktop, Claude Code, Cursor, etc.) to search movies and TV shows with torrent downloads and streaming availability. ## Tools (6) - search_content: primary search with filters (title, genre, year, rating, quality, language, sort). Returns content metadata + torrent magnet links. - get_popular: trending content ranked by clicks - get_recent: most recently added content - get_watch_providers: streaming availability by country (Netflix, Disney+, etc.) - get_credits: director and top 10 cast members - get_torrent_url: .torrent file download URL from info_hash ## Resources - torrentclaw://stats: catalog statistics (content/torrent counts, sources) ## Prompts (4) - search_movie, search_show, whats_new, where_to_watch ## LLM Usability - Server description with workflow guidance for tool chaining - Tool descriptions include trigger phrases, cross-tool references, and explicit parameter examples - Formatted output includes info_hash for tool chaining and call-syntax cross-references (e.g. "use with get_watch_providers(content_id=42)") - Popular/recent output hints to use search_content for torrents - Error messages include status-specific recovery guidance (400, 404, 429, 5xx) - Prompts reference tools by name for reliable LLM execution ## Security - SSRF protection: validates TORRENTCLAW_API_URL against private IPs, localhost, link-local, and IPv6 loopback addresses - Protocol whitelist: only http/https allowed - Error body sanitization: 4xx truncated to 200 chars, 5xx bodies omitted - Input validation: control char filter on queries, regex on country codes, genre character whitelist, content_id bounds ## Testing - 85 tests across 13 test files - Coverage: 99.5% statements, 95.2% branches, 98.1% functions, 99.5% lines - Vitest v4 with v8 coverage provider, 80% thresholds enforced ## Stack - TypeScript ESM, Node.js >= 18 (native fetch) - @modelcontextprotocol/sdk v1.12, zod v3.24 - STDIO transport, runnable via npx torrentclaw-mcp --- .gitignore | 6 + LICENSE | 21 + README.md | 109 + package-lock.json | 2918 +++++++++++++++++++++++ package.json | 58 + src/api-client.ts | 137 ++ src/config.ts | 42 + src/formatters/content.ts | 142 ++ src/formatters/credits.ts | 23 + src/formatters/providers.ts | 48 + src/index.ts | 45 + src/prompts.ts | 78 + src/resources/stats.ts | 29 + src/tools/get-credits.ts | 37 + src/tools/get-popular.ts | 47 + src/tools/get-recent.ts | 47 + src/tools/get-torrent-url.ts | 32 + src/tools/get-watch-providers.ts | 52 + src/tools/search-content.ts | 124 + src/types.ts | 158 ++ tests/api-client.test.ts | 217 ++ tests/config.test.ts | 85 + tests/formatters/content.test.ts | 503 ++++ tests/formatters/credits.test.ts | 58 + tests/formatters/providers.test.ts | 101 + tests/helpers.ts | 63 + tests/prompts.test.ts | 83 + tests/resources/stats.test.ts | 60 + tests/tools/get-credits.test.ts | 97 + tests/tools/get-popular.test.ts | 108 + tests/tools/get-recent.test.ts | 108 + tests/tools/get-torrent-url.test.ts | 58 + tests/tools/get-watch-providers.test.ts | 106 + tests/tools/search-content.test.ts | 165 ++ tsconfig.json | 17 + vitest.config.ts | 18 + 36 files changed, 6000 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/api-client.ts create mode 100644 src/config.ts create mode 100644 src/formatters/content.ts create mode 100644 src/formatters/credits.ts create mode 100644 src/formatters/providers.ts create mode 100644 src/index.ts create mode 100644 src/prompts.ts create mode 100644 src/resources/stats.ts create mode 100644 src/tools/get-credits.ts create mode 100644 src/tools/get-popular.ts create mode 100644 src/tools/get-recent.ts create mode 100644 src/tools/get-torrent-url.ts create mode 100644 src/tools/get-watch-providers.ts create mode 100644 src/tools/search-content.ts create mode 100644 src/types.ts create mode 100644 tests/api-client.test.ts create mode 100644 tests/config.test.ts create mode 100644 tests/formatters/content.test.ts create mode 100644 tests/formatters/credits.test.ts create mode 100644 tests/formatters/providers.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/prompts.test.ts create mode 100644 tests/resources/stats.test.ts create mode 100644 tests/tools/get-credits.test.ts create mode 100644 tests/tools/get-popular.test.ts create mode 100644 tests/tools/get-recent.test.ts create mode 100644 tests/tools/get-torrent-url.test.ts create mode 100644 tests/tools/get-watch-providers.test.ts create mode 100644 tests/tools/search-content.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d49673 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +build/ +coverage/ +*.tsbuildinfo +.env +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9d044e4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 buryni + +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 new file mode 100644 index 0000000..4bf117c --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# torrentclaw-mcp + +MCP server for [TorrentClaw](https://torrentclaw.com) — search movies and TV shows with torrent download options, streaming availability, and metadata. + +## Quick Start + +```bash +npx torrentclaw-mcp +``` + +No API key required. + +## Available Tools + +| Tool | Description | +|------|-------------| +| `search_content` | Search movies/shows with filters (query, type, genre, year, rating, quality, language, sort). Returns content with torrents and magnet links. | +| `get_popular` | Get popular content ranked by user clicks | +| `get_recent` | Get recently added content | +| `get_watch_providers` | Streaming availability by country (Netflix, Disney+, etc.) | +| `get_credits` | Cast and director for a title | +| `get_torrent_download_url` | Get .torrent file download URL from info hash | + +## Resources + +| URI | Description | +|-----|-------------| +| `torrentclaw://stats` | Catalog statistics (content/torrent counts by source) | + +## Configuration + +### Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "torrentclaw": { + "command": "npx", + "args": ["-y", "torrentclaw-mcp"] + } + } +} +``` + +### Claude Code + +Add to `.mcp.json` or `~/.claude/settings.json`: + +```json +{ + "mcpServers": { + "torrentclaw": { + "command": "npx", + "args": ["-y", "torrentclaw-mcp"] + } + } +} +``` + +### Self-hosted + +Point to your own TorrentClaw instance: + +```json +{ + "mcpServers": { + "torrentclaw": { + "command": "npx", + "args": ["-y", "torrentclaw-mcp"], + "env": { + "TORRENTCLAW_API_URL": "http://localhost:3030" + } + } + } +} +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `TORRENTCLAW_API_URL` | `https://torrentclaw.com` | Base URL of the TorrentClaw API | + +## Development + +```bash +git clone https://github.com/buryni/torrentclaw-mcp.git +cd torrentclaw-mcp +npm install +npm run build +``` + +Test with MCP Inspector: + +```bash +npx @modelcontextprotocol/inspector node build/index.js +``` + +Run tests: + +```bash +npm test +``` + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0b7d649 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2918 @@ +{ + "name": "torrentclaw-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "torrentclaw-mcp", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + }, + "bin": { + "torrentclaw-mcp": "build/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^4.0.18", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^4.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/@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/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz", + "integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "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/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "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/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/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/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/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": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "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.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "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-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "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/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "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/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "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-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "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/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.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "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/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/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/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.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/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.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "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/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "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.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "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/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/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": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.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.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "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/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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/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/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c7ada40 --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "torrentclaw-mcp", + "version": "1.0.0", + "description": "MCP server for TorrentClaw — search and discover movies and TV shows with torrent downloads, magnet links, streaming availability, and cast/crew metadata", + "type": "module", + "bin": { + "torrentclaw-mcp": "./build/index.js" + }, + "main": "./build/index.js", + "files": [ + "build" + ], + "scripts": { + "build": "tsc && chmod 755 build/index.js", + "dev": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "torrent", + "movies", + "tv-shows", + "search", + "torrentclaw", + "claude", + "ai", + "streaming", + "magnet", + "download", + "llm", + "agent", + "media" + ], + "author": "buryni", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/buryni/torrentclaw-mcp" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^4.0.18", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^4.0.0" + } +} diff --git a/src/api-client.ts b/src/api-client.ts new file mode 100644 index 0000000..2e69a41 --- /dev/null +++ b/src/api-client.ts @@ -0,0 +1,137 @@ +import { config } from "./config.js"; +import type { + SearchResponse, + PopularResponse, + RecentResponse, + WatchProvidersResponse, + CreditsResponse, + StatsResponse, +} from "./types.js"; + +export class ApiError extends Error { + constructor( + public status: number, + public body: string, + ) { + const messages: Record = { + 400: "Bad request — check that all parameters are valid.", + 404: "Not found — the requested content ID does not exist. Use search_content to find valid IDs.", + 429: "Rate limit exceeded. Wait 10-30 seconds before retrying.", + 500: "TorrentClaw server error. Try again in a moment.", + 502: "TorrentClaw is temporarily unavailable. Try again in a moment.", + 503: "TorrentClaw is under maintenance. Try again later.", + }; + super(messages[status] || `API request failed with status ${status}`); + this.name = "ApiError"; + } +} + +export interface SearchParams { + query: string; + type?: string; + genre?: string; + year_min?: number; + year_max?: number; + min_rating?: number; + quality?: string; + language?: string; + sort?: string; + page?: number; + limit?: number; + country?: string; +} + +export class TorrentClawClient { + private baseUrl: string; + private userAgent: string; + + constructor() { + this.baseUrl = config.apiUrl; + this.userAgent = `torrentclaw-mcp/${config.version}`; + } + + private async request( + path: string, + params?: Record, + ): Promise { + const url = new URL(path, this.baseUrl); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + } + + const response = await fetch(url.toString(), { + headers: { + "User-Agent": this.userAgent, + Accept: "application/json", + "X-Search-Source": "mcp", + }, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + // Only expose body for 4xx (client errors); omit for 5xx (may leak internals) + let body = ""; + if (response.status >= 400 && response.status < 500) { + try { + body = (await response.text()).slice(0, 200); + } catch {} + } + throw new ApiError(response.status, body); + } + + return response.json() as Promise; + } + + async search(params: SearchParams): Promise { + return this.request("/api/v1/search", { + q: params.query, + type: params.type, + genre: params.genre, + year_min: params.year_min, + year_max: params.year_max, + min_rating: params.min_rating, + quality: params.quality, + lang: params.language, + sort: params.sort, + page: params.page, + limit: params.limit, + country: params.country, + }); + } + + async getPopular(limit?: number, page?: number): Promise { + return this.request("/api/v1/popular", { limit, page }); + } + + async getRecent(limit?: number, page?: number): Promise { + return this.request("/api/v1/recent", { limit, page }); + } + + async getWatchProviders( + contentId: number, + country: string, + ): Promise { + return this.request( + `/api/v1/content/${contentId}/watch-providers`, + { country }, + ); + } + + async getCredits(contentId: number): Promise { + return this.request( + `/api/v1/content/${contentId}/credits`, + ); + } + + async getStats(): Promise { + return this.request("/api/v1/stats"); + } + + getTorrentDownloadUrl(infoHash: string): string { + return `${this.baseUrl}/api/v1/torrent/${infoHash}`; + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6a37a6f --- /dev/null +++ b/src/config.ts @@ -0,0 +1,42 @@ +const PRIVATE_IP_PATTERNS = [ + /^localhost$/i, + /^127\.\d+\.\d+\.\d+$/, + /^::1$/, + /^::$/, + /^0\.0\.0\.0$/, + /^10\.\d+\.\d+\.\d+$/, + /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/, + /^192\.168\.\d+\.\d+$/, + /^169\.254\.\d+\.\d+$/, // link-local / cloud metadata +]; + +export function validateApiUrl(raw: string): string { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error(`Invalid TORRENTCLAW_API_URL: not a valid URL`); + } + + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error( + `Invalid TORRENTCLAW_API_URL: only http/https protocols allowed`, + ); + } + + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + if (PRIVATE_IP_PATTERNS.some((re) => re.test(hostname))) { + throw new Error( + `Invalid TORRENTCLAW_API_URL: private/reserved addresses not allowed`, + ); + } + + return raw; +} + +export const config = { + apiUrl: validateApiUrl( + process.env.TORRENTCLAW_API_URL || "https://torrentclaw.com", + ), + version: process.env.npm_package_version || "1.0.0", +} as const; diff --git a/src/formatters/content.ts b/src/formatters/content.ts new file mode 100644 index 0000000..2d05833 --- /dev/null +++ b/src/formatters/content.ts @@ -0,0 +1,142 @@ +import type { + SearchResponse, + SearchResult, + TorrentInfo, + PopularResponse, + PopularItem, + RecentResponse, + RecentItem, +} from "../types.js"; + +function formatSize(bytes: string | null): string { + if (!bytes) return "?"; + const b = parseInt(bytes, 10); + if (isNaN(b)) return "?"; + if (b >= 1_073_741_824) return `${(b / 1_073_741_824).toFixed(1)} GB`; + if (b >= 1_048_576) return `${(b / 1_048_576).toFixed(0)} MB`; + return `${(b / 1024).toFixed(0)} KB`; +} + +function formatRating(imdb: string | null, tmdb: string | null): string { + const parts: string[] = []; + if (imdb) parts.push(`IMDb: ${imdb}`); + if (tmdb) parts.push(`TMDB: ${tmdb}`); + return parts.length > 0 ? parts.join(" | ") : "No ratings"; +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, max - 3) + "..."; +} + +function formatTorrent(t: TorrentInfo): string { + const parts: string[] = []; + if (t.quality) parts.push(t.quality); + if (t.sourceType) parts.push(t.sourceType); + if (t.codec) parts.push(t.codec); + if (t.hdrType) parts.push(t.hdrType); + const label = parts.length > 0 ? parts.join(" ") : "Unknown quality"; + const size = formatSize(t.sizeBytes); + const seeds = `${t.seeders} seeders`; + const score = t.qualityScore !== null ? `Score: ${t.qualityScore}` : null; + + let line = ` - ${label} (${size}) | ${seeds}`; + if (score) line += ` | ${score}`; + line += `\n Info hash: ${t.infoHash}`; + if (t.magnetUrl) line += `\n Magnet: ${t.magnetUrl}`; + return line; +} + +function formatResult(r: SearchResult, index: number): string { + const lines: string[] = []; + const yearStr = r.year ? ` (${r.year})` : ""; + lines.push(`${index}. ${r.title}${yearStr} [${r.contentType}]`); + lines.push(` ${formatRating(r.ratingImdb, r.ratingTmdb)}`); + if (r.genres && r.genres.length > 0) { + lines.push(` Genres: ${r.genres.join(", ")}`); + } + if (r.overview) { + lines.push(` ${truncate(r.overview, 200)}`); + } + + if (r.torrents.length > 0) { + const top = r.torrents + .sort((a, b) => (b.qualityScore ?? 0) - (a.qualityScore ?? 0)) + .slice(0, 5); + lines.push(` Torrents (${r.torrents.length} total, top ${top.length}):`); + for (const t of top) { + lines.push(formatTorrent(t)); + } + } else { + lines.push(" No torrents available"); + } + + if (r.streaming) { + const providers: string[] = []; + if (r.streaming.flatrate.length > 0) + providers.push( + `Stream: ${r.streaming.flatrate.map((p) => p.name).join(", ")}`, + ); + if (r.streaming.free.length > 0) + providers.push( + `Free: ${r.streaming.free.map((p) => p.name).join(", ")}`, + ); + if (providers.length > 0) lines.push(` ${providers.join(" | ")}`); + } + + lines.push( + ` Content ID: ${r.id} — use with get_watch_providers(content_id=${r.id}) or get_credits(content_id=${r.id})`, + ); + if (r.imdbId) lines.push(` IMDb: ${r.imdbId}`); + + return lines.join("\n"); +} + +export function formatSearchResults(data: SearchResponse): string { + if (data.results.length === 0) { + return "No results found. Try: (1) a shorter or alternate title, (2) removing filters like quality or year, (3) checking spelling. You can also try get_popular or get_recent to browse available content."; + } + + const header = `Found ${data.total} results (page ${data.page}, showing ${data.results.length}):`; + const results = data.results.map((r, i) => formatResult(r, i + 1)); + return [header, "", ...results].join("\n"); +} + +function formatPopularItem(item: PopularItem, index: number): string { + const yearStr = item.year ? ` (${item.year})` : ""; + const rating = formatRating(item.ratingImdb, item.ratingTmdb); + return `${index}. ${item.title}${yearStr} [${item.contentType}] — ${rating} — ${item.clickCount} clicks — ID: ${item.id}`; +} + +export function formatPopularResults(data: PopularResponse): string { + if (data.items.length === 0) { + return "No popular content found."; + } + + const header = `Popular content (${data.total} total, page ${data.page}):`; + const hint = "(Use search_content with a title to get torrents and full details)"; + const items = data.items.map((item, i) => formatPopularItem(item, i + 1)); + return [header, hint, "", ...items].join("\n"); +} + +function formatRecentItem(item: RecentItem, index: number): string { + const yearStr = item.year ? ` (${item.year})` : ""; + const rating = formatRating(item.ratingImdb, item.ratingTmdb); + const date = new Date(item.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + return `${index}. ${item.title}${yearStr} [${item.contentType}] — ${rating} — Added: ${date} — ID: ${item.id}`; +} + +export function formatRecentResults(data: RecentResponse): string { + if (data.items.length === 0) { + return "No recent content found."; + } + + const header = `Recently added content (${data.total} total, page ${data.page}):`; + const hint = "(Use search_content with a title to get torrents and full details)"; + const items = data.items.map((item, i) => formatRecentItem(item, i + 1)); + return [header, hint, "", ...items].join("\n"); +} diff --git a/src/formatters/credits.ts b/src/formatters/credits.ts new file mode 100644 index 0000000..6e1ae3b --- /dev/null +++ b/src/formatters/credits.ts @@ -0,0 +1,23 @@ +import type { CreditsResponse } from "../types.js"; + +export function formatCredits(data: CreditsResponse): string { + const lines: string[] = []; + lines.push(`Credits for content #${data.contentId}:`); + lines.push(""); + + if (data.director) { + lines.push(` Director: ${data.director}`); + } + + if (data.cast.length > 0) { + lines.push(` Cast:`); + for (const member of data.cast) { + const character = member.character ? ` as ${member.character}` : ""; + lines.push(` - ${member.name}${character}`); + } + } else { + lines.push(" No cast information available."); + } + + return lines.join("\n"); +} diff --git a/src/formatters/providers.ts b/src/formatters/providers.ts new file mode 100644 index 0000000..482631f --- /dev/null +++ b/src/formatters/providers.ts @@ -0,0 +1,48 @@ +import type { WatchProvidersResponse, WatchProviderItem } from "../types.js"; + +function formatProviderList( + label: string, + providers: WatchProviderItem[], +): string | null { + if (providers.length === 0) return null; + const names = providers + .sort((a, b) => a.displayPriority - b.displayPriority) + .map((p) => p.name) + .join(", "); + return ` ${label}: ${names}`; +} + +export function formatWatchProviders(data: WatchProvidersResponse): string { + const lines: string[] = []; + lines.push( + `Watch providers for content #${data.contentId} in ${data.country}:`, + ); + lines.push(""); + + const sections = [ + formatProviderList("Stream", data.providers.flatrate), + formatProviderList("Free", data.providers.free), + formatProviderList("Rent", data.providers.rent), + formatProviderList("Buy", data.providers.buy), + ].filter(Boolean) as string[]; + + if (sections.length === 0) { + lines.push( + ` No watch providers found in ${data.country}.`, + ); + } else { + lines.push(...sections); + } + + if (data.vpnSuggestion) { + lines.push(""); + lines.push( + ` Available in other countries: ${data.vpnSuggestion.availableIn.join(", ")}`, + ); + } + + lines.push(""); + lines.push(data.attribution); + + return lines.join("\n"); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f3396bb --- /dev/null +++ b/src/index.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { TorrentClawClient } from "./api-client.js"; +import { config } from "./config.js"; +import { registerSearchContent } from "./tools/search-content.js"; +import { registerGetPopular } from "./tools/get-popular.js"; +import { registerGetRecent } from "./tools/get-recent.js"; +import { registerGetWatchProviders } from "./tools/get-watch-providers.js"; +import { registerGetCredits } from "./tools/get-credits.js"; +import { registerGetTorrentUrl } from "./tools/get-torrent-url.js"; +import { registerStatsResource } from "./resources/stats.js"; +import { registerPrompts } from "./prompts.js"; + +const client = new TorrentClawClient(); + +const server = new McpServer({ + name: "torrentclaw", + version: config.version, + description: + "Search and discover movies and TV shows with torrent downloads, streaming availability, and cast/crew metadata. Start with search_content to find content, then use get_watch_providers or get_credits with the content_id. Use get_popular/get_recent to browse (no torrents — search for a title to get torrents).", +}); + +// Register tools +registerSearchContent(server, client); +registerGetPopular(server, client); +registerGetRecent(server, client); +registerGetWatchProviders(server, client); +registerGetCredits(server, client); +registerGetTorrentUrl(server, client); + +// Register resources +registerStatsResource(server, client); + +// Register prompts +registerPrompts(server); + +// Graceful shutdown +process.on("SIGTERM", () => process.exit(0)); +process.on("SIGINT", () => process.exit(0)); + +// Connect via STDIO +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error("TorrentClaw MCP server running on stdio"); diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..f6321fb --- /dev/null +++ b/src/prompts.ts @@ -0,0 +1,78 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerPrompts(server: McpServer): void { + server.prompt( + "search_movie", + "Search for a movie by title and get torrent download options", + { title: z.string().describe("Movie title to search for") }, + ({ title }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Search for the movie "${title}" using search_content with type="movie". Present the results showing: title, year, ratings, and the top torrents sorted by quality score with their magnet links. If results are found, also call get_watch_providers with the content_id to check streaming availability.`, + }, + }, + ], + }), + ); + + server.prompt( + "search_show", + "Search for a TV show by title and get torrent download options", + { title: z.string().describe("TV show title to search for") }, + ({ title }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Search for the TV show "${title}" using search_content with type="show". Present the results showing: title, year, ratings, and the top torrents sorted by quality score with their magnet links.`, + }, + }, + ], + }), + ); + + server.prompt( + "whats_new", + "Discover recently added movies and TV shows", + {}, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: "Use get_recent to show the most recently added movies and TV shows. Present each with its title, year, type (movie/show), and ratings.", + }, + }, + ], + }), + ); + + server.prompt( + "where_to_watch", + "Find where to watch a movie or TV show via streaming services", + { + title: z.string().describe("Movie or TV show title"), + country: z + .string() + .optional() + .describe("2-letter country code (e.g. US, ES)"), + }, + ({ title, country }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Search for "${title}" using search_content${country ? ` with country="${country}"` : ' with country="US"'}. Show the streaming availability (which services offer it for subscription, rent, or purchase) and the best torrent download options.`, + }, + }, + ], + }), + ); +} diff --git a/src/resources/stats.ts b/src/resources/stats.ts new file mode 100644 index 0000000..199267b --- /dev/null +++ b/src/resources/stats.ts @@ -0,0 +1,29 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { TorrentClawClient } from "../api-client.js"; + +export function registerStatsResource( + server: McpServer, + client: TorrentClawClient, +): void { + server.resource( + "stats", + "torrentclaw://stats", + { + description: + "TorrentClaw catalog statistics. Returns JSON with: content counts (movies, shows, TMDB-enriched), torrent counts (total, with seeders, by source), and recent ingestion job history. Useful for understanding catalog coverage and data freshness.", + mimeType: "application/json", + }, + async (uri) => { + const stats = await client.getStats(); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify(stats, null, 2), + }, + ], + }; + }, + ); +} diff --git a/src/tools/get-credits.ts b/src/tools/get-credits.ts new file mode 100644 index 0000000..87e6a39 --- /dev/null +++ b/src/tools/get-credits.ts @@ -0,0 +1,37 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; +import { ApiError } from "../api-client.js"; +import { formatCredits } from "../formatters/credits.js"; + +export function registerGetCredits( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "get_credits", + "Get the director and top 10 cast members (with character names) for a movie or TV show. Use when the user asks about actors, cast, director, or 'who is in' a title. Requires content_id from search_content results.", + { + content_id: z + .number() + .int() + .positive() + .max(999_999_999, "Content ID out of valid range") + .describe( + "Numeric content ID from search_content results (the 'Content ID' field). Example: 42", + ), + }, + async (params) => { + try { + const data = await client.getCredits(params.content_id); + return { content: [{ type: "text", text: formatCredits(data) }] }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); +} diff --git a/src/tools/get-popular.ts b/src/tools/get-popular.ts new file mode 100644 index 0000000..d4b1542 --- /dev/null +++ b/src/tools/get-popular.ts @@ -0,0 +1,47 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; +import { ApiError } from "../api-client.js"; +import { formatPopularResults } from "../formatters/content.js"; + +export function registerGetPopular( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "get_popular", + "Get trending movies and TV shows ranked by user click count. Use when the user asks for recommendations, trending titles, or 'what's popular'. Returns a paginated list with title, year, type, ratings, and content_id. Note: results do NOT include torrents — to get torrents for a title, call search_content with its name.", + { + limit: z + .number() + .int() + .min(1) + .max(24) + .optional() + .describe("Number of items (default: 10)"), + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + }, + async (params) => { + try { + const data = await client.getPopular( + params.limit ?? 10, + params.page, + ); + return { + content: [{ type: "text", text: formatPopularResults(data) }], + }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); +} diff --git a/src/tools/get-recent.ts b/src/tools/get-recent.ts new file mode 100644 index 0000000..20e02aa --- /dev/null +++ b/src/tools/get-recent.ts @@ -0,0 +1,47 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; +import { ApiError } from "../api-client.js"; +import { formatRecentResults } from "../formatters/content.js"; + +export function registerGetRecent( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "get_recent", + "Get the most recently added movies and TV shows, sorted by addition date. Use when the user asks 'what's new', 'latest additions', or 'recently added'. Returns a paginated list with title, year, type, ratings, date added, and content_id. Note: results do NOT include torrents — to get torrents for a title, call search_content with its name.", + { + limit: z + .number() + .int() + .min(1) + .max(24) + .optional() + .describe("Number of items (default: 10)"), + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (default: 1)"), + }, + async (params) => { + try { + const data = await client.getRecent( + params.limit ?? 10, + params.page, + ); + return { + content: [{ type: "text", text: formatRecentResults(data) }], + }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); +} diff --git a/src/tools/get-torrent-url.ts b/src/tools/get-torrent-url.ts new file mode 100644 index 0000000..9f05978 --- /dev/null +++ b/src/tools/get-torrent-url.ts @@ -0,0 +1,32 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; + +export function registerGetTorrentUrl( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "get_torrent_url", + "Get a direct .torrent file download URL from an info_hash. Use when the user specifically wants a .torrent file rather than a magnet link (magnet links are already in search_content results). Returns a single URL the user can open in their browser or torrent client.", + { + info_hash: z + .string() + .regex(/^[a-fA-F0-9]{40}$/) + .describe( + "40-character hex torrent info_hash from search_content results (e.g. 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2')", + ), + }, + async (params) => { + const url = client.getTorrentDownloadUrl(params.info_hash.toLowerCase()); + return { + content: [ + { + type: "text", + text: `Download .torrent file: ${url}`, + }, + ], + }; + }, + ); +} diff --git a/src/tools/get-watch-providers.ts b/src/tools/get-watch-providers.ts new file mode 100644 index 0000000..792cddf --- /dev/null +++ b/src/tools/get-watch-providers.ts @@ -0,0 +1,52 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; +import { ApiError } from "../api-client.js"; +import { formatWatchProviders } from "../formatters/providers.js"; + +export function registerGetWatchProviders( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "get_watch_providers", + "Check where a movie or TV show is available to stream, rent, or buy (Netflix, Disney+, Amazon Prime, etc.) in a specific country. Requires content_id from search_content results. Note: if you passed country to search_content, streaming info is already in those results — use this tool only for a different country or to get more detail. Returns grouped providers: Stream (subscription), Free, Rent, Buy.", + { + content_id: z + .number() + .int() + .positive() + .max(999_999_999, "Content ID out of valid range") + .describe( + "Numeric content ID from search_content results (the 'Content ID' field). Example: 42", + ), + country: z + .string() + .regex( + /^[A-Z]{2}$/, + "Must be uppercase 2-letter ISO 3166-1 country code", + ) + .default("US") + .describe( + "ISO 3166-1 country code (e.g. US, ES, GB, DE). Default: US", + ), + }, + async (params) => { + try { + const data = await client.getWatchProviders( + params.content_id, + params.country, + ); + return { + content: [{ type: "text", text: formatWatchProviders(data) }], + }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); +} diff --git a/src/tools/search-content.ts b/src/tools/search-content.ts new file mode 100644 index 0000000..44be362 --- /dev/null +++ b/src/tools/search-content.ts @@ -0,0 +1,124 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; +import { ApiError } from "../api-client.js"; +import { formatSearchResults } from "../formatters/content.js"; + +export function registerSearchContent( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "search_content", + "Search for movies and TV shows by title, genre, year, rating, or quality. Returns matching content with metadata (title, year, genres, IMDb/TMDB ratings) and torrent download options (magnet links, quality, seeders, file size). This is the primary tool — use it first when a user asks to find, download, or learn about a movie or TV show. Results include a content_id needed by get_watch_providers and get_credits.", + { + query: z + .string() + .min(1) + .max(200) + .refine( + (q) => !/[\x00-\x08\x0B-\x0C\x0E-\x1F]/.test(q), + "Query contains invalid control characters", + ) + .describe( + "Search query — typically a movie or TV show title (e.g. 'The Matrix', 'Breaking Bad'). Supports partial matches.", + ), + type: z + .enum(["movie", "show"]) + .optional() + .describe("Filter by content type: 'movie' or 'show'"), + genre: z + .string() + .max(50) + .regex( + /^[a-zA-Z\s&-]+$/, + "Genre must contain only letters, spaces, ampersands, and hyphens", + ) + .optional() + .describe( + "Filter by genre name. Common values: Action, Adventure, Animation, Comedy, Crime, Documentary, Drama, Family, Fantasy, History, Horror, Music, Mystery, Romance, Science Fiction, Thriller, War, Western", + ), + year_min: z + .number() + .int() + .optional() + .describe("Minimum release year (e.g. 2020)"), + year_max: z + .number() + .int() + .optional() + .describe("Maximum release year (e.g. 2025)"), + min_rating: z + .number() + .min(0) + .max(10) + .optional() + .describe( + "Minimum IMDb rating (0-10). Example: 7 for well-rated content", + ), + quality: z + .enum(["480p", "720p", "1080p", "2160p"]) + .optional() + .describe("Filter torrents by resolution"), + language: z + .string() + .optional() + .describe( + "ISO 639-1 language code to filter torrents (e.g. 'en' for English, 'es' for Spanish, 'fr' for French). Lowercase 2-letter code.", + ), + sort: z + .enum(["relevance", "seeders", "year", "rating", "added"]) + .default("relevance") + .describe("Sort order for results"), + page: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Page number (default: 1)"), + limit: z + .number() + .int() + .min(1) + .max(20) + .optional() + .describe("Results per page (default: 10, max: 20)"), + country: z + .string() + .regex( + /^[A-Z]{2}$/, + "Must be uppercase 2-letter ISO 3166-1 country code", + ) + .optional() + .describe( + "ISO 3166-1 country code for streaming availability (e.g. US, ES, GB, DE). If provided, results include which streaming services offer each title. If omitted, no streaming data is returned.", + ), + }, + async (params) => { + try { + const data = await client.search({ + query: params.query, + type: params.type, + genre: params.genre, + year_min: params.year_min, + year_max: params.year_max, + min_rating: params.min_rating, + quality: params.quality, + language: params.language, + sort: params.sort, + page: params.page, + limit: params.limit ?? 10, + country: params.country, + }); + return { content: [{ type: "text", text: formatSearchResults(data) }] }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2001e0f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,158 @@ +// Response types mirrored from TorrentClaw API (src/types/api.ts) + +export interface TorrentInfo { + infoHash: string; + quality: string | null; + codec: string | null; + sourceType: string | null; + sizeBytes: string | null; + seeders: number; + leechers: number; + magnetUrl: string | null; + source: string; + qualityScore: number | null; + uploadedAt: string | null; + languages: string[]; + audioCodec: string | null; + hdrType: string | null; + releaseGroup: string | null; + isProper: boolean | null; + isRepack: boolean | null; + isRemastered: boolean | null; +} + +export interface StreamingProviderItem { + providerId: number; + name: string; + logo: string | null; + link: string | null; +} + +export interface StreamingInfo { + flatrate: StreamingProviderItem[]; + rent: StreamingProviderItem[]; + buy: StreamingProviderItem[]; + free: StreamingProviderItem[]; +} + +export interface SearchResult { + id: number; + imdbId: string | null; + tmdbId: string | null; + contentType: string; + title: string; + titleOriginal: string | null; + year: number | null; + overview: string | null; + posterUrl: string | null; + backdropUrl: string | null; + genres: string[] | null; + ratingImdb: string | null; + ratingTmdb: string | null; + hasTorrents: boolean; + torrents: TorrentInfo[]; + streaming?: StreamingInfo; +} + +export interface SearchResponse { + total: number; + page: number; + pageSize: number; + results: SearchResult[]; +} + +export interface PopularItem { + id: number; + title: string; + year: number | null; + contentType: string; + posterUrl: string | null; + ratingImdb: string | null; + ratingTmdb: string | null; + clickCount: number; +} + +export interface PopularResponse { + items: PopularItem[]; + total: number; + page: number; + pageSize: number; +} + +export interface RecentItem { + id: number; + title: string; + year: number | null; + contentType: string; + posterUrl: string | null; + ratingImdb: string | null; + ratingTmdb: string | null; + createdAt: string; +} + +export interface RecentResponse { + items: RecentItem[]; + total: number; + page: number; + pageSize: number; +} + +export interface CastMember { + name: string; + character: string; + profileUrl: string | null; +} + +export interface CreditsResponse { + contentId: number; + director: string | null; + cast: CastMember[]; +} + +export interface WatchProviderItem { + providerId: number; + name: string; + logo: string | null; + link: string | null; + displayPriority: number; +} + +export interface WatchProviders { + flatrate: WatchProviderItem[]; + rent: WatchProviderItem[]; + buy: WatchProviderItem[]; + free: WatchProviderItem[]; +} + +export interface WatchProvidersResponse { + contentId: number; + country: string; + providers: WatchProviders; + vpnSuggestion?: { + availableIn: string[]; + affiliateUrl: string; + }; + attribution: string; +} + +export interface StatsResponse { + content: { + movies: number; + shows: number; + tmdbEnriched: number; + }; + torrents: { + total: number; + withSeeders: number; + bySource: Record; + }; + recentIngestions: { + source: string; + status: string; + startedAt: string; + completedAt: string | null; + fetched: number; + new: number; + updated: number; + }[]; +} diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts new file mode 100644 index 0000000..174da2f --- /dev/null +++ b/tests/api-client.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { TorrentClawClient, ApiError } from "../src/api-client.js"; + +describe("TorrentClawClient", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockFetch(body: unknown, status = 200) { + (globalThis.fetch as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + } + + function mockFetchError(body: string, status: number) { + (globalThis.fetch as ReturnType).mockResolvedValueOnce( + new Response(body, { status }), + ); + } + + it("builds correct search URL with all parameters", async () => { + mockFetch({ total: 0, page: 1, pageSize: 10, results: [] }); + + const client = new TorrentClawClient(); + await client.search({ + query: "inception", + type: "movie", + sort: "seeders", + quality: "1080p", + min_rating: 7, + }); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("q=inception"); + expect(calledUrl).toContain("type=movie"); + expect(calledUrl).toContain("sort=seeders"); + expect(calledUrl).toContain("quality=1080p"); + expect(calledUrl).toContain("min_rating=7"); + }); + + it("omits undefined parameters from URL", async () => { + mockFetch({ total: 0, page: 1, pageSize: 10, results: [] }); + + const client = new TorrentClawClient(); + await client.search({ query: "test" }); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("q=test"); + expect(calledUrl).not.toContain("type="); + expect(calledUrl).not.toContain("genre="); + }); + + it("includes correct headers", async () => { + mockFetch({ total: 0, page: 1, pageSize: 10, results: [] }); + + const client = new TorrentClawClient(); + await client.search({ query: "test" }); + + const fetchMock = globalThis.fetch as ReturnType; + const options = fetchMock.mock.calls[0][1] as RequestInit; + const headers = options.headers as Record; + expect(headers["Accept"]).toBe("application/json"); + expect(headers["X-Search-Source"]).toBe("mcp"); + expect(headers["User-Agent"]).toMatch(/^torrentclaw-mcp\//); + }); + + it("throws ApiError on 400 response", async () => { + mockFetchError("Bad request", 400); + + const client = new TorrentClawClient(); + await expect(client.search({ query: "test" })).rejects.toThrow(ApiError); + }); + + it("throws ApiError with rate limit message on 429", async () => { + mockFetchError("Too many requests", 429); + + const client = new TorrentClawClient(); + try { + await client.search({ query: "test" }); + expect.fail("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).message).toContain("Rate limit exceeded"); + expect((e as ApiError).status).toBe(429); + } + }); + + it("constructs torrent download URL", () => { + const client = new TorrentClawClient(); + const url = client.getTorrentDownloadUrl("aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e"); + expect(url).toBe( + "https://torrentclaw.com/api/v1/torrent/aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + ); + }); + + it("calls correct endpoint for popular", async () => { + mockFetch({ items: [], total: 0, page: 1, pageSize: 10 }); + + const client = new TorrentClawClient(); + await client.getPopular(5, 2); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/popular"); + expect(calledUrl).toContain("limit=5"); + expect(calledUrl).toContain("page=2"); + }); + + it("calls correct endpoint for watch providers", async () => { + mockFetch({ + contentId: 42, + country: "ES", + providers: { flatrate: [], rent: [], buy: [], free: [] }, + attribution: "JustWatch", + }); + + const client = new TorrentClawClient(); + await client.getWatchProviders(42, "ES"); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/content/42/watch-providers"); + expect(calledUrl).toContain("country=ES"); + }); + + it("calls correct endpoint for recent", async () => { + mockFetch({ items: [], total: 0, page: 1, pageSize: 10 }); + + const client = new TorrentClawClient(); + await client.getRecent(10, 3); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/recent"); + expect(calledUrl).toContain("limit=10"); + expect(calledUrl).toContain("page=3"); + }); + + it("calls correct endpoint for credits", async () => { + mockFetch({ contentId: 7, director: "Nolan", cast: [] }); + + const client = new TorrentClawClient(); + await client.getCredits(7); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/content/7/credits"); + }); + + it("calls correct endpoint for stats", async () => { + mockFetch({ + content: { movies: 100, shows: 50, tmdbEnriched: 80 }, + torrents: { total: 1000, withSeeders: 500, bySource: {} }, + recentIngestions: [], + }); + + const client = new TorrentClawClient(); + const result = await client.getStats(); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/stats"); + expect(result.content.movies).toBe(100); + }); + + it("includes 4xx body truncated to 200 chars", async () => { + const longBody = "x".repeat(300); + mockFetchError(longBody, 422); + + const client = new TorrentClawClient(); + try { + await client.search({ query: "test" }); + expect.fail("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).body.length).toBeLessThanOrEqual(200); + } + }); + + it("omits body for 5xx responses", async () => { + mockFetchError("Internal server error with stack trace", 500); + + const client = new TorrentClawClient(); + try { + await client.search({ query: "test" }); + expect.fail("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).body).toBe(""); + expect((e as ApiError).status).toBe(500); + } + }); + + it("omits body for 502 responses", async () => { + mockFetchError("Bad gateway details", 502); + + const client = new TorrentClawClient(); + try { + await client.search({ query: "test" }); + expect.fail("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).body).toBe(""); + } + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..df2e0fd --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { validateApiUrl } from "../src/config.js"; + +describe("validateApiUrl", () => { + it("accepts valid https URL", () => { + expect(validateApiUrl("https://torrentclaw.com")).toBe( + "https://torrentclaw.com", + ); + }); + + it("accepts valid http URL", () => { + expect(validateApiUrl("http://api.example.com")).toBe( + "http://api.example.com", + ); + }); + + it("rejects invalid URL", () => { + expect(() => validateApiUrl("not-a-url")).toThrow("not a valid URL"); + }); + + it("rejects ftp protocol", () => { + expect(() => validateApiUrl("ftp://example.com")).toThrow( + "only http/https", + ); + }); + + it("rejects file protocol", () => { + expect(() => validateApiUrl("file:///etc/passwd")).toThrow( + "only http/https", + ); + }); + + it("rejects localhost", () => { + expect(() => validateApiUrl("http://localhost:3030")).toThrow( + "private/reserved", + ); + }); + + it("rejects 127.0.0.1", () => { + expect(() => validateApiUrl("http://127.0.0.1")).toThrow( + "private/reserved", + ); + }); + + it("rejects 0.0.0.0", () => { + expect(() => validateApiUrl("http://0.0.0.0")).toThrow( + "private/reserved", + ); + }); + + it("rejects 10.x.x.x range", () => { + expect(() => validateApiUrl("http://10.0.0.1")).toThrow( + "private/reserved", + ); + }); + + it("rejects 172.16-31.x.x range", () => { + expect(() => validateApiUrl("http://172.16.0.1")).toThrow( + "private/reserved", + ); + expect(() => validateApiUrl("http://172.31.255.255")).toThrow( + "private/reserved", + ); + }); + + it("accepts 172.15.x.x (not private)", () => { + expect(validateApiUrl("http://172.15.0.1")).toBe("http://172.15.0.1"); + }); + + it("rejects 192.168.x.x range", () => { + expect(() => validateApiUrl("http://192.168.1.1")).toThrow( + "private/reserved", + ); + }); + + it("rejects AWS metadata IP (169.254.x.x)", () => { + expect(() => validateApiUrl("http://169.254.169.254")).toThrow( + "private/reserved", + ); + }); + + it("rejects IPv6 loopback ::1", () => { + expect(() => validateApiUrl("http://[::1]")).toThrow("private/reserved"); + }); +}); diff --git a/tests/formatters/content.test.ts b/tests/formatters/content.test.ts new file mode 100644 index 0000000..42665ea --- /dev/null +++ b/tests/formatters/content.test.ts @@ -0,0 +1,503 @@ +import { describe, it, expect } from "vitest"; +import { + formatSearchResults, + formatPopularResults, + formatRecentResults, +} from "../../src/formatters/content.js"; +import type { + SearchResponse, + PopularResponse, + RecentResponse, +} from "../../src/types.js"; + +describe("formatSearchResults", () => { + it("formats empty results", () => { + const response: SearchResponse = { + total: 0, + page: 1, + pageSize: 10, + results: [], + }; + const text = formatSearchResults(response); + expect(text).toContain("No results found"); + }); + + it("formats a single movie with torrents", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 42, + imdbId: "tt1375666", + tmdbId: "27205", + contentType: "movie", + title: "Inception", + titleOriginal: "Inception", + year: 2010, + overview: "A thief who steals corporate secrets through dream-sharing technology.", + posterUrl: "https://image.tmdb.org/t/p/w500/poster.jpg", + backdropUrl: null, + genres: ["Action", "Science Fiction"], + ratingImdb: "8.8", + ratingTmdb: "8.4", + hasTorrents: true, + torrents: [ + { + infoHash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + quality: "1080p", + codec: "x265", + sourceType: "BluRay", + sizeBytes: "2147483648", + seeders: 847, + leechers: 23, + magnetUrl: "magnet:?xt=urn:btih:aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + source: "yts", + qualityScore: 85, + uploadedAt: "2024-03-15T12:00:00Z", + languages: ["en"], + audioCodec: "aac", + hdrType: null, + releaseGroup: "YTS", + isProper: false, + isRepack: false, + isRemastered: false, + }, + ], + }, + ], + }; + + const text = formatSearchResults(response); + expect(text).toContain("Found 1 results"); + expect(text).toContain("Inception (2010) [movie]"); + expect(text).toContain("IMDb: 8.8"); + expect(text).toContain("TMDB: 8.4"); + expect(text).toContain("Action, Science Fiction"); + expect(text).toContain("1080p BluRay x265"); + expect(text).toContain("2.0 GB"); + expect(text).toContain("847 seeders"); + expect(text).toContain("Score: 85"); + expect(text).toContain("magnet:"); + expect(text).toContain("Content ID: 42"); + expect(text).toContain("tt1375666"); + }); + + it("caps torrents at 5 and sorts by qualityScore", () => { + const torrents = Array.from({ length: 8 }, (_, i) => ({ + infoHash: `${"a".repeat(39)}${i}`, + quality: `${720 + i * 10}p`, + codec: "x264", + sourceType: "WEB-DL", + sizeBytes: "1073741824", + seeders: 100 + i, + leechers: 10, + magnetUrl: `magnet:?xt=urn:btih:${"a".repeat(39)}${i}`, + source: "knaben:test", + qualityScore: i * 10, + uploadedAt: null, + languages: ["en"], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: false, + isRepack: false, + isRemastered: false, + })); + + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Test Movie", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + hasTorrents: true, + torrents, + }, + ], + }; + + const text = formatSearchResults(response); + expect(text).toContain("8 total, top 5"); + // Highest score (70) should appear first + expect(text).toContain("Score: 70"); + }); + + it("formats KB-sized torrent", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Small File", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + hasTorrents: true, + torrents: [ + { + infoHash: "a".repeat(40), + quality: null, + codec: null, + sourceType: null, + sizeBytes: "512000", + seeders: 5, + leechers: 0, + magnetUrl: null, + source: "test", + qualityScore: null, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("500 KB"); + expect(text).toContain("Unknown quality"); + expect(text).not.toContain("Score:"); + }); + + it("formats MB-sized torrent", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Medium File", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + hasTorrents: true, + torrents: [ + { + infoHash: "b".repeat(40), + quality: "720p", + codec: null, + sourceType: null, + sizeBytes: "524288000", + seeders: 10, + leechers: 1, + magnetUrl: null, + source: "test", + qualityScore: 50, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("500 MB"); + }); + + it("handles null and NaN sizeBytes", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "No Size", + titleOriginal: null, + year: null, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + hasTorrents: true, + torrents: [ + { + infoHash: "c".repeat(40), + quality: "1080p", + codec: null, + sourceType: null, + sizeBytes: null, + seeders: 1, + leechers: 0, + magnetUrl: null, + source: "test", + qualityScore: null, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + }, + { + infoHash: "d".repeat(40), + quality: "720p", + codec: null, + sourceType: null, + sizeBytes: "not-a-number", + seeders: 1, + leechers: 0, + magnetUrl: null, + source: "test", + qualityScore: null, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + // Both null and NaN should produce "?" + expect(text).toContain("(?)"); + // No year in title + expect(text).toContain("No Size [movie]"); + // No ratings + expect(text).toContain("No ratings"); + }); + + it("truncates long overviews", () => { + const longOverview = "A".repeat(300); + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "show", + title: "Long Overview", + titleOriginal: null, + year: 2024, + overview: longOverview, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + hasTorrents: false, + torrents: [], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("..."); + expect(text).not.toContain("A".repeat(300)); + }); + + it("shows HDR type in torrent label", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "HDR Movie", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: "7.0", + ratingTmdb: null, + hasTorrents: true, + torrents: [ + { + infoHash: "e".repeat(40), + quality: "2160p", + codec: "x265", + sourceType: "WEB-DL", + sizeBytes: "10737418240", + seeders: 50, + leechers: 2, + magnetUrl: "magnet:?xt=urn:btih:" + "e".repeat(40), + source: "yts", + qualityScore: 95, + uploadedAt: null, + languages: ["en"], + audioCodec: null, + hdrType: "hdr10", + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("2160p WEB-DL x265 hdr10"); + expect(text).toContain("10.0 GB"); + }); + + it("shows streaming info when available", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Streaming Test", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + hasTorrents: false, + torrents: [], + streaming: { + flatrate: [ + { providerId: 8, name: "Netflix", logo: null, link: null }, + { providerId: 337, name: "Disney+", logo: null, link: null }, + ], + rent: [], + buy: [], + free: [{ providerId: 100, name: "Tubi", logo: null, link: null }], + }, + }, + ], + }; + + const text = formatSearchResults(response); + expect(text).toContain("Stream: Netflix, Disney+"); + expect(text).toContain("Free: Tubi"); + }); +}); + +describe("formatPopularResults", () => { + it("formats empty results", () => { + const response: PopularResponse = { items: [], total: 0, page: 1, pageSize: 10 }; + expect(formatPopularResults(response)).toContain("No popular content"); + }); + + it("formats popular items with click counts", () => { + const response: PopularResponse = { + items: [ + { + id: 1, + title: "Popular Movie", + year: 2024, + contentType: "movie", + posterUrl: null, + ratingImdb: "7.5", + ratingTmdb: "7.2", + clickCount: 150, + }, + ], + total: 1, + page: 1, + pageSize: 10, + }; + + const text = formatPopularResults(response); + expect(text).toContain("Popular Movie (2024) [movie]"); + expect(text).toContain("150 clicks"); + expect(text).toContain("ID: 1"); + }); +}); + +describe("formatRecentResults", () => { + it("formats empty results", () => { + const response: RecentResponse = { items: [], total: 0, page: 1, pageSize: 10 }; + expect(formatRecentResults(response)).toContain("No recent content"); + }); + + it("formats recent items with dates", () => { + const response: RecentResponse = { + items: [ + { + id: 5, + title: "New Show", + year: 2025, + contentType: "show", + posterUrl: null, + ratingImdb: null, + ratingTmdb: "6.8", + createdAt: "2025-12-25T10:00:00Z", + }, + ], + total: 1, + page: 1, + pageSize: 10, + }; + + const text = formatRecentResults(response); + expect(text).toContain("New Show (2025) [show]"); + expect(text).toContain("TMDB: 6.8"); + expect(text).toContain("Dec"); + expect(text).toContain("ID: 5"); + }); +}); diff --git a/tests/formatters/credits.test.ts b/tests/formatters/credits.test.ts new file mode 100644 index 0000000..b08adb4 --- /dev/null +++ b/tests/formatters/credits.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { formatCredits } from "../../src/formatters/credits.js"; +import type { CreditsResponse } from "../../src/types.js"; + +describe("formatCredits", () => { + it("formats director and cast", () => { + const data: CreditsResponse = { + contentId: 42, + director: "Christopher Nolan", + cast: [ + { name: "Leonardo DiCaprio", character: "Cobb", profileUrl: null }, + { name: "Tom Hardy", character: "Eames", profileUrl: null }, + ], + }; + + const text = formatCredits(data); + expect(text).toContain("Credits for content #42"); + expect(text).toContain("Director: Christopher Nolan"); + expect(text).toContain("Leonardo DiCaprio as Cobb"); + expect(text).toContain("Tom Hardy as Eames"); + }); + + it("handles missing director", () => { + const data: CreditsResponse = { + contentId: 10, + director: null, + cast: [{ name: "Actor One", character: "Role", profileUrl: null }], + }; + + const text = formatCredits(data); + expect(text).not.toContain("Director:"); + expect(text).toContain("Actor One as Role"); + }); + + it("handles empty cast", () => { + const data: CreditsResponse = { + contentId: 5, + director: "Some Director", + cast: [], + }; + + const text = formatCredits(data); + expect(text).toContain("Director: Some Director"); + expect(text).toContain("No cast information available"); + }); + + it("handles cast member without character name", () => { + const data: CreditsResponse = { + contentId: 1, + director: null, + cast: [{ name: "Mystery Actor", character: "", profileUrl: null }], + }; + + const text = formatCredits(data); + expect(text).toContain("- Mystery Actor"); + expect(text).not.toContain(" as "); + }); +}); diff --git a/tests/formatters/providers.test.ts b/tests/formatters/providers.test.ts new file mode 100644 index 0000000..d65b3a8 --- /dev/null +++ b/tests/formatters/providers.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { formatWatchProviders } from "../../src/formatters/providers.js"; +import type { WatchProvidersResponse } from "../../src/types.js"; + +describe("formatWatchProviders", () => { + it("formats providers by availability type", () => { + const data: WatchProvidersResponse = { + contentId: 42, + country: "ES", + providers: { + flatrate: [ + { providerId: 8, name: "Netflix", logo: null, link: null, displayPriority: 1 }, + { providerId: 337, name: "Disney+", logo: null, link: null, displayPriority: 2 }, + ], + rent: [ + { providerId: 2, name: "Apple TV", logo: null, link: null, displayPriority: 1 }, + ], + buy: [ + { providerId: 3, name: "Google Play", logo: null, link: null, displayPriority: 1 }, + ], + free: [], + }, + attribution: "Watch provider data provided by JustWatch via TMDB.", + }; + + const text = formatWatchProviders(data); + expect(text).toContain("Watch providers for content #42 in ES"); + expect(text).toContain("Stream: Netflix, Disney+"); + expect(text).toContain("Rent: Apple TV"); + expect(text).toContain("Buy: Google Play"); + expect(text).not.toContain("Free:"); + expect(text).toContain("JustWatch"); + }); + + it("handles no providers", () => { + const data: WatchProvidersResponse = { + contentId: 10, + country: "US", + providers: { flatrate: [], rent: [], buy: [], free: [] }, + attribution: "JustWatch", + }; + + const text = formatWatchProviders(data); + expect(text).toContain("No watch providers found in US"); + }); + + it("shows VPN suggestion when available", () => { + const data: WatchProvidersResponse = { + contentId: 5, + country: "AR", + providers: { flatrate: [], rent: [], buy: [], free: [] }, + vpnSuggestion: { + availableIn: ["US", "ES", "FR"], + affiliateUrl: "https://example.com/vpn", + }, + attribution: "JustWatch", + }; + + const text = formatWatchProviders(data); + expect(text).toContain("Available in other countries: US, ES, FR"); + }); + + it("sorts providers by display priority", () => { + const data: WatchProvidersResponse = { + contentId: 1, + country: "GB", + providers: { + flatrate: [ + { providerId: 2, name: "Second", logo: null, link: null, displayPriority: 20 }, + { providerId: 1, name: "First", logo: null, link: null, displayPriority: 1 }, + ], + rent: [], + buy: [], + free: [], + }, + attribution: "JustWatch", + }; + + const text = formatWatchProviders(data); + expect(text).toContain("Stream: First, Second"); + }); + + it("shows free providers", () => { + const data: WatchProvidersResponse = { + contentId: 1, + country: "US", + providers: { + flatrate: [], + rent: [], + buy: [], + free: [ + { providerId: 100, name: "Tubi", logo: null, link: null, displayPriority: 1 }, + ], + }, + attribution: "JustWatch", + }; + + const text = formatWatchProviders(data); + expect(text).toContain("Free: Tubi"); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..e1a045f --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,63 @@ +import { vi } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +type ToolHandler = (params: Record) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; +}>; + +type ResourceHandler = (uri: URL) => Promise<{ + contents: { uri: string; mimeType: string; text: string }[]; +}>; + +/** + * Creates a mock McpServer that captures registered tool and resource handlers. + */ +export function createMockServer() { + const tools = new Map(); + const resources = new Map(); + + const server = { + tool: vi.fn( + ( + name: string, + _descriptionOrSchema: unknown, + _schemaOrHandler: unknown, + handler?: ToolHandler, + ) => { + // server.tool(name, description, schema, handler) — 4-arg form + if (typeof handler === "function") { + tools.set(name, handler); + } + // server.tool(name, schema, handler) — 3-arg form + else if (typeof _schemaOrHandler === "function") { + tools.set(name, _schemaOrHandler as ToolHandler); + } + }, + ), + resource: vi.fn( + ( + _name: string, + _uri: string, + _opts: unknown, + handler: ResourceHandler, + ) => { + resources.set(_uri, handler); + }, + ), + } as unknown as McpServer; + + return { + server, + getToolHandler(name: string): ToolHandler { + const handler = tools.get(name); + if (!handler) throw new Error(`Tool "${name}" not registered`); + return handler; + }, + getResourceHandler(uri: string): ResourceHandler { + const handler = resources.get(uri); + if (!handler) throw new Error(`Resource "${uri}" not registered`); + return handler; + }, + }; +} diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts new file mode 100644 index 0000000..a10206c --- /dev/null +++ b/tests/prompts.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerPrompts } from "../src/prompts.js"; + +type PromptHandler = ( + params: Record, +) => { messages: { role: string; content: { type: string; text: string } }[] }; + +describe("registerPrompts", () => { + function createMockServer() { + const prompts = new Map(); + + const server = { + prompt: vi.fn( + ( + name: string, + _description: string, + _schema: unknown, + handler: PromptHandler, + ) => { + prompts.set(name, handler); + }, + ), + } as unknown as McpServer; + + return { server, prompts }; + } + + it("registers 4 prompts", () => { + const { server, prompts } = createMockServer(); + registerPrompts(server); + expect(prompts.size).toBe(4); + expect(prompts.has("search_movie")).toBe(true); + expect(prompts.has("search_show")).toBe(true); + expect(prompts.has("whats_new")).toBe(true); + expect(prompts.has("where_to_watch")).toBe(true); + }); + + it("search_movie includes title in prompt", () => { + const { server, prompts } = createMockServer(); + registerPrompts(server); + const handler = prompts.get("search_movie")!; + const result = handler({ title: "Inception" }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("user"); + expect(result.messages[0].content.text).toContain("Inception"); + expect(result.messages[0].content.text).toContain("search_content"); + }); + + it("search_show includes title in prompt", () => { + const { server, prompts } = createMockServer(); + registerPrompts(server); + const handler = prompts.get("search_show")!; + const result = handler({ title: "Breaking Bad" }); + expect(result.messages[0].content.text).toContain("Breaking Bad"); + }); + + it("whats_new returns discovery prompt", () => { + const { server, prompts } = createMockServer(); + registerPrompts(server); + const handler = prompts.get("whats_new")!; + const result = handler({}); + expect(result.messages[0].content.text).toContain("recently added"); + }); + + it("where_to_watch includes country when provided", () => { + const { server, prompts } = createMockServer(); + registerPrompts(server); + const handler = prompts.get("where_to_watch")!; + const result = handler({ title: "Dune", country: "ES" }); + expect(result.messages[0].content.text).toContain("Dune"); + expect(result.messages[0].content.text).toContain("ES"); + }); + + it("where_to_watch works without country", () => { + const { server, prompts } = createMockServer(); + registerPrompts(server); + const handler = prompts.get("where_to_watch")!; + const result = handler({ title: "Dune" }); + expect(result.messages[0].content.text).toContain("Dune"); + expect(result.messages[0].content.text).not.toContain("undefined"); + }); +}); diff --git a/tests/resources/stats.test.ts b/tests/resources/stats.test.ts new file mode 100644 index 0000000..d1d4d9d --- /dev/null +++ b/tests/resources/stats.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerStatsResource } from "../../src/resources/stats.js"; +import { TorrentClawClient } from "../../src/api-client.js"; +import type { StatsResponse } from "../../src/types.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("torrentclaw://stats resource", () => { + it("returns stats as JSON", async () => { + const statsData: StatsResponse = { + content: { movies: 5000, shows: 1000, tmdbEnriched: 4500 }, + torrents: { + total: 50000, + withSeeders: 30000, + bySource: { yts: 20000, eztv: 15000, "knaben:1337x": 15000 }, + }, + recentIngestions: [ + { + source: "yts", + status: "completed", + startedAt: "2026-02-09T01:00:00Z", + completedAt: "2026-02-09T01:35:00Z", + fetched: 100, + new: 10, + updated: 90, + }, + ], + }; + + const client = createMockClient({ + getStats: vi.fn().mockResolvedValue(statsData), + }); + const { server, getResourceHandler } = createMockServer(); + registerStatsResource(server, client); + + const handler = getResourceHandler("torrentclaw://stats"); + const result = await handler(new URL("torrentclaw://stats")); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].mimeType).toBe("application/json"); + expect(result.contents[0].uri).toBe("torrentclaw://stats"); + + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.content.movies).toBe(5000); + expect(parsed.torrents.total).toBe(50000); + expect(parsed.recentIngestions).toHaveLength(1); + }); +}); diff --git a/tests/tools/get-credits.test.ts b/tests/tools/get-credits.test.ts new file mode 100644 index 0000000..7a10260 --- /dev/null +++ b/tests/tools/get-credits.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerGetCredits } from "../../src/tools/get-credits.js"; +import { TorrentClawClient, ApiError } from "../../src/api-client.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("get_credits tool", () => { + it("returns formatted credits", async () => { + const client = createMockClient({ + getCredits: vi.fn().mockResolvedValue({ + contentId: 42, + director: "Christopher Nolan", + cast: [ + { name: "Leonardo DiCaprio", character: "Cobb", profileUrl: null }, + ], + }), + }); + const { server, getToolHandler } = createMockServer(); + registerGetCredits(server, client); + + const handler = getToolHandler("get_credits"); + const result = await handler({ content_id: 42 }); + + expect(result.content[0].text).toContain("Christopher Nolan"); + expect(result.content[0].text).toContain("Leonardo DiCaprio"); + }); + + it("passes content_id to client", async () => { + const getCreditsMock = vi.fn().mockResolvedValue({ + contentId: 99, + director: null, + cast: [], + }); + const client = createMockClient({ getCredits: getCreditsMock }); + const { server, getToolHandler } = createMockServer(); + registerGetCredits(server, client); + + const handler = getToolHandler("get_credits"); + await handler({ content_id: 99 }); + + expect(getCreditsMock).toHaveBeenCalledWith(99); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + getCredits: vi.fn().mockRejectedValue(new ApiError(503, "TMDB unavailable")), + }); + const { server, getToolHandler } = createMockServer(); + registerGetCredits(server, client); + + const handler = getToolHandler("get_credits"); + const result = await handler({ content_id: 1 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (503)"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + getCredits: vi.fn().mockRejectedValue(new Error("Boom")), + }); + const { server, getToolHandler } = createMockServer(); + registerGetCredits(server, client); + + const handler = getToolHandler("get_credits"); + const result = await handler({ content_id: 1 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Request failed: Boom"); + }); + + it("handles non-Error throw", async () => { + const client = createMockClient({ + getCredits: vi.fn().mockRejectedValue(undefined), + }); + const { server, getToolHandler } = createMockServer(); + registerGetCredits(server, client); + + const handler = getToolHandler("get_credits"); + const result = await handler({ content_id: 1 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); diff --git a/tests/tools/get-popular.test.ts b/tests/tools/get-popular.test.ts new file mode 100644 index 0000000..b5246c2 --- /dev/null +++ b/tests/tools/get-popular.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerGetPopular } from "../../src/tools/get-popular.js"; +import { TorrentClawClient, ApiError } from "../../src/api-client.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("get_popular tool", () => { + it("returns formatted popular results", async () => { + const client = createMockClient({ + getPopular: vi.fn().mockResolvedValue({ + items: [ + { + id: 1, + title: "Popular Movie", + year: 2024, + contentType: "movie", + posterUrl: null, + ratingImdb: "8.0", + ratingTmdb: "7.5", + clickCount: 200, + }, + ], + total: 1, + page: 1, + pageSize: 10, + }), + }); + const { server, getToolHandler } = createMockServer(); + registerGetPopular(server, client); + + const handler = getToolHandler("get_popular"); + const result = await handler({ limit: 5 }); + + expect(result.content[0].text).toContain("Popular Movie"); + expect(result.content[0].text).toContain("200 clicks"); + }); + + it("defaults limit to 10", async () => { + const getPopularMock = vi.fn().mockResolvedValue({ + items: [], + total: 0, + page: 1, + pageSize: 10, + }); + const client = createMockClient({ getPopular: getPopularMock }); + const { server, getToolHandler } = createMockServer(); + registerGetPopular(server, client); + + const handler = getToolHandler("get_popular"); + await handler({}); + + expect(getPopularMock).toHaveBeenCalledWith(10, undefined); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + getPopular: vi.fn().mockRejectedValue(new ApiError(500, "Server error")), + }); + const { server, getToolHandler } = createMockServer(); + registerGetPopular(server, client); + + const handler = getToolHandler("get_popular"); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (500)"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + getPopular: vi.fn().mockRejectedValue(new Error("Timeout")), + }); + const { server, getToolHandler } = createMockServer(); + registerGetPopular(server, client); + + const handler = getToolHandler("get_popular"); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Request failed: Timeout"); + }); + + it("handles non-Error throw", async () => { + const client = createMockClient({ + getPopular: vi.fn().mockRejectedValue("string error"), + }); + const { server, getToolHandler } = createMockServer(); + registerGetPopular(server, client); + + const handler = getToolHandler("get_popular"); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); diff --git a/tests/tools/get-recent.test.ts b/tests/tools/get-recent.test.ts new file mode 100644 index 0000000..dde0b1b --- /dev/null +++ b/tests/tools/get-recent.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerGetRecent } from "../../src/tools/get-recent.js"; +import { TorrentClawClient, ApiError } from "../../src/api-client.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("get_recent tool", () => { + it("returns formatted recent results", async () => { + const client = createMockClient({ + getRecent: vi.fn().mockResolvedValue({ + items: [ + { + id: 3, + title: "New Show", + year: 2025, + contentType: "show", + posterUrl: null, + ratingImdb: null, + ratingTmdb: "7.0", + createdAt: "2025-12-01T00:00:00Z", + }, + ], + total: 1, + page: 1, + pageSize: 10, + }), + }); + const { server, getToolHandler } = createMockServer(); + registerGetRecent(server, client); + + const handler = getToolHandler("get_recent"); + const result = await handler({}); + + expect(result.content[0].text).toContain("New Show"); + expect(result.content[0].text).toContain("[show]"); + }); + + it("defaults limit to 10", async () => { + const getRecentMock = vi.fn().mockResolvedValue({ + items: [], + total: 0, + page: 1, + pageSize: 10, + }); + const client = createMockClient({ getRecent: getRecentMock }); + const { server, getToolHandler } = createMockServer(); + registerGetRecent(server, client); + + const handler = getToolHandler("get_recent"); + await handler({}); + + expect(getRecentMock).toHaveBeenCalledWith(10, undefined); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + getRecent: vi.fn().mockRejectedValue(new ApiError(503, "Unavailable")), + }); + const { server, getToolHandler } = createMockServer(); + registerGetRecent(server, client); + + const handler = getToolHandler("get_recent"); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (503)"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + getRecent: vi.fn().mockRejectedValue(new Error("DNS failure")), + }); + const { server, getToolHandler } = createMockServer(); + registerGetRecent(server, client); + + const handler = getToolHandler("get_recent"); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("DNS failure"); + }); + + it("handles non-Error throw", async () => { + const client = createMockClient({ + getRecent: vi.fn().mockRejectedValue(42), + }); + const { server, getToolHandler } = createMockServer(); + registerGetRecent(server, client); + + const handler = getToolHandler("get_recent"); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); diff --git a/tests/tools/get-torrent-url.test.ts b/tests/tools/get-torrent-url.test.ts new file mode 100644 index 0000000..3827722 --- /dev/null +++ b/tests/tools/get-torrent-url.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerGetTorrentUrl } from "../../src/tools/get-torrent-url.js"; +import { TorrentClawClient } from "../../src/api-client.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi + .fn() + .mockImplementation( + (hash: string) => + `https://torrentclaw.com/api/v1/torrent/${hash}`, + ), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("get_torrent_url tool", () => { + it("returns download URL for valid info hash", async () => { + const client = createMockClient(); + const { server, getToolHandler } = createMockServer(); + registerGetTorrentUrl(server, client); + + const handler = getToolHandler("get_torrent_url"); + const hash = "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e"; + const result = await handler({ info_hash: hash }); + + expect(result.content[0].text).toContain("Download .torrent file:"); + expect(result.content[0].text).toContain(hash); + expect(result.isError).toBeUndefined(); + }); + + it("lowercases the info hash", async () => { + const getTorrentDownloadUrlMock = vi + .fn() + .mockReturnValue("https://torrentclaw.com/api/v1/torrent/abc"); + const client = createMockClient({ + getTorrentDownloadUrl: getTorrentDownloadUrlMock, + }); + const { server, getToolHandler } = createMockServer(); + registerGetTorrentUrl(server, client); + + const handler = getToolHandler("get_torrent_url"); + await handler({ + info_hash: "AAF1E71C0A0E3B1C0F1A2B3C4D5E6F7A8B9C0D1E", + }); + + expect(getTorrentDownloadUrlMock).toHaveBeenCalledWith( + "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + ); + }); +}); diff --git a/tests/tools/get-watch-providers.test.ts b/tests/tools/get-watch-providers.test.ts new file mode 100644 index 0000000..290f155 --- /dev/null +++ b/tests/tools/get-watch-providers.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerGetWatchProviders } from "../../src/tools/get-watch-providers.js"; +import { TorrentClawClient, ApiError } from "../../src/api-client.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("get_watch_providers tool", () => { + it("returns formatted watch providers", async () => { + const client = createMockClient({ + getWatchProviders: vi.fn().mockResolvedValue({ + contentId: 42, + country: "ES", + providers: { + flatrate: [ + { providerId: 8, name: "Netflix", logo: null, link: null, displayPriority: 1 }, + ], + rent: [], + buy: [], + free: [], + }, + attribution: "JustWatch", + }), + }); + const { server, getToolHandler } = createMockServer(); + registerGetWatchProviders(server, client); + + const handler = getToolHandler("get_watch_providers"); + const result = await handler({ content_id: 42, country: "ES" }); + + expect(result.content[0].text).toContain("Netflix"); + expect(result.content[0].text).toContain("content #42"); + }); + + it("passes content_id and country to client", async () => { + const getWatchProvidersMock = vi.fn().mockResolvedValue({ + contentId: 10, + country: "US", + providers: { flatrate: [], rent: [], buy: [], free: [] }, + attribution: "JustWatch", + }); + const client = createMockClient({ + getWatchProviders: getWatchProvidersMock, + }); + const { server, getToolHandler } = createMockServer(); + registerGetWatchProviders(server, client); + + const handler = getToolHandler("get_watch_providers"); + await handler({ content_id: 10, country: "US" }); + + expect(getWatchProvidersMock).toHaveBeenCalledWith(10, "US"); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + getWatchProviders: vi.fn().mockRejectedValue(new ApiError(404, "Not found")), + }); + const { server, getToolHandler } = createMockServer(); + registerGetWatchProviders(server, client); + + const handler = getToolHandler("get_watch_providers"); + const result = await handler({ content_id: 999, country: "US" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (404)"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + getWatchProviders: vi.fn().mockRejectedValue(new Error("Connection refused")), + }); + const { server, getToolHandler } = createMockServer(); + registerGetWatchProviders(server, client); + + const handler = getToolHandler("get_watch_providers"); + const result = await handler({ content_id: 1, country: "US" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Connection refused"); + }); + + it("handles non-Error throw", async () => { + const client = createMockClient({ + getWatchProviders: vi.fn().mockRejectedValue(null), + }); + const { server, getToolHandler } = createMockServer(); + registerGetWatchProviders(server, client); + + const handler = getToolHandler("get_watch_providers"); + const result = await handler({ content_id: 1, country: "US" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); diff --git a/tests/tools/search-content.test.ts b/tests/tools/search-content.test.ts new file mode 100644 index 0000000..58b7573 --- /dev/null +++ b/tests/tools/search-content.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerSearchContent } from "../../src/tools/search-content.js"; +import { TorrentClawClient, ApiError } from "../../src/api-client.js"; +import type { SearchResponse } from "../../src/types.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("search_content tool", () => { + it("returns formatted search results on success", async () => { + const mockResponse: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: "tt1375666", + tmdbId: "27205", + contentType: "movie", + title: "Inception", + titleOriginal: null, + year: 2010, + overview: "A mind-bending thriller", + posterUrl: null, + backdropUrl: null, + genres: ["Action"], + ratingImdb: "8.8", + ratingTmdb: "8.4", + hasTorrents: true, + torrents: [], + }, + ], + }; + + const client = createMockClient({ + search: vi.fn().mockResolvedValue(mockResponse), + }); + const { server, getToolHandler } = createMockServer(); + registerSearchContent(server, client); + + const handler = getToolHandler("search_content"); + const result = await handler({ query: "inception", type: "movie" }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("Inception"); + expect(result.content[0].text).toContain("Found 1 results"); + }); + + it("passes all parameters to client.search", async () => { + const searchMock = vi.fn().mockResolvedValue({ + total: 0, + page: 1, + pageSize: 10, + results: [], + }); + const client = createMockClient({ search: searchMock }); + const { server, getToolHandler } = createMockServer(); + registerSearchContent(server, client); + + const handler = getToolHandler("search_content"); + await handler({ + query: "test", + type: "show", + genre: "Drama", + year_min: 2020, + year_max: 2025, + min_rating: 7, + quality: "1080p", + language: "es", + sort: "seeders", + page: 2, + limit: 15, + country: "ES", + }); + + expect(searchMock).toHaveBeenCalledWith({ + query: "test", + type: "show", + genre: "Drama", + year_min: 2020, + year_max: 2025, + min_rating: 7, + quality: "1080p", + language: "es", + sort: "seeders", + page: 2, + limit: 15, + country: "ES", + }); + }); + + it("defaults limit to 10", async () => { + const searchMock = vi.fn().mockResolvedValue({ + total: 0, + page: 1, + pageSize: 10, + results: [], + }); + const client = createMockClient({ search: searchMock }); + const { server, getToolHandler } = createMockServer(); + registerSearchContent(server, client); + + const handler = getToolHandler("search_content"); + await handler({ query: "test" }); + + expect(searchMock).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10 }), + ); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + search: vi.fn().mockRejectedValue(new ApiError(429, "Rate limited")), + }); + const { server, getToolHandler } = createMockServer(); + registerSearchContent(server, client); + + const handler = getToolHandler("search_content"); + const result = await handler({ query: "test" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (429)"); + expect(result.content[0].text).toContain("Rate limit exceeded"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + search: vi.fn().mockRejectedValue(new Error("Network timeout")), + }); + const { server, getToolHandler } = createMockServer(); + registerSearchContent(server, client); + + const handler = getToolHandler("search_content"); + const result = await handler({ query: "test" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Request failed: Network timeout"); + }); + + it("handles non-Error throw", async () => { + const client = createMockClient({ + search: vi.fn().mockRejectedValue("string error"), + }); + const { server, getToolHandler } = createMockServer(); + registerSearchContent(server, client); + + const handler = getToolHandler("search_content"); + const result = await handler({ query: "test" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a13cd78 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3f497ad --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/index.ts"], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, +});