diff --git a/cspell.config.json b/cspell.config.json new file mode 100644 index 0000000..e8f6d4a --- /dev/null +++ b/cspell.config.json @@ -0,0 +1,16 @@ +{ + "version": "0.2", + "language": "en", + "words": [ + "bbox", + "denoise", + "isochrone", + "mapbox", + "mmss", + "tilequery" + ], + "ignorePaths": [ + "node_modules", + "dist" + ] +} diff --git a/package-lock.json b/package-lock.json index 1ace5b1..5f2a70d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-istanbul": "^3.2.4", + "cspell": "^9.2.1", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.21.3", @@ -277,6 +278,594 @@ "node": ">=6.9.0" } }, + "node_modules/@cspell/cspell-bundled-dicts": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.2.1.tgz", + "integrity": "sha512-85gHoZh3rgZ/EqrHIr1/I4OLO53fWNp6JZCqCdgaT7e3sMDaOOG6HoSxCvOnVspXNIf/1ZbfTCDMx9x79Xq0AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-ada": "^4.1.1", + "@cspell/dict-al": "^1.1.1", + "@cspell/dict-aws": "^4.0.15", + "@cspell/dict-bash": "^4.2.1", + "@cspell/dict-companies": "^3.2.5", + "@cspell/dict-cpp": "^6.0.12", + "@cspell/dict-cryptocurrencies": "^5.0.5", + "@cspell/dict-csharp": "^4.0.7", + "@cspell/dict-css": "^4.0.18", + "@cspell/dict-dart": "^2.3.1", + "@cspell/dict-data-science": "^2.0.9", + "@cspell/dict-django": "^4.1.5", + "@cspell/dict-docker": "^1.1.16", + "@cspell/dict-dotnet": "^5.0.10", + "@cspell/dict-elixir": "^4.0.8", + "@cspell/dict-en_us": "^4.4.18", + "@cspell/dict-en-common-misspellings": "^2.1.5", + "@cspell/dict-en-gb-mit": "^3.1.8", + "@cspell/dict-filetypes": "^3.0.13", + "@cspell/dict-flutter": "^1.1.1", + "@cspell/dict-fonts": "^4.0.5", + "@cspell/dict-fsharp": "^1.1.1", + "@cspell/dict-fullstack": "^3.2.7", + "@cspell/dict-gaming-terms": "^1.1.2", + "@cspell/dict-git": "^3.0.7", + "@cspell/dict-golang": "^6.0.23", + "@cspell/dict-google": "^1.0.9", + "@cspell/dict-haskell": "^4.0.6", + "@cspell/dict-html": "^4.0.12", + "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-java": "^5.0.12", + "@cspell/dict-julia": "^1.1.1", + "@cspell/dict-k8s": "^1.0.12", + "@cspell/dict-kotlin": "^1.1.1", + "@cspell/dict-latex": "^4.0.4", + "@cspell/dict-lorem-ipsum": "^4.0.5", + "@cspell/dict-lua": "^4.0.8", + "@cspell/dict-makefile": "^1.0.5", + "@cspell/dict-markdown": "^2.0.12", + "@cspell/dict-monkeyc": "^1.0.11", + "@cspell/dict-node": "^5.0.8", + "@cspell/dict-npm": "^5.2.15", + "@cspell/dict-php": "^4.0.15", + "@cspell/dict-powershell": "^5.0.15", + "@cspell/dict-public-licenses": "^2.0.15", + "@cspell/dict-python": "^4.2.19", + "@cspell/dict-r": "^2.1.1", + "@cspell/dict-ruby": "^5.0.9", + "@cspell/dict-rust": "^4.0.12", + "@cspell/dict-scala": "^5.0.8", + "@cspell/dict-shell": "^1.1.1", + "@cspell/dict-software-terms": "^5.1.7", + "@cspell/dict-sql": "^2.2.1", + "@cspell/dict-svelte": "^1.0.7", + "@cspell/dict-swift": "^2.0.6", + "@cspell/dict-terraform": "^1.1.3", + "@cspell/dict-typescript": "^3.2.3", + "@cspell/dict-vue": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-json-reporter": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.2.1.tgz", + "integrity": "sha512-LiiIWzLP9h2etKn0ap6g2+HrgOGcFEF/hp5D8ytmSL5sMxDcV13RrmJCEMTh1axGyW0SjQEFjPnYzNpCL1JjGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.2.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-pipe": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.2.1.tgz", + "integrity": "sha512-2N1H63If5cezLqKToY/YSXon4m4REg/CVTFZr040wlHRbbQMh5EF3c7tEC/ue3iKAQR4sm52ihfqo1n4X6kz+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-resolver": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.2.1.tgz", + "integrity": "sha512-fRPQ6GWU5eyh8LN1TZblc7t24TlGhJprdjJkfZ+HjQo+6ivdeBPT7pC7pew6vuMBQPS1oHBR36hE0ZnJqqkCeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-service-bus": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.2.1.tgz", + "integrity": "sha512-k4M6bqdvWbcGSbcfLD7Lf4coZVObsISDW+sm/VaWp9aZ7/uwiz1IuGUxL9WO4JIdr9CFEf7Ivmvd2txZpVOCIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-types": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.2.1.tgz", + "integrity": "sha512-FQHgQYdTHkcpxT0u1ddLIg5Cc5ePVDcLg9+b5Wgaubmc5I0tLotgYj8c/mvStWuKsuZIs6sUopjJrE91wk6Onw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/dict-ada": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", + "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-al": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz", + "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-aws": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.15.tgz", + "integrity": "sha512-aPY7VVR5Os4rz36EaqXBAEy14wR4Rqv+leCJ2Ug/Gd0IglJpM30LalF3e2eJChnjje3vWoEC0Rz3+e5gpZG+Kg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-bash": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.1.tgz", + "integrity": "sha512-SBnzfAyEAZLI9KFS7DUG6Xc1vDFuLllY3jz0WHvmxe8/4xV3ufFE3fGxalTikc1VVeZgZmxYiABw4iGxVldYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-shell": "1.1.1" + } + }, + "node_modules/@cspell/dict-companies": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.6.tgz", + "integrity": "sha512-cVWBk4DSUOthCsgOsoB+5L5F1Wk8lWGHnw5de75YCKSjOEV8/6kskwwDrPTIHkoGVzpIzIIQ/OdXhYwa2G+16A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cpp": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-6.0.12.tgz", + "integrity": "sha512-N4NsCTttVpMqQEYbf0VQwCj6np+pJESov0WieCN7R/0aByz4+MXEiDieWWisaiVi8LbKzs1mEj4ZTw5K/6O2UQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cryptocurrencies": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz", + "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-csharp": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.7.tgz", + "integrity": "sha512-H16Hpu8O/1/lgijFt2lOk4/nnldFtQ4t8QHbyqphqZZVE5aS4J/zD/WvduqnLY21aKhZS6jo/xF5PX9jyqPKUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-css": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.18.tgz", + "integrity": "sha512-EF77RqROHL+4LhMGW5NTeKqfUd/e4OOv6EDFQ/UQQiFyWuqkEKyEz0NDILxOFxWUEVdjT2GQ2cC7t12B6pESwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dart": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.1.tgz", + "integrity": "sha512-xoiGnULEcWdodXI6EwVyqpZmpOoh8RA2Xk9BNdR7DLamV/QMvEYn8KJ7NlRiTSauJKPNkHHQ5EVHRM6sTS7jdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-data-science": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.10.tgz", + "integrity": "sha512-vZSsz7845ugW6mY65966Ki2bMS/ZnAZoTVvpuXQ07a2rYxJhUC+6WuBMD80hFLlKwjC5T/5Llv4F/VlB00swpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-django": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.5.tgz", + "integrity": "sha512-AvTWu99doU3T8ifoMYOMLW2CXKvyKLukPh1auOPwFGHzueWYvBBN+OxF8wF7XwjTBMMeRleVdLh3aWCDEX/ZWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-docker": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.16.tgz", + "integrity": "sha512-UiVQ5RmCg6j0qGIxrBnai3pIB+aYKL3zaJGvXk1O/ertTKJif9RZikKXCEgqhaCYMweM4fuLqWSVmw3hU164Iw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dotnet": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.10.tgz", + "integrity": "sha512-ooar8BP/RBNP1gzYfJPStKEmpWy4uv/7JCq6FOnJLeD1yyfG3d/LFMVMwiJo+XWz025cxtkM3wuaikBWzCqkmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-elixir": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz", + "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en_us": { + "version": "4.4.20", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.20.tgz", + "integrity": "sha512-acAlX967bkrLwRhSJ8KGBCBUITMOe8+smwsShjei431vTB6tU5ZID6XDxR9hH/kDxfdiRTXAE8vkT3WJAHnc1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en-common-misspellings": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.6.tgz", + "integrity": "sha512-xV9yryOqZizbSqxRS7kSVRrxVEyWHUqwdY56IuT7eAWGyTCJNmitXzXa4p+AnEbhL+AB2WLynGVSbNoUC3ceFA==", + "dev": true, + "license": "CC BY-SA 4.0" + }, + "node_modules/@cspell/dict-en-gb-mit": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.10.tgz", + "integrity": "sha512-oFandL5N4B55wmOd0hOAoyaiUZBkClQ1FPCkcAY/HMuq6zeCQE/oEK9lLGDmnzLGgWnTT7wd0KOSYUPTxWQaNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-filetypes": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.14.tgz", + "integrity": "sha512-KSXaSMYYNMLLdHEnju1DyRRH3eQWPRYRnOXpuHUdOh2jC44VgQoxyMU7oB3NAhDhZKBPCihabzECsAGFbdKfEA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-flutter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz", + "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fonts": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.5.tgz", + "integrity": "sha512-BbpkX10DUX/xzHs6lb7yzDf/LPjwYIBJHJlUXSBXDtK/1HaeS+Wqol4Mlm2+NAgZ7ikIE5DQMViTgBUY3ezNoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fsharp": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz", + "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fullstack": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.7.tgz", + "integrity": "sha512-IxEk2YAwAJKYCUEgEeOg3QvTL4XLlyArJElFuMQevU1dPgHgzWElFevN5lsTFnvMFA1riYsVinqJJX0BanCFEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-gaming-terms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.2.tgz", + "integrity": "sha512-9XnOvaoTBscq0xuD6KTEIkk9hhdfBkkvJAIsvw3JMcnp1214OCGW8+kako5RqQ2vTZR3Tnf3pc57o7VgkM0q1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-git": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.0.7.tgz", + "integrity": "sha512-odOwVKgfxCQfiSb+nblQZc4ErXmnWEnv8XwkaI4sNJ7cNmojnvogYVeMqkXPjvfrgEcizEEA4URRD2Ms5PDk1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-golang": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.23.tgz", + "integrity": "sha512-oXqUh/9dDwcmVlfUF5bn3fYFqbUzC46lXFQmi5emB0vYsyQXdNWsqi6/yH3uE7bdRE21nP7Yo0mR1jjFNyLamg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-google": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz", + "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-haskell": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz", + "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.12.tgz", + "integrity": "sha512-JFffQ1dDVEyJq6tCDWv0r/RqkdSnV43P2F/3jJ9rwLgdsOIXwQbXrz6QDlvQLVvNSnORH9KjDtenFTGDyzfCaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html-symbol-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.4.tgz", + "integrity": "sha512-afea+0rGPDeOV9gdO06UW183Qg6wRhWVkgCFwiO3bDupAoyXRuvupbb5nUyqSTsLXIKL8u8uXQlJ9pkz07oVXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-java": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz", + "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-julia": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz", + "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-k8s": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.12.tgz", + "integrity": "sha512-2LcllTWgaTfYC7DmkMPOn9GsBWsA4DZdlun4po8s2ysTP7CPEnZc1ZfK6pZ2eI4TsZemlUQQ+NZxMe9/QutQxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-kotlin": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz", + "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-latex": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-4.0.4.tgz", + "integrity": "sha512-YdTQhnTINEEm/LZgTzr9Voz4mzdOXH7YX+bSFs3hnkUHCUUtX/mhKgf1CFvZ0YNM2afjhQcmLaR9bDQVyYBvpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lorem-ipsum": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz", + "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lua": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz", + "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-makefile": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz", + "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-markdown": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.12.tgz", + "integrity": "sha512-ufwoliPijAgWkD/ivAMC+A9QD895xKiJRF/fwwknQb7kt7NozTLKFAOBtXGPJAB4UjhGBpYEJVo2elQ0FCAH9A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cspell/dict-css": "^4.0.18", + "@cspell/dict-html": "^4.0.12", + "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-typescript": "^3.2.3" + } + }, + "node_modules/@cspell/dict-monkeyc": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.0.11.tgz", + "integrity": "sha512-7Q1Ncu0urALI6dPTrEbSTd//UK0qjRBeaxhnm8uY5fgYNFYAG+u4gtnTIo59S6Bw5P++4H3DiIDYoQdY/lha8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-node": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.8.tgz", + "integrity": "sha512-AirZcN2i84ynev3p2/1NCPEhnNsHKMz9zciTngGoqpdItUb2bDt1nJBjwlsrFI78GZRph/VaqTVFwYikmncpXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-npm": { + "version": "5.2.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.18.tgz", + "integrity": "sha512-uJV1T7y9ifFysO22XmxjU7y95c+02lfCZHNsTYHw2KOL6tLjc3XK/i0xt9iGLkPpcxwNJSCdu13UpjXZGqce/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-php": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.0.15.tgz", + "integrity": "sha512-iepGB2gtToMWSTvybesn4/lUp4LwXcEm0s8vasJLP76WWVkq1zYjmeS+WAIzNgsuURyZ/9mGqhS0CWMuo74ODw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-powershell": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz", + "integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-public-licenses": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.15.tgz", + "integrity": "sha512-cJEOs901H13Pfy0fl4dCD1U+xpWIMaEPq8MeYU83FfDZvellAuSo4GqWCripfIqlhns/L6+UZEIJSOZnjgy7Wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-python": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.20.tgz", + "integrity": "sha512-c1wbfb3MDMSY4UTNdGnA18NkrcX6cMlYER0HSpGYh2jLK43gS1QL3j2B49qgnRYfcLUp4xgeA05vzCQsjGbwuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-data-science": "^2.0.10" + } + }, + "node_modules/@cspell/dict-r": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz", + "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-ruby": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.0.9.tgz", + "integrity": "sha512-H2vMcERMcANvQshAdrVx0XoWaNX8zmmiQN11dZZTQAZaNJ0xatdJoSqY8C8uhEMW89bfgpN+NQgGuDXW2vmXEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-rust": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.0.12.tgz", + "integrity": "sha512-z2QiH+q9UlNhobBJArvILRxV8Jz0pKIK7gqu4TgmEYyjiu1TvnGZ1tbYHeu9w3I/wOP6UMDoCBTty5AlYfW0mw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-scala": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.8.tgz", + "integrity": "sha512-YdftVmumv8IZq9zu1gn2U7A4bfM2yj9Vaupydotyjuc+EEZZSqAafTpvW/jKLWji2TgybM1L2IhmV0s/Iv9BTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-shell": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.1.1.tgz", + "integrity": "sha512-T37oYxE7OV1x/1D4/13Y8JZGa1QgDCXV7AVt3HLXjn0Fe3TaNDvf5sU0fGnXKmBPqFFrHdpD3uutAQb1dlp15g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-software-terms": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.9.tgz", + "integrity": "sha512-lpiSpS1iTF2n8barqVkPmhe5qXs5291IqcDUPr5ttFRxPMZ7pgrMUdvcdNUdkajymjDOyWfUNhdYXW7JndThZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-sql": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz", + "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-svelte": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz", + "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-swift": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz", + "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-terraform": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.3.tgz", + "integrity": "sha512-gr6wxCydwSFyyBKhBA2xkENXtVFToheqYYGFvlMZXWjviynXmh+NK/JTvTCk/VHk3+lzbO9EEQKee6VjrAUSbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-typescript": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", + "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-vue": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz", + "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dynamic-import": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.2.1.tgz", + "integrity": "sha512-izYQbk7ck0ffNA1gf7Gi3PkUEjj+crbYeyNK1hxHx5A+GuR416ozs0aEyp995KI2v9HZlXscOj3SC3wrWzHZeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.2.1", + "import-meta-resolve": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/filetypes": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.2.1.tgz", + "integrity": "sha512-Dy1y1pQ+7hi2gPs+jERczVkACtYbUHcLodXDrzpipoxgOtVxMcyZuo+84WYHImfu0gtM0wU2uLObaVgMSTnytw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/strong-weak-map": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.2.1.tgz", + "integrity": "sha512-1HsQWZexvJSjDocVnbeAWjjgqWE/0op/txxzDPvDqI2sE6pY0oO4Cinj2I8z+IP+m6/E6yjPxdb23ydbQbPpJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/url": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.2.1.tgz", + "integrity": "sha512-9EHCoGKtisPNsEdBQ28tKxKeBmiVS3D4j+AN8Yjr+Dmtu+YACKGWiMOddNZG2VejQNIdFx7FwzU00BGX68ELhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -2154,6 +2743,13 @@ "node": ">=0.10.0" } }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2422,6 +3018,35 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.2.tgz", + "integrity": "sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/change-case": { "version": "4.1.2", "dev": true, @@ -2519,6 +3144,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clear-module": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz", + "integrity": "sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^2.0.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clear-module/node_modules/parent-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-2.0.0.tgz", + "integrity": "sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clear-module/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "dev": true, @@ -2650,6 +3315,21 @@ "node": ">=20" } }, + "node_modules/comment-json": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.4.1.tgz", + "integrity": "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -2701,6 +3381,13 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "license": "MIT", @@ -2724,6 +3411,225 @@ "node": ">= 8" } }, + "node_modules/cspell": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.2.1.tgz", + "integrity": "sha512-PoKGKE9Tl87Sn/jwO4jvH7nTqe5Xrsz2DeJT5CkulY7SoL2fmsAqfbImQOFS2S0s36qD98t6VO+Ig2elEEcHew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-json-reporter": "9.2.1", + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-types": "9.2.1", + "@cspell/dynamic-import": "9.2.1", + "@cspell/url": "9.2.1", + "chalk": "^5.6.0", + "chalk-template": "^1.1.0", + "commander": "^14.0.0", + "cspell-config-lib": "9.2.1", + "cspell-dictionary": "9.2.1", + "cspell-gitignore": "9.2.1", + "cspell-glob": "9.2.1", + "cspell-io": "9.2.1", + "cspell-lib": "9.2.1", + "fast-json-stable-stringify": "^2.1.0", + "flatted": "^3.3.3", + "semver": "^7.7.2", + "tinyglobby": "^0.2.14" + }, + "bin": { + "cspell": "bin.mjs", + "cspell-esm": "bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" + } + }, + "node_modules/cspell-config-lib": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.2.1.tgz", + "integrity": "sha512-qqhaWW+0Ilc7493lXAlXjziCyeEmQbmPMc1XSJw2EWZmzb+hDvLdFGHoX18QU67yzBtu5hgQsJDEDZKvVDTsRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.2.1", + "comment-json": "^4.2.5", + "smol-toml": "^1.4.2", + "yaml": "^2.8.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-dictionary": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.2.1.tgz", + "integrity": "sha512-0hQVFySPsoJ0fONmDPwCWGSG6SGj4ERolWdx4t42fzg5zMs+VYGXpQW4BJneQ5Tfxy98Wx8kPhmh/9E8uYzLTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-types": "9.2.1", + "cspell-trie-lib": "9.2.1", + "fast-equals": "^5.2.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-gitignore": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.2.1.tgz", + "integrity": "sha512-WPnDh03gXZoSqVyXq4L7t9ljx6lTDvkiSRUudb125egEK5e9s04csrQpLI3Yxcnc1wQA2nzDr5rX9XQVvCHf7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.2.1", + "cspell-glob": "9.2.1", + "cspell-io": "9.2.1" + }, + "bin": { + "cspell-gitignore": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-glob": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.2.1.tgz", + "integrity": "sha512-CrT/6ld3rXhB36yWFjrx1SrMQzwDrGOLr+wYEnrWI719/LTYWWCiMFW7H+qhsJDTsR+ku8+OAmfRNBDXvh9mnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.2.1", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-glob/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/cspell-grammar": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.2.1.tgz", + "integrity": "sha512-10RGFG7ZTQPdwyW2vJyfmC1t8813y8QYRlVZ8jRHWzer9NV8QWrGnL83F+gTPXiKR/lqiW8WHmFlXR4/YMV+JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-types": "9.2.1" + }, + "bin": { + "cspell-grammar": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-io": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.2.1.tgz", + "integrity": "sha512-v9uWXtRzB+RF/Mzg5qMzpb8/yt+1bwtTt2rZftkLDLrx5ybVvy6rhRQK05gFWHmWVtWEe0P/pIxaG2Vz92C8Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-service-bus": "9.2.1", + "@cspell/url": "9.2.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-lib": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.2.1.tgz", + "integrity": "sha512-KeB6NHcO0g1knWa7sIuDippC3gian0rC48cvO0B0B0QwhOxNxWVp8cSmkycXjk4ijBZNa++IwFjeK/iEqMdahQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-bundled-dicts": "9.2.1", + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-resolver": "9.2.1", + "@cspell/cspell-types": "9.2.1", + "@cspell/dynamic-import": "9.2.1", + "@cspell/filetypes": "9.2.1", + "@cspell/strong-weak-map": "9.2.1", + "@cspell/url": "9.2.1", + "clear-module": "^4.1.2", + "comment-json": "^4.2.5", + "cspell-config-lib": "9.2.1", + "cspell-dictionary": "9.2.1", + "cspell-glob": "9.2.1", + "cspell-grammar": "9.2.1", + "cspell-io": "9.2.1", + "cspell-trie-lib": "9.2.1", + "env-paths": "^3.0.0", + "fast-equals": "^5.2.2", + "gensequence": "^7.0.0", + "import-fresh": "^3.3.1", + "resolve-from": "^5.0.0", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-lib/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cspell-trie-lib": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.2.1.tgz", + "integrity": "sha512-qOtbL+/tUzGFHH0Uq2wi7sdB9iTy66QNx85P7DKeRdX9ZH53uQd7qC4nEk+/JPclx1EgXX26svxr0jTGISJhLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-types": "9.2.1", + "gensequence": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/debug": { "version": "4.4.1", "license": "MIT", @@ -2886,6 +3792,19 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/environment": { "version": "1.1.0", "dev": true, @@ -3295,6 +4214,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "dev": true, @@ -3486,6 +4419,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-equals": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz", + "integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "dev": true, @@ -3722,6 +4665,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensequence": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-7.0.0.tgz", + "integrity": "sha512-47Frx13aZh01afHJTB3zTtKIlFI6vWY+MYCN9Qpew6i52rfKjnhCF/l1YlC8UmEMvvntZZ6z4PiCcmyuedR2aQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -3837,6 +4790,32 @@ "node": "*" } }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/global-modules": { "version": "1.0.0", "dev": true, @@ -4113,6 +5092,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "dev": true, @@ -6416,6 +7406,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smol-toml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/snake-case": { "version": "3.0.4", "dev": true, @@ -7412,6 +8415,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", @@ -7496,13 +8513,28 @@ "version": "1.0.2", "license": "ISC" }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.0", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index c213241..2f5e32d 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,18 @@ "mapbox-mcp-devkit": "dist/esm/index.js" }, "scripts": { - "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\"", - "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" --fix", - "fix-lint": "npm run lint:fix && npm run format:fix", + "build": "npm run prepare && tshy && npm run generate-version && node scripts/add-shebang.cjs", "format": "prettier --check \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\"", "format:fix": "prettier --write \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\"", - "prepare": "husky && node .husky/setup-hooks.js", - "test": "vitest", - "build": "npm run prepare && npm run sync-manifest && tshy && npm run generate-version && node scripts/build-helpers.cjs copy-json && node scripts/add-shebang.cjs", "generate-version": "node scripts/build-helpers.cjs generate-version", + "inspect:build": "npm run build && npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" node dist/esm/index.js", + "inspect:dev": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" npx -y tsx src/index.ts", + "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\"", + "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" --fix", + "prepare": "husky && node .husky/setup-hooks.js", + "spellcheck": "cspell \"*.md\" \"src/**/*.ts\" \"test/**/*.ts\"", "sync-manifest": "node scripts/sync-manifest-version.cjs", - "dev": "tsc -p tsconfig.json --watch", - "dev:inspect": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_PRIVATE_TOKEN\" npx -y tsx src/index.ts" + "test": "vitest" }, "lint-staged": { "*.{js,jsx,ts,tsx}": "eslint --fix", @@ -52,6 +52,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-istanbul": "^3.2.4", + "cspell": "^9.2.1", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.21.3", diff --git a/src/index.ts b/src/index.ts index 728000a..d38e577 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { parseToolConfigFromArgs, filterTools } from './config/toolConfig.js'; diff --git a/src/schemas/style.ts b/src/schemas/style.ts new file mode 100644 index 0000000..88fcb0e --- /dev/null +++ b/src/schemas/style.ts @@ -0,0 +1,223 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Basic types +const ColorSchema = z + .string() + .describe('Color as hex, rgb, rgba, hsl, or hsla'); +const CoordinatesSchema = z.tuple([z.number(), z.number()]); + +// Transition schema +const TransitionSchema = z.object({ + duration: z.number().optional(), + delay: z.number().optional() +}); + +// Light schema +const LightSchema = z.object({ + anchor: z.enum(['map', 'viewport']).optional(), + position: z.tuple([z.number(), z.number(), z.number()]).optional(), + color: ColorSchema.optional(), + intensity: z.number().optional() +}); + +// Lights (3D) schema +const LightsSchema = z.array( + z.object({ + id: z.string(), + type: z.enum(['ambient', 'directional']), + properties: z.record(z.any()).optional() + }) +); + +// Terrain schema +const TerrainSchema = z.object({ + source: z.string(), + exaggeration: z.number().optional() +}); + +// Source schemas +const VectorSourceSchema = z.object({ + type: z.literal('vector'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + scheme: z.enum(['xyz', 'tms']).optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + attribution: z.string().optional(), + promoteId: z.union([z.string(), z.record(z.string())]).optional(), + volatile: z.boolean().optional() +}); + +const RasterSourceSchema = z.object({ + type: z.literal('raster'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + tileSize: z.number().optional(), + scheme: z.enum(['xyz', 'tms']).optional(), + attribution: z.string().optional(), + volatile: z.boolean().optional() +}); + +const RasterDemSourceSchema = z.object({ + type: z.literal('raster-dem'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + tileSize: z.number().optional(), + attribution: z.string().optional(), + encoding: z.enum(['terrarium', 'mapbox']).optional(), + volatile: z.boolean().optional() +}); + +const GeoJSONSourceSchema = z.object({ + type: z.literal('geojson'), + data: z.union([z.string(), z.any()]), // URL or inline GeoJSON + maxzoom: z.number().min(0).max(24).optional(), + attribution: z.string().optional(), + buffer: z.number().min(0).max(512).optional(), + tolerance: z.number().optional(), + cluster: z.boolean().optional(), + clusterRadius: z.number().optional(), + clusterMaxZoom: z.number().optional(), + clusterProperties: z.record(z.any()).optional(), + lineMetrics: z.boolean().optional(), + generateId: z.boolean().optional(), + promoteId: z.union([z.string(), z.record(z.string())]).optional() +}); + +const ImageSourceSchema = z.object({ + type: z.literal('image'), + url: z.string(), + coordinates: z.tuple([ + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema + ]) +}); + +const VideoSourceSchema = z.object({ + type: z.literal('video'), + urls: z.array(z.string()), + coordinates: z.tuple([ + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema + ]) +}); + +const SourceSchema = z.union([ + VectorSourceSchema, + RasterSourceSchema, + RasterDemSourceSchema, + GeoJSONSourceSchema, + ImageSourceSchema, + VideoSourceSchema +]); + +// Layer schema (simplified - full schema would be very extensive) +const LayerSchema = z.object({ + id: z.string().describe('Unique layer name'), + type: z.enum([ + 'fill', + 'line', + 'symbol', + 'circle', + 'heatmap', + 'fill-extrusion', + 'raster', + 'hillshade', + 'background', + 'sky', + 'slot', + 'clip', + 'model', + 'raster-particle', + 'building' + ]), + source: z + .string() + .optional() + .describe('Source name (not required for background/sky/slot)'), + 'source-layer': z + .string() + .optional() + .describe('Layer from vector tile source'), + minzoom: z.number().min(0).max(24).optional(), + maxzoom: z.number().min(0).max(24).optional(), + filter: z.any().optional().describe('Expression for filtering features'), + layout: z.record(z.any()).optional(), + paint: z.record(z.any()).optional(), + metadata: z.record(z.any()).optional(), + slot: z.string().optional().describe('Slot this layer is assigned to') +}); + +// Style import schema +const StyleImportSchema = z.object({ + id: z.string(), + url: z.string(), + config: z.record(z.any()).optional() +}); + +// Base Style properties (shared between input and output) +export const BaseStylePropertiesSchema = z.object({ + // Required Style Spec properties + version: z + .literal(8) + .describe('Style specification version number. Must be 8'), + sources: z.record(SourceSchema).describe('Data source specifications'), + layers: z.array(LayerSchema).describe('Layers in draw order'), + + // Optional Style Spec properties + metadata: z + .record(z.any()) + .optional() + .describe('Arbitrary properties for tracking'), + center: CoordinatesSchema.optional().describe( + 'Default map center [longitude, latitude]' + ), + zoom: z.number().optional().describe('Default zoom level'), + bearing: z.number().optional().describe('Default bearing in degrees'), + pitch: z.number().optional().describe('Default pitch in degrees'), + sprite: z + .string() + .optional() + .describe('Base URL for sprite image and metadata'), + glyphs: z.string().optional().describe('URL template for glyph sets'), + light: LightSchema.optional() + .nullable() + .describe('Global light source (deprecated, use lights)'), + lights: LightsSchema.optional() + .nullable() + .describe('Array of 3D light sources'), + terrain: TerrainSchema.optional() + .nullable() + .describe('Global terrain elevation'), + fog: z.record(z.any()).optional().nullable().describe('Fog properties'), + projection: z + .record(z.any()) + .optional() + .nullable() + .describe('Map projection'), + transition: TransitionSchema.optional() + .nullable() + .describe('Default transition timing'), + imports: z + .array(StyleImportSchema) + .optional() + .nullable() + .describe('Imported styles') +}); + +export type MapboxSource = z.infer; +export type MapboxLayer = z.infer; diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index bea2c64..d1d499b 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -1,102 +1,62 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; -import { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolResult, + ToolAnnotations +} from '@modelcontextprotocol/sdk/types.js'; import { z, ZodTypeAny } from 'zod'; -const ContentItemSchema = z.union([ - z.object({ - type: z.literal('text'), - text: z.string() - }), - z.object({ - type: z.literal('image'), - data: z.string(), - mimeType: z.string() - }) -]); - -export const OutputSchema = z.object({ - content: z.array(ContentItemSchema), - isError: z.boolean().default(false) -}); - -export type ContentItem = z.infer; -export type ToolOutput = z.infer; - -export abstract class BaseTool { +export abstract class BaseTool< + InputSchema extends ZodTypeAny, + OutputSchema extends ZodTypeAny = ZodTypeAny +> { abstract readonly name: string; abstract readonly description: string; abstract readonly annotations: ToolAnnotations; readonly inputSchema: InputSchema; + readonly outputSchema?: OutputSchema; protected server: McpServer | null = null; - constructor(params: { inputSchema: InputSchema }) { + constructor(params: { + inputSchema: InputSchema; + outputSchema?: OutputSchema; + }) { this.inputSchema = params.inputSchema; + this.outputSchema = params.outputSchema; } /** - * Validates and runs the tool logic. + * Tool logic to be implemented by subclasses. */ async run( rawInput: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any extra?: RequestHandlerExtra - ): Promise> { + ): Promise { try { const input = this.inputSchema.parse(rawInput); const accessToken = extra?.authInfo?.token || process.env.MAPBOX_ACCESS_TOKEN; - const result = await this.execute(input, accessToken); - - // Check if result is already a content object (image or text) - if ( - result && - typeof result === 'object' && - 'type' in result && - (result.type === 'image' || result.type === 'text') - ) { - return { - content: [result as ContentItem], - isError: false - }; - } - - // Otherwise return as text - return { - content: [{ type: 'text', text: JSON.stringify(result) }], - isError: false - }; + return this.execute(input, accessToken); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - this.log( - 'error', - `${this.name}: Error during execution: ${errorMessage}` - ); - return { - content: [ - { - type: 'text', - text: errorMessage || 'Internal error has occurred.' - } - ], - isError: true + isError: true, + content: [{ type: 'text', text: (error as Error).message }] }; } } - /** - * Tool logic to be implemented by subclasses. - */ protected abstract execute( - _input: z.infer, + inputSchema: z.infer, accessToken?: string - ): Promise; + ): Promise; /** * Installs the tool to the given MCP server. @@ -104,18 +64,33 @@ export abstract class BaseTool { installTo(server: McpServer): RegisteredTool { this.server = server; - return server.registerTool( - this.name, - { - description: this.description, - inputSchema: ( - this.inputSchema as unknown as z.ZodObject< - Record - > - ).shape, - annotations: this.annotations - }, - (args: any, extra: any) => this.run(args, extra) + const config: { + title?: string; + description?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputSchema?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputSchema?: any; + annotations?: ToolAnnotations; + } = { + title: this.annotations.title, + description: this.description, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputSchema: (this.inputSchema as unknown as z.ZodObject).shape, + annotations: this.annotations + }; + + // Add outputSchema if provided + if (this.outputSchema) { + // Pass the schema shape directly - don't wrap + // The MCP SDK will validate structuredContent against this schema + config.outputSchema = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.outputSchema as unknown as z.ZodObject).shape; + } + + return server.registerTool(this.name, config, (args, extra) => + this.run(args, extra) ); } diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 2f1a16a..8bd19e1 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -1,10 +1,31 @@ -import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; -import { z, ZodTypeAny } from 'zod'; -import { BaseTool, OutputSchema } from './BaseTool.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { ZodTypeAny, z } from 'zod'; +import { BaseTool } from './BaseTool.js'; +import type { + CallToolResult, + ToolAnnotations +} from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../utils/types.js'; + +/** + * Standard error response format from Mapbox API + */ +interface MapboxApiError { + message?: string; + [key: string]: unknown; +} export abstract class MapboxApiBasedTool< - InputSchema extends ZodTypeAny -> extends BaseTool { + InputSchema extends ZodTypeAny, + OutputSchema extends ZodTypeAny = ZodTypeAny +> extends BaseTool { + abstract readonly name: string; + abstract readonly description: string; + abstract readonly annotations: ToolAnnotations; + static get mapboxAccessToken() { return process.env.MAPBOX_ACCESS_TOKEN; } @@ -13,51 +34,15 @@ export abstract class MapboxApiBasedTool< return process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'; } - constructor(params: { inputSchema: InputSchema }) { - super(params); - } + protected httpRequest: HttpRequest; - /** - * Extracts the username from the Mapbox access token. - * Mapbox tokens are JWT tokens where the payload contains the username. - * @throws Error if the token is not set, invalid, or doesn't contain username - */ - static getUserNameFromToken(access_token?: string): string { - if (!access_token) { - if (!MapboxApiBasedTool.mapboxAccessToken) { - throw new Error( - 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' - ); - } - access_token = MapboxApiBasedTool.mapboxAccessToken; - } - - try { - // JWT format: header.payload.signature - const parts = access_token.split('.'); - if (parts.length !== 3) { - throw new Error('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); - } - - // Decode the payload (second part) - const payload = JSON.parse( - Buffer.from(parts[1], 'base64').toString('utf-8') - ); - - // The username is stored in the 'u' field - if (!payload.u) { - throw new Error( - 'MAPBOX_ACCESS_TOKEN does not contain username in payload' - ); - } - - return payload.u; - } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error('Failed to parse MAPBOX_ACCESS_TOKEN'); - } + constructor(params: { + inputSchema: InputSchema; + outputSchema?: OutputSchema; + httpRequest: HttpRequest; + }) { + super(params); + this.httpRequest = params.httpRequest; } /** @@ -76,12 +61,13 @@ export abstract class MapboxApiBasedTool< } /** - * Validates Mapbox token and runs the tool logic. + * Validates and runs the tool logic. */ async run( rawInput: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any extra?: RequestHandlerExtra - ): Promise> { + ): Promise { try { // First check if token is provided via authentication context // Check both standard token field and accessToken in extra for compatibility @@ -90,18 +76,28 @@ export abstract class MapboxApiBasedTool< const authToken = extra?.authInfo?.token; const accessToken = authToken || MapboxApiBasedTool.mapboxAccessToken; if (!accessToken) { - throw new Error( - 'No access token available. Please provide via Bearer auth or MAPBOX_ACCESS_TOKEN env var' - ); + const errorMessage = + 'No access token available. Please provide via Bearer auth or MAPBOX_ACCESS_TOKEN env var'; + this.log('error', `${this.name}: ${errorMessage}`); + return { + content: [{ type: 'text', text: errorMessage }], + isError: true + }; } // Validate that the token has the correct JWT format if (!this.isValidJwtFormat(accessToken)) { - throw new Error('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); + const errorMessage = 'Access token is not in valid JWT format'; + this.log('error', `${this.name}: ${errorMessage}`); + return { + content: [{ type: 'text', text: errorMessage }], + isError: true + }; } - // Call parent run method which handles the rest - return await super.run(rawInput, extra); + const input = this.inputSchema.parse(rawInput); + const result = await this.execute(input, accessToken); + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -115,11 +111,78 @@ export abstract class MapboxApiBasedTool< content: [ { type: 'text', - text: errorMessage || 'Internal error has occurred.' + text: errorMessage } ], isError: true }; } } + + /** + * Handles HTTP error responses from Mapbox API. + * Attempts to parse the error response body to extract helpful messages. + * + * @param response - The failed HTTP response + * @param operation - Description of the operation that failed (e.g., "list styles", "create token") + * @returns A CallToolResult with error details + */ + protected async handleApiError( + response: Response, + operation: string + ): Promise { + let errorMessage = `Failed to ${operation}: ${response.status} ${response.statusText}`; + + try { + // Try to parse the response as JSON to get more details + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const errorData = (await response.json()) as MapboxApiError; + + // Mapbox API typically returns { "message": "..." } for errors + if (errorData.message) { + errorMessage = `Failed to ${operation}: ${errorData.message}`; + + // Check if it's a scope/permission error + if ( + errorData.message.toLowerCase().includes('scope') || + errorData.message.toLowerCase().includes('permission') + ) { + errorMessage += + '\n\nThis operation requires a token with appropriate scopes. Please check your MAPBOX_ACCESS_TOKEN has the necessary permissions.'; + } + } + } else { + // If not JSON, try to get text + const errorText = await response.text(); + if (errorText) { + errorMessage += `\n${errorText}`; + } + } + } catch (parseError) { + // If we can't parse the error body, just use the basic message + this.log('warning', `Failed to parse error response: ${parseError}`); + } + + this.log('error', `${this.name}: ${errorMessage}`); + + return { + content: [ + { + type: 'text', + text: errorMessage + } + ], + isError: true + }; + } + + /** + * Tool logic to be implemented by subclasses. + * Must return a complete OutputSchema with content and optional structured content. + */ + protected abstract execute( + _input: z.infer, + accessToken: string + ): Promise; } diff --git a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap deleted file mode 100644 index 204a4e5..0000000 --- a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ /dev/null @@ -1,86 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Tool Naming Convention should maintain consistent tool list (snapshot test) 1`] = ` -[ - { - "className": "BoundingBoxTool", - "description": "Calculates bounding box of given GeoJSON content, returns as [minX, minY, maxX, maxY]", - "toolName": "bounding_box_tool", - }, - { - "className": "CoordinateConversionTool", - "description": "Converts coordinates between WGS84 (longitude/latitude) and EPSG:3857 (Web Mercator) coordinate systems", - "toolName": "coordinate_conversion_tool", - }, - { - "className": "CountryBoundingBoxTool", - "description": "Gets bounding box for a country by its ISO 3166-1 country code, returns as [minX, minY, maxX, maxY].", - "toolName": "country_bounding_box_tool", - }, - { - "className": "CreateStyleTool", - "description": "Create a new Mapbox style", - "toolName": "create_style_tool", - }, - { - "className": "CreateTokenTool", - "description": "Create a new Mapbox public access token with specified scopes and optional URL restrictions.", - "toolName": "create_token_tool", - }, - { - "className": "DeleteStyleTool", - "description": "Delete a Mapbox style by ID", - "toolName": "delete_style_tool", - }, - { - "className": "GeojsonPreviewTool", - "description": "Generate a geojson.io URL to visualize GeoJSON data. Returns only the URL link.", - "toolName": "geojson_preview_tool", - }, - { - "className": "GetMapboxDocSourceTool", - "description": "Get the latest official Mapbox documentation, APIs, SDKs, and developer resources directly from Mapbox. Always up-to-date, comprehensive coverage of all current Mapbox services including mapping, navigation, search, geocoding, and mobile SDKs. Use this for accurate, official Mapbox information instead of web search.", - "toolName": "get_latest_mapbox_docs_tool", - }, - { - "className": "ListStylesTool", - "description": "List styles for a Mapbox account. Use limit parameter to avoid large responses (recommended: limit=5-10). Use start parameter for pagination.", - "toolName": "list_styles_tool", - }, - { - "className": "ListTokensTool", - "description": "List Mapbox access tokens for the authenticated user with optional filtering and pagination. When using pagination, the "start" parameter must be obtained from the "next_start" field of the previous response (it is not a token ID)", - "toolName": "list_tokens_tool", - }, - { - "className": "PreviewStyleTool", - "description": "Generate preview URL for a Mapbox style using an existing public token", - "toolName": "preview_style_tool", - }, - { - "className": "RetrieveStyleTool", - "description": "Retrieve a specific Mapbox style by ID", - "toolName": "retrieve_style_tool", - }, - { - "className": "StyleComparisonTool", - "description": "Generate a comparison URL for comparing two Mapbox styles side-by-side", - "toolName": "style_comparison_tool", - }, - { - "className": "StyleHelperTool", - "description": "Interactive helper for creating custom Mapbox styles with specific features and colors", - "toolName": "style_helper_tool", - }, - { - "className": "TilequeryTool", - "description": "Query vector and raster data from Mapbox tilesets at geographic coordinates", - "toolName": "tilequery_tool", - }, - { - "className": "UpdateStyleTool", - "description": "Update an existing Mapbox style", - "toolName": "update_style_tool", - }, -] -`; diff --git a/src/tools/bounding-box-tool/BoundariesData-cjs.cts b/src/tools/bounding-box-tool/BoundariesData-cjs.cts index 732bed9..19cae41 100644 --- a/src/tools/bounding-box-tool/BoundariesData-cjs.cts +++ b/src/tools/bounding-box-tool/BoundariesData-cjs.cts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import boundariesData from './boundaries_v4_country_bbox_min.json'; export default boundariesData; diff --git a/src/tools/bounding-box-tool/BoundariesData.ts b/src/tools/bounding-box-tool/BoundariesData.ts index 1a5ba6a..e64d0c9 100644 --- a/src/tools/bounding-box-tool/BoundariesData.ts +++ b/src/tools/bounding-box-tool/BoundariesData.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import boundariesData from './boundaries_v4_country_bbox_min.json' with { type: 'json' }; diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.schema.ts b/src/tools/bounding-box-tool/BoundingBoxTool.input.schema.ts similarity index 91% rename from src/tools/bounding-box-tool/BoundingBoxTool.schema.ts rename to src/tools/bounding-box-tool/BoundingBoxTool.input.schema.ts index 03cd826..d986f32 100644 --- a/src/tools/bounding-box-tool/BoundingBoxTool.schema.ts +++ b/src/tools/bounding-box-tool/BoundingBoxTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; // Define a loose GeoJSON schema that accepts any valid GeoJSON structure diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.output.schema.ts b/src/tools/bounding-box-tool/BoundingBoxTool.output.schema.ts new file mode 100644 index 0000000..c8616b5 --- /dev/null +++ b/src/tools/bounding-box-tool/BoundingBoxTool.output.schema.ts @@ -0,0 +1,18 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const BoundingBoxOutputSchema = z.object({ + bbox: z + .tuple([ + z.number().describe('minX (west longitude)'), + z.number().describe('minY (south latitude)'), + z.number().describe('maxX (east longitude)'), + z.number().describe('maxY (north latitude)') + ]) + .describe('Bounding box as [minX, minY, maxX, maxY]'), + message: z.string().optional().describe('Status or error message') +}); + +export type BoundingBoxOutput = z.infer; diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.ts b/src/tools/bounding-box-tool/BoundingBoxTool.ts index 2cbaa3c..b5d9900 100644 --- a/src/tools/bounding-box-tool/BoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/BoundingBoxTool.ts @@ -1,3 +1,7 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { GeoJSON, Feature, @@ -10,9 +14,13 @@ import { BaseTool } from '../BaseTool.js'; import { BoundingBoxSchema, BoundingBoxInput -} from './BoundingBoxTool.schema.js'; +} from './BoundingBoxTool.input.schema.js'; +import { BoundingBoxOutputSchema } from './BoundingBoxTool.output.schema.js'; -export class BoundingBoxTool extends BaseTool { +export class BoundingBoxTool extends BaseTool< + typeof BoundingBoxSchema, + typeof BoundingBoxOutputSchema +> { readonly name = 'bounding_box_tool'; readonly description = 'Calculates bounding box of given GeoJSON content, returns as [minX, minY, maxX, maxY]'; @@ -25,26 +33,49 @@ export class BoundingBoxTool extends BaseTool { }; constructor() { - super({ inputSchema: BoundingBoxSchema }); + super({ + inputSchema: BoundingBoxSchema, + outputSchema: BoundingBoxOutputSchema + }); } - protected async execute( - input: BoundingBoxInput - ): Promise<{ type: 'text'; text: string }> { + protected async execute(input: BoundingBoxInput): Promise { const { geojson } = input; - // Parse GeoJSON if it's a string - const geojsonObject = - typeof geojson === 'string' - ? (JSON.parse(geojson) as GeoJSON) - : (geojson as GeoJSON); - // Calculate bounding box - const bbox = this.calculateBoundingBox(geojsonObject); + let bbox; + try { + // Parse GeoJSON if it's a string + const geojsonObject = + typeof geojson === 'string' + ? (JSON.parse(geojson) as GeoJSON) + : (geojson as GeoJSON); + + bbox = this.calculateBoundingBox(geojsonObject); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error calculating bounding box: ${(error as Error).message}` + } + ], + structuredContent: { + error: (error as Error).message + }, + isError: true + }; + } return { - type: 'text', - text: JSON.stringify(bbox, null, 2) + content: [ + { + type: 'text', + text: JSON.stringify({ bbox }, null, 2) + } + ], + structuredContent: { bbox }, + isError: false }; } @@ -131,13 +162,4 @@ export class BoundingBoxTool extends BaseTool { return [minX, minY, maxX, maxY]; } - - private isPosition(coords: unknown): coords is Position { - return ( - Array.isArray(coords) && - coords.length >= 2 && - typeof coords[0] === 'number' && - typeof coords[1] === 'number' - ); - } } diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.schema.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.input.schema.ts similarity index 82% rename from src/tools/bounding-box-tool/CountryBoundingBoxTool.schema.ts rename to src/tools/bounding-box-tool/CountryBoundingBoxTool.input.schema.ts index 4d13641..2e1b3ef 100644 --- a/src/tools/bounding-box-tool/CountryBoundingBoxTool.schema.ts +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const CountryBoundingBoxSchema = z.object({ diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.output.schema.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.output.schema.ts new file mode 100644 index 0000000..e503c00 --- /dev/null +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.output.schema.ts @@ -0,0 +1,20 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const CountryBoundingBoxOutputSchema = z.object({ + bbox: z + .tuple([ + z.number().describe('minX (west longitude)'), + z.number().describe('minY (south latitude)'), + z.number().describe('maxX (east longitude)'), + z.number().describe('maxY (north latitude)') + ]) + .describe('Bounding box as [minX, minY, maxX, maxY]'), + message: z.string().optional().describe('Status or error message') +}); + +export type CountryBoundingBoxOutput = z.infer< + typeof CountryBoundingBoxOutputSchema +>; diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts index e834169..57c2dc6 100644 --- a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts @@ -1,12 +1,18 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { BaseTool } from '../BaseTool.js'; import { CountryBoundingBoxSchema, CountryBoundingBoxInput -} from './CountryBoundingBoxTool.schema.js'; +} from './CountryBoundingBoxTool.input.schema.js'; +import { CountryBoundingBoxOutputSchema } from './CountryBoundingBoxTool.output.schema.js'; import boundariesData from './BoundariesData.js'; export class CountryBoundingBoxTool extends BaseTool< - typeof CountryBoundingBoxSchema + typeof CountryBoundingBoxSchema, + typeof CountryBoundingBoxOutputSchema > { readonly name = 'country_bounding_box_tool'; readonly description = @@ -26,25 +32,40 @@ export class CountryBoundingBoxTool extends BaseTool< >; constructor() { - super({ inputSchema: CountryBoundingBoxSchema }); + super({ + inputSchema: CountryBoundingBoxSchema, + outputSchema: CountryBoundingBoxOutputSchema + }); } protected async execute( input: CountryBoundingBoxInput - ): Promise<{ type: 'text'; text: string }> { + ): Promise { const { iso_3166_1 } = input; const upperCaseCode = iso_3166_1.toUpperCase(); const bbox = this.boundariesData[upperCaseCode]; if (!bbox) { - throw new Error( - `Country code "${iso_3166_1}" not found. Please use a valid ISO 3166-1 country code (e.g., "CN", "US", "AE").` - ); + return { + content: [ + { + type: 'text', + text: `Country code "${iso_3166_1}" not found. Please use a valid ISO 3166-1 country code (e.g., "CN", "US", "AE").` + } + ], + isError: true + }; } return { - type: 'text', - text: JSON.stringify(bbox, null, 2) + content: [ + { + type: 'text', + text: JSON.stringify({ bbox }, null, 2) + } + ], + structuredContent: { bbox }, + isError: false }; } diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.schema.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.input.schema.ts similarity index 90% rename from src/tools/coordinate-conversion-tool/CoordinateConversionTool.schema.ts rename to src/tools/coordinate-conversion-tool/CoordinateConversionTool.input.schema.ts index 7ab9b66..1e3255f 100644 --- a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.schema.ts +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const CoordinateConversionSchema = z.object({ diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.output.schema.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.output.schema.ts new file mode 100644 index 0000000..aa1d99d --- /dev/null +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.output.schema.ts @@ -0,0 +1,16 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const CoordinateConversionOutputSchema = z.object({ + input: z.array(z.number()).length(2).describe('Input coordinates'), + output: z.array(z.number()).length(2).describe('Converted coordinates'), + from: z.enum(['wgs84', 'epsg3857']).describe('Source coordinate system'), + to: z.enum(['wgs84', 'epsg3857']).describe('Target coordinate system'), + message: z.string().optional().describe('Conversion status message') +}); + +export type CoordinateConversionOutput = z.infer< + typeof CoordinateConversionOutputSchema +>; diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts index aab9c64..c14f5a0 100644 --- a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts @@ -1,11 +1,20 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { BaseTool } from '../BaseTool.js'; +import { + CoordinateConversionOutput, + CoordinateConversionOutputSchema +} from './CoordinateConversionTool.output.schema.js'; import { CoordinateConversionSchema, CoordinateConversionInput -} from './CoordinateConversionTool.schema.js'; +} from './CoordinateConversionTool.input.schema.js'; export class CoordinateConversionTool extends BaseTool< - typeof CoordinateConversionSchema + typeof CoordinateConversionSchema, + typeof CoordinateConversionOutputSchema > { readonly name = 'coordinate_conversion_tool'; readonly description = @@ -19,53 +28,90 @@ export class CoordinateConversionTool extends BaseTool< }; constructor() { - super({ inputSchema: CoordinateConversionSchema }); + super({ + inputSchema: CoordinateConversionSchema, + outputSchema: CoordinateConversionOutputSchema + }); } protected async execute( input: CoordinateConversionInput - ): Promise<{ type: 'text'; text: string }> { + ): Promise { const { coordinates, from, to } = input; if (from === to) { + const outputResult: CoordinateConversionOutput = { + input: coordinates, + output: coordinates, + from, + to, + message: 'No conversion needed - source and target are the same' + }; + return { - type: 'text', - text: JSON.stringify( + content: [ { - input: coordinates, - output: coordinates, - from, - to, - message: 'No conversion needed - source and target are the same' - }, - null, - 2 - ) + type: 'text', + text: JSON.stringify(outputResult, null, 2) + } + ], + isError: false, + structuredContent: outputResult }; } let result: [number, number]; - if (from === 'wgs84' && to === 'epsg3857') { - result = this.wgs84ToEpsg3857(coordinates[0], coordinates[1]); - } else if (from === 'epsg3857' && to === 'wgs84') { - result = this.epsg3857ToWgs84(coordinates[0], coordinates[1]); - } else { - throw new Error(`Unsupported conversion: ${from} to ${to}`); + const method = + from === 'wgs84' && to === 'epsg3857' + ? this.wgs84ToEpsg3857.bind(this) + : from === 'epsg3857' && to === 'wgs84' + ? this.epsg3857ToWgs84.bind(this) + : undefined; + + if (!method) { + return { + content: [ + { + type: 'text', + text: `Unsupported conversion: ${from} to ${to}` + } + ], + isError: true + }; } + try { + result = method(coordinates[0], coordinates[1]); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error during conversion: ${(error as Error).message}` + } + ], + isError: true + }; + } + + const outputResult: CoordinateConversionOutput = { + input: coordinates, + output: result, + from, + to, + message: 'Conversion successful' + }; + return { - type: 'text', - text: JSON.stringify( + content: [ { - input: coordinates, - output: result, - from, - to - }, - null, - 2 - ) + type: 'text', + text: JSON.stringify(outputResult, null, 2) + } + ], + isError: false, + structuredContent: outputResult }; } diff --git a/src/tools/create-style-tool/CreateStyleTool.input.schema.ts b/src/tools/create-style-tool/CreateStyleTool.input.schema.ts new file mode 100644 index 0000000..a3d5bce --- /dev/null +++ b/src/tools/create-style-tool/CreateStyleTool.input.schema.ts @@ -0,0 +1,17 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// INPUT Schema - For creating/updating styles (PATCH/POST request body) +export const MapboxStyleInputSchema = BaseStylePropertiesSchema.extend({ + name: z + .string() + .describe('Human-readable name for the style (REQUIRED for updates)') + // These fields should NOT be included in input - they're read-only + // If present, they'll be ignored or cause API errors +}).passthrough(); + +// Type exports +export type MapboxStyleInput = z.infer; diff --git a/src/tools/create-style-tool/CreateStyleTool.output.schema.ts b/src/tools/create-style-tool/CreateStyleTool.output.schema.ts new file mode 100644 index 0000000..91a2602 --- /dev/null +++ b/src/tools/create-style-tool/CreateStyleTool.output.schema.ts @@ -0,0 +1,30 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// OUTPUT Schema - For API responses (GET/PATCH response) +// TODO: Refactor into shared schema +export const MapboxStyleOutputSchema = BaseStylePropertiesSchema.extend({ + name: z.string().describe('Human-readable name for the style'), + + // API-specific properties (only present in responses) + id: z.string().describe('Unique style identifier'), + owner: z.string().describe('Username of the style owner'), + created: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was created'), + modified: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was last modified'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), + draft: z.boolean().optional().describe('Whether this is a draft version') +}).passthrough(); + +// Type exports +export type MapboxStyleOutput = z.infer; diff --git a/src/tools/create-style-tool/CreateStyleTool.schema.ts b/src/tools/create-style-tool/CreateStyleTool.schema.ts deleted file mode 100644 index 2e5c606..0000000 --- a/src/tools/create-style-tool/CreateStyleTool.schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod'; - -export const CreateStyleSchema = z.object({ - name: z.string().describe('Name for the new style'), - style: z.record(z.any()).describe('Mapbox style specification object') -}); - -export type CreateStyleInput = z.infer; diff --git a/src/tools/create-style-tool/CreateStyleTool.ts b/src/tools/create-style-tool/CreateStyleTool.ts index f1dbdb7..edf0f90 100644 --- a/src/tools/create-style-tool/CreateStyleTool.ts +++ b/src/tools/create-style-tool/CreateStyleTool.ts @@ -1,13 +1,23 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { filterExpandedMapboxStyles } from '../../utils/styleUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { - CreateStyleSchema, - CreateStyleInput -} from './CreateStyleTool.schema.js'; + MapboxStyleInputSchema, + MapboxStyleInput +} from './CreateStyleTool.input.schema.js'; +import { + MapboxStyleOutput, + MapboxStyleOutputSchema +} from './CreateStyleTool.output.schema.js'; export class CreateStyleTool extends MapboxApiBasedTool< - typeof CreateStyleSchema + typeof MapboxStyleInputSchema, + typeof MapboxStyleOutputSchema > { name = 'create_style_tool'; description = 'Create a new Mapbox style'; @@ -19,38 +29,58 @@ export class CreateStyleTool extends MapboxApiBasedTool< title: 'Create Mapbox Style Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: CreateStyleSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: MapboxStyleInputSchema, + outputSchema: MapboxStyleOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( - input: CreateStyleInput, + input: MapboxStyleInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}?access_token=${accessToken}`; - const payload = { - name: input.name, - ...input.style - }; - - const response = await this.fetch(url, { + const response = await this.httpRequest(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(input) }); if (!response.ok) { - throw new Error( - `Failed to create style: ${response.status} ${response.statusText}` + return this.handleApiError(response, 'create style'); + } + + const rawData = await response.json(); + // Validate response against schema with graceful fallback + let data: MapboxStyleOutput; + try { + data = MapboxStyleOutputSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` ); + // Graceful fallback to raw data + data = rawData as MapboxStyleOutput; } - const data = await response.json(); - // Return full style but filter out expanded Mapbox styles - return filterExpandedMapboxStyles(data); + this.log('info', `CreateStyleTool: Successfully created style ${data.id}`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + } + ], + structuredContent: filterExpandedMapboxStyles(data), + isError: false + }; } } diff --git a/src/tools/create-token-tool/CreateTokenTool.schema.ts b/src/tools/create-token-tool/CreateTokenTool.input.schema.ts similarity index 93% rename from src/tools/create-token-tool/CreateTokenTool.schema.ts rename to src/tools/create-token-tool/CreateTokenTool.input.schema.ts index 5e58d92..5ab815a 100644 --- a/src/tools/create-token-tool/CreateTokenTool.schema.ts +++ b/src/tools/create-token-tool/CreateTokenTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; // Valid scopes for public tokens diff --git a/src/tools/create-token-tool/CreateTokenTool.output.schema.ts b/src/tools/create-token-tool/CreateTokenTool.output.schema.ts new file mode 100644 index 0000000..370d0f4 --- /dev/null +++ b/src/tools/create-token-tool/CreateTokenTool.output.schema.ts @@ -0,0 +1,31 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const CreateTokenOutputSchema = z.object({ + id: z.string().describe("The token's unique identifier"), + usage: z + .enum(['pk', 'sk', 'tk']) + .describe('Token usage type: pk (public), sk (secret), or tk (temporary)'), + client: z.string().describe('The client for the token'), + default: z.boolean().describe('Whether this is the default token'), + scopes: z.array(z.string()).describe('Array of scopes granted to the token'), + note: z + .string() + .nullable() + .describe('Human-readable description of the token'), + created: z.string().describe('ISO 8601 creation timestamp'), + modified: z.string().describe('ISO 8601 last modified timestamp'), + allowedUrls: z + .array(z.string()) + .optional() + .describe('URLs that the token is restricted to'), + token: z.string().describe('The actual access token string'), + expires: z + .string() + .optional() + .describe('Expiration time in ISO 8601 format (temporary tokens only)') +}); + +export type CreateTokenOutput = z.infer; diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index d242550..a32b47b 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -1,12 +1,19 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { CreateTokenSchema, CreateTokenInput -} from './CreateTokenTool.schema.js'; +} from './CreateTokenTool.input.schema.js'; +import { CreateTokenOutputSchema } from './CreateTokenTool.output.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class CreateTokenTool extends MapboxApiBasedTool< - typeof CreateTokenSchema + typeof CreateTokenSchema, + typeof CreateTokenOutputSchema > { readonly name = 'create_token_tool'; readonly description = @@ -19,15 +26,19 @@ export class CreateTokenTool extends MapboxApiBasedTool< title: 'Create Mapbox Token Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: CreateTokenSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: CreateTokenSchema, + outputSchema: CreateTokenOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: CreateTokenInput, accessToken?: string - ): Promise<{ type: 'text'; text: string }> { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); this.log( 'info', @@ -48,7 +59,15 @@ export class CreateTokenTool extends MapboxApiBasedTool< if (input.allowedUrls) { if (input.allowedUrls.length > 100) { - throw new Error('Maximum 100 allowed URLs per token'); + return { + content: [ + { + type: 'text', + text: 'Maximum 100 allowed URLs per token' + } + ], + isError: true + }; } body.allowedUrls = input.allowedUrls; } @@ -57,38 +76,46 @@ export class CreateTokenTool extends MapboxApiBasedTool< body.expires = input.expires; } - try { - const response = await this.fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - const errorBody = await response.text(); - this.log( - 'error', - `CreateTokenTool: API Error - Status: ${response.status}, Body: ${errorBody}` - ); - throw new Error( - `Failed to create token: ${response.status} ${response.statusText}` - ); - } + const response = await this.httpRequest(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); - const data = await response.json(); - this.log('info', `CreateTokenTool: Successfully created token`); + if (!response.ok) { + return this.handleApiError(response, 'create token'); + } + const data = await response.json(); + const parseResult = CreateTokenOutputSchema.safeParse(data); + if (!parseResult.success) { + this.log( + 'error', + `CreateTokenTool: Output schema validation failed\n${parseResult.error}` + ); return { - type: 'text', - text: JSON.stringify(data, null, 2) + content: [ + { + type: 'text', + text: `CreateTokenTool: Response does not conform to output schema:\n${parseResult.error}` + } + ], + isError: true }; - } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error(`Failed to create token: ${String(error)}`); } + this.log('info', `CreateTokenTool: Successfully created token`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(parseResult.data, null, 2) + } + ], + structuredContent: parseResult.data, + isError: false + }; } } diff --git a/src/tools/delete-style-tool/DeleteStyleTool.schema.ts b/src/tools/delete-style-tool/DeleteStyleTool.input.schema.ts similarity index 100% rename from src/tools/delete-style-tool/DeleteStyleTool.schema.ts rename to src/tools/delete-style-tool/DeleteStyleTool.input.schema.ts diff --git a/src/tools/delete-style-tool/DeleteStyleTool.ts b/src/tools/delete-style-tool/DeleteStyleTool.ts index 1be3a4c..1595373 100644 --- a/src/tools/delete-style-tool/DeleteStyleTool.ts +++ b/src/tools/delete-style-tool/DeleteStyleTool.ts @@ -1,9 +1,14 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { DeleteStyleSchema, DeleteStyleInput -} from './DeleteStyleTool.schema.js'; +} from './DeleteStyleTool.input.schema.js'; export class DeleteStyleTool extends MapboxApiBasedTool< typeof DeleteStyleSchema @@ -18,33 +23,33 @@ export class DeleteStyleTool extends MapboxApiBasedTool< title: 'Delete Mapbox Style Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: DeleteStyleSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ inputSchema: DeleteStyleSchema, httpRequest: params.httpRequest }); } protected async execute( input: DeleteStyleInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; - const response = await this.fetch(url, { + const response = await this.httpRequest(url, { method: 'DELETE' }); - if (!response.ok) { - throw new Error( - `Failed to delete style: ${response.status} ${response.statusText}` - ); - } - - // Delete typically returns 204 No Content - if (response.status === 204) { - return { success: true, message: 'Style deleted successfully' }; + if (response.status !== 204) { + return this.handleApiError(response, 'delete style'); } - const data = await response.json(); - return data; + return { + content: [ + { + type: 'text', + text: 'Style deleted successfully' + } + ], + isError: false + }; } } diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.schema.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.input.schema.ts similarity index 87% rename from src/tools/geojson-preview-tool/GeojsonPreviewTool.schema.ts rename to src/tools/geojson-preview-tool/GeojsonPreviewTool.input.schema.ts index 45a19ee..fb8d27e 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.schema.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; // Simplified GeoJSON schema for maximum MCP client compatibility diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index cc573cd..6d1e640 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -1,9 +1,13 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { GeoJSON } from 'geojson'; import { BaseTool } from '../BaseTool.js'; import { GeojsonPreviewSchema, GeojsonPreviewInput -} from './GeojsonPreviewTool.schema.js'; +} from './GeojsonPreviewTool.input.schema.js'; export class GeojsonPreviewTool extends BaseTool { name = 'geojson_preview_tool'; @@ -45,16 +49,22 @@ export class GeojsonPreviewTool extends BaseTool { ); } - protected async execute( - input: GeojsonPreviewInput - ): Promise<{ type: 'text'; text: string }> { + protected async execute(input: GeojsonPreviewInput): Promise { try { // Parse and validate JSON format const geojsonData = JSON.parse(input.geojson); // Validate GeoJSON structure if (!this.isValidGeoJSON(geojsonData)) { - throw new Error('Invalid GeoJSON structure'); + return { + isError: true, + content: [ + { + type: 'text', + text: 'GeoJSON processing failed: Invalid GeoJSON structure' + } + ] + }; } // Generate geojson.io URL @@ -63,13 +73,26 @@ export class GeojsonPreviewTool extends BaseTool { const geojsonIOUrl = `https://geojson.io/#data=data:application/json,${encodedGeoJSON}`; return { - type: 'text', - text: geojsonIOUrl + isError: false, + content: [ + { + type: 'text', + text: geojsonIOUrl + } + ] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - throw new Error(`GeoJSON processing failed: ${errorMessage}`); + return { + isError: true, + content: [ + { + type: 'text', + text: `GeoJSON processing failed: ${errorMessage}` + } + ] + }; } } } diff --git a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.schema.ts b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.input.schema.ts similarity index 70% rename from src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.schema.ts rename to src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.input.schema.ts index ccc29ef..7701ebd 100644 --- a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.schema.ts +++ b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const GetMapboxDocSourceSchema = z.object({}); diff --git a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts index e1df7f2..ad7cb00 100644 --- a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts +++ b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts @@ -1,9 +1,13 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { BaseTool } from '../BaseTool.js'; import { GetMapboxDocSourceSchema, GetMapboxDocSourceInput -} from './GetMapboxDocSourceTool.schema.js'; +} from './GetMapboxDocSourceTool.input.schema.js'; export class GetMapboxDocSourceTool extends BaseTool< typeof GetMapboxDocSourceSchema @@ -19,31 +23,59 @@ export class GetMapboxDocSourceTool extends BaseTool< title: 'Get Mapbox Documentation Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: GetMapboxDocSourceSchema }); + private httpRequest: HttpRequest; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: GetMapboxDocSourceSchema + }); + this.httpRequest = params.httpRequest; } protected async execute( // eslint-disable-next-line @typescript-eslint/no-unused-vars _input: GetMapboxDocSourceInput - ): Promise<{ type: 'text'; text: string }> { + ): Promise { try { - const response = await this.fetch('https://docs.mapbox.com/llms.txt'); + const response = await this.httpRequest( + 'https://docs.mapbox.com/llms.txt' + ); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + return { + content: [ + { + type: 'text', + text: `Failed to fetch Mapbox documentation: ${response.statusText}` + } + ], + isError: true + }; } const content = await response.text(); return { - type: 'text', - text: content + content: [ + { + type: 'text', + text: content + } + ], + isError: false }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - throw new Error(`Failed to fetch Mapbox documentation: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Failed to fetch Mapbox documentation: ${errorMessage}` + } + ], + isError: true + }; } } } diff --git a/src/tools/list-styles-tool/ListStylesTool.schema.ts b/src/tools/list-styles-tool/ListStylesTool.input.schema.ts similarity index 87% rename from src/tools/list-styles-tool/ListStylesTool.schema.ts rename to src/tools/list-styles-tool/ListStylesTool.input.schema.ts index e2715ba..adf6e12 100644 --- a/src/tools/list-styles-tool/ListStylesTool.schema.ts +++ b/src/tools/list-styles-tool/ListStylesTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const ListStylesSchema = z.object({ diff --git a/src/tools/list-styles-tool/ListStylesTool.output.schema.ts b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts new file mode 100644 index 0000000..3c73b6d --- /dev/null +++ b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts @@ -0,0 +1,50 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** + * Schema for style metadata returned by the list styles endpoint. + * Note: This is different from a full style specification - it contains + * metadata about the style but may not include all style properties like layers. + */ +const StyleMetadataSchema = z.object({ + // Core metadata fields always present + id: z.string().describe('Unique style ID'), + name: z.string().describe('Style name'), + owner: z.string().describe('Username of the style owner'), + created: z.string().describe('ISO 8601 timestamp of creation'), + modified: z.string().describe('ISO 8601 timestamp of last modification'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), + + // Optional Style Spec fields that may be included + version: z.literal(8).optional().describe('Style specification version'), + center: z + .tuple([z.number(), z.number()]) + .optional() + .describe('Default center [longitude, latitude]'), + zoom: z.number().optional().describe('Default zoom level'), + bearing: z.number().optional().describe('Default bearing in degrees'), + pitch: z.number().optional().describe('Default pitch in degrees'), + + // Sources and layers may or may not be included in list responses + sources: z.record(z.any()).optional().describe('Style data sources'), + layers: z.array(z.any()).optional().describe('Style layers'), + + // Additional metadata fields + protected: z.boolean().optional().describe('Whether style is protected'), + draft: z.boolean().optional().describe('Whether style is a draft') +}); + +// API returns an array of styles +const StylesArraySchema = z.array(StyleMetadataSchema); + +// But structuredContent wraps it in an object +export const ListStylesOutputSchema = z.object({ + styles: StylesArraySchema +}); + +export type ListStylesOutput = z.infer; +export { StylesArraySchema }; diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index d0d1b52..a32e6c2 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -1,9 +1,22 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { ListStylesSchema, ListStylesInput } from './ListStylesTool.schema.js'; +import { + ListStylesSchema, + ListStylesInput +} from './ListStylesTool.input.schema.js'; +import { + ListStylesOutputSchema, + StylesArraySchema +} from './ListStylesTool.output.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class ListStylesTool extends MapboxApiBasedTool< - typeof ListStylesSchema + typeof ListStylesSchema, + typeof ListStylesOutputSchema > { name = 'list_styles_tool'; description = @@ -16,15 +29,19 @@ export class ListStylesTool extends MapboxApiBasedTool< title: 'List Mapbox Styles Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: ListStylesSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: ListStylesSchema, + outputSchema: ListStylesOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: ListStylesInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); // Build query parameters const params = new URLSearchParams(); @@ -43,15 +60,42 @@ export class ListStylesTool extends MapboxApiBasedTool< const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}?${params.toString()}`; - const response = await this.fetch(url); + const response = await this.httpRequest(url); if (!response.ok) { - throw new Error( - `Failed to list styles: ${response.status} ${response.statusText}` - ); + return this.handleApiError(response, 'list styles'); } const data = await response.json(); - return data; + // Validate the API response (which is an array) + const parseResult = StylesArraySchema.safeParse(data); + if (!parseResult.success) { + this.log( + 'error', + `ListStylesTool: Output schema validation failed\n${parseResult.error}` + ); + return { + content: [ + { + type: 'text' as const, + text: `ListStylesTool: Response does not conform to output schema:\n${parseResult.error}` + } + ], + isError: true + }; + } + this.log('info', `ListStylesTool: Successfully listed styles`); + + const wrappedData = { styles: parseResult.data }; + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(wrappedData, null, 2) + } + ], + structuredContent: wrappedData, + isError: false + }; } } diff --git a/src/tools/list-tokens-tool/ListTokensTool.schema.ts b/src/tools/list-tokens-tool/ListTokensTool.input.schema.ts similarity index 100% rename from src/tools/list-tokens-tool/ListTokensTool.schema.ts rename to src/tools/list-tokens-tool/ListTokensTool.input.schema.ts diff --git a/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts b/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts new file mode 100644 index 0000000..15c5568 --- /dev/null +++ b/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts @@ -0,0 +1,43 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const TokenObjectSchema = z.object({ + id: z.string().describe("The token's unique identifier"), + usage: z + .enum(['pk', 'sk', 'tk']) + .describe('Token usage type: pk (public), sk (secret), or tk (temporary)'), + client: z.string().describe('The client for the token'), + default: z.boolean().describe('Whether this is the default token'), + scopes: z.array(z.string()).describe('Array of scopes granted to the token'), + note: z + .string() + .nullable() + .describe('Human-readable description of the token'), + created: z.string().describe('ISO 8601 creation timestamp'), + modified: z.string().describe('ISO 8601 last modified timestamp'), + allowedUrls: z + .array(z.string()) + .optional() + .describe('URLs that the token is restricted to'), + token: z + .string() + .optional() + .describe( + 'The actual access token string (omitted for secret tokens in list responses)' + ), + expires: z + .string() + .optional() + .describe('Expiration time in ISO 8601 format (temporary tokens only)') +}); + +export const ListTokensOutputSchema = z.object({ + tokens: z.array(TokenObjectSchema), + count: z.number().describe('Total number of tokens returned'), + next_start: z.string().optional().describe('Pagination token for next page') +}); + +export type ListTokensOutput = z.infer; +export type TokenObject = z.infer; diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index 5f8d6b2..7a84d5d 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -1,9 +1,22 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { ListTokensSchema, ListTokensInput } from './ListTokensTool.schema.js'; +import { + ListTokensSchema, + ListTokensInput +} from './ListTokensTool.input.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import { + ListTokensOutputSchema, + TokenObjectSchema +} from './ListTokensTool.output.schema.js'; export class ListTokensTool extends MapboxApiBasedTool< - typeof ListTokensSchema + typeof ListTokensSchema, + typeof ListTokensOutputSchema > { readonly name = 'list_tokens_tool'; readonly description = @@ -16,19 +29,44 @@ export class ListTokensTool extends MapboxApiBasedTool< title: 'List Mapbox Tokens Tool' }; - constructor(private fetchImpl: typeof fetch = fetchClient) { - super({ inputSchema: ListTokensSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: ListTokensSchema, + outputSchema: ListTokensOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: ListTokensInput, accessToken?: string - ): Promise<{ type: 'text'; text: string }> { + ): Promise { if (!accessToken) { - throw new Error('MAPBOX_ACCESS_TOKEN is not set'); + return { + isError: true, + content: [ + { + type: 'text', + text: 'MAPBOX_ACCESS_TOKEN is not set' + } + ] + }; } - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + let userName; + try { + userName = getUserNameFromToken(accessToken); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Invalid access token: ${(error as Error).message}` + } + ] + }; + } this.log( 'info', @@ -56,7 +94,7 @@ export class ListTokensTool extends MapboxApiBasedTool< } let url: string | null = - `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${username}?${params.toString()}`; + `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${userName}?${params.toString()}`; const allTokens: unknown[] = []; let pageCount = 0; let nextPageUrl: string | null = null; @@ -72,7 +110,7 @@ export class ListTokensTool extends MapboxApiBasedTool< this.log('info', `ListTokensTool: Fetching page ${pageCount}`); this.log('debug', `ListTokensTool: Fetching URL: ${url}`); - const response = await this.fetchImpl(url, { + const response = await this.httpRequest(url, { method: 'GET', headers: { 'Content-Type': 'application/json' @@ -80,14 +118,7 @@ export class ListTokensTool extends MapboxApiBasedTool< }); if (!response.ok) { - const errorBody = await response.text(); - this.log( - 'error', - `ListTokensTool: API Error - Status: ${response.status}, Body: ${errorBody}` - ); - throw new Error( - `Failed to list tokens: ${response.status} ${response.statusText}` - ); + return this.handleApiError(response, 'list tokens'); } const data = await response.json(); @@ -97,7 +128,24 @@ export class ListTokensTool extends MapboxApiBasedTool< ? data : (data as { tokens?: unknown[] }).tokens || []; - allTokens.push(...tokens); + // Validate tokens array against TokenObjectSchema + const parseResult = TokenObjectSchema.array().safeParse(tokens); + if (!parseResult.success) { + this.log( + 'error', + `ListTokensTool: Token array schema validation failed\n${parseResult.error}` + ); + return { + isError: true, + content: [ + { + type: 'text', + text: `ListTokensTool: Response does not conform to token array schema:\n${parseResult.error}` + } + ] + }; + } + allTokens.push(...parseResult.data); this.log( 'info', `ListTokensTool: Retrieved ${tokens.length} tokens on page ${pageCount}` @@ -153,14 +201,26 @@ export class ListTokensTool extends MapboxApiBasedTool< } return { - type: 'text', - text: JSON.stringify(result, null, 2) + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ], + structuredContent: result, + isError: false }; } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error(`Failed to list tokens: ${String(error)}`); + this.log('error', `ListTokensTool: Unexpected error: ${error}`); + return { + isError: true, + content: [ + { + type: 'text', + text: `ListTokensTool: Unexpected error: ${error}` + } + ] + }; } } diff --git a/src/tools/preview-style-tool/PreviewStyleTool.schema.ts b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts similarity index 100% rename from src/tools/preview-style-tool/PreviewStyleTool.schema.ts rename to src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index 852ed6c..bb0adc8 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -1,9 +1,11 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { BaseTool } from '../BaseTool.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { PreviewStyleSchema, PreviewStyleInput -} from './PreviewStyleTool.schema.js'; +} from './PreviewStyleTool.input.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class PreviewStyleTool extends BaseTool { readonly name = 'preview_style_tool'; @@ -21,10 +23,21 @@ export class PreviewStyleTool extends BaseTool { super({ inputSchema: PreviewStyleSchema }); } - protected async execute( - input: PreviewStyleInput - ): Promise<{ type: 'text'; text: string }> { - const username = MapboxApiBasedTool.getUserNameFromToken(input.accessToken); + protected async execute(input: PreviewStyleInput): Promise { + let userName: string; + try { + userName = getUserNameFromToken(input.accessToken); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: error instanceof Error ? error.message : String(error) + } + ] + }; + } // Use the user-provided public token const publicToken = input.accessToken; @@ -48,11 +61,16 @@ export class PreviewStyleTool extends BaseTool { const hashFragment = hashParams.length > 0 ? `#${hashParams.join('/')}` : ''; - const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}.html?${params.toString()}${hashFragment}`; + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${userName}/${input.styleId}.html?${params.toString()}${hashFragment}`; return { - type: 'text', - text: url + content: [ + { + type: 'text', + text: url + } + ], + isError: false }; } } diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.schema.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.ts similarity index 100% rename from src/tools/retrieve-style-tool/RetrieveStyleTool.schema.ts rename to src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.ts diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts new file mode 100644 index 0000000..dc5f0ce --- /dev/null +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts @@ -0,0 +1,34 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// OUTPUT Schema - For API responses (GET/PATCH response) +// TODO: Refactor into shared schema +export const MapboxStyleOutputSchema = BaseStylePropertiesSchema.extend({ + name: z.string().describe('Human-readable name for the style'), + + // API-specific properties (only present in responses) + id: z.string().describe('Unique style identifier'), + owner: z.string().describe('Username of the style owner'), + created: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was created'), + modified: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was last modified'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), + protected: z + .boolean() + .optional() + .describe('Whether style is protected from modifications'), + draft: z.boolean().optional().describe('Whether this is a draft version') +}); + +// Type exports +export type MapboxStyleOutput = z.infer; diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts index a8167da..d8454aa 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts @@ -1,13 +1,23 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { filterExpandedMapboxStyles } from '../../utils/styleUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { RetrieveStyleSchema, RetrieveStyleInput -} from './RetrieveStyleTool.schema.js'; +} from './RetrieveStyleTool.input.schema.js'; +import { HttpRequest } from '../../utils/types.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { + MapboxStyleOutput, + MapboxStyleOutputSchema +} from './RetrieveStyleTool.output.schema.js'; export class RetrieveStyleTool extends MapboxApiBasedTool< - typeof RetrieveStyleSchema + typeof RetrieveStyleSchema, + typeof MapboxStyleOutputSchema > { name = 'retrieve_style_tool'; description = 'Retrieve a specific Mapbox style by ID'; @@ -19,27 +29,52 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< title: 'Retrieve Mapbox Style Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: RetrieveStyleSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: RetrieveStyleSchema, + outputSchema: MapboxStyleOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: RetrieveStyleInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; - const response = await this.fetch(url); + const response = await this.httpRequest(url); if (!response.ok) { - throw new Error( - `Failed to retrieve style: ${response.status} ${response.statusText}` + return this.handleApiError(response, 'retrieve style'); + } + + const rawData = await response.json(); + // Validate response against schema with graceful fallback + let data: MapboxStyleOutput; + try { + data = MapboxStyleOutputSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` ); + // Graceful fallback to raw data + data = rawData as MapboxStyleOutput; } - const data = await response.json(); - // Always filter out expanded Mapbox styles to prevent token overflow - return filterExpandedMapboxStyles(data); + this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + } + ], + structuredContent: filterExpandedMapboxStyles(data), + isError: false + }; } } diff --git a/src/tools/style-builder-tool/StyleBuilderTool.schema.ts b/src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts similarity index 99% rename from src/tools/style-builder-tool/StyleBuilderTool.schema.ts rename to src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts index 256f4f6..0d34073 100644 --- a/src/tools/style-builder-tool/StyleBuilderTool.schema.ts +++ b/src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; const LayerConfigSchema = z.object({ diff --git a/src/tools/style-builder-tool/StyleBuilderTool.ts b/src/tools/style-builder-tool/StyleBuilderTool.ts index 47d3585..0f093f5 100644 --- a/src/tools/style-builder-tool/StyleBuilderTool.ts +++ b/src/tools/style-builder-tool/StyleBuilderTool.ts @@ -1,8 +1,12 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { StyleBuilderToolSchema, type StyleBuilderToolInput -} from './StyleBuilderTool.schema.js'; +} from './StyleBuilderTool.input.schema.js'; // Using STREETS_V8_FIELDS as single source of truth instead of MAPBOX_STYLE_LAYERS import { STREETS_V8_FIELDS } from '../../constants/mapboxStreetsV8Fields.js'; import type { Layer, Filter, MapboxStyle } from '../../types/mapbox-style.js'; @@ -52,7 +56,6 @@ const SOURCE_LAYER_GEOMETRY: Record< export class StyleBuilderTool extends BaseTool { name = 'style_builder_tool'; - private currentSourceLayer?: string; // Track current source layer for better error messages readonly annotations = { readOnlyHint: true, destructiveHint: false, @@ -126,7 +129,9 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho super({ inputSchema: StyleBuilderToolSchema }); } - protected async execute(input: StyleBuilderToolInput) { + protected async execute( + input: StyleBuilderToolInput + ): Promise { try { const result = this.buildStyle(input); const { style, corrections, layerHelp, availableProperties } = result; @@ -1436,9 +1441,6 @@ ${JSON.stringify(style, null, 2)} const filters: unknown[] = []; const corrections: string[] = []; - // Set current source layer for better error messages - this.currentSourceLayer = sourceLayer; - // Get field definitions for this source layer const layerFields = STREETS_V8_FIELDS[sourceLayer as keyof typeof STREETS_V8_FIELDS]; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index aa62aec..ec25e25 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const StyleComparisonSchema = z.object({ diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 2aad35f..1bfb6ac 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -1,9 +1,13 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { BaseTool } from '../BaseTool.js'; -import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { StyleComparisonSchema, StyleComparisonInput } from './StyleComparisonTool.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class StyleComparisonTool extends BaseTool< typeof StyleComparisonSchema @@ -39,7 +43,7 @@ export class StyleComparisonTool extends BaseTool< // If it's just a style ID, try to get username from the token try { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + const username = getUserNameFromToken(accessToken); return `${username}/${style}`; } catch (error) { throw new Error( @@ -54,10 +58,27 @@ export class StyleComparisonTool extends BaseTool< protected async execute( input: StyleComparisonInput - ): Promise<{ type: 'text'; text: string }> { - // Process style IDs to get username/styleId format - const beforeStyleId = this.processStyleId(input.before, input.accessToken); - const afterStyleId = this.processStyleId(input.after, input.accessToken); + ): Promise { + let beforeStyleId; + let afterStyleId; + try { + // Process style IDs to get username/styleId format + beforeStyleId = this.processStyleId(input.before, input.accessToken); + afterStyleId = this.processStyleId(input.after, input.accessToken); + } catch (error) { + return { + content: [ + { + type: 'text', + text: + error instanceof Error + ? error.message + : 'An unknown error occurred' + } + ], + isError: true + }; + } // Build the comparison URL const params = new URLSearchParams(); @@ -79,8 +100,13 @@ export class StyleComparisonTool extends BaseTool< } return { - type: 'text', - text: url + content: [ + { + type: 'text', + text: url + } + ], + isError: false }; } } diff --git a/src/tools/tilequery-tool/TilequeryTool.schema.ts b/src/tools/tilequery-tool/TilequeryTool.input.schema.ts similarity index 95% rename from src/tools/tilequery-tool/TilequeryTool.schema.ts rename to src/tools/tilequery-tool/TilequeryTool.input.schema.ts index b50a264..1808987 100644 --- a/src/tools/tilequery-tool/TilequeryTool.schema.ts +++ b/src/tools/tilequery-tool/TilequeryTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const TilequerySchema = z.object({ diff --git a/src/tools/tilequery-tool/TilequeryTool.output.schema.ts b/src/tools/tilequery-tool/TilequeryTool.output.schema.ts new file mode 100644 index 0000000..d4b3c86 --- /dev/null +++ b/src/tools/tilequery-tool/TilequeryTool.output.schema.ts @@ -0,0 +1,81 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Coordinate pair schema +const CoordinatesSchema = z.tuple([z.number(), z.number()]); + +// Vector Tileset Feature Schema +const VectorTilequeryFeatureSchema = z.object({ + type: z.literal('Feature'), + id: z.string().describe('Feature identifier'), + geometry: z.object({ + type: z.literal('Point'), + coordinates: CoordinatesSchema + }), + properties: z + .object({ + tilequery: z.object({ + distance: z + .number() + .describe( + 'Approximate surface distance from feature to queried point, in meters' + ), + geometry: z + .enum(['point', 'linestring', 'polygon']) + .describe('Original geometry type of the feature'), + layer: z + .string() + .describe('The vector tile layer of the feature result') + }) + }) + .and(z.record(z.any())) // Allow additional properties from the original feature +}); + +// Rasterarray Tileset Feature Schema +const RasterarrayTilequeryFeatureSchema = z.object({ + type: z.literal('Feature'), + id: z.null(), + geometry: z.object({ + type: z.literal('Point'), + coordinates: CoordinatesSchema + }), + properties: z.object({ + tilequery: z.object({ + layer: z.string().describe('The layer that the feature belongs to'), + band: z.string().describe('The band that the feature belongs to'), + zoom: z + .number() + .describe('The maxzoom level at which the point value was extracted'), + units: z.string().describe('The unit of measurement for the point value') + }), + val: z + .union([z.number(), z.array(z.number())]) + .describe( + 'Point value at the requested location (number for single-band, array for multi-dimensional data)' + ) + }) +}); + +// Union of both feature types +const TilequeryFeatureSchema = z.union([ + VectorTilequeryFeatureSchema, + RasterarrayTilequeryFeatureSchema +]); + +// Main Tilequery Response Schema +export const TilequeryResponseSchema = z.object({ + type: z.literal('FeatureCollection'), + features: z.array(TilequeryFeatureSchema) +}); + +// Type exports inferred from Zod schemas +export type VectorTilequeryFeature = z.infer< + typeof VectorTilequeryFeatureSchema +>; +export type RasterarrayTilequeryFeature = z.infer< + typeof RasterarrayTilequeryFeatureSchema +>; +export type TilequeryFeature = z.infer; +export type TilequeryResponse = z.infer; diff --git a/src/tools/tilequery-tool/TilequeryTool.ts b/src/tools/tilequery-tool/TilequeryTool.ts index f5e75ca..3737293 100644 --- a/src/tools/tilequery-tool/TilequeryTool.ts +++ b/src/tools/tilequery-tool/TilequeryTool.ts @@ -1,8 +1,22 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { TilequerySchema, TilequeryInput } from './TilequeryTool.schema.js'; +import { + TilequerySchema, + TilequeryInput +} from './TilequeryTool.input.schema.js'; +import { + TilequeryResponse, + TilequeryResponseSchema +} from './TilequeryTool.output.schema.js'; -export class TilequeryTool extends MapboxApiBasedTool { +export class TilequeryTool extends MapboxApiBasedTool< + typeof TilequerySchema, + typeof TilequeryResponseSchema +> { name = 'tilequery_tool'; description = 'Query vector and raster data from Mapbox tilesets at geographic coordinates'; @@ -14,14 +28,18 @@ export class TilequeryTool extends MapboxApiBasedTool { title: 'Mapbox Tilequery Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: TilequerySchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: TilequerySchema, + outputSchema: TilequeryResponseSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: TilequeryInput, accessToken?: string - ): Promise { + ): Promise { const { tilesetId, longitude, latitude, ...queryParams } = input; const url = new URL( `${MapboxApiBasedTool.mapboxApiEndpoint}v4/${tilesetId}/tilequery/${longitude},${latitude}.json` @@ -53,16 +71,41 @@ export class TilequeryTool extends MapboxApiBasedTool { url.searchParams.set('access_token', accessToken || ''); - const response = await this.fetch(url.toString()); + const response = await this.httpRequest(url.toString()); if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Tilequery request failed: ${response.status} ${response.statusText}. ${errorText}` + return this.handleApiError(response, 'query tile'); + } + + const rawData = await response.json(); + + // Validate response against schema with graceful fallback + let data: TilequeryResponse; + try { + data = TilequeryResponseSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` ); + // Graceful fallback to raw data + data = rawData as TilequeryResponse; } - const data = await response.json(); - return data; + this.log( + 'info', + `TilequeryTool: Successfully completed query, found ${data.features?.length || 0} results` + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2) + } + ], + structuredContent: data, + isError: false + }; } } diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 68666ea..fdfd910 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { BoundingBoxTool } from './bounding-box-tool/BoundingBoxTool.js'; import { CountryBoundingBoxTool } from './bounding-box-tool/CountryBoundingBoxTool.js'; import { CoordinateConversionTool } from './coordinate-conversion-tool/CoordinateConversionTool.js'; @@ -14,25 +17,26 @@ import { StyleBuilderTool } from './style-builder-tool/StyleBuilderTool.js'; import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js'; import { TilequeryTool } from './tilequery-tool/TilequeryTool.js'; import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js'; +import { httpRequest } from '../utils/httpPipeline.js'; // Central registry of all tools export const ALL_TOOLS = [ - new ListStylesTool(), - new CreateStyleTool(), - new RetrieveStyleTool(), - new UpdateStyleTool(), - new DeleteStyleTool(), + new ListStylesTool({ httpRequest }), + new CreateStyleTool({ httpRequest }), + new RetrieveStyleTool({ httpRequest }), + new UpdateStyleTool({ httpRequest }), + new DeleteStyleTool({ httpRequest }), new PreviewStyleTool(), new StyleBuilderTool(), new GeojsonPreviewTool(), - new CreateTokenTool(), - new ListTokensTool(), + new CreateTokenTool({ httpRequest }), + new ListTokensTool({ httpRequest }), new BoundingBoxTool(), new CountryBoundingBoxTool(), new CoordinateConversionTool(), - new GetMapboxDocSourceTool(), + new GetMapboxDocSourceTool({ httpRequest }), new StyleComparisonTool(), - new TilequeryTool() + new TilequeryTool({ httpRequest }) ] as const; export type ToolInstance = (typeof ALL_TOOLS)[number]; diff --git a/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts new file mode 100644 index 0000000..3638cdc --- /dev/null +++ b/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts @@ -0,0 +1,25 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// INPUT Schema - For creating/updating styles (PATCH/POST request body) +export const MapboxStyleInputSchema = BaseStylePropertiesSchema.extend({ + name: z + .string() + .describe('Human-readable name for the style (REQUIRED for updates)') + .optional() + // These fields should NOT be included in input - they're read-only + // If present, they'll be ignored or cause API errors +}).passthrough(); + +export const UpdateStyleInputSchema = z.object({ + styleId: z.string().describe('Style ID to update'), + name: z.string().optional().describe('New name for the style'), + style: MapboxStyleInputSchema.optional().describe( + 'Updated Mapbox style specification object' + ) +}); + +export type UpdateStyleInput = z.infer; diff --git a/src/tools/update-style-tool/UpdateStyleTool.output.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.output.schema.ts new file mode 100644 index 0000000..9cab567 --- /dev/null +++ b/src/tools/update-style-tool/UpdateStyleTool.output.schema.ts @@ -0,0 +1,29 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// OUTPUT Schema - For API responses (GET/PATCH response) +export const MapboxStyleOutputSchema = BaseStylePropertiesSchema.extend({ + name: z.string().describe('Human-readable name for the style'), + + // API-specific properties (only present in responses) + id: z.string().describe('Unique style identifier'), + owner: z.string().describe('Username of the style owner'), + created: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was created'), + modified: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was last modified'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), + draft: z.boolean().optional().describe('Whether this is a draft version') +}).passthrough(); + +// Type exports +export type MapboxStyleOutput = z.infer; diff --git a/src/tools/update-style-tool/UpdateStyleTool.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.schema.ts deleted file mode 100644 index 08d8229..0000000 --- a/src/tools/update-style-tool/UpdateStyleTool.schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from 'zod'; - -export const UpdateStyleSchema = z.object({ - styleId: z.string().describe('Style ID to update'), - name: z.string().optional().describe('New name for the style'), - style: z - .record(z.any()) - .optional() - .describe('Updated Mapbox style specification object') -}); - -export type UpdateStyleInput = z.infer; diff --git a/src/tools/update-style-tool/UpdateStyleTool.ts b/src/tools/update-style-tool/UpdateStyleTool.ts index b487322..7f96023 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.ts @@ -1,13 +1,23 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { HttpRequest } from '../../utils/types.js'; import { filterExpandedMapboxStyles } from '../../utils/styleUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { - UpdateStyleSchema, - UpdateStyleInput -} from './UpdateStyleTool.schema.js'; + UpdateStyleInput, + UpdateStyleInputSchema +} from './UpdateStyleTool.input.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { + MapboxStyleOutputSchema, + MapboxStyleOutput +} from './UpdateStyleTool.output.schema.js'; export class UpdateStyleTool extends MapboxApiBasedTool< - typeof UpdateStyleSchema + typeof UpdateStyleInputSchema, + typeof MapboxStyleOutputSchema > { name = 'update_style_tool'; description = 'Update an existing Mapbox style'; @@ -19,22 +29,26 @@ export class UpdateStyleTool extends MapboxApiBasedTool< title: 'Update Mapbox Style Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: UpdateStyleSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: UpdateStyleInputSchema, + outputSchema: MapboxStyleOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: UpdateStyleInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; - const payload: any = {}; + const payload: Record = {}; if (input.name) payload.name = input.name; if (input.style) Object.assign(payload, input.style); - const response = await this.fetch(url, { + const response = await this.httpRequest(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' @@ -43,13 +57,34 @@ export class UpdateStyleTool extends MapboxApiBasedTool< }); if (!response.ok) { - throw new Error( - `Failed to update style: ${response.status} ${response.statusText}` + return this.handleApiError(response, 'update style'); + } + + const rawData = await response.json(); + // Validate response against schema with graceful fallback + let data: MapboxStyleOutput; + try { + data = MapboxStyleOutputSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` ); + // Graceful fallback to raw data + data = rawData as MapboxStyleOutput; } - const data = await response.json(); - // Return full style but filter out expanded Mapbox styles - return filterExpandedMapboxStyles(data); + this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + } + ], + structuredContent: filterExpandedMapboxStyles(data), + isError: false + }; } } diff --git a/src/utils/fetchRequest.ts b/src/utils/httpPipeline.ts similarity index 69% rename from src/utils/fetchRequest.ts rename to src/utils/httpPipeline.ts index 74981dc..22afc0e 100644 --- a/src/utils/fetchRequest.ts +++ b/src/utils/httpPipeline.ts @@ -1,27 +1,35 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { getVersionInfo } from './versionUtils.js'; +import { type HttpRequest } from './types.js'; + +function createRandomId(prefix: string): string { + return `${prefix}${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; +} -export interface FetchPolicy { +export interface HttpPolicy { id: string; handle( input: string | URL | Request, init: RequestInit, - next: typeof fetch + next: HttpRequest ): Promise; } -export class PolicyPipeline { - private policies: FetchPolicy[] = []; - private fetchImpl: typeof fetch; +export class HttpPipeline { + private policies: HttpPolicy[] = []; + private httpRequestImpl: HttpRequest; - constructor(fetchImpl?: typeof fetch) { - this.fetchImpl = fetchImpl ?? fetch; + constructor(httpRequestImpl?: HttpRequest) { + this.httpRequestImpl = httpRequestImpl ?? fetch; } - usePolicy(policy: FetchPolicy) { + usePolicy(policy: HttpPolicy) { this.policies.push(policy); } - removePolicy(policyOrId: FetchPolicy | string) { + removePolicy(policyOrId: HttpPolicy | string) { if (typeof policyOrId === 'string') { this.policies = this.policies.filter((p) => p.id !== policyOrId); } else { @@ -29,7 +37,7 @@ export class PolicyPipeline { } } - findPolicyById(id: string): FetchPolicy | undefined { + findPolicyById(id: string): HttpPolicy | undefined { return this.policies.find((p) => p.id === id); } @@ -37,7 +45,7 @@ export class PolicyPipeline { return this.policies; } - async fetch( + async execute( input: string | URL | Request, init: RequestInit = {} ): Promise { @@ -47,31 +55,32 @@ export class PolicyPipeline { options: RequestInit ): Promise => { if (i < this.policies.length) { - return this.policies[i].handle(req, options, (nextReq, nextOptions) => - dispatch(i + 1, nextReq, nextOptions!) + return this.policies[i].handle( + req, + options, + (nextReq: string | URL | Request, nextOptions?: RequestInit) => + dispatch(i + 1, nextReq, nextOptions || {}) ); } - return this.fetchImpl(req, options); // Use injected fetch + return this.httpRequestImpl(req, options); // Use injected httpRequest }; return dispatch(0, input, init); } } -export class UserAgentPolicy implements FetchPolicy { +export class UserAgentPolicy implements HttpPolicy { id: string; constructor( private userAgent: string, id?: string ) { - this.id = - id ?? - `user-agent-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + this.id = id ?? createRandomId('user-agent-'); } async handle( input: string | URL | Request, init: RequestInit, - next: typeof fetch + next: HttpRequest ): Promise { let headers: Headers | Record; @@ -106,7 +115,7 @@ export class UserAgentPolicy implements FetchPolicy { } } -export class RetryPolicy implements FetchPolicy { +export class RetryPolicy implements HttpPolicy { id: string; constructor( @@ -115,15 +124,13 @@ export class RetryPolicy implements FetchPolicy { private maxDelayMs: number = 2000, id?: string ) { - this.id = - id ?? - `retry-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + this.id = id ?? createRandomId('retry-'); } async handle( input: string | URL | Request, init: RequestInit, - next: typeof fetch + next: HttpRequest ): Promise { let attempt = 0; let lastError: Response | undefined; @@ -153,12 +160,12 @@ export class RetryPolicy implements FetchPolicy { } } -const pipeline = new PolicyPipeline(); +const pipeline = new HttpPipeline(); const versionInfo = getVersionInfo(); pipeline.usePolicy( UserAgentPolicy.fromVersionInfo(versionInfo, 'system-user-agent-policy') ); pipeline.usePolicy(new RetryPolicy(3, 200, 2000, 'system-retry-policy')); -export const fetchClient = pipeline.fetch.bind(pipeline); -export const systemFetchPipeline = pipeline; +export const httpRequest = pipeline.execute.bind(pipeline); +export const systemHttpPipeline = pipeline; diff --git a/src/utils/jwtUtils.ts b/src/utils/jwtUtils.ts new file mode 100644 index 0000000..9a3d3e6 --- /dev/null +++ b/src/utils/jwtUtils.ts @@ -0,0 +1,53 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import process from 'node:process'; + +export function mapboxAccessToken() { + return process.env.MAPBOX_ACCESS_TOKEN; +} + +export function mapboxApiEndpoint() { + return process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'; +} + +/** + * Extracts the username from the Mapbox access token. + * Mapbox tokens are JWT tokens where the payload contains the username. + * @throws Error if the token is not set, invalid, or doesn't contain username + */ +export function getUserNameFromToken(accessToken?: string): string { + const token = accessToken || mapboxAccessToken(); + if (!token) { + throw new Error( + 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' + ); + } + + try { + // JWT format: header.payload.signature + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); + } + + // Decode the payload (second part) + const payload = JSON.parse( + Buffer.from(parts[1], 'base64').toString('utf-8') + ); + + // The username is stored in the 'u' field + if (!payload.u) { + throw new Error( + 'MAPBOX_ACCESS_TOKEN does not contain username in payload' + ); + } + + return payload.u; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error('Failed to parse MAPBOX_ACCESS_TOKEN'); + } +} diff --git a/src/utils/styleUtils.ts b/src/utils/styleUtils.ts index 2a8cd40..a88de18 100644 --- a/src/utils/styleUtils.ts +++ b/src/utils/styleUtils.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + /** * Filters out expanded Mapbox styles from imports to reduce response size. * This preserves the reference to the style (e.g., mapbox://styles/mapbox/standard) diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..689240f --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,9 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * HttpRequest interface that includes tracing information + */ +export interface HttpRequest { + (input: string | URL | Request, init?: RequestInit): Promise; +} diff --git a/src/utils/versionUtils-cjs.cts b/src/utils/versionUtils-cjs.cts index e8c9d34..4e3bf55 100644 --- a/src/utils/versionUtils-cjs.cts +++ b/src/utils/versionUtils-cjs.cts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { readFileSync } from 'node:fs'; import path from 'node:path'; diff --git a/src/utils/versionUtils.ts b/src/utils/versionUtils.ts index 44779ea..c628473 100644 --- a/src/utils/versionUtils.ts +++ b/src/utils/versionUtils.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/test/tools/MapboxApiBasedTool.test.ts b/test/tools/MapboxApiBasedTool.test.ts index ff6ed04..c877770 100644 --- a/test/tools/MapboxApiBasedTool.test.ts +++ b/test/tools/MapboxApiBasedTool.test.ts @@ -7,9 +7,20 @@ process.env.MAPBOX_ACCESS_TOKEN = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { z } from 'zod'; import { MapboxApiBasedTool } from '../../src/tools/MapboxApiBasedTool.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../src/utils/types.js'; +import { setupHttpRequest } from '../utils/httpPipelineUtils.js'; // Create a minimal implementation of MapboxApiBasedTool for testing class TestTool extends MapboxApiBasedTool { + // Provide minimal but realistic annotations for the test tool + annotations = { + title: 'Test Tool', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + destructiveHint: false + }; readonly name = 'test_tool'; readonly description = 'Tool for testing MapboxApiBasedTool error handling'; @@ -17,13 +28,16 @@ class TestTool extends MapboxApiBasedTool { testParam: z.string() }); - constructor() { - super({ inputSchema: TestTool.inputSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: TestTool.inputSchema, + httpRequest: params.httpRequest + }); } protected async execute( _input: z.infer - ): Promise { + ): Promise { throw new Error('Test error message'); } } @@ -43,7 +57,8 @@ describe('MapboxApiBasedTool', () => { configurable: true }); - testTool = new TestTool(); + const { httpRequest } = setupHttpRequest(); + testTool = new TestTool({ httpRequest }); // Mock the log method to test that errors are properly logged testTool['log'] = vi.fn(); }); @@ -55,62 +70,6 @@ describe('MapboxApiBasedTool', () => { vi.unstubAllEnvs(); }); - describe('getUserNameFromToken', () => { - it('extracts username from valid token', () => { - const testPayload = Buffer.from( - JSON.stringify({ u: 'myusername' }) - ).toString('base64'); - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue(`eyJhbGciOiJIUzI1NiJ9.${testPayload}.signature`); - - const username = MapboxApiBasedTool.getUserNameFromToken(); - expect(username).toBe('myusername'); - - spy.mockRestore(); - }); - - it('throws error when token is not set', () => { - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue(undefined); - - expect(() => MapboxApiBasedTool.getUserNameFromToken()).toThrow( - 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' - ); - - spy.mockRestore(); - }); - - it('throws error when token has invalid format', () => { - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue('invalid-token-format'); - - expect(() => MapboxApiBasedTool.getUserNameFromToken()).toThrow( - 'MAPBOX_ACCESS_TOKEN is not in valid JWT format' - ); - - spy.mockRestore(); - }); - - it('throws error when payload does not contain username', () => { - const invalidPayload = Buffer.from( - JSON.stringify({ sub: 'test' }) - ).toString('base64'); - - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue(`eyJhbGciOiJIUzI1NiJ9.${invalidPayload}.signature`); - - expect(() => MapboxApiBasedTool.getUserNameFromToken()).toThrow( - 'MAPBOX_ACCESS_TOKEN does not contain username in payload' - ); - - spy.mockRestore(); - }); - }); - describe('JWT token validation', () => { it('throws an error when the token is not in a valid JWT format', async () => { const spy = vi @@ -118,7 +77,8 @@ describe('MapboxApiBasedTool', () => { .mockReturnValue('invalid-token-format'); // Create a new instance with the modified token - const toolWithInvalidToken = new TestTool(); + const { httpRequest } = setupHttpRequest(); + const toolWithInvalidToken = new TestTool({ httpRequest }); // Mock the log method separately for this instance toolWithInvalidToken['log'] = vi.fn(); @@ -152,7 +112,10 @@ describe('MapboxApiBasedTool', () => { process.env.MAPBOX_ACCESS_TOKEN = `eyJhbGciOiJIUzI1NiJ9.${validPayload}.signature`; // Override execute to return a success result instead of throwing an error - testTool['execute'] = vi.fn().mockResolvedValue({ success: true }); + testTool['execute'] = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ success: true }) }], + isError: false + }); const result = await testTool.run({ testParam: 'test' }); @@ -165,35 +128,6 @@ describe('MapboxApiBasedTool', () => { }); }); - describe('username extraction from token', () => { - it('throws error for invalid JWT format', () => { - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue('invalid-token'); - - expect(() => { - MapboxApiBasedTool.getUserNameFromToken(); - }).toThrow('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); - - spy.mockRestore(); - }); - - it('throws error when username field is missing', () => { - const tokenWithoutUsername = - 'eyJhbGciOiJIUzI1NiJ9.eyJhIjoidGVzdC1hcGkifQ.signature'; - - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue(tokenWithoutUsername); - - expect(() => { - MapboxApiBasedTool.getUserNameFromToken(); - }).toThrow('MAPBOX_ACCESS_TOKEN does not contain username in payload'); - - spy.mockRestore(); - }); - }); - describe('error handling', () => { it('returns generic error message when VERBOSE_ERRORS is not set to true', async () => { // Make sure VERBOSE_ERRORS is not set to true diff --git a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts index 85f130a..747e4f1 100644 --- a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { BoundingBoxTool } from '../../../src/tools/bounding-box-tool/BoundingBoxTool.js'; @@ -22,9 +25,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -73.9857, 40.7484, -73.9857, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + bbox: [-73.9857, 40.7484, -73.9857, 40.7484] + }); }); it('should calculate bounding box for a Point with string input', async () => { @@ -38,9 +41,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -73.9857, 40.7484, -73.9857, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + bbox: [-73.9857, 40.7484, -73.9857, 40.7484] + }); }); it('should calculate bounding box for a LineString', async () => { @@ -58,9 +61,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -73.9919, 40.7484, -73.9857, 40.7614 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + bbox: [-73.9919, 40.7484, -73.9857, 40.7614] + }); }); it('should calculate bounding box for a Polygon', async () => { @@ -82,9 +85,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -73.9919, 40.7484, -73.9857, 40.7614 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + bbox: [-73.9919, 40.7484, -73.9857, 40.7614] + }); }); it('should calculate bounding box for a FeatureCollection', async () => { @@ -115,9 +118,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -74.006, 40.7128, -73.9857, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + bbox: [-74.006, 40.7128, -73.9857, 40.7484] + }); }); it('should calculate bounding box for a MultiPoint', async () => { @@ -135,9 +138,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -74.006, 40.7128, -73.9352, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + bbox: [-74.006, 40.7128, -73.9352, 40.7484] + }); }); it('should calculate bounding box for a MultiPolygon', async () => { @@ -170,7 +173,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([0, 0, 3, 3]); + expect(JSON.parse(textContent.text)).toEqual({ + bbox: [0, 0, 3, 3] + }); }); it('should calculate bounding box for a GeometryCollection', async () => { @@ -196,9 +201,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -74.006, 40.7128, -73.9352, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + bbox: [-74.006, 40.7128, -73.9352, 40.7484] + }); }); it('should handle Feature with null geometry', async () => { @@ -268,7 +273,7 @@ describe('BoundingBoxTool', () => { it('should have correct input schema', async () => { const { BoundingBoxSchema } = await import( - '../../../src/tools/bounding-box-tool/BoundingBoxTool.schema.js' + '../../../src/tools/bounding-box-tool/BoundingBoxTool.input.schema.js' ); expect(BoundingBoxSchema).toBeDefined(); expect(BoundingBoxSchema.shape.geojson).toBeDefined(); diff --git a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts index 8d8d197..1245d74 100644 --- a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { CountryBoundingBoxTool } from '../../../src/tools/bounding-box-tool/CountryBoundingBoxTool.js'; @@ -18,7 +21,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([73.599819, 21.144707, 134.762115, 53.424591]); + expect(bbox).toEqual({ + bbox: [73.599819, 21.144707, 134.762115, 53.424591] + }); }); it('should return bounding box for valid country code - United States (US)', async () => { @@ -28,7 +33,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([-168.069693, 25.133463, -67.292669, 71.284212]); + expect(bbox).toEqual({ + bbox: [-168.069693, 25.133463, -67.292669, 71.284212] + }); }); it('should return bounding box for valid country code - UAE (AE)', async () => { @@ -38,7 +45,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([51.590737, 22.705773, 56.376954, 26.050548]); + expect(bbox).toEqual({ + bbox: [51.590737, 22.705773, 56.376954, 26.050548] + }); }); it('should handle lowercase country codes', async () => { @@ -48,7 +57,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([73.599819, 21.144707, 134.762115, 53.424591]); + expect(bbox).toEqual({ + bbox: [73.599819, 21.144707, 134.762115, 53.424591] + }); }); it('should handle mixed case country codes', async () => { @@ -58,7 +69,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([-168.069693, 25.133463, -67.292669, 71.284212]); + expect(bbox).toEqual({ + bbox: [-168.069693, 25.133463, -67.292669, 71.284212] + }); }); it('should return error for invalid country code', async () => { @@ -138,7 +151,7 @@ describe('CountryBoundingBoxTool', () => { it('should have correct input schema', async () => { const { CountryBoundingBoxSchema } = await import( - '../../../src/tools/bounding-box-tool/CountryBoundingBoxTool.schema.js' + '../../../src/tools/bounding-box-tool/CountryBoundingBoxTool.input.schema.js' ); expect(CountryBoundingBoxSchema).toBeDefined(); }); @@ -162,7 +175,7 @@ describe('CountryBoundingBoxTool', () => { if (!result.isError) { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - const bbox = JSON.parse(textContent.text); + const bbox = JSON.parse(textContent.text).bbox; expect(Array.isArray(bbox)).toBe(true); expect(bbox).toHaveLength(4); } diff --git a/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts b/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts index 8e64cfb..a45c60d 100644 --- a/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts +++ b/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { CoordinateConversionTool } from '../../../src/tools/coordinate-conversion-tool/CoordinateConversionTool.js'; @@ -18,7 +21,7 @@ describe('CoordinateConversionTool', () => { it('should have correct input schema', async () => { const { CoordinateConversionSchema } = await import( - '../../../src/tools/coordinate-conversion-tool/CoordinateConversionTool.schema.js' + '../../../src/tools/coordinate-conversion-tool/CoordinateConversionTool.input.schema.js' ); expect(CoordinateConversionSchema).toBeDefined(); }); diff --git a/test/tools/create-style-tool/CreateStyleTool.test.ts b/test/tools/create-style-tool/CreateStyleTool.test.ts index f5beb61..79df03d 100644 --- a/test/tools/create-style-tool/CreateStyleTool.test.ts +++ b/test/tools/create-style-tool/CreateStyleTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { CreateStyleTool } from '../../../src/tools/create-style-tool/CreateStyleTool.js'; const mockToken = @@ -19,42 +22,53 @@ describe('CreateStyleTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new CreateStyleTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CreateStyleTool({ httpRequest }); expect(tool.name).toBe('create_style_tool'); expect(tool.description).toBe('Create a new Mapbox style'); }); it('should have correct input schema', async () => { - const { CreateStyleSchema } = await import( - '../../../src/tools/create-style-tool/CreateStyleTool.schema.js' + const { MapboxStyleInputSchema } = await import( + '../../../src/tools/create-style-tool/CreateStyleTool.input.schema.js' ); - expect(CreateStyleSchema).toBeDefined(); + expect(MapboxStyleInputSchema).toBeDefined(); }); }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, - json: async () => ({ id: 'new-style-id', name: 'Test Style' }) + json: async () => ({ + id: 'new-style-id', + name: 'Test Style', + version: 8, + sources: {}, + layers: [] + }) }); - await new CreateStyleTool(fetch).run({ + await new CreateStyleTool({ httpRequest }).run({ name: 'Test Style', - style: { version: 8, sources: {}, layers: [] } + version: 8, + sources: {}, + layers: [] }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 400, statusText: 'Bad Request' }); - const result = await new CreateStyleTool(fetch).run({ + const result = await new CreateStyleTool({ httpRequest }).run({ name: 'Test Style', - style: { version: 8, sources: {}, layers: [] } + version: 8, + sources: {}, + layers: [] }); expect(result.isError).toBe(true); @@ -62,6 +76,6 @@ describe('CreateStyleTool', () => { type: 'text', text: 'Failed to create style: 400 Bad Request' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index cb21087..bb4aa86 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -1,10 +1,14 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { MapboxApiBasedTool } from '../../../src/tools/MapboxApiBasedTool.js'; import { CreateTokenTool } from '../../../src/tools/create-token-tool/CreateTokenTool.js'; +import { HttpRequest } from 'src/utils/types.js'; // Create a token with username in the payload const payload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( @@ -23,15 +27,16 @@ describe('CreateTokenTool', () => { vi.clearAllMocks(); }); - function createTokenTool(fetchImpl?: typeof fetch) { - const instance = new CreateTokenTool(fetchImpl); + function createTokenTool(httpRequest: HttpRequest) { + const instance = new CreateTokenTool({ httpRequest }); instance['log'] = vi.fn(); return instance; } describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = createTokenTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createTokenTool(httpRequest); expect(tool.name).toBe('create_token_tool'); expect(tool.description).toBe( 'Create a new Mapbox public access token with specified scopes and optional URL restrictions.' @@ -40,7 +45,7 @@ describe('CreateTokenTool', () => { it('should have correct input schema', async () => { const { CreateTokenSchema } = await import( - '../../../src/tools/create-token-tool/CreateTokenTool.schema.js' + '../../../src/tools/create-token-tool/CreateTokenTool.input.schema.js' ); expect(CreateTokenSchema).toBeDefined(); }); @@ -48,7 +53,8 @@ describe('CreateTokenTool', () => { describe('validation', () => { it('validates required input fields', async () => { - const tool = createTokenTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createTokenTool(httpRequest); const result = await tool.run({}); expect(result.isError).toBe(true); @@ -58,7 +64,8 @@ describe('CreateTokenTool', () => { }); it('validates allowedUrls array length', async () => { - const tool = createTokenTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createTokenTool(httpRequest); const urls = new Array(101).fill('https://example.com'); @@ -75,7 +82,8 @@ describe('CreateTokenTool', () => { }); it('validates invalid scopes', async () => { - const tool = createTokenTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Test token', @@ -101,8 +109,7 @@ describe('CreateTokenTool', () => { vi.stubEnv('MAPBOX_ACCESS_TOKEN', invalidToken); // Setup fetch mock to prevent actual API calls - const { fetch, mockFetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest } = setupHttpRequest({ ok: true, status: 200, statusText: 'OK', @@ -110,7 +117,7 @@ describe('CreateTokenTool', () => { json: async () => ({ token: 'test-token' }) } as Response); - const toolWithInvalidToken = new CreateTokenTool(fetch); + const toolWithInvalidToken = new CreateTokenTool({ httpRequest }); toolWithInvalidToken['log'] = vi.fn(); const result = await toolWithInvalidToken.run({ @@ -142,16 +149,18 @@ describe('CreateTokenTool', () => { id: 'cktest123', scopes: ['styles:read', 'fonts:read'], created: '2024-01-01T00:00:00.000Z', - modified: '2024-01-01T00:00:00.000Z' + modified: '2024-01-01T00:00:00.000Z', + usage: 'pk', + client: 'api', + default: false }; - const { fetch, mockFetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => mockResponse } as Response); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Test token', @@ -170,7 +179,7 @@ describe('CreateTokenTool', () => { }); // Verify the request - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( `https://api.mapbox.com/tokens/v2/testuser?access_token=eyJhbGciOiJIUzI1NiJ9.${payload}.signature`, { method: 'POST', @@ -185,7 +194,7 @@ describe('CreateTokenTool', () => { ); // Verify User-Agent header was sent - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('creates a token with allowed URLs', async () => { @@ -196,16 +205,18 @@ describe('CreateTokenTool', () => { scopes: ['styles:read'], created: '2024-01-01T00:00:00.000Z', modified: '2024-01-01T00:00:00.000Z', - allowedUrls: ['https://example.com', 'https://app.example.com'] + allowedUrls: ['https://example.com', 'https://app.example.com'], + usage: 'pk', + client: 'api', + default: false }; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => mockResponse } as Response); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Restricted token', @@ -218,7 +229,7 @@ describe('CreateTokenTool', () => { expect(responseData.allowedUrls).toEqual(mockResponse.allowedUrls); // Verify the request body included allowedUrls - const lastCall = mockFetch.mock.calls[0]; + const lastCall = mockHttpRequest.mock.calls[0]; const requestBody = JSON.parse(lastCall[1].body as string); expect(requestBody.allowedUrls).toEqual([ 'https://example.com', @@ -235,16 +246,18 @@ describe('CreateTokenTool', () => { scopes: ['styles:read'], created: '2024-01-01T00:00:00.000Z', modified: '2024-01-01T00:00:00.000Z', - expires: expiresAt + expires: expiresAt, + usage: 'pk', + client: 'api', + default: false }; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { mockHttpRequest, httpRequest } = setupHttpRequest({ ok: true, json: async () => mockResponse } as Response); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Token with expiration', @@ -257,14 +270,13 @@ describe('CreateTokenTool', () => { expect(responseData.expires).toEqual(expiresAt); // Verify the request body included expires - const lastCall = mockFetch.mock.calls[0]; + const lastCall = mockHttpRequest.mock.calls[0]; const requestBody = JSON.parse(lastCall[1].body as string); expect(requestBody.expires).toEqual(expiresAt); }); it('handles API errors gracefully', async () => { - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest } = setupHttpRequest({ ok: false, status: 401, statusText: 'Unauthorized', @@ -272,7 +284,7 @@ describe('CreateTokenTool', () => { '{"message": "Token does not have required scopes", "code": "TokenScopesInvalid"}' } as Response); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Test token', @@ -286,10 +298,14 @@ describe('CreateTokenTool', () => { }); it('handles network errors', async () => { - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockRejectedValueOnce(new Error('Network error')); + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 0, + statusText: 'Network Error', + text: async () => 'Network error' + }); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Test token', @@ -299,7 +315,7 @@ describe('CreateTokenTool', () => { expect(result.isError).toBe(true); expect(result.content[0]).toHaveProperty('type', 'text'); const errorText = (result.content[0] as TextContent).text; - expect(errorText).toContain('Network error'); + expect(errorText).toContain('Network Error'); }); it('uses custom API endpoint when provided', async () => { @@ -315,16 +331,18 @@ describe('CreateTokenTool', () => { id: 'cktest', scopes: ['styles:read'], created: '2024-01-01T00:00:00.000Z', - modified: '2024-01-01T00:00:00.000Z' + modified: '2024-01-01T00:00:00.000Z', + usage: 'pk', + client: 'api', + default: false }; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { mockHttpRequest, httpRequest } = setupHttpRequest({ ok: true, json: async () => mockResponse } as Response); - const toolWithCustomEndpoint = new CreateTokenTool(fetch); + const toolWithCustomEndpoint = new CreateTokenTool({ httpRequest }); toolWithCustomEndpoint['log'] = vi.fn(); await toolWithCustomEndpoint.run({ @@ -332,7 +350,7 @@ describe('CreateTokenTool', () => { scopes: ['styles:read'] }); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('https://api.staging.mapbox.com/tokens/v2/'), expect.any(Object) ); diff --git a/test/tools/delete-style-tool/DeleteStyleTool.test.ts b/test/tools/delete-style-tool/DeleteStyleTool.test.ts index 382081d..6a21b08 100644 --- a/test/tools/delete-style-tool/DeleteStyleTool.test.ts +++ b/test/tools/delete-style-tool/DeleteStyleTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { DeleteStyleTool } from '../../../src/tools/delete-style-tool/DeleteStyleTool.js'; const mockToken = @@ -19,80 +22,52 @@ describe('DeleteStyleTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new DeleteStyleTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DeleteStyleTool({ httpRequest }); expect(tool.name).toBe('delete_style_tool'); expect(tool.description).toBe('Delete a Mapbox style by ID'); }); it('should have correct input schema', async () => { const { DeleteStyleSchema } = await import( - '../../../src/tools/delete-style-tool/DeleteStyleTool.schema.js' + '../../../src/tools/delete-style-tool/DeleteStyleTool.input.schema.js' ); expect(DeleteStyleSchema).toBeDefined(); }); }); it('returns success for 204 No Content', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, status: 204 }); - const result = await new DeleteStyleTool(fetch).run({ + const result = await new DeleteStyleTool({ httpRequest }).run({ styleId: 'style-123' }); expect(result.content[0]).toEqual({ type: 'text', - text: '{"success":true,"message":"Style deleted successfully"}' - }); - assertHeadersSent(mockFetch); - }); - - it('returns response body for non-204 success', async () => { - const { fetch, mockFetch } = setupFetch({ - ok: true, - status: 200, - json: async () => ({ deleted: true }) - }); - - const result = await new DeleteStyleTool(fetch).run({ - styleId: 'style-123' - }); - - expect(result.isError).toBe(false); - expect(result.content[0]).toMatchObject({ - type: 'text', - text: `{"deleted":true}` + text: 'Style deleted successfully' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' }); - let result; - try { - result = await new DeleteStyleTool(fetch).run({ - styleId: 'style-123' - }); - } catch (e) { - if (e instanceof Error) { - expect(e.message).toContain('Failed to update style: 404 Not Found'); - } else { - expect.fail('Thrown error is not an instance of Error'); - } - return; - } + const result = await new DeleteStyleTool({ httpRequest }).run({ + styleId: 'style-123' + }); expect(result.isError).toBe(true); expect(result.content[0]).toMatchObject({ type: 'text', text: 'Failed to delete style: 404 Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts index e28159b..f62b670 100644 --- a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts +++ b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach } from 'vitest'; import { GeojsonPreviewTool } from '../../../src/tools/geojson-preview-tool/GeojsonPreviewTool.js'; @@ -17,7 +20,7 @@ describe('GeojsonPreviewTool', () => { it('should have correct input schema', async () => { const { GeojsonPreviewSchema } = await import( - '../../../src/tools/geojson-preview-tool/GeojsonPreviewTool.schema.js' + '../../../src/tools/geojson-preview-tool/GeojsonPreviewTool.input.schema.js' ); expect(GeojsonPreviewSchema).toBeDefined(); }); diff --git a/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts b/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts index 7e93f52..6180576 100644 --- a/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts +++ b/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts @@ -1,10 +1,14 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, expect, it } from 'vitest'; import { GetMapboxDocSourceTool } from '../../../src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.js'; -import { setupFetch } from 'test/utils/fetchRequestUtils.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('GetMapboxDocSourceTool', () => { it('should have correct name and description', () => { - const tool = new GetMapboxDocSourceTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new GetMapboxDocSourceTool({ httpRequest }); expect(tool.name).toBe('get_latest_mapbox_docs_tool'); expect(tool.description).toContain( @@ -26,21 +30,23 @@ This is the Mapbox developer documentation for LLMs. ## APIs - Geocoding API for address search - Directions API for routing`; - - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, status: 200, text: () => Promise.resolve(mockContent) }); - const tool = new GetMapboxDocSourceTool(fetch); + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); - expect(mockFetch).toHaveBeenCalledWith('https://docs.mapbox.com/llms.txt', { - headers: { - 'User-Agent': 'TestServer/1.0.0 (default, no-tag, abcdef)' + expect(mockHttpRequest).toHaveBeenCalledWith( + 'https://docs.mapbox.com/llms.txt', + { + headers: { + 'User-Agent': 'TestServer/1.0.0 (default, no-tag, abcdef)' + } } - }); + ); expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe('text'); @@ -51,12 +57,13 @@ This is the Mapbox developer documentation for LLMs. }); it('should handle HTTP errors', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: false, - status: 404 + status: 404, + statusText: 'Not Found' }); - const tool = new GetMapboxDocSourceTool(fetch); + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); @@ -67,17 +74,16 @@ This is the Mapbox developer documentation for LLMs. expect(result.content[0].text).toContain( 'Failed to fetch Mapbox documentation' ); - expect(result.content[0].text).toContain('HTTP error! status: 404'); + expect(result.content[0].text).toContain('Not Found'); } }); it('should handle network errors', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ text: () => Promise.reject(new Error('Network error')) }); - const tool = new GetMapboxDocSourceTool(fetch); - + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); expect(result.isError).toBe(true); @@ -92,12 +98,11 @@ This is the Mapbox developer documentation for LLMs. }); it('should handle unknown errors', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ text: () => Promise.reject(new Error('Unknown error occurred')) }); - const tool = new GetMapboxDocSourceTool(fetch); - + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); expect(result.isError).toBe(true); @@ -114,13 +119,13 @@ This is the Mapbox developer documentation for LLMs. it('should work with empty input object', async () => { const mockContent = 'Test documentation content'; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: true, status: 200, text: () => Promise.resolve(mockContent) }); - const tool = new GetMapboxDocSourceTool(fetch); + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index 2db891e..0f85d1f 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { ListStylesTool } from '../../../src/tools/list-styles-tool/ListStylesTool.js'; const mockToken = 'sk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; @@ -18,7 +21,8 @@ describe('ListStylesTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new ListStylesTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ListStylesTool({ httpRequest }); expect(tool.name).toBe('list_styles_tool'); expect(tool.description).toBe( 'List styles for a Mapbox account. Use limit parameter to avoid large responses (recommended: limit=5-10). Use start parameter for pagination.' @@ -27,14 +31,14 @@ describe('ListStylesTool', () => { it('should have correct input schema', async () => { const { ListStylesSchema } = await import( - '../../../src/tools/list-styles-tool/ListStylesTool.schema.js' + '../../../src/tools/list-styles-tool/ListStylesTool.input.schema.js' ); expect(ListStylesSchema).toBeDefined(); }); }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [ { id: 'style1', name: 'Test Style 1' }, @@ -42,100 +46,203 @@ describe('ListStylesTool', () => { ] }); - await new ListStylesTool(fetch).run({}); - assertHeadersSent(mockFetch); + await new ListStylesTool({ httpRequest }).run({}); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' }); - const result = await new ListStylesTool(fetch).run({}); + const result = await new ListStylesTool({ httpRequest }).run({}); expect(result.isError).toBe(true); expect(result.content[0]).toMatchObject({ type: 'text', text: 'Failed to list styles: 404 Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); + }); + + it('handles scope/permission errors with helpful message', async () => { + const mockHeaders = new Map([['content-type', 'application/json']]); + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: { + get: (name: string) => mockHeaders.get(name.toLowerCase()) + } as Headers, + json: async () => ({ + message: 'This API requires a token with styles:list scope.' + }) + }); + + const result = await new ListStylesTool({ httpRequest }).run({}); + + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe('text'); + const errorText = (result.content[0] as { type: 'text'; text: string }) + .text; + expect(errorText).toContain( + 'This API requires a token with styles:list scope' + ); + expect(errorText).toContain('appropriate scopes'); + expect(errorText).toContain('MAPBOX_ACCESS_TOKEN'); + assertHeadersSent(mockHttpRequest); }); it('extracts username from token for API call', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [] }); - await new ListStylesTool(fetch).run({}); + await new ListStylesTool({ httpRequest }).run({}); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('/styles/v1/test-user?access_token='), expect.any(Object) ); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('includes limit parameter when provided', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [] }); - await new ListStylesTool(fetch).run({ limit: 10 }); + await new ListStylesTool({ httpRequest }).run({ limit: 10 }); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringMatching(/\/styles\/v1\/test-user\?.*limit=10/), expect.any(Object) ); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('includes start parameter when provided', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [] }); - await new ListStylesTool(fetch).run({ start: 'abc123' }); + await new ListStylesTool({ httpRequest }).run({ start: 'abc123' }); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringMatching(/\/styles\/v1\/test-user\?.*start=abc123/), expect.any(Object) ); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('includes both limit and start parameters when provided', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [] }); - await new ListStylesTool(fetch).run({ limit: 5, start: 'xyz789' }); + await new ListStylesTool({ httpRequest }).run({ + limit: 5, + start: 'xyz789' + }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toMatch(/\/styles\/v1\/test-user\?/); expect(calledUrl).toMatch(/limit=5/); expect(calledUrl).toMatch(/start=xyz789/); expect(calledUrl).toMatch(/access_token=/); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('returns style list on success', async () => { const mockStyles = [ - { id: 'style1', name: 'Test Style 1', owner: 'testuser' }, - { id: 'style2', name: 'Test Style 2', owner: 'testuser' } + { + id: 'style1', + name: 'Test Style 1', + owner: 'testuser', + created: '2020-05-05T08:27:39.280Z', + modified: '2020-05-05T08:27:41.353Z', + visibility: 'private' as const, + version: 8, + sources: {}, + layers: [] + }, + { + id: 'style2', + name: 'Test Style 2', + owner: 'testuser', + created: '2020-05-06T08:27:39.280Z', + modified: '2020-05-06T08:27:41.353Z', + visibility: 'public' as const, + version: 8, + sources: {}, + layers: [] + } + ]; + + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: true, + json: async () => mockStyles + }); + + const result = await new ListStylesTool({ httpRequest }).run({}); + + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + + const content = result.content[0]; + if (content.type === 'text') { + const parsedResponse = JSON.parse(content.text); + expect(parsedResponse).toHaveProperty('styles'); + expect(parsedResponse.styles).toEqual(mockStyles); + } + + // Verify structuredContent has the expected shape + if (result.structuredContent) { + expect(result.structuredContent).toHaveProperty('styles'); + expect( + (result.structuredContent as { styles: unknown[] }).styles + ).toEqual(mockStyles); + } + + assertHeadersSent(mockHttpRequest); + }); + + it('handles styles without layers field (real API response)', async () => { + // This matches the actual production API response format + const mockStyles = [ + { + center: [139.7667, 35.681249], + created: '2020-05-05T08:27:39.280Z', + id: 'ck9tnguii0ipm1ipf54wqhhwm', + modified: '2020-05-05T08:27:41.353Z', + name: 'Yahoo! Japan Streets', + owner: 'svc-okta-mapbox-staff-access', + sources: { + composite: { + url: 'mapbox://mapbox.mapbox-streets-v8,mapbox.road-detail-v1-33,mapbox.transit-v2', + type: 'vector' + } + }, + version: 8, + visibility: 'private' as const, + zoom: 16, + protected: false + } ]; - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => mockStyles }); - const result = await new ListStylesTool(fetch).run({}); + const result = await new ListStylesTool({ httpRequest }).run({}); expect(result.isError).toBe(false); expect(result.content).toHaveLength(1); @@ -144,9 +251,20 @@ describe('ListStylesTool', () => { const content = result.content[0]; if (content.type === 'text') { const parsedResponse = JSON.parse(content.text); - expect(parsedResponse).toEqual(mockStyles); + expect(parsedResponse).toHaveProperty('styles'); + expect(parsedResponse.styles).toHaveLength(1); + expect(parsedResponse.styles[0]).toMatchObject({ + id: 'ck9tnguii0ipm1ipf54wqhhwm', + name: 'Yahoo! Japan Streets', + owner: 'svc-okta-mapbox-staff-access', + visibility: 'private', + version: 8, + protected: false + }); + // Verify layers field is not required + expect(parsedResponse.styles[0].layers).toBeUndefined(); } - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index d5418d8..d4fc7a8 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -1,20 +1,21 @@ -import { describe, it, expect, afterEach, vi, beforeAll } from 'vitest'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, afterEach, vi } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { MapboxApiBasedTool } from '../../../src/tools/MapboxApiBasedTool.js'; import { ListTokensTool } from '../../../src/tools/list-tokens-tool/ListTokensTool.js'; +import { HttpRequest } from '../../../src//utils/types.js'; // Create a token with username in the payload const payload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( 'base64' ); const mockToken = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; - -beforeAll(() => { - process.env.MAPBOX_ACCESS_TOKEN = mockToken; -}); +process.env.MAPBOX_ACCESS_TOKEN = mockToken; type TextContent = { type: 'text'; text: string }; @@ -23,8 +24,8 @@ describe('ListTokensTool', () => { vi.clearAllMocks(); }); - function createListTokensTool(fetchImpl?: typeof fetch): ListTokensTool { - const tool = new ListTokensTool(fetchImpl); + function createListTokensTool(httpRequest: HttpRequest): ListTokensTool { + const tool = new ListTokensTool({ httpRequest }); // Mock the log method to prevent actual logging during tests tool['log'] = vi.fn(); return tool; @@ -32,7 +33,8 @@ describe('ListTokensTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = createListTokensTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createListTokensTool(httpRequest); expect(tool.name).toBe('list_tokens_tool'); expect(tool.description).toBe( @@ -42,7 +44,7 @@ describe('ListTokensTool', () => { it('should have correct input schema', async () => { const { ListTokensSchema } = await import( - '../../../src/tools/list-tokens-tool/ListTokensTool.schema.js' + '../../../src/tools/list-tokens-tool/ListTokensTool.input.schema.js' ); expect(ListTokensSchema).toBeDefined(); }); @@ -50,7 +52,8 @@ describe('ListTokensTool', () => { describe('validation', () => { it('validates limit range', async () => { - const tool = createListTokensTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ limit: 101 }); expect(result.isError).toBe(true); @@ -60,7 +63,8 @@ describe('ListTokensTool', () => { }); it('validates sortby enum values', async () => { - const tool = createListTokensTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ sortby: 'invalid' as unknown as 'created' | 'modified' }); @@ -70,7 +74,8 @@ describe('ListTokensTool', () => { }); it('validates usage enum values', async () => { - const tool = createListTokensTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ usage: 'invalid' as unknown as 'pk' }); @@ -92,8 +97,7 @@ describe('ListTokensTool', () => { vi.stubEnv('MAPBOX_ACCESS_TOKEN', invalidToken); // Setup fetch mock to prevent actual API calls - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest } = setupHttpRequest({ ok: true, status: 200, statusText: 'OK', @@ -101,7 +105,7 @@ describe('ListTokensTool', () => { json: async () => [] } as Response); - const toolWithInvalidToken = createListTokensTool(fetch); + const toolWithInvalidToken = createListTokensTool(httpRequest); const result = await toolWithInvalidToken.run({}); @@ -128,30 +132,33 @@ describe('ListTokensTool', () => { id: 'cktest123', note: 'Default public token', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test123', scopes: ['styles:read', 'fonts:read'], created: '2023-01-01T00:00:00.000Z', - modified: '2023-01-01T00:00:00.000Z' + modified: '2023-01-01T00:00:00.000Z', + default: false }, { id: 'cktest456', note: 'Secret token', usage: 'sk', + client: 'api', token: 'sk.eyJ1IjoidGVzdHVzZXIifQ.test456', scopes: ['styles:read', 'fonts:read', 'tokens:read'], created: '2023-02-01T00:00:00.000Z', - modified: '2023-02-01T00:00:00.000Z' + modified: '2023-02-01T00:00:00.000Z', + default: false } ]; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); @@ -165,7 +172,7 @@ describe('ListTokensTool', () => { expect(responseData.tokens[1].id).toBe('cktest456'); // Verify the request - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining( 'https://api.mapbox.com/tokens/v2/testuser?access_token=' ), @@ -178,7 +185,7 @@ describe('ListTokensTool', () => { ); // Verify User-Agent header was sent - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('filters by default token', async () => { @@ -187,6 +194,7 @@ describe('ListTokensTool', () => { id: 'ckdefault', note: 'Default public token', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.default', default: true, scopes: ['styles:read', 'fonts:read'], @@ -195,14 +203,14 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ default: true }); expect(result.isError).toBe(false); @@ -211,7 +219,7 @@ describe('ListTokensTool', () => { expect(responseData.tokens[0].default).toBe(true); // Verify the request included the default parameter - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('default=true'), expect.any(Object) ); @@ -223,27 +231,29 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); const headers = new Headers(); headers.set( 'Link', '; rel="next"' ); - mockFetch.mockResolvedValueOnce({ + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers, json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ limit: 10, start: 'cktest789', @@ -255,7 +265,7 @@ describe('ListTokensTool', () => { expect(responseData.tokens).toHaveLength(1); // Verify all parameters were included in the request - const callUrl = mockFetch.mock.calls[0][0] as string; + const callUrl = mockHttpRequest.mock.calls[0][0] as string; expect(callUrl).toContain('limit=10'); expect(callUrl).toContain('start=cktest789'); expect(callUrl).toContain('sortby=created'); @@ -267,27 +277,29 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); const headers = new Headers(); headers.set( 'Link', '; rel="next"' ); - mockFetch.mockResolvedValueOnce({ + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers, json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ limit: 10 }); @@ -303,27 +315,28 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; - const { mockFetch, fetch } = setupFetch(); const headers = new Headers(); headers.set( 'Link', '; rel="next"' ); - mockFetch.mockResolvedValueOnce({ + const { httpRequest } = setupHttpRequest({ ok: true, headers, json: async () => mockTokens - } as Response); + }); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ start: 'cktest789' }); @@ -339,14 +352,15 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; - const { mockFetch, fetch } = setupFetch(); // First page with Link header const headers1 = new Headers(); headers1.set( @@ -357,19 +371,22 @@ describe('ListTokensTool', () => { // Second page without Link header (end of results) const headers2 = new Headers(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + // Reset from default response + mockHttpRequest.mockReset(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: headers1, json: async () => mockTokens } as Response); - mockFetch.mockResolvedValueOnce({ + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: headers2, json: async () => [] // Empty array for second page } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); @@ -385,21 +402,23 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ limit: 10 }); @@ -415,21 +434,23 @@ describe('ListTokensTool', () => { id: 'pktest123', note: 'Public token', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.pub123', scopes: ['styles:read'], created: '2023-04-01T00:00:00.000Z', - modified: '2023-04-01T00:00:00.000Z' + modified: '2023-04-01T00:00:00.000Z', + default: false } ]; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ usage: 'pk' }); @@ -439,15 +460,15 @@ describe('ListTokensTool', () => { expect(responseData.tokens[0].usage).toBe('pk'); // Verify the usage parameter was included - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('usage=pk'), expect.any(Object) ); }); it('handles API errors gracefully', async () => { - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized', @@ -455,7 +476,7 @@ describe('ListTokensTool', () => { '{"message": "Invalid access token", "code": "TokenInvalid"}' } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); @@ -466,10 +487,10 @@ describe('ListTokensTool', () => { }); it('handles network errors', async () => { - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockRejectedValueOnce(new Error('Network error')); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockRejectedValueOnce(new Error('Network error')); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); expect(result.isError).toBe(true); @@ -487,18 +508,18 @@ describe('ListTokensTool', () => { const mockTokens: object[] = []; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); await tool.run({}); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('https://api.staging.mapbox.com/tokens/v2/'), expect.any(Object) ); @@ -518,20 +539,24 @@ describe('ListTokensTool', () => { id: 'cktest123', note: 'Test token', usage: 'pk', + client: 'api', token: 'pk.test', - scopes: ['styles:read'] + scopes: ['styles:read'], + created: '2023-04-01T00:00:00.000Z', + modified: '2023-04-01T00:00:00.000Z', + default: false } ] }; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockResponse } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); expect(result.isError).toBe(false); diff --git a/test/tools/preview-style-tool/PreviewStyleTool.test.ts b/test/tools/preview-style-tool/PreviewStyleTool.test.ts index d98b82c..ea70336 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + process.env.MAPBOX_ACCESS_TOKEN = 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; @@ -19,7 +22,7 @@ describe('PreviewStyleTool', () => { it('should have correct input schema', async () => { const { PreviewStyleSchema } = await import( - '../../../src/tools/preview-style-tool/PreviewStyleTool.schema.js' + '../../../src/tools/preview-style-tool/PreviewStyleTool.input.schema.js' ); expect(PreviewStyleSchema).toBeDefined(); }); @@ -28,7 +31,9 @@ describe('PreviewStyleTool', () => { it('uses user-provided public token and returns preview URL', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', - accessToken: TEST_ACCESS_TOKEN + accessToken: TEST_ACCESS_TOKEN, + title: false, + zoomwheel: false }); expect(result.isError).toBe(false); @@ -43,7 +48,9 @@ describe('PreviewStyleTool', () => { it('includes styleId in URL', async () => { const result = await new PreviewStyleTool().run({ styleId: 'my-custom-style', - accessToken: TEST_ACCESS_TOKEN + accessToken: TEST_ACCESS_TOKEN, + title: false, + zoomwheel: false }); expect(result.content[0]).toMatchObject({ @@ -56,7 +63,8 @@ describe('PreviewStyleTool', () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', accessToken: TEST_ACCESS_TOKEN, - title: true + title: true, + zoomwheel: false }); expect(result.content[0]).toMatchObject({ @@ -69,7 +77,8 @@ describe('PreviewStyleTool', () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', accessToken: TEST_ACCESS_TOKEN, - zoomwheel: false + zoomwheel: false, + title: false }); expect(result.content[0]).toMatchObject({ @@ -81,7 +90,9 @@ describe('PreviewStyleTool', () => { it('includes fresh parameter for secure access', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', - accessToken: TEST_ACCESS_TOKEN + accessToken: TEST_ACCESS_TOKEN, + title: false, + zoomwheel: false }); expect(result.content[0]).toMatchObject({ @@ -94,7 +105,9 @@ describe('PreviewStyleTool', () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', accessToken: - 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.secret_token' + 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.secret_token', + title: false, + zoomwheel: false }); expect(result.isError).toBe(true); @@ -109,7 +122,9 @@ describe('PreviewStyleTool', () => { it('rejects temporary tokens', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', - accessToken: 'tk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.temp_token' + accessToken: 'tk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.temp_token', + title: false, + zoomwheel: false }); expect(result.isError).toBe(true); @@ -124,7 +139,9 @@ describe('PreviewStyleTool', () => { it('returns URL on success', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', - accessToken: TEST_ACCESS_TOKEN + accessToken: TEST_ACCESS_TOKEN, + title: false, + zoomwheel: false }); expect(result.isError).toBe(false); diff --git a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts index 1f7115f..4d0858b 100644 --- a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts +++ b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { RetrieveStyleTool } from '../../../src/tools/retrieve-style-tool/RetrieveStyleTool.js'; const mockToken = @@ -19,14 +22,15 @@ describe('RetrieveStyleTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new RetrieveStyleTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new RetrieveStyleTool({ httpRequest }); expect(tool.name).toBe('retrieve_style_tool'); expect(tool.description).toBe('Retrieve a specific Mapbox style by ID'); }); it('should have correct input schema', async () => { const { RetrieveStyleSchema } = await import( - '../../../src/tools/retrieve-style-tool/RetrieveStyleTool.schema.js' + '../../../src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.js' ); expect(RetrieveStyleSchema).toBeDefined(); }); @@ -34,25 +38,25 @@ describe('RetrieveStyleTool', () => { it('returns style data for successful fetch', async () => { const styleData = { id: 'style-123', name: 'Test Style' }; - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, status: 200, json: async () => styleData }); - const result = await new RetrieveStyleTool(fetch).run({ + const result = await new RetrieveStyleTool({ httpRequest }).run({ styleId: 'style-123' }); expect(result.content[0]).toMatchObject({ type: 'text', - text: JSON.stringify(styleData) + text: JSON.stringify(styleData, null, 2) }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' @@ -60,7 +64,7 @@ describe('RetrieveStyleTool', () => { let result; try { - result = await new RetrieveStyleTool(fetch).run({ + result = await new RetrieveStyleTool({ httpRequest }).run({ styleId: 'style-456' }); } catch (e) { @@ -77,6 +81,59 @@ describe('RetrieveStyleTool', () => { type: 'text', text: 'Failed to retrieve style: 404 Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); + }); + + it('handles styles with null terrain and other nullable fields', async () => { + // Real-world API response with null values for optional fields + const styleData = { + id: 'cjxyz123', + name: 'Production Style', + owner: 'test-user', + version: 8, + created: '2020-01-01T00:00:00.000Z', + modified: '2020-01-02T00:00:00.000Z', + visibility: 'private' as const, + sources: { + composite: { + type: 'vector' as const, + url: 'mapbox://mapbox.mapbox-streets-v8' + } + }, + layers: [ + { + id: 'background', + type: 'background' as const, + paint: { 'background-color': '#000000' } + } + ], + terrain: null, // API returns null instead of omitting the field + fog: null, + lights: null + }; + + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: true, + status: 200, + json: async () => styleData + }); + + const result = await new RetrieveStyleTool({ httpRequest }).run({ + styleId: 'cjxyz123' + }); + + expect(result.isError).toBe(false); + expect(result.content[0].type).toBe('text'); + + const content = result.content[0]; + if (content.type === 'text') { + const parsedResponse = JSON.parse(content.text); + expect(parsedResponse.terrain).toBeNull(); + expect(parsedResponse.fog).toBeNull(); + expect(parsedResponse.lights).toBeNull(); + expect(parsedResponse.id).toBe('cjxyz123'); + } + + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/style-builder-tool/StyleBuilderTool.test.ts b/test/tools/style-builder-tool/StyleBuilderTool.test.ts index b405f62..97ce8ca 100644 --- a/test/tools/style-builder-tool/StyleBuilderTool.test.ts +++ b/test/tools/style-builder-tool/StyleBuilderTool.test.ts @@ -1,6 +1,9 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { StyleBuilderTool } from '../../../src/tools/style-builder-tool/StyleBuilderTool.js'; -import type { StyleBuilderToolInput } from '../../../src/tools/style-builder-tool/StyleBuilderTool.schema.js'; +import type { StyleBuilderToolInput } from '../../../src/tools/style-builder-tool/StyleBuilderTool.input.schema.js'; describe('StyleBuilderTool', () => { let tool: StyleBuilderTool; @@ -23,17 +26,18 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0066ff' + color: '#0066ff', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); expect(result.content[0].type).toBe('text'); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain('Style Built Successfully'); expect(text).toContain('Test Style'); expect(text).toContain('"#0066ff"'); @@ -42,7 +46,7 @@ describe('StyleBuilderTool', () => { it('should handle dark mode', async () => { const input: StyleBuilderToolInput = { style_name: 'Dark Mode Style', - base_style: 'streets', // Use classic style to test background color + base_style: 'streets-v12', // Use classic style to test background color layers: [], global_settings: { mode: 'dark', @@ -50,10 +54,10 @@ describe('StyleBuilderTool', () => { } }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain('Mode:** dark'); expect(text).toContain('#000000'); }); @@ -69,13 +73,14 @@ describe('StyleBuilderTool', () => { layer_type: 'road', action: 'color', color: '#ff0000', - filter_properties: { class: 'primary' } + filter_properties: { class: 'primary' }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); expect(text).toContain('#ff0000'); @@ -91,13 +96,14 @@ describe('StyleBuilderTool', () => { action: 'highlight', color: '#ffff00', width: 5, - filter_properties: { class: 'major_rail' } + filter_properties: { class: 'major_rail' }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); expect(text).toContain('Highlighted'); @@ -111,13 +117,14 @@ describe('StyleBuilderTool', () => { layers: [ { layer_type: 'place_label', - action: 'hide' + action: 'hide', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); expect(text).toContain('Hidden'); @@ -130,13 +137,14 @@ describe('StyleBuilderTool', () => { layers: [ { layer_type: 'building', - action: 'show' + action: 'show', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); expect(text).toContain('Shown'); @@ -154,13 +162,14 @@ describe('StyleBuilderTool', () => { action: 'color', color: '#ff0000', width: 3, - filter_properties: { admin_level: 0, maritime: 'false' } + filter_properties: { admin_level: 0, maritime: 'false' }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); @@ -195,13 +204,14 @@ describe('StyleBuilderTool', () => { action: 'color', color: '#0000ff', opacity: 0.5, - filter_properties: { admin_level: 1, maritime: 'false' } + filter_properties: { admin_level: 1, maritime: 'false' }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); @@ -230,19 +240,21 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0099ff' + color: '#0099ff', + render_type: 'symbol' }, { layer_type: 'landuse', filter_properties: { class: 'park' }, action: 'color', - color: '#00ff00' + color: '#00ff00', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); expect(jsonMatch).toBeTruthy(); @@ -273,12 +285,12 @@ describe('StyleBuilderTool', () => { // Test with classic style const input: StyleBuilderToolInput = { style_name: 'Essential Layers Test', - base_style: 'streets', // Use classic style + base_style: 'streets-v12', // Use classic style layers: [] // No layers specified }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -300,16 +312,17 @@ describe('StyleBuilderTool', () => { { layer_type: 'unknown_layer' as any, action: 'color', - color: '#ff0000' + color: '#ff0000', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); // Should return help message, not error expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain('not found'); expect(text).toContain('Available source layers'); }); @@ -323,15 +336,16 @@ describe('StyleBuilderTool', () => { layer_type: 'road', action: 'color', color: '#ff0000', - filter: ['==', ['get', 'class'], 'motorway'] + filter: ['==', ['get', 'class'], 'motorway'], + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -363,10 +377,10 @@ describe('StyleBuilderTool', () => { ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -402,10 +416,10 @@ describe('StyleBuilderTool', () => { ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -442,10 +456,10 @@ describe('StyleBuilderTool', () => { ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -476,10 +490,10 @@ describe('StyleBuilderTool', () => { ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -503,7 +517,7 @@ describe('StyleBuilderTool', () => { const tool = new StyleBuilderTool(); const input: StyleBuilderToolInput = { style_name: 'Transit Test', - base_style: 'streets', + base_style: 'streets-v12', layers: [ { layer_type: 'transit', @@ -511,17 +525,17 @@ describe('StyleBuilderTool', () => { color: '#ff0000', filter_properties: { maki: 'bus' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const styleJson = JSON.parse( - result.content[0].text.match(/```json\n([\s\S]*?)\n```/)![1] - ); + const text = result.content[0].text as string; + const styleJson = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); const transitLayer = styleJson.layers.find((l: any) => l.id.includes('transit') @@ -540,24 +554,24 @@ describe('StyleBuilderTool', () => { const tool = new StyleBuilderTool(); const input: StyleBuilderToolInput = { style_name: 'Multi Transit Test', - base_style: 'streets', + base_style: 'streets-v12', layers: [ { layer_type: 'transit', action: 'highlight', filter_properties: { maki: ['bus', 'entrance', 'rail-metro'] - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const styleJson = JSON.parse( - result.content[0].text.match(/```json\n([\s\S]*?)\n```/)![1] - ); + const text = result.content[0].text as string; + const styleJson = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); const transitLayer = styleJson.layers.find((l: any) => l.id.includes('transit') @@ -591,12 +605,11 @@ describe('StyleBuilderTool', () => { ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const styleJson = JSON.parse( - result.content[0].text.match(/```json\n([\s\S]*?)\n```/)![1] - ); + const text = result.content[0].text as string; + const styleJson = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); const roadsLayer = styleJson.layers.find((l: any) => l.id.includes('road-toll-true') @@ -618,15 +631,16 @@ describe('StyleBuilderTool', () => { color: '#ff0000', filter_properties: { class: 'motorway' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -650,15 +664,16 @@ describe('StyleBuilderTool', () => { filter_properties: { class: ['motorway', 'trunk'], structure: 'bridge' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -685,15 +700,16 @@ describe('StyleBuilderTool', () => { admin_level: 0, disputed: 'false', maritime: 'false' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -717,13 +733,14 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0099ff' + color: '#0099ff', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -755,7 +772,8 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0099ff' + color: '#0099ff', + render_type: 'symbol' } ], standard_config: { @@ -784,8 +802,8 @@ describe('StyleBuilderTool', () => { } }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(text).toContain('Standard Config:** 15 properties set'); expect(text).toContain('Theme: faded'); @@ -816,7 +834,7 @@ describe('StyleBuilderTool', () => { it('should generate Classic style with sources', async () => { const input: StyleBuilderToolInput = { style_name: 'Classic Style Test', - base_style: 'streets', + base_style: 'streets-v12', layers: [ { layer_type: 'water', @@ -826,8 +844,8 @@ describe('StyleBuilderTool', () => { ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -858,25 +876,28 @@ describe('StyleBuilderTool', () => { layer_type: 'water', action: 'color', color: '#0099ff', - slot: 'bottom' + slot: 'bottom', + render_type: 'symbol' }, { layer_type: 'landuse', filter_properties: { class: 'park' }, action: 'color', color: '#00ff00', - slot: 'middle' + slot: 'middle', + render_type: 'symbol' }, { layer_type: 'poi_label', action: 'show', - slot: 'top' + slot: 'top', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -912,11 +933,12 @@ describe('StyleBuilderTool', () => { action: 'color', color: '#0099ff' } - ] + ], + base_style: 'standard' }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -948,15 +970,16 @@ describe('StyleBuilderTool', () => { color: '#00ff00', filter_properties: { type: ['wetland', 'swamp'] - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; // No longer expecting auto-correction since we're using the correct layer expect(text).toContain('Style Built Successfully'); @@ -990,15 +1013,16 @@ describe('StyleBuilderTool', () => { color: '#ff0000', filter_properties: { maki: 'restaurant' // This field only exists in poi_label - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain( 'Determined source layer "poi_label" from filter properties' ); @@ -1014,29 +1038,33 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0066ff' + color: '#0066ff', + render_type: 'symbol' }, { layer_type: 'landuse', filter_properties: { class: 'park' }, action: 'highlight', - color: '#00ff00' + color: '#00ff00', + render_type: 'symbol' }, { layer_type: 'place_label', - action: 'hide' + action: 'hide', + render_type: 'symbol' }, { layer_type: 'building', - action: 'show' + action: 'show', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain('Layers Configured:** 4'); expect(text).toContain('Set to #0066ff'); diff --git a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts index df8189f..4856ebf 100644 --- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -1,6 +1,9 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { MapboxApiBasedTool } from '../../../src/tools/MapboxApiBasedTool.js'; import { StyleComparisonTool } from '../../../src/tools/style-comparison-tool/StyleComparisonTool.js'; +import * as jwtUtils from '../../../src/utils/jwtUtils.js'; describe('StyleComparisonTool', () => { let tool: StyleComparisonTool; @@ -10,7 +13,7 @@ describe('StyleComparisonTool', () => { }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); describe('run', () => { @@ -37,7 +40,7 @@ describe('StyleComparisonTool', () => { before: 'mapbox/streets-v12', after: 'mapbox/satellite-v9' // Missing accessToken - }; + } as any; const result = await tool.run(input); @@ -63,10 +66,7 @@ describe('StyleComparisonTool', () => { }); it('should handle just style IDs with valid public token', async () => { - // Mock MapboxApiBasedTool.getUserNameFromToken to return a username - vi.spyOn(MapboxApiBasedTool, 'getUserNameFromToken').mockReturnValue( - 'testuser' - ); + vi.spyOn(jwtUtils, 'getUserNameFromToken').mockReturnValue('testuser'); const input = { before: 'style-id-1', @@ -102,8 +102,8 @@ describe('StyleComparisonTool', () => { it('should reject invalid token formats', async () => { const input = { - before: 'mapbox/streets-v12', - after: 'mapbox/outdoors-v12', + before: 'streets-v12', + after: 'outdoors-v12', accessToken: 'invalid.token' }; @@ -117,13 +117,11 @@ describe('StyleComparisonTool', () => { it('should return error for style ID without valid username in token', async () => { // Mock getUserNameFromToken to throw an error - vi.spyOn(MapboxApiBasedTool, 'getUserNameFromToken').mockImplementation( - () => { - throw new Error( - 'MAPBOX_ACCESS_TOKEN does not contain username in payload' - ); - } - ); + vi.spyOn(jwtUtils, 'getUserNameFromToken').mockImplementation(() => { + throw new Error( + 'MAPBOX_ACCESS_TOKEN does not contain username in payload' + ); + }); const input = { before: 'style-id-only', @@ -136,7 +134,7 @@ describe('StyleComparisonTool', () => { expect(result.isError).toBe(true); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('Could not determine username'); + ).toContain('Could not determine username for style ID'); }); it('should properly encode URL parameters', async () => { diff --git a/test/tools/tilequery-tool/TilequeryTool.test.ts b/test/tools/tilequery-tool/TilequeryTool.test.ts index 444d436..8acd906 100644 --- a/test/tools/tilequery-tool/TilequeryTool.test.ts +++ b/test/tools/tilequery-tool/TilequeryTool.test.ts @@ -1,12 +1,17 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { TilequeryTool } from '../../../src/tools/tilequery-tool/TilequeryTool.js'; -import { TilequeryInput } from '../../../src/tools/tilequery-tool/TilequeryTool.schema.js'; +import { TilequeryInput } from '../../../src/tools/tilequery-tool/TilequeryTool.input.schema.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('TilequeryTool', () => { let tool: TilequeryTool; beforeEach(() => { - tool = new TilequeryTool(); + const { httpRequest } = setupHttpRequest(); + tool = new TilequeryTool({ httpRequest }); }); describe('constructor', () => { diff --git a/test/tools/tool-naming-convention.test.ts b/test/tools/tool-naming-convention.test.ts index 14d8a28..1de0c80 100644 --- a/test/tools/tool-naming-convention.test.ts +++ b/test/tools/tool-naming-convention.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { BaseTool } from '../../src/tools/BaseTool.js'; import { pathToFileURL } from 'node:url'; +import { setupHttpRequest } from '../utils/httpPipelineUtils.js'; async function discoverTools(): Promise { const toolsDir = path.resolve( @@ -11,6 +12,9 @@ async function discoverTools(): Promise { ); const tools: any[] = []; + // Setup httpRequest for tools that need it + const { httpRequest } = setupHttpRequest(); + // Find all directories that end with '-tool' const entries = fs.readdirSync(toolsDir, { withFileTypes: true }); const toolDirectories = entries @@ -39,7 +43,17 @@ async function discoverTools(): Promise { ); for (const toolClass of toolClasses) { - tools.push(new (toolClass as any)()); + try { + // Try to instantiate with httpRequest parameter (for MapboxApiBasedTool subclasses) + tools.push(new (toolClass as any)({ httpRequest })); + } catch (error) { + // Fall back to no-arg constructor (for other tools) + try { + tools.push(new (toolClass as any)()); + } catch (innerError) { + throw error; // Re-throw the original error + } + } } } catch (error) { console.warn( diff --git a/test/tools/update-style-tool/UpdateStyleTool.test.ts b/test/tools/update-style-tool/UpdateStyleTool.test.ts index 518ca12..987815e 100644 --- a/test/tools/update-style-tool/UpdateStyleTool.test.ts +++ b/test/tools/update-style-tool/UpdateStyleTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { UpdateStyleTool } from '../../../src/tools/update-style-tool/UpdateStyleTool.js'; const mockToken = @@ -19,35 +22,36 @@ describe('UpdateStyleTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new UpdateStyleTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new UpdateStyleTool({ httpRequest }); expect(tool.name).toBe('update_style_tool'); expect(tool.description).toBe('Update an existing Mapbox style'); }); it('should have correct input schema', async () => { - const { UpdateStyleSchema } = await import( - '../../../src/tools/update-style-tool/UpdateStyleTool.schema.js' + const { MapboxStyleInputSchema } = await import( + '../../../src/tools/update-style-tool/UpdateStyleTool.input.schema.js' ); - expect(UpdateStyleSchema).toBeDefined(); + expect(MapboxStyleInputSchema).toBeDefined(); }); }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => ({ id: 'updated-style-id', name: 'Updated Style' }) }); - await new UpdateStyleTool(fetch).run({ + await new UpdateStyleTool({ httpRequest }).run({ styleId: 'style-123', name: 'Updated Style', style: { version: 8, sources: {}, layers: [] } }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' @@ -55,7 +59,7 @@ describe('UpdateStyleTool', () => { let result; try { - result = await new UpdateStyleTool(fetch).run({ + result = await new UpdateStyleTool({ httpRequest }).run({ styleId: 'style-123', name: 'Updated Style', style: { version: 8, sources: {}, layers: [] } @@ -74,6 +78,6 @@ describe('UpdateStyleTool', () => { type: 'text', text: 'Failed to update style: 404 Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/utils/fetchRequest.test.ts b/test/utils/fetchRequest.test.ts deleted file mode 100644 index 754c629..0000000 --- a/test/utils/fetchRequest.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { - RetryPolicy, - PolicyPipeline, - UserAgentPolicy -} from '../../src/utils/fetchRequest.js'; -import type { Mock } from 'vitest'; - -function createMockFetch( - responses: Array<{ status: number; ok?: boolean }> -): typeof fetch { - let call = 0; - return vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => { - const res = responses[Math.min(call, responses.length - 1)]; - call++; - return { - ok: res.ok ?? res.status < 400, - status: res.status, - statusText: `Status ${res.status}`, - json: async () => ({ status: res.status }) - } as Response; - }) as typeof fetch; -} - -describe('PolicyPipeline', () => { - describe('usePolicy, removePolicy, and listPolicies', () => { - it('adds policies with usePolicy', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1'); - const policy2 = new RetryPolicy(); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(2); - expect(policies[0]).toBe(policy1); - expect(policies[1]).toBe(policy2); - }); - - it('removes policies with removePolicy', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1'); - const policy2 = new RetryPolicy(); - const policy3 = new UserAgentPolicy('Agent3'); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - pipeline.usePolicy(policy3); - - pipeline.removePolicy(policy2); - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(2); - expect(policies[0]).toBe(policy1); - expect(policies[1]).toBe(policy3); - }); - - it('removePolicy does nothing if policy not found', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1'); - const policy2 = new RetryPolicy(); - - pipeline.usePolicy(policy1); - - pipeline.removePolicy(policy2); // Not in the list - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(1); - expect(policies[0]).toBe(policy1); - }); - - it('listPolicies returns empty array initially', () => { - const pipeline = new PolicyPipeline(); - expect(pipeline.listPolicies()).toEqual([]); - }); - - it('listPolicies returns the policies array', () => { - const pipeline = new PolicyPipeline(); - const policy = new UserAgentPolicy('Agent1'); - - pipeline.usePolicy(policy); - const policies1 = pipeline.listPolicies(); - const policies2 = pipeline.listPolicies(); - - expect(policies1).toBe(policies2); // Same reference - expect(policies1).toEqual(policies2); // Same content - expect(policies1).toContain(policy); - }); - }); - - describe('RetryPolicy', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('retries on 500 and returns last response after max retries', async () => { - const mockFetch = createMockFetch([ - { status: 500 }, - { status: 500 }, - { status: 500 }, - { status: 500 } - ]); - const pipeline = new PolicyPipeline(mockFetch); - pipeline.usePolicy(new RetryPolicy(3, 1, 10)); // Use small delays for test speed - - const response = await pipeline.fetch('http://test', {}); - - expect(mockFetch).toHaveBeenCalledTimes(4); - expect(response.status).toBe(500); - }); - - it('retries on 429 and succeeds if later response is ok', async () => { - const mockFetch = createMockFetch([ - { status: 429 }, - { status: 429 }, - { status: 200, ok: true } - ]); - const pipeline = new PolicyPipeline(mockFetch); - pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - - const response = await pipeline.fetch('http://test', {}); - - expect(mockFetch).toHaveBeenCalledTimes(3); - expect(response.status).toBe(200); - expect(response.ok).toBe(true); - }); - - it('does not retry on 400 errors', async () => { - const mockFetch = createMockFetch([{ status: 400 }]); - const pipeline = new PolicyPipeline(mockFetch); - pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - - const response = await pipeline.fetch('http://test', {}); - - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(response.status).toBe(400); - }); - - it('returns immediately on first success', async () => { - const mockFetch = createMockFetch([{ status: 200, ok: true }]); - const pipeline = new PolicyPipeline(mockFetch); - pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - - const response = await pipeline.fetch('http://test', {}); - - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(response.status).toBe(200); - expect(response.ok).toBe(true); - }); - }); - - describe('UserAgentPolicy', () => { - it('sets the User-Agent header if not present', async () => { - const mockFetch = vi.fn( - async (input: string | URL | Request, init?: RequestInit) => { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - headers: init?.headers - } as Response; - } - ) as Mock; - - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); - pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); - - await pipeline.fetch('http://test', {}); - - const headers = mockFetch.mock.calls[0][1]?.headers as Record< - string, - string - >; - expect(headers['User-Agent']).toBe('TestAgent/1.0'); - }); - - it('does not overwrite an existing User-Agent header', async () => { - const mockFetch = vi.fn( - async (input: string | URL | Request, init?: RequestInit) => { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - headers: init?.headers - } as Response; - } - ) as Mock; - - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); - pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); - - await pipeline.fetch('http://test', { - headers: { - 'User-Agent': 'CustomAgent/2.0' - } - }); - - const headers = mockFetch.mock.calls[0][1]?.headers as Record< - string, - string - >; - expect(headers['User-Agent']).toBe('CustomAgent/2.0'); - }); - - it('works with headers as Headers object', async () => { - const mockFetch = vi.fn( - async (_input: string | URL | Request, init?: RequestInit) => { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - headers: init?.headers - } as Response; - } - ) as Mock; - - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); - pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); - - const headers = new Headers(); - await pipeline.fetch('http://test', { headers }); - - expect(headers.get('User-Agent')).toBe('TestAgent/1.0'); - }); - }); - - describe('Policy ID functionality', () => { - it('assigns unique IDs to policies when not provided', () => { - const policy1 = new UserAgentPolicy('Agent1'); - const policy2 = new UserAgentPolicy('Agent2'); - const policy3 = new RetryPolicy(); - - expect(policy1.id).toBeDefined(); - expect(policy2.id).toBeDefined(); - expect(policy3.id).toBeDefined(); - expect(policy1.id).not.toBe(policy2.id); - expect(policy2.id).not.toBe(policy3.id); - }); - - it('uses custom ID when provided', () => { - const customId = 'my-custom-policy'; - const policy = new UserAgentPolicy('Agent1', customId); - - expect(policy.id).toBe(customId); - }); - - it('removes policies by ID using removePolicy', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1', 'policy-1'); - const policy2 = new RetryPolicy(3, 200, 2000, 'policy-2'); - const policy3 = new UserAgentPolicy('Agent3', 'policy-3'); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - pipeline.usePolicy(policy3); - - pipeline.removePolicy('policy-2'); // Remove by ID string - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(2); - expect(policies[0]).toBe(policy1); - expect(policies[1]).toBe(policy3); - }); - - it('removePolicy supports both policy instance and ID string', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1', 'policy-1'); - const policy2 = new RetryPolicy(3, 200, 2000, 'policy-2'); - const policy3 = new UserAgentPolicy('Agent3', 'policy-3'); - const policy4 = new UserAgentPolicy('Agent4', 'policy-4'); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - pipeline.usePolicy(policy3); - pipeline.usePolicy(policy4); - - // Remove by policy instance - pipeline.removePolicy(policy2); - - // Remove by ID string - pipeline.removePolicy('policy-4'); - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(2); - expect(policies[0]).toBe(policy1); - expect(policies[1]).toBe(policy3); - }); - - it('finds policies by ID', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1', 'policy-1'); - const policy2 = new RetryPolicy(3, 200, 2000, 'policy-2'); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - - expect(pipeline.findPolicyById('policy-1')).toBe(policy1); - expect(pipeline.findPolicyById('policy-2')).toBe(policy2); - expect(pipeline.findPolicyById('non-existent')).toBeUndefined(); - }); - - it('fromVersionInfo accepts optional ID parameter', () => { - const versionInfo = { - name: 'test-app', - version: '1.0.0', - sha: 'abc123', - tag: 'v1.0.0', - branch: 'main' - }; - - const policyWithoutId = UserAgentPolicy.fromVersionInfo(versionInfo); - const policyWithId = UserAgentPolicy.fromVersionInfo( - versionInfo, - 'custom-id' - ); - - expect(policyWithoutId.id).toBeDefined(); - expect(policyWithId.id).toBe('custom-id'); - }); - }); -}); diff --git a/test/utils/httpPipeline.test.ts b/test/utils/httpPipeline.test.ts new file mode 100644 index 0000000..1d52b56 --- /dev/null +++ b/test/utils/httpPipeline.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + RetryPolicy, + HttpPipeline, + UserAgentPolicy +} from '../../src/utils/httpPipeline.js'; +import type { Mock } from 'vitest'; + +function createMockFetch( + responses: Array<{ status: number; ok?: boolean }> +): typeof fetch { + let call = 0; + return vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => { + const res = responses[Math.min(call, responses.length - 1)]; + call++; + return { + ok: res.ok ?? res.status < 400, + status: res.status, + statusText: `Status ${res.status}`, + json: async () => ({ status: res.status }) + } as Response; + }) as typeof fetch; +} + +describe('HttpPipeline', () => { + describe('RetryPolicy', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('retries on 500 and returns last response after max retries', async () => { + const mockFetch = createMockFetch([ + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 } + ]); + const pipeline = new HttpPipeline(mockFetch); + pipeline.usePolicy(new RetryPolicy(3, 1, 10)); // Use small delays for test speed + + const response = await pipeline.execute('http://test', {}); + + expect(mockFetch).toHaveBeenCalledTimes(4); + expect(response.status).toBe(500); + }); + + it('retries on 429 and succeeds if later response is ok', async () => { + const mockFetch = createMockFetch([ + { status: 429 }, + { status: 429 }, + { status: 200, ok: true } + ]); + const pipeline = new HttpPipeline(mockFetch); + pipeline.usePolicy(new RetryPolicy(3, 1, 10)); + + const response = await pipeline.execute('http://test', {}); + + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(response.status).toBe(200); + expect(response.ok).toBe(true); + }); + + it('does not retry on 400 errors', async () => { + const mockFetch = createMockFetch([{ status: 400 }]); + const pipeline = new HttpPipeline(mockFetch); + pipeline.usePolicy(new RetryPolicy(3, 1, 10)); + + const response = await pipeline.execute('http://test', {}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(response.status).toBe(400); + }); + + it('returns immediately on first success', async () => { + const mockFetch = createMockFetch([{ status: 200, ok: true }]); + const pipeline = new HttpPipeline(mockFetch); + pipeline.usePolicy(new RetryPolicy(3, 1, 10)); + + const response = await pipeline.execute('http://test', {}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + expect(response.ok).toBe(true); + }); + }); + + describe('UserAgentPolicy', () => { + it('sets the User-Agent header if not present', async () => { + const mockFetch = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + headers: init?.headers + } as Response; + } + ) as Mock; + + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); + + await pipeline.execute('http://test', {}); + + const headers = mockFetch.mock.calls[0][1]?.headers as Record< + string, + string + >; + expect(headers['User-Agent']).toBe('TestAgent/1.0'); + }); + + it('does not overwrite an existing User-Agent header', async () => { + const mockFetch = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + headers: init?.headers + } as Response; + } + ) as Mock; + + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); + + await pipeline.execute('http://test', { + headers: { + 'User-Agent': 'CustomAgent/2.0' + } + }); + + const headers = mockFetch.mock.calls[0][1]?.headers as Record< + string, + string + >; + expect(headers['User-Agent']).toBe('CustomAgent/2.0'); + }); + + it('works with headers as Headers object', async () => { + const mockFetch = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + headers: init?.headers + } as Response; + } + ) as Mock; + + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); + + const headers = new Headers(); + await pipeline.execute('http://test', { headers }); + + expect(headers.get('User-Agent')).toBe('TestAgent/1.0'); + }); + }); + + describe('Policy Management', () => { + it('can add and list policies', () => { + const mockFetch = vi.fn(); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'user-agent-test' + ); + const retryPolicy = new RetryPolicy(3, 100, 1000, 'retry-test'); + + pipeline.usePolicy(userAgentPolicy); + pipeline.usePolicy(retryPolicy); + + const policies = pipeline.listPolicies(); + expect(policies).toHaveLength(2); + expect(policies[0].id).toBe('user-agent-test'); + expect(policies[1].id).toBe('retry-test'); + }); + + it('can find policy by ID', () => { + const mockFetch = vi.fn(); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'user-agent-test' + ); + pipeline.usePolicy(userAgentPolicy); + + const foundPolicy = pipeline.findPolicyById('user-agent-test'); + expect(foundPolicy).toBe(userAgentPolicy); + + const notFoundPolicy = pipeline.findPolicyById('non-existent'); + expect(notFoundPolicy).toBeUndefined(); + }); + + it('can remove policy by ID', () => { + const mockFetch = vi.fn(); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'user-agent-test' + ); + const retryPolicy = new RetryPolicy(3, 100, 1000, 'retry-test'); + + pipeline.usePolicy(userAgentPolicy); + pipeline.usePolicy(retryPolicy); + + expect(pipeline.listPolicies()).toHaveLength(2); + + pipeline.removePolicy('user-agent-test'); + + const policies = pipeline.listPolicies(); + expect(policies).toHaveLength(1); + expect(policies[0].id).toBe('retry-test'); + }); + + it('can remove policy by reference', () => { + const mockFetch = vi.fn(); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'user-agent-test' + ); + const retryPolicy = new RetryPolicy(3, 100, 1000, 'retry-test'); + + pipeline.usePolicy(userAgentPolicy); + pipeline.usePolicy(retryPolicy); + + expect(pipeline.listPolicies()).toHaveLength(2); + + pipeline.removePolicy(userAgentPolicy); + + const policies = pipeline.listPolicies(); + expect(policies).toHaveLength(1); + expect(policies[0].id).toBe('retry-test'); + }); + + it('generates automatic IDs for policies without explicit ID', () => { + const userAgentPolicy = new UserAgentPolicy('TestAgent/1.0'); + const retryPolicy = new RetryPolicy(3, 100, 1000); + + expect(userAgentPolicy.id).toMatch(/^user-agent-\d+-[a-z0-9]+$/); + expect(retryPolicy.id).toMatch(/^retry-\d+-[a-z0-9]+$/); + }); + + it('uses provided IDs when specified', () => { + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'custom-ua-id' + ); + const retryPolicy = new RetryPolicy(3, 100, 1000, 'custom-retry-id'); + + expect(userAgentPolicy.id).toBe('custom-ua-id'); + expect(retryPolicy.id).toBe('custom-retry-id'); + }); + }); +}); diff --git a/test/utils/fetchRequestUtils.ts b/test/utils/httpPipelineUtils.ts similarity index 62% rename from test/utils/fetchRequestUtils.ts rename to test/utils/httpPipelineUtils.ts index ec72a48..a8e7846 100644 --- a/test/utils/fetchRequestUtils.ts +++ b/test/utils/httpPipelineUtils.ts @@ -1,13 +1,13 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { expect, vi } from 'vitest'; import type { Mock } from 'vitest'; -import { - PolicyPipeline, - UserAgentPolicy -} from '../../src/utils/fetchRequest.js'; +import { HttpPipeline, UserAgentPolicy } from '../../src/utils/httpPipeline.js'; -export function setupFetch(overrides?: any) { - const mockFetch = vi.fn(); - mockFetch.mockResolvedValue({ +export function setupHttpRequest(overrides?: Partial) { + const mockHttpRequest = vi.fn(); + mockHttpRequest.mockResolvedValue({ ok: true, status: 200, statusText: 'OK', @@ -18,10 +18,10 @@ export function setupFetch(overrides?: any) { // Build a real pipeline with UserAgentPolicy const userAgent = 'TestServer/1.0.0 (default, no-tag, abcdef)'; - const pipeline = new PolicyPipeline(mockFetch); + const pipeline = new HttpPipeline(mockHttpRequest); pipeline.usePolicy(new UserAgentPolicy(userAgent)); - return { fetch: pipeline.fetch.bind(pipeline), mockFetch }; + return { httpRequest: pipeline.execute.bind(pipeline), mockHttpRequest }; } export function assertHeadersSent(mockFetch: Mock) { diff --git a/test/utils/jwtUtils.test.ts b/test/utils/jwtUtils.test.ts new file mode 100644 index 0000000..5398815 --- /dev/null +++ b/test/utils/jwtUtils.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as jwtUtils from '../../src/utils/jwtUtils.js'; + +describe('jwtUtils', () => { + describe('getUserNameFromToken', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it('extracts username from valid token', () => { + const testPayload = Buffer.from( + JSON.stringify({ u: 'myusername' }) + ).toString('base64'); + + vi.stubEnv( + 'MAPBOX_ACCESS_TOKEN', + `eyJhbGciOiJIUzI1NiJ9.${testPayload}.signature` + ); + + const username = jwtUtils.getUserNameFromToken(); + expect(username).toBe('myusername'); + }); + + it('throws error when token is not set', () => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', ''); + + expect(() => jwtUtils.getUserNameFromToken()).toThrow( + 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' + ); + }); + + it('throws error when token has invalid format', () => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', 'invalid-token-format'); + + expect(() => jwtUtils.getUserNameFromToken()).toThrow( + 'MAPBOX_ACCESS_TOKEN is not in valid JWT format' + ); + }); + + it('throws error when payload does not contain username', () => { + const invalidPayload = Buffer.from( + JSON.stringify({ sub: 'test' }) + ).toString('base64'); + + vi.stubEnv( + 'MAPBOX_ACCESS_TOKEN', + `eyJhbGciOiJIUzI1NiJ9.${invalidPayload}.signature` + ); + + expect(() => jwtUtils.getUserNameFromToken()).toThrow( + 'MAPBOX_ACCESS_TOKEN does not contain username in payload' + ); + }); + }); + + describe('username extraction from token', () => { + it('throws error for invalid JWT format', () => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', 'invalid-token'); + + expect(() => { + jwtUtils.getUserNameFromToken(); + }).toThrow('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); + }); + + it('throws error when username field is missing', () => { + const tokenWithoutUsername = + 'eyJhbGciOiJIUzI1NiJ9.eyJhIjoidGVzdC1hcGkifQ.signature'; + + vi.stubEnv('MAPBOX_ACCESS_TOKEN', tokenWithoutUsername); + + expect(() => { + jwtUtils.getUserNameFromToken(); + }).toThrow('MAPBOX_ACCESS_TOKEN does not contain username in payload'); + }); + }); +});