diff --git a/.gitignore b/.gitignore index 4ed54fb..8c578e4 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,6 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* +CLAUDE.md +.idea +docs/* diff --git a/README.md b/README.md index fb19a89..280a640 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Readme: Metabase MCP Server -[![npm version](https://img.shields.io/npm/v/@cognitionai/metabase-mcp-server)](https://www.npmjs.com/package/@cognitionai/metabase-mcp-server) +[![npm version](https://img.shields.io/npm/v/@thangnm93/metabase-mcp-server)](https://www.npmjs.com/package/@thangnm93/metabase-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Node.js](https://img.shields.io/badge/Node.js-43853D?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/) @@ -13,7 +13,7 @@ A comprehensive Model Context Protocol (MCP) server for Metabase integration, pr # Install and run with npx export METABASE_URL=https://your-metabase-instance.com export METABASE_API_KEY=your_metabase_api_key -npx @cognitionai/metabase-mcp-server +npx @thangnm93/metabase-mcp-server ``` ## ✨ Features @@ -32,12 +32,12 @@ npx @cognitionai/metabase-mcp-server ### Method 1: NPX (Recommended) ```bash -npx @cognitionai/metabase-mcp-server +npx @thangnm93/metabase-mcp-server ``` ### Method 2: Global Installation ```bash -npm install -g @cognitionai/metabase-mcp-server +npm install -g @thangnm93/metabase-mcp-server metabase-mcp-server ``` @@ -46,7 +46,7 @@ metabase-mcp-server docker run -it --rm \ -e METABASE_URL=https://your-metabase-instance.com \ -e METABASE_API_KEY=your_metabase_api_key \ - ghcr.io/CognitionAI/metabase-mcp-server + ghcr.io/thangnm93/metabase-mcp-server ``` ## ⚙️ Configuration @@ -66,6 +66,23 @@ export METABASE_USERNAME=your_username export METABASE_PASSWORD=your_password ``` +**Extra Request Headers (Optional):** + +Use `METABASE_HEADER_` to inject additional headers into every outgoing request — useful for reverse proxies, API gateways, or multi-tenant setups that require extra auth tokens or routing headers. + +The suffix is converted to a header name by replacing `_` with `-`: + +```bash +# Adds "Authorization: Bearer " to every request +export METABASE_HEADER_AUTHORIZATION="Bearer your-token" + +# Adds "X-Tenant-Id: my-org" +export METABASE_HEADER_X_TENANT_ID="my-org" + +# Adds "X-Api-Gateway-Key: secret" +export METABASE_HEADER_X_API_GATEWAY_KEY="secret" +``` + ### Optional Arguments The server supports command-line arguments to customize tool loading: @@ -78,16 +95,16 @@ The server supports command-line arguments to customize tool loading: ```bash # Using published package (recommended) # Default behavior (essential tools only) -npx @cognitionai/metabase-mcp-server +npx @thangnm93/metabase-mcp-server # Load all tools -npx @cognitionai/metabase-mcp-server --all +npx @thangnm93/metabase-mcp-server --all # Load only write tools -npx @cognitionai/metabase-mcp-server --write +npx @thangnm93/metabase-mcp-server --write # Load only read-only tools -npx @cognitionai/metabase-mcp-server --read +npx @thangnm93/metabase-mcp-server --read # Using local development build npm run build @@ -109,7 +126,7 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_ "mcpServers": { "metabase": { "command": "npx", - "args": ["@cognitionai/metabase-mcp-server"], + "args": ["@thangnm93/metabase-mcp-server"], "env": { "METABASE_URL": "https://your-metabase-instance.com", "METABASE_API_KEY": "your_metabase_api_key" @@ -141,7 +158,7 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_ "mcpServers": { "metabase-all": { "command": "npx", - "args": ["@cognitionai/metabase-mcp-server", "--all"], + "args": ["@thangnm93/metabase-mcp-server", "--all"], "env": { "METABASE_URL": "https://your-metabase-instance.com", "METABASE_API_KEY": "your_metabase_api_key" @@ -151,6 +168,24 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_ } ``` +**With extra headers (e.g. behind a reverse proxy or API gateway):** +```json +{ + "mcpServers": { + "metabase": { + "command": "npx", + "args": ["@thangnm93/metabase-mcp-server"], + "env": { + "METABASE_URL": "https://your-metabase-instance.com", + "METABASE_API_KEY": "your_metabase_api_key", + "METABASE_HEADER_AUTHORIZATION": "Bearer your-proxy-token", + "METABASE_HEADER_X_TENANT_ID": "my-org" + } + } + } +} +``` + ### Windsurf IDE Add to your Windsurf MCP config (`~/.windsurf/mcp_config.json`): @@ -161,7 +196,7 @@ Add to your Windsurf MCP config (`~/.windsurf/mcp_config.json`): "mcpServers": { "metabase": { "command": "npx", - "args": ["@cognitionai/metabase-mcp-server"], + "args": ["@thangnm93/metabase-mcp-server"], "env": { "METABASE_URL": "https://your-metabase-instance.com", "METABASE_API_KEY": "your_metabase_api_key" @@ -193,7 +228,7 @@ Add to your Windsurf MCP config (`~/.windsurf/mcp_config.json`): "mcpServers": { "metabase-read": { "command": "npx", - "args": ["@cognitionai/metabase-mcp-server", "--read"], + "args": ["@thangnm93/metabase-mcp-server", "--read"], "env": { "METABASE_URL": "https://your-metabase-instance.com", "METABASE_API_KEY": "your_metabase_api_key" @@ -201,7 +236,7 @@ Add to your Windsurf MCP config (`~/.windsurf/mcp_config.json`): }, "metabase-write": { "command": "npx", - "args": ["@cognitionai/metabase-mcp-server", "--write"], + "args": ["@thangnm93/metabase-mcp-server", "--write"], "env": { "METABASE_URL": "https://your-metabase-instance.com", "METABASE_API_KEY": "your_metabase_api_key" @@ -371,7 +406,7 @@ Add to your Windsurf MCP config (`~/.windsurf/mcp_config.json`): ### Setup ```bash -git clone https://github.com/CognitionAI/metabase-mcp-server.git +git clone https://github.com/thangnm93/metabase-mcp-server.git cd metabase-mcp-server npm install ``` @@ -430,7 +465,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## 🤝 Contributing -Contributions are welcome! Please visit our [GitHub repository](https://github.com/CognitionAI/metabase-mcp-server) to: +Contributions are welcome! Please visit our [GitHub repository](https://github.com/thangnm93/metabase-mcp-server) to: - 🐛 Report bugs - 💡 Request features @@ -447,8 +482,8 @@ Contributions are welcome! Please visit our [GitHub repository](https://github.c ## 🆘 Support -- 📚 [Documentation](https://github.com/CognitionAI/metabase-mcp-server/wiki) -- 🐛 [Issue Tracker](https://github.com/CognitionAI/metabase-mcp-server/issues) +- 📚 [Documentation](https://github.com/thangnm93/metabase-mcp-server/wiki) +- 🐛 [Issue Tracker](https://github.com/thangnm93/metabase-mcp-server/issues) --- diff --git a/package-lock.json b/package-lock.json index 87e147a..0a9859a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cognitionai/metabase-mcp-server", - "version": "1.0.14", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cognitionai/metabase-mcp-server", - "version": "1.0.14", + "version": "2.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^0.6.1", @@ -21,7 +21,8 @@ }, "devDependencies": { "@types/node": "^20.17.22", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "vitest": "^2.1.0" }, "engines": { "node": ">=20.19.0", @@ -38,6 +39,380 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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 + }, "node_modules/@modelcontextprotocol/sdk": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-0.6.1.tgz", @@ -49,6 +424,331 @@ "zod": "^3.23.8" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -96,6 +796,12 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, "node_modules/@types/node": { "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", @@ -106,6 +812,112 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -188,6 +1000,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "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, + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -238,6 +1059,15 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -267,6 +1097,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -377,6 +1232,15 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -448,6 +1312,12 @@ "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 + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -475,6 +1345,44 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -490,6 +1398,15 @@ "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, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -555,6 +1472,15 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "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, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -815,6 +1741,20 @@ "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, + "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", @@ -1092,6 +2032,21 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "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, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1162,6 +2117,24 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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", @@ -1281,6 +2254,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "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 + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -1290,6 +2284,34 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -1372,6 +2394,56 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "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.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/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 + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1530,6 +2602,12 @@ "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 + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1542,6 +2620,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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, + "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 + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1551,6 +2644,12 @@ "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 + }, "node_modules/strict-event-emitter-types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", @@ -1617,6 +2716,45 @@ "url": "https://github.com/sponsors/Borewit" } }, + "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 + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1736,6 +2874,152 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "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", @@ -1751,6 +3035,22 @@ "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, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/package.json b/package.json index a498bed..34b05dc 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,19 @@ { - "name": "@cognitionai/metabase-mcp-server", - "version": "2.2.0", + "name": "@thangnm93/metabase-mcp-server", + "version": "2.3.1", "description": "A Model Context Protocol server for Metabase integration", - "author": "Cognition ", + "author": "Thang Nguyen ", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/CognitionAI/metabase-mcp-server.git" + "url": "https://github.com/thangnm93/metabase-mcp-server.git" }, "publishConfig": { "access": "public" }, - "homepage": "https://github.com/CognitionAI/metabase-mcp-server", + "homepage": "https://github.com/thangnm93/metabase-mcp-server", "bugs": { - "url": "https://github.com/CognitionAI/metabase-mcp-server/issues" + "url": "https://github.com/thangnm93/metabase-mcp-server/issues" }, "keywords": [ "mcp", @@ -42,6 +42,9 @@ "start": "npm run build && node dist/server.js", "dev": "npx fastmcp dev src/server.ts", "clean": "rm -rf build dist", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "docker:build": "docker build -t metabase-mcp-server .", "docker:run": "docker run -it --rm metabase-mcp-server", "docker:compose": "docker-compose up" @@ -56,7 +59,8 @@ }, "devDependencies": { "@types/node": "^20.17.22", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "vitest": "^2.1.0" }, "engines": { "node": ">=20.19.0", diff --git a/src/client/metabase-client.test.ts b/src/client/metabase-client.test.ts new file mode 100644 index 0000000..8bd109b --- /dev/null +++ b/src/client/metabase-client.test.ts @@ -0,0 +1,411 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import axios from 'axios'; +import { MetabaseClient } from './metabase-client.js'; +import { McpError } from '../types/errors.js'; +import type { MetabaseConfig } from '../types/metabase.js'; + +vi.mock('axios', () => ({ + default: { create: vi.fn() }, +})); + +const API_KEY_CONFIG: MetabaseConfig = { + url: 'http://localhost:3000', + apiKey: 'test-api-key', +}; + +const CREDENTIALS_CONFIG: MetabaseConfig = { + url: 'http://localhost:3000', + username: 'admin', + password: 'secret', +}; + +describe('MetabaseClient', () => { + let mockInstance: { + defaults: { headers: { common: Record } }; + interceptors: { request: { use: ReturnType } }; + get: ReturnType; + post: ReturnType; + put: ReturnType; + delete: ReturnType; + request: ReturnType; + }; + let capturedInterceptor: ((config: any) => Promise) | null; + + beforeEach(() => { + capturedInterceptor = null; + mockInstance = { + defaults: { headers: { common: {} } }, + interceptors: { + request: { + use: vi.fn((handler: any) => { + capturedInterceptor = handler; + }), + }, + }, + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + request: vi.fn(), + }; + vi.mocked(axios.create).mockReturnValue(mockInstance as any); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('throws when no auth credentials provided', () => { + expect(() => new MetabaseClient({ url: 'http://localhost:3000' })).toThrow( + 'Metabase authentication credentials not provided or incomplete.' + ); + }); + + it('sets X-API-Key header when apiKey is provided', () => { + new MetabaseClient(API_KEY_CONFIG); + expect(mockInstance.defaults.headers.common['X-API-Key']).toBe('test-api-key'); + }); + + it('does not set session header in username/password mode until first request', () => { + new MetabaseClient(CREDENTIALS_CONFIG); + expect(mockInstance.defaults.headers.common['X-Metabase-Session']).toBeUndefined(); + }); + + it('registers a request interceptor', () => { + new MetabaseClient(CREDENTIALS_CONFIG); + expect(mockInstance.interceptors.request.use).toHaveBeenCalledOnce(); + }); + + it('applies extra headers before auth headers', () => { + new MetabaseClient({ + ...API_KEY_CONFIG, + extraHeaders: { 'X-Tenant-Id': 'my-org', 'X-Custom': 'value' }, + }); + expect(mockInstance.defaults.headers.common['X-Tenant-Id']).toBe('my-org'); + expect(mockInstance.defaults.headers.common['X-Custom']).toBe('value'); + expect(mockInstance.defaults.headers.common['X-API-Key']).toBe('test-api-key'); + }); + + it('does not set any extra headers when extraHeaders is undefined', () => { + new MetabaseClient(API_KEY_CONFIG); + const keys = Object.keys(mockInstance.defaults.headers.common); + expect(keys).toEqual(['X-API-Key']); + }); + }); + + describe('request interceptor (username/password auth)', () => { + it('passes /api/session requests through without triggering auth', async () => { + new MetabaseClient(CREDENTIALS_CONFIG); + const config = { url: '/api/session' }; + const result = await capturedInterceptor!(config); + expect(result).toBe(config); + expect(mockInstance.post).not.toHaveBeenCalled(); + }); + + it('fetches session token on the first authenticated request', async () => { + mockInstance.post.mockResolvedValueOnce({ data: { id: 'token-abc' } }); + new MetabaseClient(CREDENTIALS_CONFIG); + + await capturedInterceptor!({ url: '/api/dashboard' }); + + expect(mockInstance.post).toHaveBeenCalledWith('/api/session', { + username: 'admin', + password: 'secret', + }); + expect(mockInstance.defaults.headers.common['X-Metabase-Session']).toBe('token-abc'); + }); + + it('caches the session token and does not re-authenticate on subsequent requests', async () => { + mockInstance.post.mockResolvedValueOnce({ data: { id: 'token-abc' } }); + new MetabaseClient(CREDENTIALS_CONFIG); + + await capturedInterceptor!({ url: '/api/dashboard' }); + await capturedInterceptor!({ url: '/api/card' }); + await capturedInterceptor!({ url: '/api/database' }); + + expect(mockInstance.post).toHaveBeenCalledTimes(1); + }); + + it('throws McpError when authentication fails', async () => { + mockInstance.post.mockRejectedValueOnce(new Error('401 Unauthorized')); + new MetabaseClient(CREDENTIALS_CONFIG); + + await expect(capturedInterceptor!({ url: '/api/dashboard' })).rejects.toThrow(McpError); + }); + + it('skips session fetch in API key mode', async () => { + new MetabaseClient(API_KEY_CONFIG); + + await capturedInterceptor!({ url: '/api/dashboard' }); + + expect(mockInstance.post).not.toHaveBeenCalled(); + }); + }); + + describe('dashboard methods', () => { + it('getDashboards: GET /api/dashboard', async () => { + const data = [{ id: 1, name: 'Sales' }]; + mockInstance.get.mockResolvedValueOnce({ data }); + const client = new MetabaseClient(API_KEY_CONFIG); + + expect(await client.getDashboards()).toEqual(data); + expect(mockInstance.get).toHaveBeenCalledWith('/api/dashboard'); + }); + + it('getDashboard: GET /api/dashboard/:id', async () => { + const data = { id: 5, name: 'Revenue', dashcards: [] }; + mockInstance.get.mockResolvedValueOnce({ data }); + const client = new MetabaseClient(API_KEY_CONFIG); + + expect(await client.getDashboard(5)).toEqual(data); + expect(mockInstance.get).toHaveBeenCalledWith('/api/dashboard/5'); + }); + + it('createDashboard: POST /api/dashboard with payload', async () => { + const payload = { name: 'New Dashboard', collection_id: 1 }; + const created = { id: 10, ...payload }; + mockInstance.post.mockResolvedValueOnce({ data: created }); + const client = new MetabaseClient(API_KEY_CONFIG); + + expect(await client.createDashboard(payload)).toEqual(created); + expect(mockInstance.post).toHaveBeenCalledWith('/api/dashboard', payload); + }); + + it('deleteDashboard: archives by default (PUT archived:true)', async () => { + mockInstance.put.mockResolvedValueOnce({ data: {} }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.deleteDashboard(3); + + expect(mockInstance.put).toHaveBeenCalledWith('/api/dashboard/3', { archived: true }); + expect(mockInstance.delete).not.toHaveBeenCalled(); + }); + + it('deleteDashboard: hard deletes when hardDelete=true', async () => { + mockInstance.delete.mockResolvedValueOnce({ data: null }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.deleteDashboard(3, true); + + expect(mockInstance.delete).toHaveBeenCalledWith('/api/dashboard/3'); + expect(mockInstance.put).not.toHaveBeenCalled(); + }); + + it('addCardToDashboard: fetches current state then PUTs merged dashcards array', async () => { + const existingDashboard = { + id: 1, + name: 'D', + tabs: [], + dashcards: [ + { id: 10, card_id: 5, row: 0, col: 0, size_x: 6, size_y: 4, series: [], visualization_settings: {}, parameter_mappings: [] }, + ], + }; + mockInstance.get.mockResolvedValueOnce({ data: existingDashboard }); + mockInstance.put.mockResolvedValueOnce({ data: {} }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.addCardToDashboard(1, { card_id: 20, row: 1, col: 0, size_x: 12, size_y: 8 }); + + expect(mockInstance.get).toHaveBeenCalledWith('/api/dashboard/1'); + const [endpoint, body] = mockInstance.put.mock.calls[0]; + expect(endpoint).toBe('/api/dashboard/1'); + expect(body.dashcards).toHaveLength(2); + expect(body.dashcards[1]).toMatchObject({ id: -1, card_id: 20, row: 1, col: 0 }); + }); + + it('updateDashcard: merges update into matching dashcard only', async () => { + const existing = { + id: 1, + dashcards: [ + { id: 10, card_id: 5, row: 0, col: 0, size_x: 6, size_y: 4 }, + { id: 11, card_id: 6, row: 1, col: 0, size_x: 6, size_y: 4 }, + ], + }; + mockInstance.get.mockResolvedValueOnce({ data: existing }); + mockInstance.put.mockResolvedValueOnce({ data: {} }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.updateDashcard(1, 10, { size_x: 12, size_y: 8 }); + + const dashcards = mockInstance.put.mock.calls[0][1].dashcards; + expect(dashcards[0]).toMatchObject({ id: 10, size_x: 12, size_y: 8 }); + expect(dashcards[1]).toMatchObject({ id: 11, size_x: 6, size_y: 4 }); + }); + + it('updateDashcard: throws when dashcard id not found', async () => { + mockInstance.get.mockResolvedValueOnce({ data: { id: 1, dashcards: [] } }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await expect(client.updateDashcard(1, 999, {})).rejects.toThrow( + 'Dashcard with id 999 not found in dashboard 1' + ); + }); + + it('removeCardsFromDashboard: filters out specified dashcard ids', async () => { + const existing = { + id: 1, + dashcards: [ + { id: 10, card_id: 5 }, + { id: 11, card_id: 6 }, + { id: 12, card_id: 7 }, + ], + }; + mockInstance.get.mockResolvedValueOnce({ data: existing }); + mockInstance.put.mockResolvedValueOnce({ data: {} }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.removeCardsFromDashboard(1, [10, 12]); + + const dashcards = mockInstance.put.mock.calls[0][1].dashcards; + expect(dashcards).toHaveLength(1); + expect(dashcards[0].id).toBe(11); + }); + }); + + describe('card methods', () => { + it('getCard: GET /api/card/:id', async () => { + const data = { id: 7, name: 'Top Users' }; + mockInstance.get.mockResolvedValueOnce({ data }); + const client = new MetabaseClient(API_KEY_CONFIG); + + expect(await client.getCard(7)).toEqual(data); + expect(mockInstance.get).toHaveBeenCalledWith('/api/card/7'); + }); + + it('deleteCard: archives by default', async () => { + mockInstance.put.mockResolvedValueOnce({ data: {} }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.deleteCard(7); + + expect(mockInstance.put).toHaveBeenCalledWith('/api/card/7', { archived: true }); + }); + + it('deleteCard: hard deletes when hardDelete=true', async () => { + mockInstance.delete.mockResolvedValueOnce({ data: null }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.deleteCard(7, true); + + expect(mockInstance.delete).toHaveBeenCalledWith('/api/card/7'); + }); + + it('executeCard: POSTs to /api/card/:id/query with default options', async () => { + mockInstance.post.mockResolvedValueOnce({ data: { status: 'completed', data: {} } }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.executeCard(42); + + expect(mockInstance.post).toHaveBeenCalledWith('/api/card/42/query', { + ignore_cache: false, + }); + }); + + it('executeCard: includes optional fields when provided', async () => { + mockInstance.post.mockResolvedValueOnce({ data: { status: 'completed', data: {} } }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.executeCard(42, { ignore_cache: true, dashboard_id: 5 }); + + expect(mockInstance.post).toHaveBeenCalledWith('/api/card/42/query', { + ignore_cache: true, + dashboard_id: 5, + }); + }); + }); + + describe('database methods', () => { + it('executeQuery: sends correct native query structure to /api/dataset', async () => { + mockInstance.post.mockResolvedValueOnce({ data: { status: 'completed', row_count: 1, data: {} } }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.executeQuery(2, 'SELECT 1', []); + + expect(mockInstance.post).toHaveBeenCalledWith('/api/dataset', { + type: 'native', + native: { query: 'SELECT 1', template_tags: {} }, + parameters: [], + database: 2, + }); + }); + + it('executeQuery: uses empty parameters array by default', async () => { + mockInstance.post.mockResolvedValueOnce({ data: { status: 'completed', data: {} } }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.executeQuery(1, 'SELECT count(*) FROM orders'); + + const body = mockInstance.post.mock.calls[0][1]; + expect(body.parameters).toEqual([]); + }); + + it('getDatabaseSchemas: GET /api/database/:id/schemas', async () => { + const data = ['public', 'analytics']; + mockInstance.get.mockResolvedValueOnce({ data }); + const client = new MetabaseClient(API_KEY_CONFIG); + + expect(await client.getDatabaseSchemas(3)).toEqual(data); + expect(mockInstance.get).toHaveBeenCalledWith('/api/database/3/schemas'); + }); + + it('syncDatabaseSchema: POST /api/database/:id/sync_schema', async () => { + mockInstance.post.mockResolvedValueOnce({ data: { status: 'ok' } }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.syncDatabaseSchema(3); + + expect(mockInstance.post).toHaveBeenCalledWith('/api/database/3/sync_schema'); + }); + }); + + describe('collection methods', () => { + it('getCollections: GET /api/collection (non-archived by default)', async () => { + mockInstance.get.mockResolvedValueOnce({ data: [] }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.getCollections(); + + expect(mockInstance.get).toHaveBeenCalledWith('/api/collection', { params: {} }); + }); + + it('getCollections: passes archived=true when requested', async () => { + mockInstance.get.mockResolvedValueOnce({ data: [] }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.getCollections(true); + + expect(mockInstance.get).toHaveBeenCalledWith('/api/collection', { params: { archived: true } }); + }); + }); + + describe('generic apiCall', () => { + it('delegates to axiosInstance.request with correct method, url and data', async () => { + mockInstance.request.mockResolvedValueOnce({ data: { ok: true } }); + const client = new MetabaseClient(API_KEY_CONFIG); + + const result = await client.apiCall('POST', '/api/custom', { key: 'val' }); + + expect(mockInstance.request).toHaveBeenCalledWith({ + method: 'POST', + url: '/api/custom', + data: { key: 'val' }, + }); + expect(result).toEqual({ ok: true }); + }); + + it('supports GET without a body', async () => { + mockInstance.request.mockResolvedValueOnce({ data: [] }); + const client = new MetabaseClient(API_KEY_CONFIG); + + await client.apiCall('GET', '/api/custom'); + + expect(mockInstance.request).toHaveBeenCalledWith({ + method: 'GET', + url: '/api/custom', + data: undefined, + }); + }); + }); +}); diff --git a/src/client/metabase-client.ts b/src/client/metabase-client.ts index 5a9d9b9..9fd1b61 100644 --- a/src/client/metabase-client.ts +++ b/src/client/metabase-client.ts @@ -31,6 +31,13 @@ export class MetabaseClient { timeout: 30000, // 30 second timeout to prevent hanging requests }); + if (config.extraHeaders) { + for (const [key, value] of Object.entries(config.extraHeaders)) { + this.axiosInstance.defaults.headers.common[key] = value; + } + this.logInfo(`Applying ${Object.keys(config.extraHeaders).length} extra header(s).`); + } + if (config.apiKey) { this.logInfo("Using Metabase API Key for authentication."); this.axiosInstance.defaults.headers.common["X-API-Key"] = config.apiKey; diff --git a/src/server.ts b/src/server.ts index 155db37..db9e9bf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,8 @@ import { FastMCP } from "fastmcp"; import { MetabaseClient } from "./client/metabase-client.js"; -import { loadConfig, validateConfig } from "./utils/config.js"; +import { loadConfig, validateConfig, isPiiFilterEnabled } from "./utils/config.js"; +import { filterPiiFromToolResult } from "./utils/pii-filter.js"; import { addDashboardTools } from "./tools/dashboard-tools.js"; import { addDatabaseTools } from "./tools/database-tools.js"; import { addCardTools } from "./tools/card-tools.js"; @@ -12,6 +13,7 @@ import { parseToolFilterOptions } from "./utils/tool-filters.js"; // Parse command line arguments for tool filtering const filterOptions = parseToolFilterOptions(); +const piiFilterEnabled = isPiiFilterEnabled(); // Load and validate configuration const config = loadConfig(); @@ -26,7 +28,7 @@ const server = new FastMCP({ version: "2.0.1", }); -// Override addTool to apply filtering +// Override addTool to apply filtering and PII redaction const originalAddTool = server.addTool.bind(server); server.addTool = function(toolConfig: any) { const { metadata = {}, ...restConfig } = toolConfig; @@ -35,18 +37,30 @@ server.addTool = function(toolConfig: any) { // Apply filtering based on selected mode switch (filterOptions.mode) { case 'essential': - // Only load essential tools if (!isEssential) return; break; case 'write': - // Load read and write tools if (!isRead && !isWrite) return; break; case 'all': - // Load all tools - no filtering break; } + // Wrap execute with PII filter + if (restConfig.execute) { + const originalExecute = restConfig.execute; + restConfig.execute = async (...args: any[]) => { + const result = await originalExecute(...args); + if (!piiFilterEnabled || typeof result !== 'string') return result; + try { + return filterPiiFromToolResult(result); + } catch (e) { + console.error('[pii-filter] Warning: filter failed, returning original result', e); + return result; + } + }; + } + // Register the tool originalAddTool(restConfig); }; @@ -72,6 +86,7 @@ switch (filterOptions.mode) { console.error(`INFO: All tools loaded.`); break; } +console.error(`INFO: PII filter: ${piiFilterEnabled ? 'enabled' : 'disabled (METABASE_PII_FILTER=false)'}`); // Start the server server.start({ diff --git a/src/types/metabase.ts b/src/types/metabase.ts index 62eaed6..6def049 100644 --- a/src/types/metabase.ts +++ b/src/types/metabase.ts @@ -7,6 +7,7 @@ export interface MetabaseConfig { username?: string; password?: string; apiKey?: string; + extraHeaders?: Record; } export interface Dashboard { diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts new file mode 100644 index 0000000..c904ee9 --- /dev/null +++ b/src/utils/config.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { loadConfig, validateConfig, isPiiFilterEnabled } from './config.js'; + +describe('loadConfig', () => { + beforeEach(() => { + Object.keys(process.env) + .filter(k => k.startsWith('METABASE_')) + .forEach(k => delete process.env[k]); + }); + + it('throws when METABASE_URL is missing', () => { + expect(() => loadConfig()).toThrow('METABASE_URL environment variable is required'); + }); + + it('throws when URL is set but no auth provided', () => { + process.env.METABASE_URL = 'http://localhost:3000'; + expect(() => loadConfig()).toThrow( + 'Either (METABASE_URL and METABASE_API_KEY) or (METABASE_URL, METABASE_USERNAME, and METABASE_PASSWORD) environment variables are required' + ); + }); + + it('throws when only username is provided without password', () => { + process.env.METABASE_URL = 'http://localhost:3000'; + process.env.METABASE_USERNAME = 'admin'; + expect(() => loadConfig()).toThrow(); + }); + + it('loads config with API key auth', () => { + process.env.METABASE_URL = 'http://localhost:3000'; + process.env.METABASE_API_KEY = 'my-key'; + const config = loadConfig(); + expect(config.url).toBe('http://localhost:3000'); + expect(config.apiKey).toBe('my-key'); + expect(config.username).toBeUndefined(); + expect(config.password).toBeUndefined(); + }); + + it('loads config with username/password auth', () => { + process.env.METABASE_URL = 'http://localhost:3000'; + process.env.METABASE_USERNAME = 'admin'; + process.env.METABASE_PASSWORD = 'secret'; + const config = loadConfig(); + expect(config.url).toBe('http://localhost:3000'); + expect(config.username).toBe('admin'); + expect(config.password).toBe('secret'); + expect(config.apiKey).toBeUndefined(); + }); + + it('returns undefined extraHeaders when no METABASE_HEADER_* vars set', () => { + process.env.METABASE_URL = 'http://localhost:3000'; + process.env.METABASE_API_KEY = 'key'; + expect(loadConfig().extraHeaders).toBeUndefined(); + }); + + it('parses a single METABASE_HEADER_* var', () => { + process.env.METABASE_URL = 'http://localhost:3000'; + process.env.METABASE_API_KEY = 'key'; + process.env.METABASE_HEADER_AUTHORIZATION = 'Bearer token'; + expect(loadConfig().extraHeaders).toEqual({ AUTHORIZATION: 'Bearer token' }); + }); + + it('converts underscores to hyphens in header name', () => { + process.env.METABASE_URL = 'http://localhost:3000'; + process.env.METABASE_API_KEY = 'key'; + process.env.METABASE_HEADER_X_TENANT_ID = 'my-org'; + expect(loadConfig().extraHeaders).toEqual({ 'X-TENANT-ID': 'my-org' }); + }); + + it('parses multiple METABASE_HEADER_* vars', () => { + process.env.METABASE_URL = 'http://localhost:3000'; + process.env.METABASE_API_KEY = 'key'; + process.env.METABASE_HEADER_AUTHORIZATION = 'Bearer token'; + process.env.METABASE_HEADER_X_TENANT_ID = 'my-org'; + expect(loadConfig().extraHeaders).toEqual({ + AUTHORIZATION: 'Bearer token', + 'X-TENANT-ID': 'my-org', + }); + }); +}); + +describe('validateConfig', () => { + it('throws when url is empty', () => { + expect(() => validateConfig({ url: '' })).toThrow('Metabase URL is required'); + }); + + it('throws when no auth credentials provided', () => { + expect(() => validateConfig({ url: 'http://localhost:3000' })).toThrow( + 'Either API key or username/password combination is required' + ); + }); + + it('throws when URL format is invalid', () => { + expect(() => validateConfig({ url: 'not-a-url', apiKey: 'key' })).toThrow( + 'Invalid Metabase URL format' + ); + }); + + it('passes with valid API key config', () => { + expect(() => validateConfig({ url: 'http://localhost:3000', apiKey: 'key' })).not.toThrow(); + }); + + it('passes with valid username/password config', () => { + expect(() => + validateConfig({ url: 'http://localhost:3000', username: 'u', password: 'p' }) + ).not.toThrow(); + }); + + it('passes with HTTPS URL', () => { + expect(() => + validateConfig({ url: 'https://metabase.example.com', apiKey: 'key' }) + ).not.toThrow(); + }); +}); + +describe('isPiiFilterEnabled', () => { + const original = process.env.METABASE_PII_FILTER; + + afterEach(() => { + if (original === undefined) { + delete process.env.METABASE_PII_FILTER; + } else { + process.env.METABASE_PII_FILTER = original; + } + }); + + it('returns true when env var is not set', () => { + delete process.env.METABASE_PII_FILTER; + expect(isPiiFilterEnabled()).toBe(true); + }); + + it('returns true when env var is "true"', () => { + process.env.METABASE_PII_FILTER = 'true'; + expect(isPiiFilterEnabled()).toBe(true); + }); + + it('returns false when env var is "false"', () => { + process.env.METABASE_PII_FILTER = 'false'; + expect(isPiiFilterEnabled()).toBe(false); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index 20c8a14..988dc51 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -7,6 +7,22 @@ import { MetabaseConfig } from "../types/metabase.js"; /** * Load configuration from environment variables */ +const HEADER_PREFIX = "METABASE_HEADER_"; + +function loadExtraHeaders(): Record | undefined { + const headers: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith(HEADER_PREFIX) && value !== undefined) { + // METABASE_HEADER_X_CUSTOM_HEADER → X-Custom-Header + const headerName = key.slice(HEADER_PREFIX.length).replace(/_/g, "-"); + headers[headerName] = value; + } + } + + return Object.keys(headers).length > 0 ? headers : undefined; +} + export function loadConfig(): MetabaseConfig { const url = process.env.METABASE_URL; const username = process.env.METABASE_USERNAME; @@ -28,6 +44,7 @@ export function loadConfig(): MetabaseConfig { username, password, apiKey, + extraHeaders: loadExtraHeaders(), }; } @@ -52,3 +69,10 @@ export function validateConfig(config: MetabaseConfig): void { throw new Error("Invalid Metabase URL format"); } } + +/** + * Check if PII filtering is enabled + */ +export function isPiiFilterEnabled(): boolean { + return process.env.METABASE_PII_FILTER !== 'false'; +} diff --git a/src/utils/pii-filter.test.ts b/src/utils/pii-filter.test.ts new file mode 100644 index 0000000..d61deb7 --- /dev/null +++ b/src/utils/pii-filter.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { filterPii, filterPiiFromToolResult } from './pii-filter.js'; + +describe('filterPii — field name detection (Layer 1)', () => { + it('redacts value when key is "email"', () => { + expect(filterPii({ email: 'user@example.com' })).toEqual({ email: '[REDACTED]' }); + }); + + it('redacts value when key is "phone"', () => { + expect(filterPii({ phone: '+84901234567' })).toEqual({ phone: '[REDACTED]' }); + }); + + it('redacts value when key is "password"', () => { + expect(filterPii({ password: 'secret123' })).toEqual({ password: '[REDACTED]' }); + }); + + it('redacts value when key is "first_name"', () => { + expect(filterPii({ first_name: 'John' })).toEqual({ first_name: '[REDACTED]' }); + }); + + it('redacts value when key is "last_name"', () => { + expect(filterPii({ last_name: 'Doe' })).toEqual({ last_name: '[REDACTED]' }); + }); + + it('redacts value when key is "full_name"', () => { + expect(filterPii({ full_name: 'John Doe' })).toEqual({ full_name: '[REDACTED]' }); + }); + + it('redacts value when key is "address"', () => { + expect(filterPii({ address: '123 Main St' })).toEqual({ address: '[REDACTED]' }); + }); + + it('redacts value when key is "ssn"', () => { + expect(filterPii({ ssn: '123-45-6789' })).toEqual({ ssn: '[REDACTED]' }); + }); + + it('redacts value when key is "token"', () => { + expect(filterPii({ token: 'abc123xyz' })).toEqual({ token: '[REDACTED]' }); + }); + + it('redacts value when key is "credit_card"', () => { + expect(filterPii({ credit_card: '4111111111111111' })).toEqual({ credit_card: '[REDACTED]' }); + }); + + it('matches key names case-insensitively', () => { + expect(filterPii({ Email: 'x@y.com' })).toEqual({ Email: '[REDACTED]' }); + expect(filterPii({ EMAIL: 'x@y.com' })).toEqual({ EMAIL: '[REDACTED]' }); + }); + + it('does not redact safe fields', () => { + expect(filterPii({ id: 1, title: 'Revenue Q1', count: 42 })).toEqual({ + id: 1, + title: 'Revenue Q1', + count: 42, + }); + }); +}); + +describe('filterPii — value regex detection (Layer 2)', () => { + it('redacts string containing an email address', () => { + expect(filterPii({ contact: 'reach me at user@example.com' })).toEqual({ + contact: '[REDACTED]', + }); + }); + + it('redacts string that is a bare email address', () => { + expect(filterPii({ value: 'user@example.com' })).toEqual({ value: '[REDACTED]' }); + }); + + it('redacts string matching a credit card pattern', () => { + expect(filterPii({ info: '4111 1111 1111 1111' })).toEqual({ info: '[REDACTED]' }); + }); + + it('redacts string matching US SSN pattern', () => { + expect(filterPii({ ref: '123-45-6789' })).toEqual({ ref: '[REDACTED]' }); + }); + + it('redacts string matching IPv4 address', () => { + expect(filterPii({ origin: '192.168.1.1' })).toEqual({ origin: '[REDACTED]' }); + }); + + it('does not apply regex to numbers', () => { + expect(filterPii({ user_id: 1234567890 })).toEqual({ user_id: 1234567890 }); + }); + + it('does not apply regex to booleans', () => { + expect(filterPii({ active: true })).toEqual({ active: true }); + }); + + it('does not apply regex to null', () => { + expect(filterPii({ data: null })).toEqual({ data: null }); + }); +}); + +describe('filterPii — nested structures', () => { + it('recurses into nested objects', () => { + expect( + filterPii({ user: { email: 'x@y.com', age: 25 } }) + ).toEqual({ user: { email: '[REDACTED]', age: 25 } }); + }); + + it('recurses into arrays of objects', () => { + expect( + filterPii([{ email: 'john@example.com', score: 99 }, { email: 'jane@example.com', score: 87 }]) + ).toEqual([{ email: '[REDACTED]', score: 99 }, { email: '[REDACTED]', score: 87 }]); + }); + + it('recurses into mixed nested structure', () => { + const input = { + results: [ + { id: 1, email: 'a@b.com', revenue: 1000 }, + { id: 2, email: 'c@d.com', revenue: 2000 }, + ], + meta: { total: 2 }, + }; + expect(filterPii(input)).toEqual({ + results: [ + { id: 1, email: '[REDACTED]', revenue: 1000 }, + { id: 2, email: '[REDACTED]', revenue: 2000 }, + ], + meta: { total: 2 }, + }); + }); + + it('handles arrays of primitives with PII strings', () => { + expect(filterPii(['hello', 'user@example.com', 'world'])).toEqual([ + 'hello', + '[REDACTED]', + 'world', + ]); + }); +}); + +describe('filterPii — Metabase query result structure', () => { + it('redacts values in rows when column name is a PII field', () => { + const input = { + data: { + cols: [{ name: 'id' }, { name: 'email' }, { name: 'password' }], + rows: [ + [1, 'user@example.com', 'secret123'], + [2, 'other@test.com', 'hunter2'], + ], + }, + status: 'completed', + row_count: 2, + }; + const result = filterPii(input) as any; + expect(result.data.rows[0]).toEqual([1, '[REDACTED]', '[REDACTED]']); + expect(result.data.rows[1]).toEqual([2, '[REDACTED]', '[REDACTED]']); + }); + + it('preserves non-PII columns untouched', () => { + const input = { + data: { + cols: [{ name: 'id' }, { name: 'revenue' }, { name: 'region' }], + rows: [[1, 99000, 'Asia'], [2, 12000, 'EU']], + }, + status: 'completed', + }; + const result = filterPii(input) as any; + expect(result.data.rows[0]).toEqual([1, 99000, 'Asia']); + expect(result.data.rows[1]).toEqual([2, 12000, 'EU']); + }); + + it('still applies regex on non-PII-named columns if value matches', () => { + const input = { + data: { + cols: [{ name: 'info' }], + rows: [['contact: user@example.com']], + }, + status: 'completed', + }; + const result = filterPii(input) as any; + expect(result.data.rows[0]).toEqual(['[REDACTED]']); + }); + + it('preserves cols and other top-level fields unchanged', () => { + const input = { + data: { + cols: [{ name: 'id', base_type: 'type/Integer' }], + rows: [[1]], + }, + status: 'completed', + row_count: 1, + running_time: 42, + }; + const result = filterPii(input) as any; + expect(result.status).toBe('completed'); + expect(result.row_count).toBe(1); + expect(result.running_time).toBe(42); + expect(result.data.cols).toEqual([{ name: 'id', base_type: 'type/Integer' }]); + }); +}); + +describe('filterPiiFromToolResult', () => { + it('parses JSON string, filters, and re-stringifies', () => { + const input = JSON.stringify({ email: 'user@example.com', id: 1 }); + const output = filterPiiFromToolResult(input); + expect(JSON.parse(output)).toEqual({ email: '[REDACTED]', id: 1 }); + }); + + it('handles JSON array result', () => { + const input = JSON.stringify([{ email: 'a@b.com' }, { email: 'c@d.com' }]); + const output = filterPiiFromToolResult(input); + expect(JSON.parse(output)).toEqual([{ email: '[REDACTED]' }, { email: '[REDACTED]' }]); + }); + + it('applies regex directly on non-JSON string fallback', () => { + const input = 'User email: user@example.com'; + expect(filterPiiFromToolResult(input)).toBe('[REDACTED]'); + }); + + it('returns plain non-PII string unchanged', () => { + const input = 'No sensitive data here'; + expect(filterPiiFromToolResult(input)).toBe('No sensitive data here'); + }); + + it('does not throw on any valid string input', () => { + expect(() => filterPiiFromToolResult(JSON.stringify({ id: 1, title: 'safe' }))).not.toThrow(); + expect(() => filterPiiFromToolResult('plain string')).not.toThrow(); + expect(() => filterPiiFromToolResult('')).not.toThrow(); + }); +}); diff --git a/src/utils/pii-filter.ts b/src/utils/pii-filter.ts new file mode 100644 index 0000000..d039676 --- /dev/null +++ b/src/utils/pii-filter.ts @@ -0,0 +1,100 @@ +const PII_FIELD_NAMES = new Set([ + 'email', 'phone', 'mobile', 'password', 'passwd', 'secret', 'token', + 'ssn', 'credit_card', 'card_number', 'cvv', 'dob', 'date_of_birth', + 'address', 'first_name', 'last_name', 'full_name', 'national_id', + 'passport', 'ip_address', 'latitude', 'longitude', 'tfa_secret', 'db_connection' +]); + +const PII_REGEXES = [ + /[\w.+\-]+@[\w\-]+\.[a-zA-Z]{2,}/, // email + /\+?\d[\d\s\-(). ]{7,}\d/, // phone + /\b\d{3}-\d{2}-\d{4}\b/, // US SSN + /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, // credit card + /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, // IPv4 +]; + +function isPiiFieldName(key: string): boolean { + return PII_FIELD_NAMES.has(key.toLowerCase()); +} + +function matchesPiiRegex(value: string): boolean { + return PII_REGEXES.some(re => re.test(value)); +} + +function isMetabaseQueryResult(obj: Record): boolean { + const data = obj.data as Record | undefined; + return ( + data !== null && + typeof data === 'object' && + Array.isArray(data.cols) && + Array.isArray(data.rows) + ); +} + +function filterMetabaseQueryResult(obj: Record): Record { + const data = obj.data as Record; + const cols = data.cols as Array>; + const rows = data.rows as unknown[][]; + + const colNames = cols.map(c => String(c.name ?? '')); + + const filteredRows = rows.map(row => + row.map((cell, i) => { + const colName = colNames[i] ?? ''; + if (isPiiFieldName(colName)) return '[REDACTED]'; + if (typeof cell === 'string' && matchesPiiRegex(cell)) return '[REDACTED]'; + return cell; + }) + ); + + return { + ...obj, + data: { ...data, rows: filteredRows }, + }; +} + +export function filterPii(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(item => filterPii(item)); + } + + if (value !== null && typeof value === 'object') { + const obj = value as Record; + + // Special handling for Metabase query results: rows are positional arrays, + // so we match values to column names from cols[] instead of object keys. + if (isMetabaseQueryResult(obj)) { + return filterMetabaseQueryResult(obj); + } + + const result: Record = {}; + for (const [key, val] of Object.entries(obj)) { + if (isPiiFieldName(key)) { + result[key] = '[REDACTED]'; + } else { + result[key] = filterPii(val); + } + } + return result; + } + + if (typeof value === 'string' && matchesPiiRegex(value)) { + return '[REDACTED]'; + } + + return value; +} + +export function filterPiiFromToolResult(result: string): string { + try { + const parsed = JSON.parse(result); + const filtered = filterPii(parsed); + return JSON.stringify(filtered, null, 2); + } catch { + // Not valid JSON — apply regex on the raw string directly + if (typeof result === 'string' && matchesPiiRegex(result)) { + return '[REDACTED]'; + } + return result; + } +} diff --git a/src/utils/tool-filters.test.ts b/src/utils/tool-filters.test.ts new file mode 100644 index 0000000..87e3233 --- /dev/null +++ b/src/utils/tool-filters.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { parseToolFilterOptions } from './tool-filters.js'; + +describe('parseToolFilterOptions', () => { + const originalArgv = process.argv.slice(); + + afterEach(() => { + process.argv.length = 0; + process.argv.push(...originalArgv); + }); + + it('defaults to essential mode when no flags given', () => { + process.argv = ['node', 'server.js']; + expect(parseToolFilterOptions()).toEqual({ mode: 'essential' }); + }); + + it('returns essential mode with explicit --essential flag', () => { + process.argv = ['node', 'server.js', '--essential']; + expect(parseToolFilterOptions()).toEqual({ mode: 'essential' }); + }); + + it('returns all mode with --all flag', () => { + process.argv = ['node', 'server.js', '--all']; + expect(parseToolFilterOptions()).toEqual({ mode: 'all' }); + }); + + it('returns write mode with --write flag', () => { + process.argv = ['node', 'server.js', '--write']; + expect(parseToolFilterOptions()).toEqual({ mode: 'write' }); + }); + + it('--all takes priority over --write', () => { + process.argv = ['node', 'server.js', '--all', '--write']; + expect(parseToolFilterOptions()).toEqual({ mode: 'all' }); + }); + + it('--write takes priority over --essential', () => { + process.argv = ['node', 'server.js', '--write', '--essential']; + expect(parseToolFilterOptions()).toEqual({ mode: 'write' }); + }); + + it('ignores unrelated flags', () => { + process.argv = ['node', 'server.js', '--verbose', '--port=3000']; + expect(parseToolFilterOptions()).toEqual({ mode: 'essential' }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7eeb3f8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});