From a64ffb6e4b4017c0f9ae7259be53bb372301fea5 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 8 Oct 2025 10:01:17 -0400 Subject: [PATCH 1/9] [tools] Update tools to use structuredContent --- cspell.config.json | 16 + package-lock.json | 1037 ++++++++++++++++- package.json | 15 +- src/schemas/geojson.ts | 163 +++ src/tools/BaseTool.ts | 8 +- src/tools/MapboxApiBasedTool.schema.ts | 7 + src/tools/MapboxApiBasedTool.ts | 39 +- .../category-list-tool/CategoryListTool.ts | 22 +- .../CategorySearchTool.ts | 43 +- src/tools/directions-tool/DirectionsTool.ts | 111 +- src/tools/isochrone-tool/IsochroneTool.ts | 33 +- src/tools/matrix-tool/MatrixTool.ts | 158 ++- .../ReverseGeocodeTool.ts | 60 +- .../SearchAndGeocodeTool.ts | 41 +- .../StaticMapImageTool.ts | 26 +- src/tools/version-tool/VersionTool.ts | 11 +- test/tools/MapboxApiBasedTool.test.ts | 5 +- .../isochrone-tool/IsochroneTool.test.ts | 2 +- test/tools/matrix-tool/MatrixTool.test.ts | 370 +++--- .../SearchAndGeocodeTool.test.ts | 14 +- test/tools/structured-content.test.ts | 143 +++ test/tools/version-tool/VersionTool.test.ts | 28 +- 22 files changed, 2011 insertions(+), 341 deletions(-) create mode 100644 cspell.config.json create mode 100644 src/schemas/geojson.ts create mode 100644 test/tools/structured-content.test.ts 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 ac2d615..c0a0d91 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", @@ -296,6 +297,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.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.5.tgz", + "integrity": "sha512-H51R0w7c6RwJJPqH7Gs65tzP6ouZsYDEHmmol6MIIk0kQaOIBuFP2B3vIxHLUr2EPRVFZsMW8Ni7NmVyaQlwsg==", + "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.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.9.tgz", + "integrity": "sha512-wTOFMlxv06veIwKdXUwdGxrQcK44Zqs426m6JGgHIB/GqvieZQC5n0UI+tUm5OCxuNyo4OV6mylT4cRMjtKtWQ==", + "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.19", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.19.tgz", + "integrity": "sha512-JYYgzhGqSGuIMNY1cTlmq3zrNpehrExMHqLmLnSM2jEGFeHydlL+KLBwBYxMy4e73w+p1+o/rmAiGsMj9g3MCw==", + "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.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.9.tgz", + "integrity": "sha512-1lSnphnHTOxnpNLpPLg1XXv8df3hs4oL0LJ6dkQ0IqNROl8Jzl6PD55BDTlKy4YOAA76dJlePB0wyrxB+VVKbg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-filetypes": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.13.tgz", + "integrity": "sha512-g6rnytIpQlMNKGJT1JKzWkC+b3xCliDKpQ3ANFSq++MnR4GaLiifaC4JkVON11Oh/UTplYOR1nY3BR4X30bswA==", + "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.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.17.tgz", + "integrity": "sha512-0yp7lBXtN3CtxBrpvTu/yAuPdOHR2ucKzPxdppc3VKO068waZNpKikn1NZCzBS3dIAFGVITzUPtuTXxt9cxnSg==", + "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.19", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.19.tgz", + "integrity": "sha512-9S2gTlgILp1eb6OJcVZeC8/Od83N8EqBSg5WHVpx97eMMJhifOzePkE0kDYjyHMtAFznCQTUu0iQEJohNQ5B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-data-science": "^2.0.9" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.8.tgz", + "integrity": "sha512-iwCHLP11OmVHEX2MzE8EPxpPw7BelvldxWe5cJ3xXIDL8TjF2dBTs2noGcrqnZi15SLYIlO8897BIOa33WHHZA==", + "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", @@ -2240,6 +2829,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", @@ -2530,6 +3126,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", "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", @@ -2635,6 +3260,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", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2733,6 +3398,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", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2792,6 +3472,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", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2818,6 +3505,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", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3011,6 +3917,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", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -3437,6 +4356,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", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -3646,6 +4579,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", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3904,6 +4847,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", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4029,6 +4982,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", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", @@ -4326,6 +5305,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", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6721,6 +7711,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "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", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -7774,6 +8777,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", @@ -7953,6 +8970,19 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "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", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7960,10 +8990,11 @@ "dev": true }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "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": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 7492ba3..509984f 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,18 @@ "mcp-server": "dist/esm/index.js" }, "scripts": { - "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\"", - "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" --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 && tshy && npm run generate-version && 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:inspect": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" npx -y tsx src/index.ts" + "test": "vitest" }, "lint-staged": { "*.{js,jsx,ts,tsx}": "eslint --fix", @@ -33,6 +35,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/schemas/geojson.ts b/src/schemas/geojson.ts new file mode 100644 index 0000000..d18f7f0 --- /dev/null +++ b/src/schemas/geojson.ts @@ -0,0 +1,163 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * GeoJSON interfaces based on RFC 7946 + * https://tools.ietf.org/html/rfc7946 + */ + +export type GeoJSONGeometryType = + | 'Point' + | 'LineString' + | 'Polygon' + | 'MultiPoint' + | 'MultiLineString' + | 'MultiPolygon' + | 'GeometryCollection'; + +export type GeoJSONFeatureType = 'Feature'; + +export type GeoJSONFeatureCollectionType = 'FeatureCollection'; + +export type GeoJSONType = + | GeoJSONGeometryType + | GeoJSONFeatureType + | GeoJSONFeatureCollectionType; + +/** + * Position array [longitude, latitude] or [longitude, latitude, elevation] + */ +export type Position = [number, number] | [number, number, number]; + +/** + * Base interface for all GeoJSON objects + */ +export interface GeoJSONBase { + type: GeoJSONType; + bbox?: + | [number, number, number, number] + | [number, number, number, number, number, number]; +} + +/** + * Point geometry + */ +export interface Point extends GeoJSONBase { + type: 'Point'; + coordinates: Position; +} + +/** + * LineString geometry + */ +export interface LineString extends GeoJSONBase { + type: 'LineString'; + coordinates: Position[]; +} + +/** + * Polygon geometry + */ +export interface Polygon extends GeoJSONBase { + type: 'Polygon'; + coordinates: Position[][]; +} + +/** + * MultiPoint geometry + */ +export interface MultiPoint extends GeoJSONBase { + type: 'MultiPoint'; + coordinates: Position[]; +} + +/** + * MultiLineString geometry + */ +export interface MultiLineString extends GeoJSONBase { + type: 'MultiLineString'; + coordinates: Position[][]; +} + +/** + * MultiPolygon geometry + */ +export interface MultiPolygon extends GeoJSONBase { + type: 'MultiPolygon'; + coordinates: Position[][][]; +} + +/** + * GeometryCollection + */ +export interface GeometryCollection extends GeoJSONBase { + type: 'GeometryCollection'; + geometries: Geometry[]; +} + +/** + * Union of all geometry types + */ +export type Geometry = + | Point + | LineString + | Polygon + | MultiPoint + | MultiLineString + | MultiPolygon + | GeometryCollection; + +/** + * GeoJSON Feature with properties + */ +export interface Feature< + P = Record, + G extends Geometry = Geometry +> extends GeoJSONBase { + type: 'Feature'; + geometry: G | null; + properties: P | null; + id?: string | number; +} + +/** + * GeoJSON FeatureCollection + */ +export interface FeatureCollection< + P = Record, + G extends Geometry = Geometry +> extends GeoJSONBase { + type: 'FeatureCollection'; + features: Feature[]; +} + +/** + * Union of all GeoJSON objects + */ +export type GeoJSON = Geometry | Feature | FeatureCollection; + +/** + * Mapbox-specific properties commonly found in Mapbox API responses + */ +export interface MapboxFeatureProperties extends Record { + name?: string; + name_preferred?: string; + full_address?: string; + place_formatted?: string; + feature_type?: string; + poi_category?: string | string[]; + category?: string; + mapbox_id?: string; + address?: string; +} + +/** + * Mapbox Feature with common properties + */ +export type MapboxFeature = Feature; + +/** + * Mapbox FeatureCollection with common properties + */ +export type MapboxFeatureCollection = + FeatureCollection; diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 253d5a9..42997a2 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -9,6 +9,7 @@ import type { ToolAnnotations, CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { ZodTypeAny } from 'zod'; import type { z } from 'zod'; @@ -34,6 +35,7 @@ export abstract class BaseTool { { 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 }, @@ -44,7 +46,11 @@ export abstract class BaseTool { /** * Tool logic to be implemented by subclasses. */ - abstract run(rawInput: unknown, extra?: any): Promise; + abstract run( + rawInput: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extra?: RequestHandlerExtra + ): Promise; /** * Helper method to send logging messages diff --git a/src/tools/MapboxApiBasedTool.schema.ts b/src/tools/MapboxApiBasedTool.schema.ts index 55f75dc..e822a08 100644 --- a/src/tools/MapboxApiBasedTool.schema.ts +++ b/src/tools/MapboxApiBasedTool.schema.ts @@ -17,5 +17,12 @@ export const OutputSchema = z.object({ }) ]) ), + /** + * An object containing structured tool output. + * + * If the Tool defines an outputSchema, this field MUST be present in the result, + * and contain a JSON object that matches the schema. + */ + structuredContent: z.object({}).passthrough().optional(), isError: z.boolean().default(false) }); diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 08b8908..2b86709 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -55,36 +55,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('Access token is not in valid JWT format'); - } - - const input = this.inputSchema.parse(rawInput); - const result = await this.execute(input, accessToken); - - // Check if result is already a content object (image or text) - if ( - result && - typeof result === 'object' && - (result.type === 'image' || result.type === 'text') - ) { + const errorMessage = 'Access token is not in valid JWT format'; + this.log('error', `${this.name}: ${errorMessage}`); return { - content: [result], - isError: false + content: [{ type: 'text', text: errorMessage }], + isError: true }; } - // Otherwise return as text - return { - content: [{ type: 'text', text: JSON.stringify(result) }], - isError: false - }; + 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); @@ -108,9 +100,10 @@ export abstract class MapboxApiBasedTool< /** * 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; + ): Promise>; } diff --git a/src/tools/category-list-tool/CategoryListTool.ts b/src/tools/category-list-tool/CategoryListTool.ts index 397ee01..d1faada 100644 --- a/src/tools/category-list-tool/CategoryListTool.ts +++ b/src/tools/category-list-tool/CategoryListTool.ts @@ -2,9 +2,11 @@ // Licensed under the MIT License. import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; import type { CategoryListInput } from './CategoryListTool.schema.js'; import { CategoryListInputSchema } from './CategoryListTool.schema.js'; +import type { z } from 'zod'; interface CategoryListResponse { listItems: Array<{ @@ -41,7 +43,7 @@ export class CategoryListTool extends MapboxApiBasedTool< protected async execute( input: CategoryListInput, accessToken: string - ): Promise { + ): Promise> { const url = new URL( 'https://api.mapbox.com/search/searchbox/v1/list/category' ); @@ -60,9 +62,15 @@ export class CategoryListTool extends MapboxApiBasedTool< }); if (!response.ok) { - throw new Error( - `Mapbox API request failed: ${response.status} ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Mapbox API request failed: ${response.status} ${response.statusText}` + } + ], + isError: true + }; } const data = (await response.json()) as CategoryListResponse; @@ -80,8 +88,12 @@ export class CategoryListTool extends MapboxApiBasedTool< .slice(startIndex, endIndex) .map((item) => item.canonical_id); + const result = { listItems: categoryIds }; + return { - listItems: categoryIds + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + isError: false }; } } diff --git a/src/tools/category-search-tool/CategorySearchTool.ts b/src/tools/category-search-tool/CategorySearchTool.ts index 788a13f..cc9d4e3 100644 --- a/src/tools/category-search-tool/CategorySearchTool.ts +++ b/src/tools/category-search-tool/CategorySearchTool.ts @@ -3,8 +3,13 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; import { CategorySearchInputSchema } from './CategorySearchTool.schema.js'; +import type { + MapboxFeatureCollection, + MapboxFeature +} from '../../schemas/geojson.js'; export class CategorySearchTool extends MapboxApiBasedTool< typeof CategorySearchInputSchema @@ -24,7 +29,9 @@ export class CategorySearchTool extends MapboxApiBasedTool< super({ inputSchema: CategorySearchInputSchema }); } - private formatGeoJsonToText(geoJsonResponse: any): string { + private formatGeoJsonToText( + geoJsonResponse: MapboxFeatureCollection + ): string { if ( !geoJsonResponse || !geoJsonResponse.features || @@ -34,9 +41,9 @@ export class CategorySearchTool extends MapboxApiBasedTool< } const results = geoJsonResponse.features.map( - (feature: any, index: number) => { + (feature: MapboxFeature, index: number) => { const props = feature.properties || {}; - const geom = feature.geometry || {}; + const geom = feature.geometry; let result = `${index + 1}. `; @@ -54,7 +61,7 @@ export class CategorySearchTool extends MapboxApiBasedTool< } // Geographic coordinates - if (geom.coordinates && Array.isArray(geom.coordinates)) { + if (geom && geom.type === 'Point' && geom.coordinates) { const [lng, lat] = geom.coordinates; result += `\n Coordinates: ${lat}, ${lng}`; } @@ -81,7 +88,7 @@ export class CategorySearchTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise<{ type: 'text'; text: string }> { + ): Promise> { // Build URL with required parameters const url = new URL( `${MapboxApiBasedTool.mapboxApiEndpoint}search/searchbox/v1/category/${encodeURIComponent(input.category)}` @@ -135,17 +142,31 @@ export class CategorySearchTool extends MapboxApiBasedTool< const response = await this.fetch(url.toString()); if (!response.ok) { - throw new Error( - `Failed to search category: ${response.status} ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Failed to search category: ${response.status} ${response.statusText}` + } + ], + isError: true + }; } - const data = await response.json(); + const data = (await response.json()) as MapboxFeatureCollection; if (input.format === 'json_string') { - return { type: 'text', text: JSON.stringify(data, null, 2) }; + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data as unknown as Record, + isError: false + }; } else { - return { type: 'text', text: this.formatGeoJsonToText(data) }; + return { + content: [{ type: 'text', text: this.formatGeoJsonToText(data) }], + structuredContent: data as unknown as Record, + isError: false + }; } } } diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index d7eda44..06308c3 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -4,6 +4,7 @@ import { URLSearchParams } from 'node:url'; import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; import { cleanResponseData } from './cleanResponseData.js'; import { formatIsoDateTime } from '../../utils/dateUtils.js'; import { fetchClient } from '../../utils/fetchRequest.js'; @@ -31,7 +32,7 @@ export class DirectionsTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise { + ): Promise> { // Validate exclude parameter against the actual routing_profile // This is needed because some exclusions are only driving specific if (input.exclude) { @@ -57,15 +58,27 @@ export class DirectionsTool extends MapboxApiBasedTool< item.endsWith(')') && !isDrivingProfile ) { - throw new Error( - `Point exclusions (${item}) are only available for 'driving' and 'driving-traffic' profiles` - ); + return { + content: [ + { + type: 'text', + text: `Point exclusions (${item}) are only available for 'driving' and 'driving-traffic' profiles` + } + ], + isError: true + }; } // Check for driving-only exclusions else if (drivingOnlyExclusions.includes(item) && !isDrivingProfile) { - throw new Error( - `Exclusion option '${item}' is only available for 'driving' and 'driving-traffic' profiles` - ); + return { + content: [ + { + type: 'text', + text: `Exclusion option '${item}' is only available for 'driving' and 'driving-traffic' profiles` + } + ], + isError: true + }; } // Check if it's one of the valid enum values else if ( @@ -73,11 +86,18 @@ export class DirectionsTool extends MapboxApiBasedTool< !drivingOnlyExclusions.includes(item) && !(item.startsWith('point(') && item.endsWith(')')) ) { - throw new Error( - `Invalid exclude option: '${item}'.Available options:\n` + - '- All profiles: ferry, cash_only_tolls\n' + - '- Driving/Driving-traffic profiles only: `motorway`, `toll`, `unpaved`, `tunnel`, `country_border`, `state_border` or `point( )` for custom locations (note lng and lat are space separated)\n' - ); + return { + content: [ + { + type: 'text', + text: + `Invalid exclude option: '${item}'.Available options:\\n` + + '- All profiles: ferry, cash_only_tolls\\n' + + '- Driving/Driving-traffic profiles only: `motorway`, `toll`, `unpaved`, `tunnel`, `country_border`, `state_border` or `point( )` for custom locations (note lng and lat are space separated)\\n' + } + ], + isError: true + }; } } } @@ -88,23 +108,41 @@ export class DirectionsTool extends MapboxApiBasedTool< // Validate depart_at is only used with driving profiles if (input.depart_at && !isDrivingProfile) { - throw new Error( - `The depart_at parameter is only available for 'driving' and 'driving-traffic' profiles` - ); + return { + content: [ + { + type: 'text', + text: `The depart_at parameter is only available for 'driving' and 'driving-traffic' profiles` + } + ], + isError: true + }; } // Validate arrive_by is only used with driving profile (not driving-traffic) if (input.arrive_by && input.routing_profile !== 'driving') { - throw new Error( - `The arrive_by parameter is only available for the 'driving' profile` - ); + return { + content: [ + { + type: 'text', + text: `The arrive_by parameter is only available for the 'driving' profile` + } + ], + isError: true + }; } // Validate that depart_at and arrive_by are not used together if (input.depart_at && input.arrive_by) { - throw new Error( - `The depart_at and arrive_by parameters cannot be used together in the same request` - ); + return { + content: [ + { + type: 'text', + text: `The depart_at and arrive_by parameters cannot be used together in the same request` + } + ], + isError: true + }; } // Validate vehicle dimension parameters are only used with driving profiles @@ -114,9 +152,15 @@ export class DirectionsTool extends MapboxApiBasedTool< input.max_weight !== undefined) && !isDrivingProfile ) { - throw new Error( - `Vehicle dimension parameters (max_height, max_width, max_weight) are only available for 'driving' and 'driving-traffic' profiles` - ); + return { + content: [ + { + type: 'text', + text: `Vehicle dimension parameters (max_height, max_width, max_weight) are only available for 'driving' and 'driving-traffic' profiles` + } + ], + isError: true + }; } const joined = input.coordinates @@ -188,12 +232,23 @@ export class DirectionsTool extends MapboxApiBasedTool< const response = await this.fetch(url); if (!response.ok) { - throw new Error( - `Request failed with status ${response.status}: ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Request failed with status ${response.status}: ${response.statusText}` + } + ], + isError: true + }; } const data = await response.json(); - return cleanResponseData(input, data); + const cleanedData = cleanResponseData(input, data); + return { + content: [{ type: 'text', text: JSON.stringify(cleanedData, null, 2) }], + structuredContent: cleanedData as Record, + isError: false + }; } } diff --git a/src/tools/isochrone-tool/IsochroneTool.ts b/src/tools/isochrone-tool/IsochroneTool.ts index 04b3a32..73f37bb 100644 --- a/src/tools/isochrone-tool/IsochroneTool.ts +++ b/src/tools/isochrone-tool/IsochroneTool.ts @@ -3,6 +3,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; import { IsochroneInputSchema } from './IsochroneTool.schema.js'; @@ -33,7 +34,7 @@ export class IsochroneTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise { + ): Promise> { const url = new URL( `${MapboxApiBasedTool.mapboxApiEndpoint}isochrone/v1/${input.profile}/${input.coordinates.longitude}%2C${input.coordinates.latitude}` ); @@ -42,9 +43,15 @@ export class IsochroneTool extends MapboxApiBasedTool< (!input.contours_minutes || input.contours_minutes.length === 0) && (!input.contours_meters || input.contours_meters.length === 0) ) { - throw new Error( - "At least one of 'contours_minutes' or 'contours_meters' must be provided" - ); + return { + content: [ + { + type: 'text', + text: "At least one of 'contours_minutes' or 'contours_meters' must be provided" + } + ], + isError: true + }; } if (input.contours_minutes && input.contours_minutes.length > 0) { url.searchParams.append( @@ -83,12 +90,22 @@ export class IsochroneTool extends MapboxApiBasedTool< const response = await this.fetch(url); if (!response.ok) { - throw new Error( - `Failed to calculate isochrones: ${response.status} ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Failed to calculate isochrones: ${response.status} ${response.statusText}` + } + ], + isError: true + }; } const data = await response.json(); - return data; + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data as Record, + isError: false + }; } } diff --git a/src/tools/matrix-tool/MatrixTool.ts b/src/tools/matrix-tool/MatrixTool.ts index 3516e78..c114bf1 100644 --- a/src/tools/matrix-tool/MatrixTool.ts +++ b/src/tools/matrix-tool/MatrixTool.ts @@ -4,6 +4,7 @@ import type { z } from 'zod'; import { URLSearchParams } from 'node:url'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; import { MatrixInputSchema } from './MatrixTool.schema.js'; @@ -31,12 +32,18 @@ export class MatrixTool extends MapboxApiBasedTool { protected async execute( input: z.infer, accessToken: string - ): Promise { + ): Promise> { // Validate input based on profile type if (input.profile === 'driving-traffic' && input.coordinates.length > 10) { - throw new Error( - 'The driving-traffic profile supports a maximum of 10 coordinate pairs.' - ); + return { + content: [ + { + type: 'text', + text: 'The driving-traffic profile supports a maximum of 10 coordinate pairs.' + } + ], + isError: true + }; } // Validate approaches parameter if provided @@ -44,9 +51,15 @@ export class MatrixTool extends MapboxApiBasedTool { input.approaches && input.approaches.split(';').length !== input.coordinates.length ) { - throw new Error( - 'When provided, the number of approaches (including empty/skipped) must match the number of coordinates.' - ); + return { + content: [ + { + type: 'text', + text: 'When provided, the number of approaches (including empty/skipped) must match the number of coordinates.' + } + ], + isError: true + }; } // Validate that all approaches values are either "curb" or "unrestricted" @@ -61,9 +74,15 @@ export class MatrixTool extends MapboxApiBasedTool { approach !== 'unrestricted' ) ) { - throw new Error( - 'Approaches parameter contains invalid values. Each value must be either "curb" or "unrestricted".' - ); + return { + content: [ + { + type: 'text', + text: 'Approaches parameter contains invalid values. Each value must be either "curb" or "unrestricted".' + } + ], + isError: true + }; } // Validate bearings parameter if provided @@ -71,35 +90,60 @@ export class MatrixTool extends MapboxApiBasedTool { input.bearings && input.bearings.split(';').length !== input.coordinates.length ) { - throw new Error( - 'When provided, the number of bearings (including empty/skipped) must match the number of coordinates.' - ); + return { + content: [ + { + type: 'text', + text: 'When provided, the number of bearings (including empty/skipped) must match the number of coordinates.' + } + ], + isError: true + }; } // Additional validation for bearings values if (input.bearings) { const bearingsArr = input.bearings.split(';'); - bearingsArr.forEach((bearing, idx) => { - if (bearing.trim() === '') return; // allow skipped + for (let idx = 0; idx < bearingsArr.length; idx++) { + const bearing = bearingsArr[idx]; + if (bearing.trim() === '') continue; // allow skipped const parts = bearing.split(','); if (parts.length !== 2) { - throw new Error( - `Invalid bearings format at index ${idx}: '${bearing}'. Each bearing must be two comma-separated numbers (angle,degrees).` - ); + return { + content: [ + { + type: 'text', + text: `Invalid bearings format at index ${idx}: '${bearing}'. Each bearing must be two comma-separated numbers (angle,degrees).` + } + ], + isError: true + }; } const angle = Number(parts[0]); const degrees = Number(parts[1]); if (isNaN(angle) || angle < 0 || angle > 360) { - throw new Error( - `Invalid bearing angle at index ${idx}: '${parts[0]}'. Angle must be a number between 0 and 360.` - ); + return { + content: [ + { + type: 'text', + text: `Invalid bearing angle at index ${idx}: '${parts[0]}'. Angle must be a number between 0 and 360.` + } + ], + isError: true + }; } if (isNaN(degrees) || degrees < 0 || degrees > 180) { - throw new Error( - `Invalid bearing degrees at index ${idx}: '${parts[1]}'. Degrees must be a number between 0 and 180.` - ); + return { + content: [ + { + type: 'text', + text: `Invalid bearing degrees at index ${idx}: '${parts[1]}'. Degrees must be a number between 0 and 180.` + } + ], + isError: true + }; } - }); + } } // Validate sources parameter if provided - ensure all indices are valid @@ -115,11 +159,18 @@ export class MatrixTool extends MapboxApiBasedTool { ); }) ) { - throw new Error( - 'Sources parameter contains invalid indices. All indices must be between 0 and ' + - (input.coordinates.length - 1) + - '.' - ); + return { + content: [ + { + type: 'text', + text: + 'Sources parameter contains invalid indices. All indices must be between 0 and ' + + (input.coordinates.length - 1) + + '.' + } + ], + isError: true + }; } // Validate destinations parameter if provided - ensure all indices are valid @@ -135,11 +186,18 @@ export class MatrixTool extends MapboxApiBasedTool { ); }) ) { - throw new Error( - 'Destinations parameter contains invalid indices. All indices must be between 0 and ' + - (input.coordinates.length - 1) + - '.' - ); + return { + content: [ + { + type: 'text', + text: + 'Destinations parameter contains invalid indices. All indices must be between 0 and ' + + (input.coordinates.length - 1) + + '.' + } + ], + isError: true + }; } // Validate that when specifying both sources and destinations, all coordinates are used @@ -160,9 +218,15 @@ export class MatrixTool extends MapboxApiBasedTool { // Check if all coordinate indices are used if (usedIndices.size < input.coordinates.length) { - throw new Error( - 'When specifying both sources and destinations, all coordinates must be used as either a source or destination.' - ); + return { + content: [ + { + type: 'text', + text: 'When specifying both sources and destinations, all coordinates must be used as either a source or destination.' + } + ], + isError: true + }; } } @@ -207,13 +271,23 @@ export class MatrixTool extends MapboxApiBasedTool { const response = await this.fetch(url); if (!response.ok) { - throw new Error( - `Request failed with status ${response.status}: ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Request failed with status ${response.status}: ${response.statusText}` + } + ], + isError: true + }; } // Return the matrix data const data = await response.json(); - return data; + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data as Record, + isError: false + }; } } diff --git a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts index 1ea40d8..f25cd8e 100644 --- a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts +++ b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts @@ -3,8 +3,13 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; import { ReverseGeocodeInputSchema } from './ReverseGeocodeTool.schema.js'; +import type { + MapboxFeatureCollection, + MapboxFeature +} from '../../schemas/geojson.js'; export class ReverseGeocodeTool extends MapboxApiBasedTool< typeof ReverseGeocodeInputSchema @@ -24,7 +29,9 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< super({ inputSchema: ReverseGeocodeInputSchema }); } - private formatGeoJsonToText(geoJsonResponse: any): string { + private formatGeoJsonToText( + geoJsonResponse: MapboxFeatureCollection + ): string { if ( !geoJsonResponse || !geoJsonResponse.features || @@ -34,9 +41,9 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< } const results = geoJsonResponse.features.map( - (feature: any, index: number) => { + (feature: MapboxFeature, index: number) => { const props = feature.properties || {}; - const geom = feature.geometry || {}; + const geom = feature.geometry; let result = `${index + 1}. `; @@ -54,7 +61,7 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< } // Geographic coordinates - if (geom.coordinates && Array.isArray(geom.coordinates)) { + if (geom && geom.type === 'Point' && geom.coordinates) { const [lng, lat] = geom.coordinates; result += `\n Coordinates: ${lat}, ${lng}`; } @@ -74,16 +81,22 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise<{ type: 'text'; text: string }> { + ): Promise> { // When limit > 1, must specify exactly one type if ( input.limit && input.limit > 1 && (!input.types || input.types.length !== 1) ) { - throw new Error( - 'When limit > 1 for reverse geocoding, you must specify exactly one type in the types parameter (e.g., types: ["address"]). Consider using limit: 1 instead for best results.' - ); + return { + content: [ + { + type: 'text', + text: 'When limit > 1 for reverse geocoding, you must specify exactly one type in the types parameter (e.g., types: ["address"]). Consider using limit: 1 instead for best results.' + } + ], + isError: true + }; } const url = new URL( @@ -115,22 +128,39 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< const response = await this.fetch(url.toString()); if (!response.ok) { - throw new Error( - `Failed to reverse geocode: ${response.status} ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Failed to reverse geocode: ${response.status} ${response.statusText}` + } + ], + isError: true + }; } - const data = (await response.json()) as any; + const data = (await response.json()) as MapboxFeatureCollection; // Check if the response has features if (!data || !data.features || data.features.length === 0) { - return { type: 'text', text: 'No results found.' }; + return { + content: [{ type: 'text', text: 'No results found.' }], + isError: false + }; } if (input.format === 'json_string') { - return { type: 'text', text: JSON.stringify(data, null, 2) }; + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data as unknown as Record, + isError: false + }; } else { - return { type: 'text', text: this.formatGeoJsonToText(data) }; + return { + content: [{ type: 'text', text: this.formatGeoJsonToText(data) }], + structuredContent: data as unknown as Record, + isError: false + }; } } } diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts index 2f1978c..3827ed2 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts @@ -3,8 +3,13 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; import { SearchAndGeocodeInputSchema } from './SearchAndGeocodeTool.schema.js'; +import type { + MapboxFeatureCollection, + MapboxFeature +} from '../../schemas/geojson.js'; export class SearchAndGeocodeTool extends MapboxApiBasedTool< typeof SearchAndGeocodeInputSchema @@ -24,7 +29,9 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< super({ inputSchema: SearchAndGeocodeInputSchema }); } - private formatGeoJsonToText(geoJsonResponse: any): string { + private formatGeoJsonToText( + geoJsonResponse: MapboxFeatureCollection + ): string { if ( !geoJsonResponse || !geoJsonResponse.features || @@ -33,10 +40,10 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< return 'No results found.'; } - const results = (geoJsonResponse as any).features.map( - (feature: any, index: number) => { + const results = geoJsonResponse.features.map( + (feature: MapboxFeature, index: number) => { const props = feature.properties || {}; - const geom = feature.geometry || {}; + const geom = feature.geometry; let result = `${index + 1}. `; @@ -54,7 +61,7 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< } // Geographic coordinates - if (geom.coordinates && Array.isArray(geom.coordinates)) { + if (geom && geom.type === 'Point' && geom.coordinates) { const [lng, lat] = geom.coordinates; result += `\n Coordinates: ${lat}, ${lng}`; } @@ -81,7 +88,7 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise<{ type: 'text'; text: string }> { + ): Promise> { this.log( 'info', `SearchAndGeocodeTool: Starting search with input: ${JSON.stringify(input)}` @@ -169,17 +176,27 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< 'error', `SearchAndGeocodeTool: API Error - Status: ${response.status}, Body: ${errorBody}` ); - throw new Error( - `Failed to search: ${response.status} ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Failed to search: ${response.status} ${response.statusText}` + } + ], + isError: true + }; } - const data = await response.json(); + const data = (await response.json()) as MapboxFeatureCollection; this.log( 'info', - `SearchAndGeocodeTool: Successfully completed search, found ${(data as any).features?.length || 0} results` + `SearchAndGeocodeTool: Successfully completed search, found ${data.features?.length || 0} results` ); - return { type: 'text', text: this.formatGeoJsonToText(data) }; + return { + content: [{ type: 'text', text: this.formatGeoJsonToText(data) }], + structuredContent: data as unknown as Record, + isError: false + }; } } diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index 5e4898e..5a10b50 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -3,6 +3,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; import { StaticMapImageInputSchema } from './StaticMapImageTool.schema.js'; import type { OverlaySchema } from './StaticMapImageTool.schema.js'; @@ -78,7 +79,7 @@ export class StaticMapImageTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise { + ): Promise> { const { longitude: lng, latitude: lat } = input.center; const { width, height } = input.size; @@ -97,9 +98,15 @@ export class StaticMapImageTool extends MapboxApiBasedTool< const response = await this.fetch(url); if (!response.ok) { - throw new Error( - `Failed to fetch map image: ${response.status} ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Failed to fetch map image: ${response.status} ${response.statusText}` + } + ], + isError: true + }; } const buffer = await response.arrayBuffer(); @@ -111,9 +118,14 @@ export class StaticMapImageTool extends MapboxApiBasedTool< const mimeType = isRasterStyle ? 'image/jpeg' : 'image/png'; return { - type: 'image', - data: base64Data, - mimeType + content: [ + { + type: 'image', + data: base64Data, + mimeType + } + ], + isError: false }; } } diff --git a/src/tools/version-tool/VersionTool.ts b/src/tools/version-tool/VersionTool.ts index f8b12a6..6e4fc8c 100644 --- a/src/tools/version-tool/VersionTool.ts +++ b/src/tools/version-tool/VersionTool.ts @@ -4,6 +4,8 @@ import { BaseTool } from '../BaseTool.js'; import { getVersionInfo } from '../../utils/versionUtils.js'; import { VersionSchema } from './VersionTool.schema.js'; +import type { z } from 'zod'; +import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; export class VersionTool extends BaseTool { readonly name = 'version_tool'; @@ -22,13 +24,7 @@ export class VersionTool extends BaseTool { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - async run(_rawInput: unknown): Promise<{ - content: Array<{ - type: 'text'; - text: string; - }>; - isError: boolean; - }> { + async run(_rawInput: unknown): Promise> { try { const versionInfo = getVersionInfo(); @@ -41,6 +37,7 @@ export class VersionTool extends BaseTool { return { content: [{ type: 'text', text: versionText }], + structuredContent: versionInfo as unknown as Record, isError: false }; } catch (error) { diff --git a/test/tools/MapboxApiBasedTool.test.ts b/test/tools/MapboxApiBasedTool.test.ts index f01e713..4f048f3 100644 --- a/test/tools/MapboxApiBasedTool.test.ts +++ b/test/tools/MapboxApiBasedTool.test.ts @@ -85,7 +85,10 @@ describe('MapboxApiBasedTool', () => { 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.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' }); diff --git a/test/tools/isochrone-tool/IsochroneTool.test.ts b/test/tools/isochrone-tool/IsochroneTool.test.ts index bad0268..a36070f 100644 --- a/test/tools/isochrone-tool/IsochroneTool.test.ts +++ b/test/tools/isochrone-tool/IsochroneTool.test.ts @@ -103,7 +103,7 @@ describe('IsochroneTool', () => { assertHeadersSent(mockFetch); expect(result.content[0].type).toEqual('text'); if (result.content[0].type == 'text') { - expect(result.content[0].text).toEqual(JSON.stringify(geojson)); + expect(result.content[0].text).toEqual(JSON.stringify(geojson, null, 2)); } }); diff --git a/test/tools/matrix-tool/MatrixTool.test.ts b/test/tools/matrix-tool/MatrixTool.test.ts index 3bbd190..7ba2e93 100644 --- a/test/tools/matrix-tool/MatrixTool.test.ts +++ b/test/tools/matrix-tool/MatrixTool.test.ts @@ -277,17 +277,21 @@ describe('MatrixTool', () => { expect(mockFetch).not.toHaveBeenCalled(); // Test for specific error message by calling execute directly - await expect(async () => { - await tool['execute']( - { - coordinates, - profile: 'driving-traffic' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'The driving-traffic profile supports a maximum of 10 coordinate pairs.' + const errorResult = await tool['execute']( + { + coordinates, + profile: 'driving-traffic' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(errorResult.isError).toBe(true); + expect(errorResult.content[0].type).toBe('text'); + if (errorResult.content[0].type === 'text') { + expect(errorResult.content[0].text).toBe( + 'The driving-traffic profile supports a maximum of 10 coordinate pairs.' + ); + } }); // Input validation tests @@ -384,22 +388,26 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error for approaches length mismatch - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 }, - { longitude: -122.48, latitude: 37.73 } - ], - profile: 'driving', - approaches: 'curb;unrestricted' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'When provided, the number of approaches (including empty/skipped) must match the number of coordinates.' + const approachesResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 }, + { longitude: -122.48, latitude: 37.73 } + ], + profile: 'driving', + approaches: 'curb;unrestricted' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(approachesResult.isError).toBe(true); + expect(approachesResult.content[0].type).toBe('text'); + if (approachesResult.content[0].type === 'text') { + expect(approachesResult.content[0].text).toContain( + 'When provided, the number of approaches (including empty/skipped) must match the number of coordinates.' + ); + } }); it('validates approaches parameter values', async () => { @@ -415,21 +423,25 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error for invalid approach value - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 } - ], - profile: 'driving', - approaches: 'curb;invalid' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'Approaches parameter contains invalid values. Each value must be either "curb" or "unrestricted".' + const invalidApproachResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 } + ], + profile: 'driving', + approaches: 'curb;invalid' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(invalidApproachResult.isError).toBe(true); + expect(invalidApproachResult.content[0].type).toBe('text'); + if (invalidApproachResult.content[0].type === 'text') { + expect(invalidApproachResult.content[0].text).toContain( + 'Approaches parameter contains invalid values. Each value must be either "curb" or "unrestricted".' + ); + } }); it('validates bearings parameter length', async () => { @@ -446,22 +458,26 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error for bearings length mismatch - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 }, - { longitude: -122.48, latitude: 37.73 } - ], - profile: 'driving', - bearings: '45,90;120,45' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'When provided, the number of bearings (including empty/skipped) must match the number of coordinates.' + const bearingsLengthResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 }, + { longitude: -122.48, latitude: 37.73 } + ], + profile: 'driving', + bearings: '45,90;120,45' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(bearingsLengthResult.isError).toBe(true); + expect(bearingsLengthResult.content[0].type).toBe('text'); + if (bearingsLengthResult.content[0].type === 'text') { + expect(bearingsLengthResult.content[0].text).toContain( + 'When provided, the number of bearings (including empty/skipped) must match the number of coordinates.' + ); + } }); it('validates bearings parameter format', async () => { @@ -477,19 +493,25 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error for invalid bearing format - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 } - ], - profile: 'driving', - bearings: '45,90;invalid' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' + const invalidBearingResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 } + ], + profile: 'driving', + bearings: '45,90;invalid' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' + ); + + expect(invalidBearingResult.isError).toBe(true); + expect(invalidBearingResult.content[0].type).toBe('text'); + if (invalidBearingResult.content[0].type === 'text') { + expect(invalidBearingResult.content[0].text).toContain( + 'Invalid bearings format at index 1' ); - }).rejects.toThrow('Invalid bearings format at index 1'); + } }); it('validates bearings parameter angle range', async () => { @@ -505,19 +527,25 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error for invalid bearing angle - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 } - ], - profile: 'driving', - bearings: '400,90;120,45' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' + const invalidAngleResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 } + ], + profile: 'driving', + bearings: '400,90;120,45' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' + ); + + expect(invalidAngleResult.isError).toBe(true); + expect(invalidAngleResult.content[0].type).toBe('text'); + if (invalidAngleResult.content[0].type === 'text') { + expect(invalidAngleResult.content[0].text).toContain( + 'Invalid bearing angle at index 0' ); - }).rejects.toThrow('Invalid bearing angle at index 0'); + } }); it('validates bearings parameter degrees range', async () => { @@ -533,19 +561,25 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error for invalid bearing degrees - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 } - ], - profile: 'driving', - bearings: '45,200;120,45' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' + const invalidDegreesResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 } + ], + profile: 'driving', + bearings: '45,200;120,45' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' + ); + + expect(invalidDegreesResult.isError).toBe(true); + expect(invalidDegreesResult.content[0].type).toBe('text'); + if (invalidDegreesResult.content[0].type === 'text') { + expect(invalidDegreesResult.content[0].text).toContain( + 'Invalid bearing degrees at index 0' ); - }).rejects.toThrow('Invalid bearing degrees at index 0'); + } }); it('validates sources parameter indices', async () => { @@ -561,21 +595,25 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error message for invalid sources indices - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 } - ], - profile: 'driving', - sources: '0;2' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'Sources parameter contains invalid indices. All indices must be between 0 and 1.' + const invalidSourcesResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 } + ], + profile: 'driving', + sources: '0;2' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(invalidSourcesResult.isError).toBe(true); + expect(invalidSourcesResult.content[0].type).toBe('text'); + if (invalidSourcesResult.content[0].type === 'text') { + expect(invalidSourcesResult.content[0].text).toContain( + 'Sources parameter contains invalid indices. All indices must be between 0 and 1.' + ); + } }); it('validates destinations parameter indices', async () => { @@ -591,21 +629,25 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error message for invalid destinations indices - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 } - ], - profile: 'driving', - destinations: '3' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'Destinations parameter contains invalid indices. All indices must be between 0 and 1.' + const invalidDestinationsResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 } + ], + profile: 'driving', + destinations: '3' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(invalidDestinationsResult.isError).toBe(true); + expect(invalidDestinationsResult.content[0].type).toBe('text'); + if (invalidDestinationsResult.content[0].type === 'text') { + expect(invalidDestinationsResult.content[0].text).toContain( + 'Destinations parameter contains invalid indices. All indices must be between 0 and 1.' + ); + } }); it('validates destinations parameter index negative', async () => { @@ -621,21 +663,25 @@ describe('MatrixTool', () => { expect(result.isError).toBe(true); // Test direct error message for invalid destinations indices - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 } - ], - profile: 'driving', - destinations: '-1' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'Destinations parameter contains invalid indices. All indices must be between 0 and 1.' + const negativeDestinationsResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 } + ], + profile: 'driving', + destinations: '-1' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(negativeDestinationsResult.isError).toBe(true); + expect(negativeDestinationsResult.content[0].type).toBe('text'); + if (negativeDestinationsResult.content[0].type === 'text') { + expect(negativeDestinationsResult.content[0].text).toContain( + 'Destinations parameter contains invalid indices. All indices must be between 0 and 1.' + ); + } }); it('accepts valid "all" value for sources', async () => { @@ -773,23 +819,27 @@ describe('MatrixTool', () => { expect(mockFetch).not.toHaveBeenCalled(); // Test direct error message for unused coordinates - await expect(async () => { - await tool['execute']( - { - coordinates: [ - { longitude: -122.42, latitude: 37.78 }, - { longitude: -122.45, latitude: 37.91 }, - { longitude: -122.48, latitude: 37.73 } - ], - profile: 'driving', - sources: '1', - destinations: '2' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'When specifying both sources and destinations, all coordinates must be used as either a source or destination.' + const unusedCoordsResult = await tool['execute']( + { + coordinates: [ + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.45, latitude: 37.91 }, + { longitude: -122.48, latitude: 37.73 } + ], + profile: 'driving', + sources: '1', + destinations: '2' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(unusedCoordsResult.isError).toBe(true); + expect(unusedCoordsResult.content[0].type).toBe('text'); + if (unusedCoordsResult.content[0].type === 'text') { + expect(unusedCoordsResult.content[0].text).toContain( + 'When specifying both sources and destinations, all coordinates must be used as either a source or destination.' + ); + } }); it('accepts sources and destinations with single indices when all coordinates are used', async () => { @@ -905,17 +955,21 @@ describe('MatrixTool', () => { expect(mockFetch).not.toHaveBeenCalled(); // Test direct error message for exceeding coordinate limit - await expect(async () => { - await tool['execute']( - { - coordinates, - profile: 'driving-traffic' - }, - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' - ); - }).rejects.toThrow( - 'The driving-traffic profile supports a maximum of 10 coordinate pairs.' + const trafficErrorResult = await tool['execute']( + { + coordinates, + profile: 'driving-traffic' + }, + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); + + expect(trafficErrorResult.isError).toBe(true); + expect(trafficErrorResult.content[0].type).toBe('text'); + if (trafficErrorResult.content[0].type === 'text') { + expect(trafficErrorResult.content[0].text).toContain( + 'The driving-traffic profile supports a maximum of 10 coordinate pairs.' + ); + } }); }); diff --git a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts index e7e7adf..7839e49 100644 --- a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts +++ b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts @@ -122,7 +122,7 @@ describe('SearchAndGeocodeTool', () => { }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { fetch } = setupFetch({ ok: false, status: 404, statusText: 'Not Found', @@ -227,7 +227,7 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch, mockFetch } = setupFetch({ + const { fetch } = setupFetch({ json: async () => mockResponse }); @@ -292,7 +292,7 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch, mockFetch } = setupFetch({ + const { fetch } = setupFetch({ json: async () => mockResponse }); @@ -343,7 +343,7 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch, mockFetch } = setupFetch({ + const { fetch } = setupFetch({ json: async () => mockResponse }); @@ -367,7 +367,7 @@ describe('SearchAndGeocodeTool', () => { features: [] }; - const { fetch, mockFetch } = setupFetch({ + const { fetch } = setupFetch({ json: async () => mockResponse }); @@ -399,7 +399,7 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch, mockFetch } = setupFetch({ + const { fetch } = setupFetch({ json: async () => mockResponse }); @@ -437,7 +437,7 @@ describe('SearchAndGeocodeTool', () => { await expect( tool.run({ q: 'test', - proximity: 'invalid-format' as any + proximity: 'invalid-format' }) ).resolves.toMatchObject({ isError: true diff --git a/test/tools/structured-content.test.ts b/test/tools/structured-content.test.ts new file mode 100644 index 0000000..f0f0262 --- /dev/null +++ b/test/tools/structured-content.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MapboxApiBasedTool } from '../../src/tools/MapboxApiBasedTool.js'; +import type { OutputSchema } from '../../src/tools/MapboxApiBasedTool.schema.js'; +import { z } from 'zod'; + +const TestInputSchema = z.object({ + test: z.string() +}); + +class TestTool extends MapboxApiBasedTool { + name = 'test_tool'; + description = 'Test tool for structured content'; + annotations = { + title: 'Test Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + + constructor() { + super({ inputSchema: TestInputSchema }); + } + + protected async execute( + input: z.infer, + _accessToken: string + ): Promise> { + // Return different types based on input + if (input.test === 'object') { + const data = { + message: 'This is structured content', + data: { value: 42, success: true }, + timestamp: new Date().toISOString() + }; + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data, + isError: false + }; + } + if (input.test === 'content') { + return { + content: [{ type: 'text', text: 'This is direct content' }], + isError: false + }; + } + if (input.test === 'complete') { + // Return complete OutputSchema + return { + content: [ + { type: 'text', text: 'Custom content message' }, + { type: 'text', text: 'Additional context' } + ], + structuredContent: { + operation: 'complete_output', + results: ['item1', 'item2'], + metadata: { count: 2, status: 'success' } + }, + isError: false + }; + } + return { + content: [{ type: 'text', text: '"Simple string response"' }], + isError: false + }; + } +} + +describe('MapboxApiBasedTool Structured Content', () => { + let tool: TestTool; + + beforeEach(() => { + tool = new TestTool(); + vi.stubEnv('MAPBOX_ACCESS_TOKEN', 'pk.test.token'); + }); + + it('should return structured content for object responses', async () => { + const result = await tool.run({ test: 'object' }); + + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toHaveProperty( + 'message', + 'This is structured content' + ); + expect(result.structuredContent).toHaveProperty('data'); + expect(result.structuredContent?.data).toEqual({ + value: 42, + success: true + }); + }); + + it('should return direct content without structured content', async () => { + const result = await tool.run({ test: 'content' }); + + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ + type: 'text', + text: 'This is direct content' + }); + expect(result.structuredContent).toBeUndefined(); + }); + + it('should return simple content for primitive responses', async () => { + const result = await tool.run({ test: 'string' }); + + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ + type: 'text', + text: '"Simple string response"' + }); + expect(result.structuredContent).toBeUndefined(); + }); + + it('should return complete OutputSchema when provided', async () => { + const result = await tool.run({ test: 'complete' }); + + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(2); + expect(result.content[0]).toEqual({ + type: 'text', + text: 'Custom content message' + }); + expect(result.content[1]).toEqual({ + type: 'text', + text: 'Additional context' + }); + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toEqual({ + operation: 'complete_output', + results: ['item1', 'item2'], + metadata: { count: 2, status: 'success' } + }); + }); +}); diff --git a/test/tools/version-tool/VersionTool.test.ts b/test/tools/version-tool/VersionTool.test.ts index 20678d2..108327a 100644 --- a/test/tools/version-tool/VersionTool.test.ts +++ b/test/tools/version-tool/VersionTool.test.ts @@ -33,12 +33,28 @@ describe('VersionTool', () => { expect(result.isError).toBe(false); expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Test MCP Server'); - expect(result.content[0].text).toContain('1.0.0'); - expect(result.content[0].text).toContain('abc123'); - expect(result.content[0].text).toContain('v1.0.0'); - expect(result.content[0].text).toContain('main'); + + // Best approach: exact match with template literal for readability and precision + const expectedText = `MCP Server Version Information: +- Name: Test MCP Server +- Version: 1.0.0 +- SHA: abc123 +- Tag: v1.0.0 +- Branch: main`; + expect(result.content[0]).toEqual({ + type: 'text', + text: expectedText + }); + + // Verify structured content is included + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent).toEqual({ + name: 'Test MCP Server', + version: '1.0.0', + sha: 'abc123', + tag: 'v1.0.0', + branch: 'main' + }); }); it('should handle errors gracefully', async () => { From a685231ceb32b31b0a4e03bf579c5ed1d5fb0121 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 8 Oct 2025 15:23:53 -0400 Subject: [PATCH 2/9] [tools] Update tools to use structuredContent --- src/tools/BaseTool.ts | 46 ++- ...ts => MapboxApiBasedTool.output.schema.ts} | 0 src/tools/MapboxApiBasedTool.ts | 13 +- ...ma.ts => CategoryListTool.input.schema.ts} | 0 .../CategoryListTool.output.schema.ts | 12 + .../category-list-tool/CategoryListTool.ts | 40 +- ....ts => CategorySearchTool.input.schema.ts} | 0 .../CategorySearchTool.output.schema.ts | 173 ++++++++ .../CategorySearchTool.ts | 30 +- ...hema.ts => DirectionsTool.input.schema.ts} | 0 .../DirectionsTool.output.schema.ts | 388 ++++++++++++++++++ src/tools/directions-tool/DirectionsTool.ts | 34 +- .../directions-tool/cleanResponseData.ts | 2 +- ...chema.ts => IsochroneTool.input.schema.ts} | 0 .../IsochroneTool.output.schema.ts | 66 +++ src/tools/isochrone-tool/IsochroneTool.ts | 88 +++- ...l.schema.ts => MatrixTool.input.schema.ts} | 0 .../matrix-tool/MatrixTool.output.schema.ts | 24 ++ src/tools/matrix-tool/MatrixTool.ts | 33 +- ....ts => ReverseGeocodeTool.input.schema.ts} | 0 .../ReverseGeocodeTool.output.schema.ts | 232 +++++++++++ .../ReverseGeocodeTool.ts | 31 +- ...s => SearchAndGeocodeTool.input.schema.ts} | 0 .../SearchAndGeocodeTool.output.schema.ts | 139 +++++++ .../SearchAndGeocodeTool.ts | 43 +- ....ts => StaticMapImageTool.input.schema.ts} | 0 .../StaticMapImageTool.ts | 6 +- ....schema.ts => VersionTool.input.schema.ts} | 0 .../version-tool/VersionTool.output.schema.ts | 15 + src/tools/version-tool/VersionTool.ts | 59 +-- .../CategoryListTool.output.schema.test.ts | 158 +++++++ .../CategoryListTool.test.ts | 6 + .../CategorySearchTool.output.schema.test.ts | 314 ++++++++++++++ .../CategorySearchTool.test.ts | 6 + .../DirectionsTool.output.schema.test.ts | 70 ++++ .../IsochroneTool.output.schema.test.ts | 129 ++++++ .../IsochroneTool.registration.test.ts | 83 ++++ .../MatrixTool.output.schema.test.ts | 142 +++++++ .../ReverseGeocodeTool.output.schema.test.ts | 276 +++++++++++++ .../ReverseGeocodeTool.test.ts | 7 + ...SearchAndGeocodeTool.output.schema.test.ts | 307 ++++++++++++++ test/tools/structured-content.test.ts | 2 +- .../VersionTool.output.schema.test.ts | 161 ++++++++ test/tools/version-tool/VersionTool.test.ts | 28 +- 44 files changed, 3063 insertions(+), 100 deletions(-) rename src/tools/{MapboxApiBasedTool.schema.ts => MapboxApiBasedTool.output.schema.ts} (100%) rename src/tools/category-list-tool/{CategoryListTool.schema.ts => CategoryListTool.input.schema.ts} (100%) create mode 100644 src/tools/category-list-tool/CategoryListTool.output.schema.ts rename src/tools/category-search-tool/{CategorySearchTool.schema.ts => CategorySearchTool.input.schema.ts} (100%) create mode 100644 src/tools/category-search-tool/CategorySearchTool.output.schema.ts rename src/tools/directions-tool/{DirectionsTool.schema.ts => DirectionsTool.input.schema.ts} (100%) create mode 100644 src/tools/directions-tool/DirectionsTool.output.schema.ts rename src/tools/isochrone-tool/{IsochroneTool.schema.ts => IsochroneTool.input.schema.ts} (100%) create mode 100644 src/tools/isochrone-tool/IsochroneTool.output.schema.ts rename src/tools/matrix-tool/{MatrixTool.schema.ts => MatrixTool.input.schema.ts} (100%) create mode 100644 src/tools/matrix-tool/MatrixTool.output.schema.ts rename src/tools/reverse-geocode-tool/{ReverseGeocodeTool.schema.ts => ReverseGeocodeTool.input.schema.ts} (100%) create mode 100644 src/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.ts rename src/tools/search-and-geocode-tool/{SearchAndGeocodeTool.schema.ts => SearchAndGeocodeTool.input.schema.ts} (100%) create mode 100644 src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts rename src/tools/static-map-image-tool/{StaticMapImageTool.schema.ts => StaticMapImageTool.input.schema.ts} (100%) rename src/tools/version-tool/{VersionTool.schema.ts => VersionTool.input.schema.ts} (100%) create mode 100644 src/tools/version-tool/VersionTool.output.schema.ts create mode 100644 test/tools/category-list-tool/CategoryListTool.output.schema.test.ts create mode 100644 test/tools/category-search-tool/CategorySearchTool.output.schema.test.ts create mode 100644 test/tools/directions-tool/DirectionsTool.output.schema.test.ts create mode 100644 test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts create mode 100644 test/tools/isochrone-tool/IsochroneTool.registration.test.ts create mode 100644 test/tools/matrix-tool/MatrixTool.output.schema.test.ts create mode 100644 test/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.test.ts create mode 100644 test/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.test.ts create mode 100644 test/tools/version-tool/VersionTool.output.schema.test.ts diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 42997a2..02c35b6 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -13,16 +13,24 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/proto import type { ZodTypeAny } from 'zod'; import type { z } from 'zod'; -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; } /** @@ -30,16 +38,32 @@ export abstract class BaseTool { */ installTo(server: McpServer): RegisteredTool { this.server = server; - return server.registerTool( - this.name, - { - title: this.annotations.title, - description: this.description, + + 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) { + config.outputSchema = // eslint-disable-next-line @typescript-eslint/no-explicit-any - inputSchema: (this.inputSchema as unknown as z.ZodObject).shape, - annotations: this.annotations - }, - (args, extra) => this.run(args, extra) + (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.schema.ts b/src/tools/MapboxApiBasedTool.output.schema.ts similarity index 100% rename from src/tools/MapboxApiBasedTool.schema.ts rename to src/tools/MapboxApiBasedTool.output.schema.ts diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 2b86709..edfef1e 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -4,11 +4,12 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { ZodTypeAny, z } from 'zod'; import { BaseTool } from './BaseTool.js'; -import type { OutputSchema } from './MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from './MapboxApiBasedTool.output.schema.js'; 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: import('@modelcontextprotocol/sdk/types.js').ToolAnnotations; @@ -21,7 +22,10 @@ export abstract class MapboxApiBasedTool< return process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'; } - constructor(params: { inputSchema: InputSchema }) { + constructor(params: { + inputSchema: InputSchema; + outputSchema?: OutputSchema; + }) { super(params); } @@ -45,6 +49,7 @@ export abstract class MapboxApiBasedTool< */ async run( rawInput: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any extra?: RequestHandlerExtra ): Promise> { try { diff --git a/src/tools/category-list-tool/CategoryListTool.schema.ts b/src/tools/category-list-tool/CategoryListTool.input.schema.ts similarity index 100% rename from src/tools/category-list-tool/CategoryListTool.schema.ts rename to src/tools/category-list-tool/CategoryListTool.input.schema.ts diff --git a/src/tools/category-list-tool/CategoryListTool.output.schema.ts b/src/tools/category-list-tool/CategoryListTool.output.schema.ts new file mode 100644 index 0000000..793a72a --- /dev/null +++ b/src/tools/category-list-tool/CategoryListTool.output.schema.ts @@ -0,0 +1,12 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Schema for the simplified output that the tool actually returns +// Just an array of category ID strings +export const CategoryListResponseSchema = z.object({ + listItems: z.array(z.string()) +}); + +export type CategoryListResponse = z.infer; diff --git a/src/tools/category-list-tool/CategoryListTool.ts b/src/tools/category-list-tool/CategoryListTool.ts index d1faada..5e3dfb6 100644 --- a/src/tools/category-list-tool/CategoryListTool.ts +++ b/src/tools/category-list-tool/CategoryListTool.ts @@ -2,13 +2,14 @@ // Licensed under the MIT License. import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; -import type { CategoryListInput } from './CategoryListTool.schema.js'; -import { CategoryListInputSchema } from './CategoryListTool.schema.js'; -import type { z } from 'zod'; +import type { CategoryListInput } from './CategoryListTool.input.schema.js'; +import { CategoryListInputSchema } from './CategoryListTool.input.schema.js'; +import { CategoryListResponseSchema } from './CategoryListTool.output.schema.js'; -interface CategoryListResponse { +// Interface for the full API response from Mapbox +interface MapboxApiResponse { listItems: Array<{ canonical_id: string; icon: string; @@ -16,14 +17,19 @@ interface CategoryListResponse { version?: string; uuid?: string; }>; + attribution: string; version: string; } +import type { z } from 'zod'; + +// API Documentation: https://docs.mapbox.com/api/search/search-box/#list-categories /** * Tool for retrieving the list of supported categories from Mapbox Search API */ export class CategoryListTool extends MapboxApiBasedTool< - typeof CategoryListInputSchema + typeof CategoryListInputSchema, + typeof CategoryListResponseSchema > { name = 'category_list_tool'; description = @@ -37,7 +43,10 @@ export class CategoryListTool extends MapboxApiBasedTool< }; constructor(private fetchImpl: typeof fetch = fetchClient) { - super({ inputSchema: CategoryListInputSchema }); + super({ + inputSchema: CategoryListInputSchema, + outputSchema: CategoryListResponseSchema + }); } protected async execute( @@ -73,7 +82,10 @@ export class CategoryListTool extends MapboxApiBasedTool< }; } - const data = (await response.json()) as CategoryListResponse; + const rawData = await response.json(); + + // Parse the API response (which has the full structure) + const data = rawData as MapboxApiResponse; // Apply pagination - if no limit specified, return all const startIndex = input.offset || 0; @@ -83,13 +95,23 @@ export class CategoryListTool extends MapboxApiBasedTool< endIndex = Math.min(startIndex + input.limit, data.listItems.length); } - // Return simple object with listItems array + // Extract just the category IDs for our simplified response const categoryIds = data.listItems .slice(startIndex, endIndex) .map((item) => item.canonical_id); const result = { listItems: categoryIds }; + // Validate our simplified output against the schema + try { + CategoryListResponseSchema.parse(result); + } catch (validationError) { + this.log( + 'warning', + `Output schema validation failed: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` + ); + } + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, diff --git a/src/tools/category-search-tool/CategorySearchTool.schema.ts b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts similarity index 100% rename from src/tools/category-search-tool/CategorySearchTool.schema.ts rename to src/tools/category-search-tool/CategorySearchTool.input.schema.ts diff --git a/src/tools/category-search-tool/CategorySearchTool.output.schema.ts b/src/tools/category-search-tool/CategorySearchTool.output.schema.ts new file mode 100644 index 0000000..d51af7a --- /dev/null +++ b/src/tools/category-search-tool/CategorySearchTool.output.schema.ts @@ -0,0 +1,173 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Context sub-object schemas for different geographic levels +const ContextCountrySchema = z.object({ + id: z.string().optional(), + name: z.string(), + country_code: z.string(), + country_code_alpha_3: z.string() +}); + +const ContextRegionSchema = z.object({ + id: z.string().optional(), + name: z.string(), + region_code: z.string(), + region_code_full: z.string() +}); + +const ContextPostcodeSchema = z.object({ + id: z.string().optional(), + name: z.string() +}); + +const ContextDistrictSchema = z.object({ + id: z.string().optional(), + name: z.string() +}); + +const ContextPlaceSchema = z.object({ + id: z.string().optional(), + name: z.string() +}); + +const ContextLocalitySchema = z.object({ + id: z.string().optional(), + name: z.string() +}); + +const ContextNeighborhoodSchema = z.object({ + id: z.string().optional(), + name: z.string() +}); + +const ContextAddressSchema = z.object({ + id: z.string().optional(), + name: z.string(), + address_number: z.string().optional(), + street_name: z.string().optional() +}); + +const ContextStreetSchema = z.object({ + id: z.string().optional(), + name: z.string() +}); + +// Context object schema +const ContextSchema = z.object({ + country: ContextCountrySchema.optional(), + region: ContextRegionSchema.optional(), + postcode: ContextPostcodeSchema.optional(), + district: ContextDistrictSchema.optional(), + place: ContextPlaceSchema.optional(), + locality: ContextLocalitySchema.optional(), + neighborhood: ContextNeighborhoodSchema.optional(), + address: ContextAddressSchema.optional(), + street: ContextStreetSchema.optional() +}); + +// Routable point schema +const RoutablePointSchema = z.object({ + name: z.string(), + latitude: z.number(), + longitude: z.number(), + note: z.string().optional() +}); + +// Coordinates object schema +const CoordinatesSchema = z.object({ + longitude: z.number(), + latitude: z.number(), + accuracy: z + .enum([ + 'rooftop', + 'parcel', + 'point', + 'interpolated', + 'intersection', + 'approximate', + 'street' + ]) + .optional(), + routable_points: z.array(RoutablePointSchema).optional() +}); + +// Metadata schema for additional feature information +const MetadataSchema = z.object({ + primary_photo: z.array(z.string()).optional(), + reading: z + .object({ + ja_kana: z.string().optional(), + ja_latin: z.string().optional() + }) + .optional() +}); + +// Feature properties schema +const FeaturePropertiesSchema = z.object({ + name: z.string(), + name_preferred: z.string().optional(), + mapbox_id: z.string(), + feature_type: z.enum([ + 'poi', + 'country', + 'region', + 'postcode', + 'district', + 'place', + 'locality', + 'neighborhood', + 'address' + ]), + address: z.string().optional(), + full_address: z.string().optional(), + place_formatted: z.string().optional(), + context: ContextSchema, + coordinates: CoordinatesSchema, + bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + language: z.string().optional(), + maki: z.string().optional(), + poi_category: z.array(z.string()).optional(), + poi_category_ids: z.array(z.string()).optional(), + brand: z.array(z.string()).optional(), + brand_id: z.array(z.string()).optional(), + external_ids: z.record(z.string()).optional(), + metadata: MetadataSchema.optional(), + distance: z.number().optional(), + eta: z.number().optional(), + added_distance: z.number().optional(), + added_time: z.number().optional() +}); + +// GeoJSON Point geometry schema +const PointGeometrySchema = z.object({ + type: z.literal('Point'), + coordinates: z.tuple([z.number(), z.number()]) +}); + +// Feature schema +const FeatureSchema = z.object({ + type: z.literal('Feature'), + geometry: PointGeometrySchema, + properties: FeaturePropertiesSchema +}); + +// Main Search Box API Category Search response schema (FeatureCollection) +export const CategorySearchResponseSchema = z.object({ + type: z.literal('FeatureCollection'), + features: z.array(FeatureSchema), + attribution: z.string() +}); + +export type CategorySearchResponse = z.infer< + typeof CategorySearchResponseSchema +>; +export type CategorySearchFeature = z.infer; +export type CategorySearchFeatureProperties = z.infer< + typeof FeaturePropertiesSchema +>; +export type CategorySearchContext = z.infer; +export type CategorySearchCoordinates = z.infer; +export type CategorySearchMetadata = z.infer; diff --git a/src/tools/category-search-tool/CategorySearchTool.ts b/src/tools/category-search-tool/CategorySearchTool.ts index cc9d4e3..b15a49a 100644 --- a/src/tools/category-search-tool/CategorySearchTool.ts +++ b/src/tools/category-search-tool/CategorySearchTool.ts @@ -3,16 +3,20 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; -import { CategorySearchInputSchema } from './CategorySearchTool.schema.js'; +import { CategorySearchInputSchema } from './CategorySearchTool.input.schema.js'; +import { CategorySearchResponseSchema } from './CategorySearchTool.output.schema.js'; import type { MapboxFeatureCollection, MapboxFeature } from '../../schemas/geojson.js'; +// API Documentation: https://docs.mapbox.com/api/search/search-box/#category-search + export class CategorySearchTool extends MapboxApiBasedTool< - typeof CategorySearchInputSchema + typeof CategorySearchInputSchema, + typeof CategorySearchResponseSchema > { name = 'category_search_tool'; description = @@ -26,7 +30,10 @@ export class CategorySearchTool extends MapboxApiBasedTool< }; constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: CategorySearchInputSchema }); + super({ + inputSchema: CategorySearchInputSchema, + outputSchema: CategorySearchResponseSchema + }); } private formatGeoJsonToText( @@ -153,7 +160,20 @@ export class CategorySearchTool extends MapboxApiBasedTool< }; } - const data = (await response.json()) as MapboxFeatureCollection; + const rawData = await response.json(); + + // Validate response against schema with graceful fallback + let data: MapboxFeatureCollection; + try { + data = CategorySearchResponseSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for category search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` + ); + // Graceful fallback to raw data + data = rawData as MapboxFeatureCollection; + } if (input.format === 'json_string') { return { diff --git a/src/tools/directions-tool/DirectionsTool.schema.ts b/src/tools/directions-tool/DirectionsTool.input.schema.ts similarity index 100% rename from src/tools/directions-tool/DirectionsTool.schema.ts rename to src/tools/directions-tool/DirectionsTool.input.schema.ts diff --git a/src/tools/directions-tool/DirectionsTool.output.schema.ts b/src/tools/directions-tool/DirectionsTool.output.schema.ts new file mode 100644 index 0000000..0523025 --- /dev/null +++ b/src/tools/directions-tool/DirectionsTool.output.schema.ts @@ -0,0 +1,388 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// GeoJSON LineString schema +const GeoJSONLineStringSchema = z.object({ + type: z.literal('LineString'), + coordinates: z.array( + z + .tuple([z.number(), z.number()]) + .or(z.tuple([z.number(), z.number(), z.number()])) + ) +}); + +// Time zone information schema +const TimeZoneSchema = z.object({ + identifier: z.string(), + offset: z.string(), + abbreviation: z.string().optional() +}); + +// Waypoint metadata for charging stations (EV routing) +const ChargingStationMetadataSchema = z.object({ + type: z.literal('charging-station'), + name: z.string(), + charge_time: z.number(), + charge_to: z.number(), + charge_at_arrival: z.number(), + plug_type: z.string(), + current_type: z.string().optional(), + power_kw: z.number(), + station_id: z.string(), + provider_names: z.array(z.string()).optional() +}); + +// Silent waypoint metadata +const SilentMetadataSchema = z.object({ + type: z.literal('silent'), + distance_from_route_start: z.number(), + geometry_index: z.number() +}); + +// Regular waypoint metadata +const RegularMetadataSchema = z.object({ + type: z.literal('regular') +}); + +// Union of all waypoint metadata types +const WaypointMetadataSchema = z.union([ + ChargingStationMetadataSchema, + SilentMetadataSchema, + RegularMetadataSchema +]); + +// Waypoint object schema +const WaypointSchema = z.object({ + name: z.string(), + location: z.tuple([z.number(), z.number()]), + distance: z.number().optional(), + time_zone: TimeZoneSchema.optional(), + metadata: WaypointMetadataSchema.nullable().optional() +}); + +// Admin boundary schema +const AdminSchema = z.object({ + iso_3166_1: z.string(), + iso_3166_1_alpha3: z.string() +}); + +// Incident congestion schema +const IncidentCongestionSchema = z.object({ + value: z.number() +}); + +// Incident object schema +const IncidentSchema = z.object({ + id: z.string(), + type: z.enum([ + 'accident', + 'congestion', + 'construction', + 'disabled_vehicle', + 'lane_restriction', + 'mass_transit', + 'miscellaneous', + 'other_news', + 'planned_event', + 'road_closure', + 'road_hazard', + 'weather' + ]), + description: z.string(), + long_description: z.string(), + creation_time: z.string(), + start_time: z.string(), + end_time: z.string(), + impact: z.enum(['unknown', 'critical', 'major', 'minor', 'low']), + lanes_blocked: z.array(z.string()), + num_lanes_blocked: z.number(), + congestion: IncidentCongestionSchema, + closed: z.boolean(), + geometry_index_start: z.number(), + geometry_index_end: z.number(), + sub_type: z.string().optional(), + sub_type_description: z.string().optional(), + iso_3166_1_alpha2: z.string(), + iso_3166_1_alpha3: z.string(), + affected_road_names: z.array(z.string()), + south: z.number(), + west: z.number(), + north: z.number(), + east: z.number() +}); + +// Maxspeed annotation schema +const MaxspeedSchema = z.object({ + speed: z.number().optional(), + unit: z.string().optional(), + none: z.boolean().optional(), + unknown: z.boolean().optional() +}); + +// Route leg annotation schema +const AnnotationSchema = z.object({ + distance: z.array(z.number()).optional(), + duration: z.array(z.number()).optional(), + speed: z.array(z.number()).optional(), + congestion: z.array(z.string()).optional(), + congestion_numeric: z.array(z.number().nullable()).optional(), + maxspeed: z.array(MaxspeedSchema).optional(), + state_of_charge: z.array(z.number()).optional() +}); + +// Via waypoint schema +const ViaWaypointSchema = z.object({ + waypoint_index: z.number(), + distance_from_start: z.number(), + geometry_index: z.number() +}); + +// Notification details schema +const NotificationDetailsSchema = z.object({ + requested_value: z.union([z.string(), z.number()]).optional(), + actual_value: z.union([z.string(), z.number()]).optional(), + unit: z.string().optional(), + message: z.string().optional() +}); + +// Notification object schema +const NotificationSchema = z.object({ + type: z.enum(['violation', 'alert']), + subtype: z.string().optional(), + refresh_type: z.enum(['static', 'dynamic']), + geometry_index: z.number().optional(), + geometry_index_start: z.number().optional(), + geometry_index_end: z.number().optional(), + station_id: z.string().optional(), + reason: z.string().optional(), + details: NotificationDetailsSchema.optional() +}); + +// Lane object schema +const LaneSchema = z.object({ + valid: z.boolean(), + active: z.boolean().optional(), + valid_indication: z.string().optional(), + indications: z.array(z.string()), + access: z + .object({ + designated: z.array(z.string()).optional() + }) + .optional() +}); + +// Rest stop schema +const RestStopSchema = z.object({ + type: z.enum(['rest_area', 'service_area']), + name: z.string().optional() +}); + +// Toll collection schema +const TollCollectionSchema = z.object({ + type: z.enum(['toll_booth', 'toll_gantry']), + name: z.string().optional() +}); + +// Mapbox Streets v8 schema +const MapboxStreetsV8Schema = z.object({ + class: z.string() +}); + +// Intersection object schema +const IntersectionSchema = z.object({ + location: z.tuple([z.number(), z.number()]), + bearings: z.array(z.number()), + classes: z.array(z.string()).optional(), + entry: z.array(z.boolean()), + geometry_index: z.number().optional(), + in: z.number().optional(), + out: z.number().optional(), + lanes: z.array(LaneSchema).optional(), + duration: z.number().optional(), + tunnel_name: z.string().optional(), + mapbox_streets_v8: MapboxStreetsV8Schema.optional(), + is_urban: z.boolean().optional(), + admin_index: z.number().optional(), + rest_stop: RestStopSchema.optional(), + toll_collection: TollCollectionSchema.optional(), + railway_crossing: z.boolean().optional(), + traffic_signal: z.boolean().optional(), + stop_sign: z.boolean().optional(), + yield_sign: z.boolean().optional() +}); + +// Step maneuver object schema +const StepManeuverSchema = z.object({ + bearing_before: z.number(), + bearing_after: z.number(), + instruction: z.string(), + location: z.tuple([z.number(), z.number()]), + modifier: z.string().optional(), + type: z.enum([ + 'turn', + 'new_name', + 'depart', + 'arrive', + 'merge', + 'on_ramp', + 'off_ramp', + 'fork', + 'end_of_road', + 'continue', + 'roundabout', + 'rotary', + 'roundabout_turn', + 'notification', + 'exit_roundabout', + 'exit_rotary' + ]), + exit: z.number().optional() +}); + +// Voice instruction object schema +const VoiceInstructionSchema = z.object({ + distanceAlongGeometry: z.number(), + announcement: z.string(), + ssmlAnnouncement: z.string().optional() +}); + +// Banner instruction component schema +const BannerComponentSchema = z.object({ + type: z.enum(['text', 'icon', 'delimiter', 'lane']), + text: z.string(), + abbr: z.string().optional(), + abbr_priority: z.number().optional(), + imageBaseURL: z.string().optional(), + directions: z.array(z.enum(['left', 'right', 'straight'])).optional(), + active: z.boolean().optional(), + active_direction: z.string().optional() +}); + +// Banner instruction content schema +const BannerContentSchema = z.object({ + type: z.string().optional(), + modifier: z.string().optional(), + degrees: z.number().optional(), + driving_side: z.enum(['left', 'right']).optional(), + text: z.string(), + components: z.array(BannerComponentSchema) +}); + +// Banner instruction object schema +const BannerInstructionSchema = z.object({ + distanceAlongGeometry: z.number(), + primary: BannerContentSchema, + secondary: BannerContentSchema.nullable().optional(), + sub: BannerContentSchema.optional() +}); + +// Route step object schema +const RouteStepSchema = z.object({ + maneuver: StepManeuverSchema, + distance: z.number(), + duration: z.number(), + weight: z.number(), + duration_typical: z.number().optional(), + weight_typical: z.number().optional(), + geometry: z.union([z.string(), GeoJSONLineStringSchema]), + name: z.string(), + ref: z.string().optional(), + destinations: z.string().optional(), + exits: z.string().optional(), + driving_side: z.enum(['left', 'right']), + mode: z.string(), + pronunciation: z.string().optional(), + intersections: z.array(IntersectionSchema), + speedLimitSign: z.enum(['mutcd', 'vienna']).optional(), + speedLimitUnit: z.enum(['km/h', 'mph']).optional(), + voiceInstructions: z.array(VoiceInstructionSchema).optional(), + bannerInstructions: z.array(BannerInstructionSchema).optional(), + rotary_name: z.string().optional(), + rotary_pronunciation: z.string().optional(), + exit: z.number().optional() +}); + +// Closure object schema +const ClosureSchema = z.object({ + geometry_index_start: z.number(), + geometry_index_end: z.number() +}); + +// Route leg object schema +const RouteLegSchema = z.object({ + distance: z.number(), + duration: z.number(), + weight: z.number(), + duration_typical: z.number().optional(), + weight_typical: z.number().optional(), + steps: z.array(RouteStepSchema), + summary: z.string(), + admins: z.array(AdminSchema), + incidents: z.array(IncidentSchema).optional(), + closures: z.array(ClosureSchema).optional(), + annotation: AnnotationSchema.optional(), + via_waypoints: z.array(ViaWaypointSchema).optional(), + notifications: z.array(NotificationSchema).optional() +}); + +// Route object schema +const RouteSchema = z.object({ + duration: z.number(), + distance: z.number(), + weight_name: z.enum(['auto', 'pedestrian']).optional(), // Removed by cleanResponseData + weight: z.number().optional(), // Removed by cleanResponseData + duration_typical: z.number().optional(), + weight_typical: z.number().optional(), + geometry: z.union([z.string(), GeoJSONLineStringSchema]).optional(), // Can be removed when geometries='none' + legs: z.array(RouteLegSchema).optional(), // Removed by cleanResponseData, replaced with leg_summaries + voiceLocale: z.string().optional(), + waypoints: z.array(WaypointSchema).optional(), // Present when waypoints_per_route=true + // Fields added by cleanResponseData + leg_summaries: z.array(z.string()).optional(), + intersecting_admins: z.array(z.string()).optional(), + notifications_summary: z.array(z.string()).optional(), + incidents_summary: z.array(z.any()).optional(), + instructions: z.array(z.string()).optional(), + num_legs: z.number().optional(), + congestion_information: z + .object({ + length_low: z.number(), + length_moderate: z.number(), + length_heavy: z.number(), + length_severe: z.number() + }) + .optional(), + average_speed_kph: z.number().optional(), + duration_under_typical_traffic_conditions: z.number().optional() +}); + +// Modified waypoint schema for cleanResponseData output +const CleanedWaypointSchema = z.object({ + name: z.string(), + snap_location: z.tuple([z.number(), z.number()]), // Renamed from location + snap_distance: z.number().optional(), // Renamed from distance, rounded to integer + time_zone: TimeZoneSchema.optional(), + metadata: WaypointMetadataSchema.nullable().optional() +}); + +// Main Directions API response schema +export const DirectionsResponseSchema = z.object({ + routes: z.array(RouteSchema).optional(), // Can be missing if no route found + waypoints: z.array(CleanedWaypointSchema).optional(), // Modified waypoints with renamed fields + code: z.string().optional(), // Removed by cleanResponseData for token efficiency + uuid: z.string().optional() // Removed by cleanResponseData for token efficiency +}); + +export type DirectionsResponse = z.infer; +export type Route = z.infer; +export type RouteLeg = z.infer; +export type RouteStep = z.infer; +export type Waypoint = z.infer; +export type Intersection = z.infer; +export type StepManeuver = z.infer; +export type VoiceInstruction = z.infer; +export type BannerInstruction = z.infer; +export type Incident = z.infer; +export type Notification = z.infer; diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index 06308c3..2076de9 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -4,16 +4,21 @@ import { URLSearchParams } from 'node:url'; import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { cleanResponseData } from './cleanResponseData.js'; import { formatIsoDateTime } from '../../utils/dateUtils.js'; import { fetchClient } from '../../utils/fetchRequest.js'; -import { DirectionsInputSchema } from './DirectionsTool.schema.js'; +import { DirectionsInputSchema } from './DirectionsTool.input.schema.js'; +import { + DirectionsResponseSchema, + type DirectionsResponse +} from './DirectionsTool.output.schema.js'; // Docs: https://docs.mapbox.com/api/navigation/directions/ export class DirectionsTool extends MapboxApiBasedTool< - typeof DirectionsInputSchema + typeof DirectionsInputSchema, + typeof DirectionsResponseSchema > { name = 'directions_tool'; description = @@ -27,7 +32,10 @@ export class DirectionsTool extends MapboxApiBasedTool< }; constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: DirectionsInputSchema }); + super({ + inputSchema: DirectionsInputSchema, + outputSchema: DirectionsResponseSchema + }); } protected async execute( input: z.infer, @@ -245,9 +253,23 @@ export class DirectionsTool extends MapboxApiBasedTool< const data = await response.json(); const cleanedData = cleanResponseData(input, data); + + // Validate the response data against our schema + let validatedData: DirectionsResponse; + try { + validatedData = DirectionsResponseSchema.parse(cleanedData); + } catch (error) { + // If validation fails, fall back to the original data + this.log( + 'warning', + `DirectionsTool: Response validation failed: ${error}` + ); + validatedData = cleanedData as DirectionsResponse; + } + return { - content: [{ type: 'text', text: JSON.stringify(cleanedData, null, 2) }], - structuredContent: cleanedData as Record, + content: [{ type: 'text', text: JSON.stringify(validatedData, null, 2) }], + structuredContent: validatedData, isError: false }; } diff --git a/src/tools/directions-tool/cleanResponseData.ts b/src/tools/directions-tool/cleanResponseData.ts index 8ed7fad..87605f0 100644 --- a/src/tools/directions-tool/cleanResponseData.ts +++ b/src/tools/directions-tool/cleanResponseData.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import type { z } from 'zod'; -import type { DirectionsInputSchema } from './DirectionsTool.schema.js'; +import type { DirectionsInputSchema } from './DirectionsTool.input.schema.js'; /** * Cleans up the API response to reduce token count while preserving useful data. diff --git a/src/tools/isochrone-tool/IsochroneTool.schema.ts b/src/tools/isochrone-tool/IsochroneTool.input.schema.ts similarity index 100% rename from src/tools/isochrone-tool/IsochroneTool.schema.ts rename to src/tools/isochrone-tool/IsochroneTool.input.schema.ts diff --git a/src/tools/isochrone-tool/IsochroneTool.output.schema.ts b/src/tools/isochrone-tool/IsochroneTool.output.schema.ts new file mode 100644 index 0000000..a4ac146 --- /dev/null +++ b/src/tools/isochrone-tool/IsochroneTool.output.schema.ts @@ -0,0 +1,66 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** + * Isochrone feature properties based on Mapbox Isochrone API documentation + * https://docs.mapbox.com/api/navigation/isochrone/ + */ +export const IsochroneFeaturePropertiesSchema = z.object({ + /** The value of the metric used in this contour (time in minutes or distance in meters) */ + contour: z.number().int(), + /** The color of the isochrone line if the geometry is LineString */ + color: z.string().optional(), + /** The opacity of the isochrone line if the geometry is LineString */ + opacity: z.number().optional(), + /** The fill color of the isochrone polygon if the geometry is Polygon (geojson.io format) */ + fill: z.string().optional(), + /** The fill opacity of the isochrone polygon if the geometry is Polygon (geojson.io format) */ + 'fill-opacity': z.number().optional(), + /** The fill color of the isochrone polygon if the geometry is Polygon (Leaflet format) */ + fillColor: z.string().optional(), + /** The fill opacity of the isochrone polygon if the geometry is Polygon (Leaflet format) */ + fillOpacity: z.number().optional(), + /** The metric that the contour represents - either "distance" or "time" */ + metric: z.enum(['distance', 'time']).optional() +}); + +/** + * Isochrone geometry - can be either LineString or Polygon + */ +export const IsochroneGeometrySchema = z.union([ + z.object({ + type: z.literal('LineString'), + coordinates: z.array(z.tuple([z.number(), z.number()])) // [longitude, latitude] pairs + }), + z.object({ + type: z.literal('Polygon'), + coordinates: z.array(z.array(z.tuple([z.number(), z.number()]))) // Array of linear rings + }) +]); + +/** + * Individual isochrone feature + */ +export const IsochroneFeatureSchema = z.object({ + type: z.literal('Feature'), + properties: IsochroneFeaturePropertiesSchema, + geometry: IsochroneGeometrySchema +}); + +/** + * Complete Isochrone API response + * Returns a GeoJSON FeatureCollection containing isochrone contours + */ +export const IsochroneResponseSchema = z.object({ + type: z.literal('FeatureCollection'), + features: z.array(IsochroneFeatureSchema) +}); + +export type IsochroneResponse = z.infer; +export type IsochroneFeature = z.infer; +export type IsochroneGeometry = z.infer; +export type IsochroneFeatureProperties = z.infer< + typeof IsochroneFeaturePropertiesSchema +>; diff --git a/src/tools/isochrone-tool/IsochroneTool.ts b/src/tools/isochrone-tool/IsochroneTool.ts index 73f37bb..9c85edc 100644 --- a/src/tools/isochrone-tool/IsochroneTool.ts +++ b/src/tools/isochrone-tool/IsochroneTool.ts @@ -3,12 +3,17 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; -import { IsochroneInputSchema } from './IsochroneTool.schema.js'; +import { IsochroneInputSchema } from './IsochroneTool.input.schema.js'; +import { + IsochroneResponseSchema, + type IsochroneResponse +} from './IsochroneTool.output.schema.js'; export class IsochroneTool extends MapboxApiBasedTool< - typeof IsochroneInputSchema + typeof IsochroneInputSchema, + typeof IsochroneResponseSchema > { name = 'isochrone_tool'; description = `Computes areas that are reachable within a specified amount of time from a location, and returns the reachable regions as contours of Polygons or LineStrings in GeoJSON format that you can display on a map. @@ -27,10 +32,53 @@ export class IsochroneTool extends MapboxApiBasedTool< private fetch: typeof globalThis.fetch; constructor(fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: IsochroneInputSchema }); + super({ + inputSchema: IsochroneInputSchema, + outputSchema: IsochroneResponseSchema + }); this.fetch = fetch; } + private formatIsochroneResponse(data: IsochroneResponse): string { + if (!data.features || data.features.length === 0) { + return 'No isochrone contours found.'; + } + + const summary = `Found ${data.features.length} isochrone contour${data.features.length > 1 ? 's' : ''}:\n\n`; + + const contours = data.features.map((feature, index) => { + const props = feature.properties; + const geomType = feature.geometry.type; + + let description = `${index + 1}. `; + description += `${geomType} contour for ${props.contour}`; + + if (props.metric === 'time') { + description += ' minutes travel time'; + } else if (props.metric === 'distance') { + description += ' meters distance'; + } else { + // Fallback - try to infer from contour value + description += props.contour <= 60 ? ' minutes' : ' meters'; + } + + if (props.color) { + description += `\n Color: ${props.color}`; + } + + if (geomType === 'Polygon' && props.fillColor) { + description += `\n Fill: ${props.fillColor}`; + if (props.fillOpacity !== undefined) { + description += ` (opacity: ${props.fillOpacity})`; + } + } + + return description; + }); + + return summary + contours.join('\n\n'); + } + protected async execute( input: z.infer, accessToken: string @@ -102,10 +150,32 @@ export class IsochroneTool extends MapboxApiBasedTool< } const data = await response.json(); - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: data as Record, - isError: false - }; + + // Validate the response against our schema + const parsedData = IsochroneResponseSchema.safeParse(data); + + if (parsedData.success) { + // Valid response - use formatted output + const formattedText = this.formatIsochroneResponse(parsedData.data); + return { + content: [{ type: 'text', text: formattedText }], + structuredContent: parsedData.data as unknown as Record< + string, + unknown + >, + isError: false + }; + } else { + // Invalid response - fall back to JSON string for backward compatibility + this.log( + 'warning', + `IsochroneTool: Response validation failed: ${parsedData.error.message}` + ); + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data as Record, + isError: false + }; + } } } diff --git a/src/tools/matrix-tool/MatrixTool.schema.ts b/src/tools/matrix-tool/MatrixTool.input.schema.ts similarity index 100% rename from src/tools/matrix-tool/MatrixTool.schema.ts rename to src/tools/matrix-tool/MatrixTool.input.schema.ts diff --git a/src/tools/matrix-tool/MatrixTool.output.schema.ts b/src/tools/matrix-tool/MatrixTool.output.schema.ts new file mode 100644 index 0000000..2b22226 --- /dev/null +++ b/src/tools/matrix-tool/MatrixTool.output.schema.ts @@ -0,0 +1,24 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Waypoint object schema (used for both sources and destinations) +const MatrixWaypointSchema = z.object({ + name: z.string(), + location: z.tuple([z.number(), z.number()]), + distance: z.number() +}); + +// Main Matrix API response schema +export const MatrixResponseSchema = z.object({ + code: z.string(), + durations: z.array(z.array(z.number().nullable())).optional(), + distances: z.array(z.array(z.number().nullable())).optional(), + sources: z.array(MatrixWaypointSchema), + destinations: z.array(MatrixWaypointSchema), + message: z.string().optional() // Present in error responses +}); + +export type MatrixResponse = z.infer; +export type MatrixWaypoint = z.infer; diff --git a/src/tools/matrix-tool/MatrixTool.ts b/src/tools/matrix-tool/MatrixTool.ts index c114bf1..3a307c4 100644 --- a/src/tools/matrix-tool/MatrixTool.ts +++ b/src/tools/matrix-tool/MatrixTool.ts @@ -4,13 +4,20 @@ import type { z } from 'zod'; import { URLSearchParams } from 'node:url'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; -import { MatrixInputSchema } from './MatrixTool.schema.js'; +import { MatrixInputSchema } from './MatrixTool.input.schema.js'; +import { + MatrixResponseSchema, + type MatrixResponse +} from './MatrixTool.output.schema.js'; // API documentation: https://docs.mapbox.com/api/navigation/matrix/ -export class MatrixTool extends MapboxApiBasedTool { +export class MatrixTool extends MapboxApiBasedTool< + typeof MatrixInputSchema, + typeof MatrixResponseSchema +> { name = 'matrix_tool'; description = 'Calculates travel times and distances between multiple points using Mapbox Matrix API.'; @@ -25,7 +32,10 @@ export class MatrixTool extends MapboxApiBasedTool { private fetch: typeof globalThis.fetch; constructor(fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: MatrixInputSchema }); + super({ + inputSchema: MatrixInputSchema, + outputSchema: MatrixResponseSchema + }); this.fetch = fetch; } @@ -284,9 +294,20 @@ export class MatrixTool extends MapboxApiBasedTool { // Return the matrix data const data = await response.json(); + + // Validate the response data against our schema + let validatedData: MatrixResponse; + try { + validatedData = MatrixResponseSchema.parse(data); + } catch (error) { + // If validation fails, fall back to the original data + this.log('warning', `MatrixTool: Response validation failed: ${error}`); + validatedData = data as MatrixResponse; + } + return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: data as Record, + content: [{ type: 'text', text: JSON.stringify(validatedData, null, 2) }], + structuredContent: validatedData, isError: false }; } diff --git a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.schema.ts b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.input.schema.ts similarity index 100% rename from src/tools/reverse-geocode-tool/ReverseGeocodeTool.schema.ts rename to src/tools/reverse-geocode-tool/ReverseGeocodeTool.input.schema.ts diff --git a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.ts b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.ts new file mode 100644 index 0000000..265f9ad --- /dev/null +++ b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.ts @@ -0,0 +1,232 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Translation object schema +const TranslationSchema = z.object({ + language: z.string(), + name: z.string() +}); + +// Context sub-object schemas for different geographic levels +const ContextAddressSchema = z.object({ + mapbox_id: z.string(), + address_number: z.string(), + street_name: z.string(), + name: z.string() +}); + +const ContextStreetSchema = z.object({ + mapbox_id: z.string(), + name: z.string() +}); + +const ContextNeighborhoodSchema = z.object({ + mapbox_id: z.string(), + name: z.string(), + alternate: z + .object({ + mapbox_id: z.string(), + name: z.string() + }) + .optional(), + translations: z.record(TranslationSchema).optional() +}); + +const ContextPostcodeSchema = z.object({ + mapbox_id: z.string(), + name: z.string(), + translations: z.record(TranslationSchema).optional() +}); + +const ContextLocalitySchema = z.object({ + mapbox_id: z.string(), + name: z.string(), + wikidata_id: z.string().optional(), + alternate: z + .object({ + mapbox_id: z.string(), + name: z.string() + }) + .optional(), + translations: z.record(TranslationSchema).optional() +}); + +const ContextPlaceSchema = z.object({ + mapbox_id: z.string(), + name: z.string(), + wikidata_id: z.string().optional(), + alternate: z + .object({ + mapbox_id: z.string(), + name: z.string() + }) + .optional(), + translations: z.record(TranslationSchema).optional() +}); + +const ContextDistrictSchema = z.object({ + mapbox_id: z.string(), + name: z.string(), + wikidata_id: z.string().optional(), + translations: z.record(TranslationSchema).optional() +}); + +const ContextRegionSchema = z.object({ + mapbox_id: z.string(), + name: z.string(), + wikidata_id: z.string().optional(), + region_code: z.string(), + region_code_full: z.string(), + translations: z.record(TranslationSchema).optional() +}); + +const ContextCountrySchema = z.object({ + mapbox_id: z.string(), + name: z.string(), + wikidata_id: z.string().optional(), + country_code: z.string(), + country_code_alpha_3: z.string(), + translations: z.record(TranslationSchema).optional() +}); + +const ContextBlockSchema = z.object({ + mapbox_id: z.string(), + name: z.string() +}); + +const ContextSecondaryAddressSchema = z.object({ + mapbox_id: z.string(), + name: z.string(), + designator: z.string(), + identifier: z.string(), + extrapolated: z.boolean().optional() +}); + +// Context object schema +const ContextSchema = z.object({ + address: ContextAddressSchema.optional(), + street: ContextStreetSchema.optional(), + neighborhood: ContextNeighborhoodSchema.optional(), + postcode: ContextPostcodeSchema.optional(), + locality: ContextLocalitySchema.optional(), + place: ContextPlaceSchema.optional(), + district: ContextDistrictSchema.optional(), + region: ContextRegionSchema.optional(), + country: ContextCountrySchema.optional(), + block: ContextBlockSchema.optional(), + secondary_address: ContextSecondaryAddressSchema.optional() +}); + +// Match code schema for Smart Address Match +const MatchCodeSchema = z.object({ + address_number: z + .enum(['matched', 'unmatched', 'not_applicable', 'inferred', 'plausible']) + .optional(), + street: z + .enum(['matched', 'unmatched', 'not_applicable', 'inferred', 'plausible']) + .optional(), + postcode: z + .enum(['matched', 'unmatched', 'not_applicable', 'inferred', 'plausible']) + .optional(), + place: z + .enum(['matched', 'unmatched', 'not_applicable', 'inferred', 'plausible']) + .optional(), + region: z + .enum(['matched', 'unmatched', 'not_applicable', 'inferred', 'plausible']) + .optional(), + locality: z + .enum(['matched', 'unmatched', 'not_applicable', 'inferred', 'plausible']) + .optional(), + country: z + .enum(['matched', 'unmatched', 'not_applicable', 'inferred', 'plausible']) + .optional(), + confidence: z.enum(['exact', 'high', 'medium', 'low']) +}); + +// Routable point schema +const RoutablePointSchema = z.object({ + name: z.string(), + longitude: z.number(), + latitude: z.number() +}); + +// Coordinates object schema +const CoordinatesSchema = z.object({ + longitude: z.number(), + latitude: z.number(), + accuracy: z + .enum([ + 'rooftop', + 'parcel', + 'point', + 'interpolated', + 'approximate', + 'intersection' + ]) + .optional(), + routable_points: z.array(RoutablePointSchema).optional() +}); + +// Japanese reading schema +const ReadingSchema = z.object({ + 'ja-Kana': z.string().optional(), + 'ja-Latn': z.string().optional() +}); + +// Feature properties schema +const FeaturePropertiesSchema = z.object({ + mapbox_id: z.string(), + feature_type: z.enum([ + 'country', + 'region', + 'postcode', + 'district', + 'place', + 'locality', + 'neighborhood', + 'street', + 'address', + 'secondary_address', + 'block' + ]), + name: z.string(), + name_preferred: z.string().optional(), + place_formatted: z.string().optional(), + full_address: z.string().optional(), + context: ContextSchema, + coordinates: CoordinatesSchema, + bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + match_code: MatchCodeSchema.optional(), + reading: ReadingSchema.optional() +}); + +// GeoJSON Point geometry schema +const PointGeometrySchema = z.object({ + type: z.literal('Point'), + coordinates: z.tuple([z.number(), z.number()]) +}); + +// Feature schema +const FeatureSchema = z.object({ + id: z.string(), + type: z.literal('Feature'), + geometry: PointGeometrySchema, + properties: FeaturePropertiesSchema +}); + +// Main Geocoding API response schema (FeatureCollection) +export const GeocodingResponseSchema = z.object({ + type: z.literal('FeatureCollection'), + features: z.array(FeatureSchema), + attribution: z.string() +}); + +export type GeocodingResponse = z.infer; +export type GeocodingFeature = z.infer; +export type GeocodingFeatureProperties = z.infer< + typeof FeaturePropertiesSchema +>; +export type GeocodingContext = z.infer; +export type GeocodingMatchCode = z.infer; diff --git a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts index f25cd8e..c2f5dbc 100644 --- a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts +++ b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts @@ -3,16 +3,20 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; -import { ReverseGeocodeInputSchema } from './ReverseGeocodeTool.schema.js'; +import { ReverseGeocodeInputSchema } from './ReverseGeocodeTool.input.schema.js'; +import { GeocodingResponseSchema } from './ReverseGeocodeTool.output.schema.js'; import type { MapboxFeatureCollection, MapboxFeature } from '../../schemas/geojson.js'; +// API Docs https://docs.mapbox.com/api/search/geocoding/ + export class ReverseGeocodeTool extends MapboxApiBasedTool< - typeof ReverseGeocodeInputSchema + typeof ReverseGeocodeInputSchema, + typeof GeocodingResponseSchema > { name = 'reverse_geocode_tool'; description = @@ -26,7 +30,10 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< }; constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: ReverseGeocodeInputSchema }); + super({ + inputSchema: ReverseGeocodeInputSchema, + outputSchema: GeocodingResponseSchema + }); } private formatGeoJsonToText( @@ -139,12 +146,26 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< }; } - const data = (await response.json()) as MapboxFeatureCollection; + const rawData = await response.json(); + + // Validate response against schema with graceful fallback + let data: MapboxFeatureCollection; + try { + data = GeocodingResponseSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for reverse geocoding response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` + ); + // Graceful fallback to raw data + data = rawData as MapboxFeatureCollection; + } // Check if the response has features if (!data || !data.features || data.features.length === 0) { return { content: [{ type: 'text', text: 'No results found.' }], + structuredContent: data as unknown as Record, isError: false }; } diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.schema.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.input.schema.ts similarity index 100% rename from src/tools/search-and-geocode-tool/SearchAndGeocodeTool.schema.ts rename to src/tools/search-and-geocode-tool/SearchAndGeocodeTool.input.schema.ts diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts new file mode 100644 index 0000000..57f0a25 --- /dev/null +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts @@ -0,0 +1,139 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Search Box API feature properties schema +const SearchBoxFeaturePropertiesSchema = z.object({ + // Basic identification + mapbox_id: z.string().optional(), + feature_type: z.string().optional(), + name: z.string().optional(), + name_preferred: z.string().optional(), + + // Address components + full_address: z.string().optional(), + place_formatted: z.string().optional(), + address_number: z.string().optional(), + street_name: z.string().optional(), + + // Administrative areas + context: z + .object({ + country: z + .object({ + name: z.string().optional(), + country_code: z.string().optional(), + country_code_alpha_3: z.string().optional() + }) + .optional(), + region: z + .object({ + name: z.string().optional(), + region_code: z.string().optional(), + region_code_full: z.string().optional() + }) + .optional(), + postcode: z + .object({ + name: z.string().optional() + }) + .optional(), + district: z + .object({ + name: z.string().optional() + }) + .optional(), + place: z + .object({ + name: z.string().optional() + }) + .optional(), + locality: z + .object({ + name: z.string().optional() + }) + .optional(), + neighborhood: z + .object({ + name: z.string().optional() + }) + .optional(), + street: z + .object({ + name: z.string().optional() + }) + .optional(), + address: z + .object({ + address_number: z.string().optional(), + street_name: z.string().optional() + }) + .optional() + }) + .optional(), + + // Coordinates and bounds + coordinates: z + .object({ + longitude: z.number(), + latitude: z.number(), + accuracy: z.string().optional(), + routable_points: z + .array( + z.object({ + name: z.string(), + latitude: z.number(), + longitude: z.number() + }) + ) + .optional() + }) + .optional(), + bbox: z.array(z.number()).length(4).optional(), + + // POI specific fields + poi_category: z.array(z.string()).optional(), + poi_category_ids: z.array(z.string()).optional(), + brand: z.array(z.string()).optional(), + brand_id: z.string().optional(), + external_ids: z.record(z.string()).optional(), + + // Additional metadata + maki: z.string().optional(), + operational_status: z.string().optional(), + + // ETA information (when requested) + eta: z + .object({ + duration: z.number().optional(), + distance: z.number().optional() + }) + .optional() +}); + +// GeoJSON geometry schema +const GeometrySchema = z.object({ + type: z.literal('Point'), + coordinates: z.array(z.number()).length(2) +}); + +// Search Box API feature schema +const SearchBoxFeatureSchema = z.object({ + type: z.literal('Feature'), + geometry: GeometrySchema, + properties: SearchBoxFeaturePropertiesSchema +}); + +// Main Search Box API response schema +export const SearchBoxResponseSchema = z.object({ + type: z.literal('FeatureCollection'), + features: z.array(SearchBoxFeatureSchema), + attribution: z.string().optional() +}); + +export type SearchBoxResponse = z.infer; +export type SearchBoxFeature = z.infer; +export type SearchBoxFeatureProperties = z.infer< + typeof SearchBoxFeaturePropertiesSchema +>; diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts index 3827ed2..eb2d1cc 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts @@ -3,16 +3,23 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; -import { SearchAndGeocodeInputSchema } from './SearchAndGeocodeTool.schema.js'; +import { SearchAndGeocodeInputSchema } from './SearchAndGeocodeTool.input.schema.js'; +import { + SearchBoxResponseSchema, + type SearchBoxResponse +} from './SearchAndGeocodeTool.output.schema.js'; import type { MapboxFeatureCollection, MapboxFeature } from '../../schemas/geojson.js'; +// API Documentation: https://docs.mapbox.com/api/search/search-box/#search-request + export class SearchAndGeocodeTool extends MapboxApiBasedTool< - typeof SearchAndGeocodeInputSchema + typeof SearchAndGeocodeInputSchema, + typeof SearchBoxResponseSchema > { name = 'search_and_geocode_tool'; description = @@ -26,7 +33,10 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< }; constructor(private fetchImpl: typeof fetch = fetchClient) { - super({ inputSchema: SearchAndGeocodeInputSchema }); + super({ + inputSchema: SearchAndGeocodeInputSchema, + outputSchema: SearchBoxResponseSchema + }); } private formatGeoJsonToText( @@ -187,15 +197,34 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< }; } - const data = (await response.json()) as MapboxFeatureCollection; + const rawData = await response.json(); + + // Validate response against schema with graceful fallback + let data: SearchBoxResponse; + try { + data = SearchBoxResponseSchema.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 SearchBoxResponse; + } + this.log( 'info', `SearchAndGeocodeTool: Successfully completed search, found ${data.features?.length || 0} results` ); return { - content: [{ type: 'text', text: this.formatGeoJsonToText(data) }], - structuredContent: data as unknown as Record, + content: [ + { + type: 'text', + text: this.formatGeoJsonToText(data as MapboxFeatureCollection) + } + ], + structuredContent: data, isError: false }; } diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.schema.ts b/src/tools/static-map-image-tool/StaticMapImageTool.input.schema.ts similarity index 100% rename from src/tools/static-map-image-tool/StaticMapImageTool.schema.ts rename to src/tools/static-map-image-tool/StaticMapImageTool.input.schema.ts diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index 5a10b50..e260aac 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -3,10 +3,10 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { fetchClient } from '../../utils/fetchRequest.js'; -import { StaticMapImageInputSchema } from './StaticMapImageTool.schema.js'; -import type { OverlaySchema } from './StaticMapImageTool.schema.js'; +import { StaticMapImageInputSchema } from './StaticMapImageTool.input.schema.js'; +import type { OverlaySchema } from './StaticMapImageTool.input.schema.js'; export class StaticMapImageTool extends MapboxApiBasedTool< typeof StaticMapImageInputSchema diff --git a/src/tools/version-tool/VersionTool.schema.ts b/src/tools/version-tool/VersionTool.input.schema.ts similarity index 100% rename from src/tools/version-tool/VersionTool.schema.ts rename to src/tools/version-tool/VersionTool.input.schema.ts diff --git a/src/tools/version-tool/VersionTool.output.schema.ts b/src/tools/version-tool/VersionTool.output.schema.ts new file mode 100644 index 0000000..ed2c377 --- /dev/null +++ b/src/tools/version-tool/VersionTool.output.schema.ts @@ -0,0 +1,15 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Schema for version tool output - matches the VersionInfo interface +export const VersionResponseSchema = z.object({ + name: z.string(), + version: z.string(), + sha: z.string(), + tag: z.string(), + branch: z.string() +}); + +export type VersionResponse = z.infer; diff --git a/src/tools/version-tool/VersionTool.ts b/src/tools/version-tool/VersionTool.ts index 6e4fc8c..96b27ba 100644 --- a/src/tools/version-tool/VersionTool.ts +++ b/src/tools/version-tool/VersionTool.ts @@ -3,11 +3,18 @@ import { BaseTool } from '../BaseTool.js'; import { getVersionInfo } from '../../utils/versionUtils.js'; -import { VersionSchema } from './VersionTool.schema.js'; +import { VersionSchema } from './VersionTool.input.schema.js'; +import { + VersionResponseSchema, + type VersionResponse +} from './VersionTool.output.schema.js'; import type { z } from 'zod'; -import type { OutputSchema } from '../MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; -export class VersionTool extends BaseTool { +export class VersionTool extends BaseTool< + typeof VersionSchema, + typeof VersionResponseSchema +> { readonly name = 'version_tool'; readonly description = 'Get the current version information of the MCP server'; @@ -20,44 +27,40 @@ export class VersionTool extends BaseTool { }; constructor() { - super({ inputSchema: VersionSchema }); + super({ + inputSchema: VersionSchema, + outputSchema: VersionResponseSchema + }); } // eslint-disable-next-line @typescript-eslint/no-unused-vars async run(_rawInput: unknown): Promise> { - try { - const versionInfo = getVersionInfo(); + const versionInfo = getVersionInfo(); - const versionText = `MCP Server Version Information: + const versionText = `MCP Server Version Information: - Name: ${versionInfo.name} - Version: ${versionInfo.version} - SHA: ${versionInfo.sha} - Tag: ${versionInfo.tag} - Branch: ${versionInfo.branch}`; - return { - content: [{ type: 'text', text: versionText }], - structuredContent: versionInfo as unknown as Record, - isError: false - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - + // Validate output against schema with graceful fallback + let validatedVersionInfo: VersionResponse; + try { + validatedVersionInfo = VersionResponseSchema.parse(versionInfo); + } catch (validationError) { this.log( - 'error', - `${this.name}: Error during execution: ${errorMessage}` + 'warning', + `Output schema validation failed: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` ); - - return { - content: [ - { - type: 'text', - text: errorMessage - } - ], - isError: true - }; + // Graceful fallback to raw data + validatedVersionInfo = versionInfo as VersionResponse; } + + return { + content: [{ type: 'text', text: versionText }], + structuredContent: validatedVersionInfo, + isError: false + }; } } diff --git a/test/tools/category-list-tool/CategoryListTool.output.schema.test.ts b/test/tools/category-list-tool/CategoryListTool.output.schema.test.ts new file mode 100644 index 0000000..db4e211 --- /dev/null +++ b/test/tools/category-list-tool/CategoryListTool.output.schema.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, vi } from 'vitest'; +import { CategoryListTool } from '../../../src/tools/category-list-tool/CategoryListTool.js'; + +describe('CategoryListTool output schema registration', () => { + it('should have an output schema defined', () => { + const tool = new CategoryListTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); + + it('should register output schema with MCP server', () => { + const tool = new CategoryListTool(); + + // Mock the installTo method to verify it gets called with output schema + const mockInstallTo = vi.fn().mockImplementation(() => { + // Verify that the tool has an output schema when being installed + expect(tool.outputSchema).toBeDefined(); + return tool; + }); + + Object.defineProperty(tool, 'installTo', { + value: mockInstallTo + }); + + // Simulate server registration + tool.installTo({} as never); + expect(mockInstallTo).toHaveBeenCalled(); + }); + + it('should validate valid category list response structure', () => { + const validResponse = { + listItems: [ + 'services', + 'shopping', + 'food_and_drink', + 'restaurant', + 'lodging' + ] + }; + + const tool = new CategoryListTool(); + + // This should not throw if the schema is correct + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(validResponse); + } + }).not.toThrow(); + }); + + it('should validate minimal valid response with single category', () => { + const minimalResponse = { + listItems: ['food'] + }; + + const tool = new CategoryListTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(minimalResponse); + } + }).not.toThrow(); + }); + + it('should validate empty list response', () => { + const emptyResponse = { + listItems: [] + }; + + const tool = new CategoryListTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(emptyResponse); + } + }).not.toThrow(); + }); + + it('should validate response with multiple categories', () => { + const multipleResponse = { + listItems: [ + 'health_services', + 'office', + 'education', + 'nightlife', + 'lodging', + 'transportation', + 'automotive', + 'recreation', + 'services', + 'shopping' + ] + }; + + const tool = new CategoryListTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(multipleResponse); + } + }).not.toThrow(); + }); + + it('should throw validation error for invalid response missing listItems', () => { + const invalidResponse = { + // Missing required listItems field + someOtherField: 'value' + }; + + const tool = new CategoryListTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidResponse); + } + }).toThrow(); + }); + + it('should throw validation error for non-string list items', () => { + const malformedResponse = { + listItems: [ + 'valid_category', + 123, // Invalid: should be string + 'another_valid_category' + ] + }; + + const tool = new CategoryListTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(malformedResponse); + } + }).toThrow(); + }); + + it('should throw validation error when listItems is not an array', () => { + const invalidTypeResponse = { + listItems: 'not an array', + attribution: 'Mapbox', + version: '1.0.0' + }; + + const tool = new CategoryListTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidTypeResponse); + } + }).toThrow(); + }); +}); diff --git a/test/tools/category-list-tool/CategoryListTool.test.ts b/test/tools/category-list-tool/CategoryListTool.test.ts index cae008f..312ce96 100644 --- a/test/tools/category-list-tool/CategoryListTool.test.ts +++ b/test/tools/category-list-tool/CategoryListTool.test.ts @@ -173,4 +173,10 @@ describe('CategoryListTool', () => { // Invalid offset should throw expect(() => tool.inputSchema.parse({ offset: -1 })).toThrow(); }); + + it('should have output schema defined', () => { + const tool = new CategoryListTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); }); diff --git a/test/tools/category-search-tool/CategorySearchTool.output.schema.test.ts b/test/tools/category-search-tool/CategorySearchTool.output.schema.test.ts new file mode 100644 index 0000000..4b413ff --- /dev/null +++ b/test/tools/category-search-tool/CategorySearchTool.output.schema.test.ts @@ -0,0 +1,314 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, vi } from 'vitest'; +import { CategorySearchTool } from '../../../src/tools/category-search-tool/CategorySearchTool.js'; + +describe('CategorySearchTool output schema registration', () => { + it('should have an output schema defined', () => { + const tool = new CategorySearchTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); + + it('should register output schema with MCP server', () => { + const tool = new CategorySearchTool(); + + // Mock the installTo method to verify it gets called with output schema + const mockInstallTo = vi.fn().mockImplementation(() => { + // Verify that the tool has an output schema when being installed + expect(tool.outputSchema).toBeDefined(); + return tool; + }); + + Object.defineProperty(tool, 'installTo', { + value: mockInstallTo + }); + + // Simulate server registration + tool.installTo({} as never); + expect(mockInstallTo).toHaveBeenCalled(); + }); + + it('should validate valid category search response structure', () => { + const validResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.582748, 39.029528] + }, + properties: { + name: 'Stonehouse Cellars', + mapbox_id: 'test_mapbox_id', + feature_type: 'poi', + address: '500 Old Long Valley Rd', + full_address: + '500 Old Long Valley Rd, Clearlake Oaks, California 95423, United States', + place_formatted: 'Clearlake Oaks, California 95423, United States', + context: { + country: { + name: 'United States', + country_code: 'US', + country_code_alpha_3: 'USA' + }, + region: { + name: 'California', + region_code: 'CA', + region_code_full: 'US-CA' + }, + postcode: { + name: '95423' + }, + place: { + name: 'Clearlake Oaks' + }, + street: { + name: 'old long valley rd' + } + }, + coordinates: { + latitude: 39.029528, + longitude: -122.582748, + routable_points: [ + { + name: 'default', + latitude: 39.029528, + longitude: -122.582748 + } + ] + }, + maki: 'restaurant', + poi_category: [ + 'restaurant', + 'food', + 'food and drink', + 'winery', + 'bar', + 'nightlife' + ], + poi_category_ids: [ + 'restaurant', + 'food', + 'food_and_drink', + 'winery', + 'bar', + 'nightlife' + ], + external_ids: { + foursquare: '55208bfe498e78a725b4030d' + }, + metadata: { + primary_photo: [] + } + } + } + ], + attribution: 'Mapbox' + }; + + const tool = new CategorySearchTool(); + + // This should not throw if the schema is correct + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(validResponse); + } + }).not.toThrow(); + }); + + it('should validate complex response with optional fields', () => { + const complexResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.676, 45.515] + }, + properties: { + name: 'Coffee Shop', + name_preferred: 'Premium Coffee Shop', + mapbox_id: 'complex_mapbox_id', + feature_type: 'poi', + address: '123 Coffee Street', + full_address: '123 Coffee Street, Portland, Oregon 97205, USA', + place_formatted: 'Portland, Oregon 97205, USA', + context: { + country: { + id: 'country_id', + name: 'United States', + country_code: 'US', + country_code_alpha_3: 'USA' + }, + region: { + id: 'region_id', + name: 'Oregon', + region_code: 'OR', + region_code_full: 'US-OR' + }, + postcode: { + id: 'postcode_id', + name: '97205' + }, + district: { + id: 'district_id', + name: 'Multnomah County' + }, + place: { + id: 'place_id', + name: 'Portland' + }, + locality: { + id: 'locality_id', + name: 'Downtown' + }, + neighborhood: { + id: 'neighborhood_id', + name: 'Pearl District' + }, + address: { + id: 'address_id', + name: '123 Coffee Street', + address_number: '123', + street_name: 'Coffee Street' + }, + street: { + id: 'street_id', + name: 'Coffee Street' + } + }, + coordinates: { + longitude: -122.676, + latitude: 45.515, + accuracy: 'rooftop', + routable_points: [ + { + name: 'main_entrance', + latitude: 45.5151, + longitude: -122.6761, + note: 'Main entrance facing the street' + } + ] + }, + bbox: [-122.677, 45.514, -122.675, 45.516], + language: 'en', + maki: 'cafe', + poi_category: ['coffee', 'food_and_drink', 'cafe'], + poi_category_ids: ['coffee', 'food_and_drink', 'cafe'], + brand: ['Starbucks'], + brand_id: ['starbucks'], + external_ids: { + foursquare: 'example_foursquare_id', + yelp: 'example_yelp_id' + }, + metadata: { + primary_photo: ['photo1.jpg', 'photo2.jpg'], + reading: { + ja_kana: 'コーヒーショップ', + ja_latin: 'koohii shoppu' + } + }, + distance: 150.5, + eta: 3, + added_distance: 25.0, + added_time: 1 + } + } + ], + attribution: 'Mapbox' + }; + + const tool = new CategorySearchTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(complexResponse); + } + }).not.toThrow(); + }); + + it('should validate minimal valid response', () => { + const minimalResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.676, 45.515] + }, + properties: { + name: 'Test POI', + mapbox_id: 'minimal_mapbox_id', + feature_type: 'poi', + context: {}, + coordinates: { + longitude: -122.676, + latitude: 45.515 + } + } + } + ], + attribution: 'Mapbox' + }; + + const tool = new CategorySearchTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(minimalResponse); + } + }).not.toThrow(); + }); + + it('should throw validation error for invalid response', () => { + const invalidResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.676, 45.515] + }, + properties: { + // Missing required fields like name, mapbox_id, feature_type, etc. + address: 'Some address' + } + } + ] + // Missing attribution field + }; + + const tool = new CategorySearchTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidResponse); + } + }).toThrow(); + }); + + it('should validate empty results response', () => { + const emptyResponse = { + type: 'FeatureCollection', + features: [], + attribution: 'Mapbox' + }; + + const tool = new CategorySearchTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(emptyResponse); + } + }).not.toThrow(); + }); +}); diff --git a/test/tools/category-search-tool/CategorySearchTool.test.ts b/test/tools/category-search-tool/CategorySearchTool.test.ts index d97326e..967cc95 100644 --- a/test/tools/category-search-tool/CategorySearchTool.test.ts +++ b/test/tools/category-search-tool/CategorySearchTool.test.ts @@ -454,4 +454,10 @@ describe('CategorySearchTool', () => { (result.content[0] as { type: 'text'; text: string }).text ).toContain('1. Test Cafe'); }); + + it('should have output schema defined', () => { + const tool = new CategorySearchTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); }); diff --git a/test/tools/directions-tool/DirectionsTool.output.schema.test.ts b/test/tools/directions-tool/DirectionsTool.output.schema.test.ts new file mode 100644 index 0000000..4c7d381 --- /dev/null +++ b/test/tools/directions-tool/DirectionsTool.output.schema.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi } from 'vitest'; +import { DirectionsTool } from '../../../src/tools/directions-tool/DirectionsTool.js'; + +describe('DirectionsTool output schema registration', () => { + it('should have an output schema defined', () => { + const tool = new DirectionsTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); + + it('should register output schema with MCP server', () => { + const tool = new DirectionsTool(); + + // Mock the installTo method to verify it gets called with output schema + const installToSpy = vi.spyOn(tool, 'installTo').mockImplementation(() => { + // Verify that the tool has an output schema when being installed + expect(tool.outputSchema).toBeDefined(); + return undefined; + }); + + const mockServer = {} as Parameters[0]; + tool.installTo(mockServer); + + expect(installToSpy).toHaveBeenCalledWith(mockServer); + }); + + it('should validate response structure matches schema', () => { + const tool = new DirectionsTool(); + const mockResponse = { + routes: [ + { + duration: 100, + distance: 1000, + leg_summaries: ['Main St', 'Oak Ave'], + intersecting_admins: ['USA'], + num_legs: 1, + congestion_information: { + length_low: 500, + length_moderate: 300, + length_heavy: 150, + length_severe: 50 + }, + average_speed_kph: 45 + } + ], + waypoints: [ + { + name: 'Start Location', + snap_location: [-122.4194, 37.7749], + snap_distance: 10 + }, + { + name: 'End Location', + snap_location: [-122.4094, 37.7849], + snap_distance: 5 + } + ] + }; + + // This should not throw if the schema is correct + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(mockResponse); + } + }).not.toThrow(); + }); +}); diff --git a/test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts b/test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts new file mode 100644 index 0000000..f374107 --- /dev/null +++ b/test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { + IsochroneResponseSchema, + IsochroneFeatureSchema +} from '../../../src/tools/isochrone-tool/IsochroneTool.output.schema.js'; + +describe('IsochroneTool Output Schema', () => { + it('should validate a valid isochrone feature', () => { + const validFeature = { + type: 'Feature', + properties: { + contour: 15, + color: '#4286f4', + opacity: 0.33, + fill: '#4286f4', + 'fill-opacity': 0.33, + fillColor: '#4286f4', + fillOpacity: 0.33, + metric: 'time' + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-118.22, 33.99], + [-118.21, 33.99], + [-118.21, 34.0], + [-118.22, 34.0], + [-118.22, 33.99] + ] + ] + } + }; + + const result = IsochroneFeatureSchema.safeParse(validFeature); + expect(result.success).toBe(true); + }); + + it('should validate a valid isochrone response', () => { + const validResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + contour: 15, + color: '#4286f4', + metric: 'time' + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-118.22, 33.99], + [-118.21, 33.99], + [-118.21, 34.0], + [-118.22, 34.0], + [-118.22, 33.99] + ] + ] + } + } + ] + }; + + const result = IsochroneResponseSchema.safeParse(validResponse); + expect(result.success).toBe(true); + }); + + it('should validate LineString geometry', () => { + const lineStringFeature = { + type: 'Feature', + properties: { + contour: 10, + color: '#04e813', + opacity: 0.5, + metric: 'time' + }, + geometry: { + type: 'LineString', + coordinates: [ + [-118.22, 33.99], + [-118.21, 33.99], + [-118.21, 34.0] + ] + } + }; + + const result = IsochroneFeatureSchema.safeParse(lineStringFeature); + expect(result.success).toBe(true); + }); + + it('should reject invalid geometry type', () => { + const invalidFeature = { + type: 'Feature', + properties: { + contour: 15, + metric: 'time' + }, + geometry: { + type: 'Point', // Invalid for isochrone + coordinates: [-118.22, 33.99] + } + }; + + const result = IsochroneFeatureSchema.safeParse(invalidFeature); + expect(result.success).toBe(false); + }); + + it('should require contour property', () => { + const invalidFeature = { + type: 'Feature', + properties: { + color: '#4286f4' + // Missing required contour property + }, + geometry: { + type: 'Polygon', + coordinates: [[]] + } + }; + + const result = IsochroneFeatureSchema.safeParse(invalidFeature); + expect(result.success).toBe(false); + }); +}); diff --git a/test/tools/isochrone-tool/IsochroneTool.registration.test.ts b/test/tools/isochrone-tool/IsochroneTool.registration.test.ts new file mode 100644 index 0000000..ec24f90 --- /dev/null +++ b/test/tools/isochrone-tool/IsochroneTool.registration.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { IsochroneTool } from '../../../src/tools/isochrone-tool/IsochroneTool.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +// Mock the fetchClient +vi.mock('../../../src/utils/fetchRequest.js', () => ({ + fetchClient: vi.fn() +})); + +describe('IsochroneTool Output Schema Registration', () => { + let mockServer: McpServer; + + beforeEach(() => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', 'test-token'); + + // Create a mock MCP server + mockServer = { + registerTool: vi.fn().mockReturnValue({ + name: 'isochrone_tool', + description: 'Test tool' + }), + server: { + sendLoggingMessage: vi.fn() + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }); + + it('should register tool with output schema', () => { + const tool = new IsochroneTool(); + + // Install the tool to the mock server + tool.installTo(mockServer); + + // Verify that registerTool was called + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + + // Get the call arguments + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [name, config, callback] = (mockServer.registerTool as any).mock + .calls[0]; + + // Verify basic tool registration + expect(name).toBe('isochrone_tool'); + expect(config.title).toBe('Isochrone Tool'); + expect(config.description).toContain('Computes areas that are reachable'); + expect(config.inputSchema).toBeDefined(); + expect(callback).toBeInstanceOf(Function); + + // Verify that outputSchema is registered + expect(config.outputSchema).toBeDefined(); + + // The outputSchema should have the expected structure for FeatureCollection + expect(config.outputSchema.type).toBeDefined(); + expect(config.outputSchema.features).toBeDefined(); + }); + + it('should register output schema as Zod schema objects', () => { + const tool = new IsochroneTool(); + tool.installTo(mockServer); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [, config] = (mockServer.registerTool as any).mock.calls[0]; + const outputSchema = config.outputSchema; + + // The outputSchema should be Zod schema objects + expect(outputSchema.type).toBeDefined(); + expect(outputSchema.type._def).toBeDefined(); + expect(outputSchema.type._def.typeName).toBe('ZodLiteral'); + expect(outputSchema.type._def.value).toBe('FeatureCollection'); + + // Features should be a ZodArray + expect(outputSchema.features).toBeDefined(); + expect(outputSchema.features._def).toBeDefined(); + expect(outputSchema.features._def.typeName).toBe('ZodArray'); + + // The MCP server will convert these Zod schemas to JSON Schema for the protocol + // This validates that we're providing the schemas in the correct format + }); +}); diff --git a/test/tools/matrix-tool/MatrixTool.output.schema.test.ts b/test/tools/matrix-tool/MatrixTool.output.schema.test.ts new file mode 100644 index 0000000..ca6e2fa --- /dev/null +++ b/test/tools/matrix-tool/MatrixTool.output.schema.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi } from 'vitest'; +import { MatrixTool } from '../../../src/tools/matrix-tool/MatrixTool.js'; + +describe('MatrixTool output schema registration', () => { + it('should have an output schema defined', () => { + const tool = new MatrixTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); + + it('should register output schema with MCP server', () => { + const tool = new MatrixTool(); + + // Mock the installTo method to verify it gets called with output schema + const installToSpy = vi.spyOn(tool, 'installTo').mockImplementation(() => { + // Verify that the tool has an output schema when being installed + expect(tool.outputSchema).toBeDefined(); + return {} as ReturnType; + }); + + const mockServer = {} as Parameters[0]; + tool.installTo(mockServer); + + expect(installToSpy).toHaveBeenCalledWith(mockServer); + }); + + it('should validate response structure matches schema', () => { + const tool = new MatrixTool(); + const mockResponse = { + code: 'Ok', + durations: [ + [0, 573, 1169.5], + [573, 0, 597], + [1169.5, 597, 0] + ], + distances: [ + [0, 1200, 2400], + [1200, 0, 1500], + [2400, 1500, 0] + ], + sources: [ + { + name: 'Mission Street', + location: [-122.418408, 37.751668], + distance: 5 + }, + { + name: '22nd Street', + location: [-122.422959, 37.755184], + distance: 8 + }, + { + name: '', + location: [-122.426911, 37.759695], + distance: 10 + } + ], + destinations: [ + { + name: 'Mission Street', + location: [-122.418408, 37.751668], + distance: 5 + }, + { + name: '22nd Street', + location: [-122.422959, 37.755184], + distance: 8 + }, + { + name: '', + location: [-122.426911, 37.759695], + distance: 10 + } + ] + }; + + // This should not throw if the schema is correct + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(mockResponse); + } + }).not.toThrow(); + }); + + it('should handle null values in durations and distances matrices', () => { + const tool = new MatrixTool(); + const mockResponseWithNulls = { + code: 'Ok', + durations: [ + [0, null, 1169.5], + [573, 0, null], + [null, 597, 0] + ], + distances: [ + [0, null, 2400], + [1200, 0, null], + [null, 1500, 0] + ], + sources: [ + { + name: 'Start', + location: [-122.418408, 37.751668], + distance: 5 + } + ], + destinations: [ + { + name: 'End', + location: [-122.422959, 37.755184], + distance: 8 + } + ] + }; + + // This should not throw - null values are allowed in matrices + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(mockResponseWithNulls); + } + }).not.toThrow(); + }); + + it('should handle error responses with message field', () => { + const tool = new MatrixTool(); + const errorResponse = { + code: 'InvalidInput', + message: 'Invalid coordinates provided', + sources: [], + destinations: [] + }; + + // This should not throw - error responses are valid + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(errorResponse); + } + }).not.toThrow(); + }); +}); diff --git a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.test.ts b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.test.ts new file mode 100644 index 0000000..f82f1a2 --- /dev/null +++ b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, vi } from 'vitest'; +import { ReverseGeocodeTool } from '../../../src/tools/reverse-geocode-tool/ReverseGeocodeTool.js'; + +describe('ReverseGeocodeTool output schema registration', () => { + it('should have an output schema defined', () => { + const tool = new ReverseGeocodeTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); + + it('should register output schema with MCP server', () => { + const tool = new ReverseGeocodeTool(); + + // Mock the installTo method to verify it gets called with output schema + const mockInstallTo = vi.fn().mockImplementation(() => { + // Verify that the tool has an output schema when being installed + expect(tool.outputSchema).toBeDefined(); + return tool; + }); + + Object.defineProperty(tool, 'installTo', { + value: mockInstallTo + }); + + // Simulate server registration + tool.installTo({} as never); + expect(mockInstallTo).toHaveBeenCalled(); + }); + + it('should validate valid geocoding response structure', () => { + const validResponse = { + type: 'FeatureCollection', + features: [ + { + id: 'address.1234567890', + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.676, 45.515] + }, + properties: { + mapbox_id: 'test_mapbox_id', + feature_type: 'address', + name: '123 Main Street', + full_address: + '123 Main Street, Portland, Oregon 97205, United States', + coordinates: { + longitude: -122.676, + latitude: 45.515, + accuracy: 'rooftop' + }, + context: { + address: { + mapbox_id: 'address_mapbox_id', + address_number: '123', + street_name: 'Main Street', + name: '123 Main Street' + }, + street: { + mapbox_id: 'street_mapbox_id', + name: 'Main Street' + }, + postcode: { + mapbox_id: 'postcode_mapbox_id', + name: '97205' + }, + place: { + mapbox_id: 'place_mapbox_id', + name: 'Portland', + wikidata_id: 'Q6106' + }, + region: { + mapbox_id: 'region_mapbox_id', + name: 'Oregon', + region_code: 'OR', + region_code_full: 'US-OR' + }, + country: { + mapbox_id: 'country_mapbox_id', + name: 'United States', + country_code: 'US', + country_code_alpha_3: 'USA' + } + }, + match_code: { + address_number: 'matched', + street: 'matched', + postcode: 'matched', + place: 'matched', + region: 'matched', + country: 'matched', + confidence: 'exact' + } + } + } + ], + attribution: 'Mapbox' + }; + + const tool = new ReverseGeocodeTool(); + + // This should not throw if the schema is correct + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(validResponse); + } + }).not.toThrow(); + }); + + it('should validate complex response with optional fields', () => { + const complexResponse = { + type: 'FeatureCollection', + features: [ + { + id: 'address.complex.1234567890', + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.676, 45.515] + }, + properties: { + mapbox_id: 'complex_mapbox_id', + feature_type: 'address', + name: '東京都渋谷区', + name_preferred: 'Shibuya City', + place_formatted: 'Shibuya, Tokyo, Japan', + full_address: '東京都渋谷区, Tokyo, Japan', + coordinates: { + longitude: -122.676, + latitude: 45.515, + accuracy: 'rooftop', + routable_points: [ + { + name: 'main_entrance', + longitude: -122.6761, + latitude: 45.5151 + } + ] + }, + bbox: [-122.677, 45.514, -122.675, 45.516], + context: { + address: { + mapbox_id: 'address_mapbox_id', + address_number: '1-1', + street_name: 'Shibuya', + name: '1-1 Shibuya' + }, + neighborhood: { + mapbox_id: 'neighborhood_mapbox_id', + name: 'Shibuya', + alternate: { + mapbox_id: 'alt_neighborhood_mapbox_id', + name: '渋谷' + }, + translations: { + ja: { + language: 'ja', + name: '渋谷' + } + } + }, + place: { + mapbox_id: 'place_mapbox_id', + name: 'Tokyo', + wikidata_id: 'Q1490', + translations: { + ja: { + language: 'ja', + name: '東京' + } + } + }, + country: { + mapbox_id: 'country_mapbox_id', + name: 'Japan', + country_code: 'JP', + country_code_alpha_3: 'JPN', + wikidata_id: 'Q17' + } + }, + match_code: { + address_number: 'matched', + street: 'matched', + place: 'matched', + country: 'matched', + confidence: 'high' + }, + reading: { + 'ja-Kana': 'トウキョウト', + 'ja-Latn': 'Toukyouto' + } + } + } + ], + attribution: 'Mapbox' + }; + + const tool = new ReverseGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(complexResponse); + } + }).not.toThrow(); + }); + + it('should validate minimal valid response', () => { + const minimalResponse = { + type: 'FeatureCollection', + features: [ + { + id: 'minimal.test', + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.676, 45.515] + }, + properties: { + mapbox_id: 'minimal_mapbox_id', + feature_type: 'place', + name: 'Test Place', + coordinates: { + longitude: -122.676, + latitude: 45.515 + }, + context: {} + } + } + ], + attribution: 'Mapbox' + }; + + const tool = new ReverseGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(minimalResponse); + } + }).not.toThrow(); + }); + + it('should throw validation error for invalid response', () => { + const invalidResponse = { + type: 'FeatureCollection', + features: [ + { + id: 'test', + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.676, 45.515] + }, + properties: { + // Missing required fields like mapbox_id, feature_type, etc. + name: 'Test Location' + } + } + ] + // Missing attribution field + }; + + const tool = new ReverseGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidResponse); + } + }).toThrow(); + }); +}); diff --git a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts index f13d5a9..c7ef375 100644 --- a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts +++ b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts @@ -397,6 +397,7 @@ describe('ReverseGeocodeTool', () => { expect((result.content[0] as { type: 'text'; text: string }).text).toBe( 'No results found.' ); + expect(result.structuredContent).toEqual(mockResponse); }); it('handles results with minimal properties', async () => { @@ -502,4 +503,10 @@ describe('ReverseGeocodeTool', () => { (result.content[0] as { type: 'text'; text: string }).text ).toContain('1. Test Location'); }); + + it('should have output schema defined', () => { + const tool = new ReverseGeocodeTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); }); diff --git a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.test.ts b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.test.ts new file mode 100644 index 0000000..dc6524d --- /dev/null +++ b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, vi } from 'vitest'; +import { SearchAndGeocodeTool } from '../../../src/tools/search-and-geocode-tool/SearchAndGeocodeTool.js'; + +describe('SearchAndGeocodeTool output schema registration', () => { + it('should have an output schema defined', () => { + const tool = new SearchAndGeocodeTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); + + it('should register output schema with MCP server', () => { + const tool = new SearchAndGeocodeTool(); + + // Mock the installTo method to verify it gets called with output schema + const mockInstallTo = vi.fn().mockImplementation(() => { + // Verify that the tool has an output schema when being installed + expect(tool.outputSchema).toBeDefined(); + return tool; + }); + + Object.defineProperty(tool, 'installTo', { + value: mockInstallTo + }); + + // Simulate server registration + tool.installTo({} as never); + expect(mockInstallTo).toHaveBeenCalled(); + }); + + it('should validate valid search box response structure', () => { + const validResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.4194, 37.7749] + }, + properties: { + mapbox_id: + 'dXJuOm1ieHBvaTo0ZGYzMzc4NS00OTc1LTRkMTItYmFkMC1jZWM0ZjQ0Mzg3N2Y', + feature_type: 'poi', + name: 'San Francisco', + name_preferred: 'San Francisco', + full_address: 'San Francisco, California, United States', + place_formatted: 'San Francisco, California, United States', + context: { + country: { + name: 'United States', + country_code: 'US', + country_code_alpha_3: 'USA' + }, + region: { + name: 'California', + region_code: 'CA', + region_code_full: 'US-CA' + }, + place: { + name: 'San Francisco' + } + }, + coordinates: { + longitude: -122.4194, + latitude: 37.7749 + }, + poi_category: ['city'], + maki: 'marker' + } + } + ], + attribution: 'Mapbox' + }; + + const tool = new SearchAndGeocodeTool(); + + // This should not throw if the schema is correct + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(validResponse); + } + }).not.toThrow(); + }); + + it('should validate minimal valid response with required fields only', () => { + const minimalResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-73.935242, 40.73061] + }, + properties: {} + } + ] + }; + + const tool = new SearchAndGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(minimalResponse); + } + }).not.toThrow(); + }); + + it('should validate empty feature collection', () => { + const emptyResponse = { + type: 'FeatureCollection', + features: [] + }; + + const tool = new SearchAndGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(emptyResponse); + } + }).not.toThrow(); + }); + + it('should validate complex POI response with all optional fields', () => { + const complexResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.084, 37.4219] + }, + properties: { + mapbox_id: + 'dXJuOm1ieHBvaTo0ZGYzMzc4NS00OTc1LTRkMTItYmFkMC1jZWM0ZjQ0Mzg3N2Y', + feature_type: 'poi', + name: 'Googleplex', + name_preferred: 'Google Headquarters', + full_address: + '1600 Amphitheatre Parkway, Mountain View, CA 94043, United States', + place_formatted: 'Mountain View, California 94043, United States', + address_number: '1600', + street_name: 'Amphitheatre Parkway', + context: { + country: { + name: 'United States', + country_code: 'US', + country_code_alpha_3: 'USA' + }, + region: { + name: 'California', + region_code: 'CA', + region_code_full: 'US-CA' + }, + postcode: { + name: '94043' + }, + place: { + name: 'Mountain View' + }, + locality: { + name: 'Mountain View' + }, + address: { + address_number: '1600', + street_name: 'Amphitheatre Parkway' + } + }, + coordinates: { + longitude: -122.084, + latitude: 37.4219, + accuracy: 'point', + routable_points: [ + { + name: 'main_entrance', + latitude: 37.4219, + longitude: -122.084 + } + ] + }, + bbox: [-122.085, 37.421, -122.083, 37.423], + poi_category: ['office', 'technology'], + poi_category_ids: ['office', 'technology'], + brand: ['Google'], + brand_id: 'google-123', + external_ids: { + foursquare: '4bf58dd8d48988d124941735', + yelp: 'google-mountain-view' + }, + maki: 'building', + operational_status: 'active', + eta: { + duration: 1200, + distance: 5000 + } + } + } + ], + attribution: '© 2021 Mapbox and its suppliers. All rights reserved.' + }; + + const tool = new SearchAndGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(complexResponse); + } + }).not.toThrow(); + }); + + it('should throw validation error for invalid feature collection type', () => { + const invalidResponse = { + type: 'Collection', // Should be 'FeatureCollection' + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.4194, 37.7749] + }, + properties: {} + } + ] + }; + + const tool = new SearchAndGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidResponse); + } + }).toThrow(); + }); + + it('should throw validation error for invalid geometry type', () => { + const invalidGeometryResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'LineString', // Should be 'Point' for search results + coordinates: [ + [-122.4194, 37.7749], + [-122.4094, 37.7849] + ] + }, + properties: {} + } + ] + }; + + const tool = new SearchAndGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidGeometryResponse); + } + }).toThrow(); + }); + + it('should throw validation error for invalid coordinates format', () => { + const invalidCoordinatesResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.4194] // Should have 2 coordinates [lng, lat] + }, + properties: {} + } + ] + }; + + const tool = new SearchAndGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidCoordinatesResponse); + } + }).toThrow(); + }); + + it('should throw validation error when features is not an array', () => { + const invalidFeaturesResponse = { + type: 'FeatureCollection', + features: 'not an array' + }; + + const tool = new SearchAndGeocodeTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidFeaturesResponse); + } + }).toThrow(); + }); +}); diff --git a/test/tools/structured-content.test.ts b/test/tools/structured-content.test.ts index f0f0262..75c4dfe 100644 --- a/test/tools/structured-content.test.ts +++ b/test/tools/structured-content.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MapboxApiBasedTool } from '../../src/tools/MapboxApiBasedTool.js'; -import type { OutputSchema } from '../../src/tools/MapboxApiBasedTool.schema.js'; +import type { OutputSchema } from '../../src/tools/MapboxApiBasedTool.output.schema.js'; import { z } from 'zod'; const TestInputSchema = z.object({ diff --git a/test/tools/version-tool/VersionTool.output.schema.test.ts b/test/tools/version-tool/VersionTool.output.schema.test.ts new file mode 100644 index 0000000..f51d0e9 --- /dev/null +++ b/test/tools/version-tool/VersionTool.output.schema.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, vi } from 'vitest'; +import { VersionTool } from '../../../src/tools/version-tool/VersionTool.js'; + +describe('VersionTool output schema registration', () => { + it('should have an output schema defined', () => { + const tool = new VersionTool(); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); + }); + + it('should register output schema with MCP server', () => { + const tool = new VersionTool(); + + // Mock the installTo method to verify it gets called with output schema + const mockInstallTo = vi.fn().mockImplementation(() => { + // Verify that the tool has an output schema when being installed + expect(tool.outputSchema).toBeDefined(); + return tool; + }); + + Object.defineProperty(tool, 'installTo', { + value: mockInstallTo + }); + + // Simulate server registration + tool.installTo({} as never); + expect(mockInstallTo).toHaveBeenCalled(); + }); + + it('should validate valid version response structure', () => { + const validResponse = { + name: 'Mapbox MCP server', + version: '0.5.5', + sha: 'a64ffb6e4b4017c0f9ae7259be53bb372301fea5', + tag: 'v0.5.5-1-ga64ffb6', + branch: 'structured_content_public' + }; + + const tool = new VersionTool(); + + // This should not throw if the schema is correct + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(validResponse); + } + }).not.toThrow(); + }); + + it('should validate minimal version response with unknown values', () => { + const minimalResponse = { + name: 'Mapbox MCP server', + version: '0.0.0', + sha: 'unknown', + tag: 'unknown', + branch: 'unknown' + }; + + const tool = new VersionTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(minimalResponse); + } + }).not.toThrow(); + }); + + it('should validate development version response', () => { + const devResponse = { + name: 'Mapbox MCP server', + version: '1.0.0-dev', + sha: 'abc123def456', + tag: 'dev-build', + branch: 'feature/new-feature' + }; + + const tool = new VersionTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(devResponse); + } + }).not.toThrow(); + }); + + it('should throw validation error for missing required fields', () => { + const invalidResponse = { + name: 'Mapbox MCP server', + version: '0.5.5' + // Missing sha, tag, branch fields + }; + + const tool = new VersionTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidResponse); + } + }).toThrow(); + }); + + it('should throw validation error for wrong field types', () => { + const invalidTypeResponse = { + name: 'Mapbox MCP server', + version: 1.0, // Should be string, not number + sha: 'abc123', + tag: 'v1.0.0', + branch: 'main' + }; + + const tool = new VersionTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidTypeResponse); + } + }).toThrow(); + }); + + it('should throw validation error for empty string fields', () => { + const emptyFieldResponse = { + name: '', // Empty string + version: '0.5.5', + sha: 'abc123', + tag: 'v0.5.5', + branch: 'main' + }; + + const tool = new VersionTool(); + + // All fields are required and should be non-empty strings + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(emptyFieldResponse); + } + }).not.toThrow(); // Actually, empty strings are valid strings in Zod + }); + + it('should throw validation error when fields are null', () => { + const nullFieldResponse = { + name: 'Mapbox MCP server', + version: null, // Should be string, not null + sha: 'abc123', + tag: 'v0.5.5', + branch: 'main' + }; + + const tool = new VersionTool(); + + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(nullFieldResponse); + } + }).toThrow(); + }); +}); diff --git a/test/tools/version-tool/VersionTool.test.ts b/test/tools/version-tool/VersionTool.test.ts index 108327a..45cf689 100644 --- a/test/tools/version-tool/VersionTool.test.ts +++ b/test/tools/version-tool/VersionTool.test.ts @@ -57,16 +57,34 @@ describe('VersionTool', () => { }); }); - it('should handle errors gracefully', async () => { - mockGetVersionInfo.mockImplementationOnce(() => { - throw new Error('Version info not available'); - }); + it('should handle fallback version info correctly', async () => { + // Mock getVersionInfo to return fallback values (which is realistic behavior) + mockGetVersionInfo.mockImplementationOnce(() => ({ + name: 'Mapbox MCP server', + version: '0.0.0', + sha: 'unknown', + tag: 'unknown', + branch: 'unknown' + })); const result = await tool.run({}); - expect(result.isError).toBe(true); + expect(result.isError).toBe(false); expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe('text'); + expect( + (result.content[0] as { type: 'text'; text: string }).text + ).toContain('Version: 0.0.0'); + expect( + (result.content[0] as { type: 'text'; text: string }).text + ).toContain('SHA: unknown'); + expect(result.structuredContent).toEqual({ + name: 'Mapbox MCP server', + version: '0.0.0', + sha: 'unknown', + tag: 'unknown', + branch: 'unknown' + }); }); }); From 77ae9cf0405ceec89e28c59a5f98785dbd4d9a24 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 13 Oct 2025 13:30:39 -0400 Subject: [PATCH 3/9] [tools] Update tools to use structuredContent with schema --- src/tools/MapboxApiBasedTool.ts | 11 +- .../category-list-tool/CategoryListTool.ts | 9 +- .../CategorySearchTool.input.schema.ts | 26 +- .../CategorySearchTool.ts | 9 +- src/tools/directions-tool/DirectionsTool.ts | 11 +- .../directions-tool/cleanResponseData.ts | 343 ++++++++++++------ src/tools/isochrone-tool/IsochroneTool.ts | 12 +- src/tools/matrix-tool/MatrixTool.ts | 12 +- .../ReverseGeocodeTool.ts | 9 +- .../SearchAndGeocodeTool.ts | 9 +- .../StaticMapImageTool.ts | 11 +- src/tools/toolRegistry.ts | 17 +- .../{fetchRequest.ts => httpPipeline.ts} | 46 +-- src/utils/types.ts | 9 + test/tools/MapboxApiBasedTool.test.ts | 23 +- .../CategoryListTool.output.schema.test.ts | 28 +- .../CategoryListTool.test.ts | 46 +-- .../CategorySearchTool.output.schema.test.ts | 22 +- .../CategorySearchTool.test.ts | 99 ++--- .../DirectionsTool.output.schema.test.ts | 26 +- .../directions-tool/DirectionsTool.test.ts | 194 +++++----- ...est.ts => input-schema-validation.test.ts} | 6 +- .../IsochroneTool.output.schema.test.ts | 214 ++++++----- .../IsochroneTool.registration.test.ts | 83 ----- .../isochrone-tool/IsochroneTool.test.ts | 36 +- .../MatrixTool.output.schema.test.ts | 16 +- test/tools/matrix-tool/MatrixTool.test.ts | 176 ++++----- .../ReverseGeocodeTool.output.schema.test.ts | 19 +- .../ReverseGeocodeTool.test.ts | 88 ++--- ...SearchAndGeocodeTool.output.schema.test.ts | 31 +- .../SearchAndGeocodeTool.test.ts | 100 ++--- .../StaticMapImageTool.test.ts | 120 +++--- ...chRequest.test.ts => httpPipeline.test.ts} | 42 +-- ...chRequestUtils.ts => httpPipelineUtils.ts} | 18 +- 34 files changed, 1067 insertions(+), 854 deletions(-) rename src/utils/{fetchRequest.ts => httpPipeline.ts} (76%) create mode 100644 src/utils/types.ts rename test/tools/{schema-validation.test.ts => input-schema-validation.test.ts} (94%) delete mode 100644 test/tools/isochrone-tool/IsochroneTool.registration.test.ts rename test/utils/{fetchRequest.test.ts => httpPipeline.test.ts} (84%) rename test/utils/{fetchRequestUtils.ts => httpPipelineUtils.ts} (62%) diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index edfef1e..626a045 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -4,7 +4,8 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { ZodTypeAny, z } from 'zod'; import { BaseTool } from './BaseTool.js'; -import type { OutputSchema } from './MapboxApiBasedTool.output.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../utils/types.js'; export abstract class MapboxApiBasedTool< InputSchema extends ZodTypeAny, @@ -22,11 +23,15 @@ export abstract class MapboxApiBasedTool< return process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'; } + protected httpRequest: HttpRequest; + constructor(params: { inputSchema: InputSchema; outputSchema?: OutputSchema; + httpRequest: HttpRequest; }) { super(params); + this.httpRequest = params.httpRequest; } /** @@ -51,7 +56,7 @@ export abstract class MapboxApiBasedTool< 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 @@ -110,5 +115,5 @@ export abstract class MapboxApiBasedTool< protected abstract execute( _input: z.infer, accessToken: string - ): Promise>; + ): Promise; } diff --git a/src/tools/category-list-tool/CategoryListTool.ts b/src/tools/category-list-tool/CategoryListTool.ts index 5e3dfb6..1644e50 100644 --- a/src/tools/category-list-tool/CategoryListTool.ts +++ b/src/tools/category-list-tool/CategoryListTool.ts @@ -3,7 +3,7 @@ import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; -import { fetchClient } from '../../utils/fetchRequest.js'; +import type { HttpRequest } from '../../utils/types.js'; import type { CategoryListInput } from './CategoryListTool.input.schema.js'; import { CategoryListInputSchema } from './CategoryListTool.input.schema.js'; import { CategoryListResponseSchema } from './CategoryListTool.output.schema.js'; @@ -42,10 +42,11 @@ export class CategoryListTool extends MapboxApiBasedTool< openWorldHint: true }; - constructor(private fetchImpl: typeof fetch = fetchClient) { + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: CategoryListInputSchema, - outputSchema: CategoryListResponseSchema + outputSchema: CategoryListResponseSchema, + httpRequest: params.httpRequest }); } @@ -63,7 +64,7 @@ export class CategoryListTool extends MapboxApiBasedTool< url.searchParams.set('language', input.language); } - const response = await this.fetchImpl(url.toString(), { + const response = await this.httpRequest(url.toString(), { method: 'GET', headers: { 'User-Agent': `@mapbox/mcp-server/${process.env.npm_package_version || 'dev'}` diff --git a/src/tools/category-search-tool/CategorySearchTool.input.schema.ts b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts index eda4b6a..9d38e5a 100644 --- a/src/tools/category-search-tool/CategorySearchTool.input.schema.ts +++ b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts @@ -31,18 +31,20 @@ export const CategorySearchInputSchema = z.object({ } // Handle JSON-stringified object: "{\"longitude\": -82.458107, \"latitude\": 27.937259}" if (val.startsWith('{') && val.endsWith('}')) { - try { - const parsed = JSON.parse(val); - if ( - typeof parsed === 'object' && - parsed !== null && - typeof parsed.longitude === 'number' && - typeof parsed.latitude === 'number' - ) { - return { longitude: parsed.longitude, latitude: parsed.latitude }; - } - } catch { - // Fall back to other formats + // Reject large payloads (should only be a lat/lng pair) + if (val.length > 200) { + throw new Error( + 'Proximity JSON string too large. Only latitude/longitude pairs are allowed.' + ); + } + const parsed = JSON.parse(val); + if ( + typeof parsed === 'object' && + parsed !== null && + typeof parsed.longitude === 'number' && + typeof parsed.latitude === 'number' + ) { + return { longitude: parsed.longitude, latitude: parsed.latitude }; } } // Handle string that looks like an array: "[-82.451668, 27.942964]" diff --git a/src/tools/category-search-tool/CategorySearchTool.ts b/src/tools/category-search-tool/CategorySearchTool.ts index b15a49a..d190042 100644 --- a/src/tools/category-search-tool/CategorySearchTool.ts +++ b/src/tools/category-search-tool/CategorySearchTool.ts @@ -4,7 +4,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; -import { fetchClient } from '../../utils/fetchRequest.js'; +import type { HttpRequest } from '../../utils/types.js'; import { CategorySearchInputSchema } from './CategorySearchTool.input.schema.js'; import { CategorySearchResponseSchema } from './CategorySearchTool.output.schema.js'; import type { @@ -29,10 +29,11 @@ export class CategorySearchTool extends MapboxApiBasedTool< openWorldHint: true }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: CategorySearchInputSchema, - outputSchema: CategorySearchResponseSchema + outputSchema: CategorySearchResponseSchema, + httpRequest: params.httpRequest }); } @@ -146,7 +147,7 @@ export class CategorySearchTool extends MapboxApiBasedTool< } // Make the request - const response = await this.fetch(url.toString()); + const response = await this.httpRequest(url.toString()); if (!response.ok) { return { diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index 2076de9..3ccf910 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -7,12 +7,12 @@ import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import { cleanResponseData } from './cleanResponseData.js'; import { formatIsoDateTime } from '../../utils/dateUtils.js'; -import { fetchClient } from '../../utils/fetchRequest.js'; import { DirectionsInputSchema } from './DirectionsTool.input.schema.js'; import { DirectionsResponseSchema, type DirectionsResponse } from './DirectionsTool.output.schema.js'; +import type { HttpRequest } from '../..//utils/types.js'; // Docs: https://docs.mapbox.com/api/navigation/directions/ @@ -31,10 +31,11 @@ export class DirectionsTool extends MapboxApiBasedTool< openWorldHint: true }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: DirectionsInputSchema, - outputSchema: DirectionsResponseSchema + outputSchema: DirectionsResponseSchema, + httpRequest: params.httpRequest }); } protected async execute( @@ -237,7 +238,7 @@ export class DirectionsTool extends MapboxApiBasedTool< const url = `${MapboxApiBasedTool.mapboxApiEndpoint}directions/v5/mapbox/${input.routing_profile}/${encodedCoords}?${queryString}`; - const response = await this.fetch(url); + const response = await this.httpRequest(url); if (!response.ok) { return { @@ -251,7 +252,7 @@ export class DirectionsTool extends MapboxApiBasedTool< }; } - const data = await response.json(); + const data = (await response.json()) as DirectionsResponse; const cleanedData = cleanResponseData(input, data); // Validate the response data against our schema diff --git a/src/tools/directions-tool/cleanResponseData.ts b/src/tools/directions-tool/cleanResponseData.ts index 87605f0..c6e8279 100644 --- a/src/tools/directions-tool/cleanResponseData.ts +++ b/src/tools/directions-tool/cleanResponseData.ts @@ -4,6 +4,127 @@ import type { z } from 'zod'; import type { DirectionsInputSchema } from './DirectionsTool.input.schema.js'; +// Raw API response types (before cleaning) +interface RawWaypoint { + name?: string; + location?: [number, number]; + distance?: number; + [key: string]: unknown; +} + +interface RawAnnotation { + distance?: number[]; + speed?: number[]; + congestion?: string[]; + [key: string]: unknown; +} + +interface RawAdmin { + iso_3166_1_alpha3?: string; + [key: string]: unknown; +} + +interface RawNotification { + details?: { + message?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface RawIncident { + type?: string; + end_time?: string; + long_description?: string; + impact?: string; + affected_road_names?: string[]; + length?: number; + [key: string]: unknown; +} + +interface RawVoiceInstruction { + announcement?: string; + [key: string]: unknown; +} + +interface RawStep { + voiceInstructions?: RawVoiceInstruction[]; + [key: string]: unknown; +} + +interface RawLeg { + summary?: string; + admins?: RawAdmin[]; + annotation?: RawAnnotation; + notifications?: RawNotification[]; + incidents?: RawIncident[]; + steps?: RawStep[]; + [key: string]: unknown; +} + +interface RawRoute { + duration?: number; + distance?: number; + weight_name?: string; + weight?: number; + duration_typical?: number; + weight_typical?: number; + geometry?: unknown; + legs?: RawLeg[]; + [key: string]: unknown; +} + +interface RawDirectionsResponse { + uuid?: string; + code?: string; + waypoints?: RawWaypoint[]; + routes?: RawRoute[]; + [key: string]: unknown; +} + +// Cleaned response types (after cleaning) +interface CleanedRoute + extends Omit< + RawRoute, + 'legs' | 'weight_name' | 'weight' | 'duration_typical' | 'weight_typical' + > { + leg_summaries?: string[]; + intersecting_admins?: string[]; + notifications_summary?: string[]; + incidents_summary?: Array<{ + type?: string; + end_time?: string; + long_description?: string; + impact?: string; + affected_road_names?: string[]; + length?: number; + }>; + instructions?: string[]; + num_legs?: number; + congestion_information?: { + length_low: number; + length_moderate: number; + length_heavy: number; + length_severe: number; + }; + average_speed_kph?: number; + duration_under_typical_traffic_conditions?: number; +} + +interface CleanedWaypoint extends Omit { + snap_location?: [number, number]; + snap_distance?: number; +} + +interface CleanedDirectionsResponse + extends Omit< + RawDirectionsResponse, + 'uuid' | 'code' | 'waypoints' | 'routes' + > { + waypoints?: CleanedWaypoint[]; + routes?: CleanedRoute[]; +} + /** * Cleans up the API response to reduce token count while preserving useful data. * @@ -13,8 +134,8 @@ import type { DirectionsInputSchema } from './DirectionsTool.input.schema.js'; */ export function cleanResponseData( input: z.infer, - data: any -): any { + data: RawDirectionsResponse +): CleanedDirectionsResponse { // Remove unnecessary keys to reduce token count if ('uuid' in data) { delete data.uuid; @@ -24,29 +145,29 @@ export function cleanResponseData( delete data.code; } - if ('waypoints' in data) { + if (data.waypoints) { // rename each waypoint's location to `snap_location` and distance to `snap_distance` // this is not really necessary, but hopefully agents will find this more obvious that we have snapping - data.waypoints = data.waypoints.map((waypoint: any) => { - const updatedWaypoint = { ...waypoint }; - if ('location' in updatedWaypoint) { - updatedWaypoint.snap_location = updatedWaypoint.location; - delete updatedWaypoint.location; + data.waypoints = data.waypoints.map((waypoint) => { + const updatedWaypoint: CleanedWaypoint = { ...waypoint }; + if (waypoint.location) { + updatedWaypoint.snap_location = waypoint.location; + delete (updatedWaypoint as RawWaypoint).location; } - if ('distance' in updatedWaypoint) { - updatedWaypoint.snap_distance = Math.round(updatedWaypoint.distance); - delete updatedWaypoint.distance; + if (waypoint.distance !== undefined) { + updatedWaypoint.snap_distance = Math.round(waypoint.distance); + delete (updatedWaypoint as RawWaypoint).distance; } return updatedWaypoint; - }); + }) as CleanedWaypoint[]; } - if (!('routes' in data)) { + if (!data.routes) { // lets return early because there is nothing more we could do here return data; } - data.routes.forEach((route: any) => { + data.routes.forEach((route) => { // Round duration and distance to integers if they exist if (route.duration !== undefined) { route.duration = Math.round(route.duration); @@ -63,7 +184,7 @@ export function cleanResponseData( delete route.geometry; } - route.leg_summaries = route.legs.map((leg: any) => leg.summary); + const routeLegSummaries = route.legs?.map((leg) => leg.summary || '') || []; // Collect all unique admins across all legs of this route const routeUniqueIsoCodes = new Set(); @@ -72,7 +193,14 @@ export function cleanResponseData( const routeUniqueNotificationMessages = new Set(); // Collect all incidents across all legs of this route - const routeIncidents: any[] = []; + const routeIncidents: Array<{ + type?: string; + end_time?: string; + long_description?: string; + impact?: string; + affected_road_names?: string[]; + length?: number; + }> = []; // Collect voice instruction announcements from all steps const routeAnnouncements: string[] = []; @@ -88,106 +216,114 @@ export function cleanResponseData( low: 0 }; - route.legs.forEach((leg: any) => { - if (leg.annotation && leg.annotation.speed && leg.annotation.distance) { - leg.annotation.speed.forEach((speed: number, index: number) => { - const speedValue = parseFloat(String(speed)); - const distance = parseFloat(String(leg.annotation.distance[index])); - // Calculate the weighted speed (speed * distance) - totalDistanceWeightedSpeed += speedValue * distance; - sumDistanceMeters += distance; - }); - } + if (route.legs) { + route.legs.forEach((leg) => { + if (leg.annotation?.speed && leg.annotation?.distance) { + leg.annotation.speed.forEach((speed: number, index: number) => { + const speedValue = parseFloat(String(speed)); + const distance = parseFloat( + String(leg.annotation!.distance![index]) + ); + // Calculate the weighted speed (speed * distance) + totalDistanceWeightedSpeed += speedValue * distance; + sumDistanceMeters += distance; + }); + } - if ( - leg.annotation && - leg.annotation.congestion && - leg.annotation.distance - ) { - // iterate every congestion string in leg.annotation.congestion - // each string is one of `severe, heavy, moderate, low, unknown` - // keep track of total distance by type of congestion - leg.annotation.congestion.forEach( - (congestion: string, index: number) => { - const distance = parseFloat(String(leg.annotation.distance[index])); - if ( - congestion === 'severe' || - congestion === 'heavy' || - congestion === 'moderate' || - congestion === 'low' - ) { - congestionTypeToDistance[congestion] += distance; + if (leg.annotation?.congestion && leg.annotation?.distance) { + // iterate every congestion string in leg.annotation.congestion + // each string is one of `severe, heavy, moderate, low, unknown` + // keep track of total distance by type of congestion + leg.annotation.congestion.forEach( + (congestion: string, index: number) => { + const distance = parseFloat( + String(leg.annotation!.distance![index]) + ); + if ( + congestion === 'severe' || + congestion === 'heavy' || + congestion === 'moderate' || + congestion === 'low' + ) { + congestionTypeToDistance[congestion] += distance; + } + // Skip 'unknown' congestion type } - // Skip 'unknown' congestion type - } - ); - } + ); + } - if (leg.admins && Array.isArray(leg.admins)) { - // Extract unique ISO codes from this leg - leg.admins.forEach((admin: any) => { - if (admin.iso_3166_1_alpha3) { - routeUniqueIsoCodes.add(admin.iso_3166_1_alpha3); - } - }); - } - - // Process notifications if they exist - if (leg.notifications && Array.isArray(leg.notifications)) { - // Extract unique notification messages from this leg - leg.notifications.forEach((notification: any) => { - if (notification.details && notification.details.message) { - routeUniqueNotificationMessages.add(notification.details.message); - } - }); - } + if (leg.admins) { + // Extract unique ISO codes from this leg + leg.admins.forEach((admin) => { + if (admin.iso_3166_1_alpha3) { + routeUniqueIsoCodes.add(admin.iso_3166_1_alpha3); + } + }); + } - // Process incidents if they exist - if (leg.incidents && Array.isArray(leg.incidents)) { - leg.incidents.forEach((incident: any) => { - // Extract only the specified fields for each incident - routeIncidents.push({ - type: incident.type, - end_time: incident.end_time, - long_description: incident.long_description, - impact: incident.impact, - affected_road_names: incident.affected_road_names, - length: incident.length + // Process notifications if they exist + if (leg.notifications) { + // Extract unique notification messages from this leg + leg.notifications.forEach((notification) => { + if (notification.details?.message) { + routeUniqueNotificationMessages.add(notification.details.message); + } }); - }); - } + } - // Process steps if they exist to collect voice instructions - if (leg.steps && Array.isArray(leg.steps)) { - leg.steps.forEach((step: any) => { - if (step.voiceInstructions && Array.isArray(step.voiceInstructions)) { - step.voiceInstructions.forEach((instruction: any) => { - if (instruction.announcement) { - routeAnnouncements.push(instruction.announcement); - } + // Process incidents if they exist + if (leg.incidents) { + leg.incidents.forEach((incident) => { + // Extract only the specified fields for each incident + routeIncidents.push({ + type: incident.type, + end_time: incident.end_time, + long_description: incident.long_description, + impact: incident.impact, + affected_road_names: incident.affected_road_names, + length: incident.length }); - } - }); - } - }); // Add all unique admins as a new property on the route - route.intersecting_admins = Array.from(routeUniqueIsoCodes); + }); + } + + // Process steps if they exist to collect voice instructions + if (leg.steps) { + leg.steps.forEach((step) => { + if (step.voiceInstructions) { + step.voiceInstructions.forEach((instruction) => { + if (instruction.announcement) { + routeAnnouncements.push(instruction.announcement); + } + }); + } + }); + } + }); + } + + // Add all unique admins as a new property on the route + const cleanedRoute = route as CleanedRoute; + cleanedRoute.leg_summaries = routeLegSummaries; + cleanedRoute.intersecting_admins = Array.from(routeUniqueIsoCodes); // Add all unique notification messages as a new property on the route - route.notifications_summary = Array.from(routeUniqueNotificationMessages); + cleanedRoute.notifications_summary = Array.from( + routeUniqueNotificationMessages + ); // Add all incidents with the specified fields as a new property on the route - route.incidents_summary = routeIncidents; + cleanedRoute.incidents_summary = routeIncidents; // Add voice instruction announcements only if there are 1 to 10 of them // If there are more than 10, it's just too many, and if there is 0 then we don't have them. if (routeAnnouncements.length >= 1 && routeAnnouncements.length <= 10) { - route.instructions = routeAnnouncements; + cleanedRoute.instructions = routeAnnouncements; } - route.num_legs = route.legs.length; + cleanedRoute.num_legs = route.legs?.length || 0; // Add congestion distance information to route - route.congestion_information = { + cleanedRoute.congestion_information = { length_low: Math.round(congestionTypeToDistance.low), length_moderate: Math.round(congestionTypeToDistance.moderate), length_heavy: Math.round(congestionTypeToDistance.heavy), @@ -200,21 +336,18 @@ export function cleanResponseData( const averageMetersPerSecond = totalDistanceWeightedSpeed / sumDistanceMeters; // Convert m/s to km/h (multiply by 3.6) and round to integer - route.average_speed_kph = Math.round(averageMetersPerSecond * 3.6); + cleanedRoute.average_speed_kph = Math.round(averageMetersPerSecond * 3.6); } if (route.duration_typical) { - route.duration_under_typical_traffic_conditions = Math.round( + cleanedRoute.duration_under_typical_traffic_conditions = Math.round( route.duration_typical ); - delete route.duration_typical; - } - - if (route.weight_typical) { - delete route.weight_typical; + delete (cleanedRoute as RawRoute).duration_typical; } - delete route.legs; + delete (cleanedRoute as RawRoute).weight_typical; + delete (cleanedRoute as RawRoute).legs; }); return data; diff --git a/src/tools/isochrone-tool/IsochroneTool.ts b/src/tools/isochrone-tool/IsochroneTool.ts index 9c85edc..8a35c20 100644 --- a/src/tools/isochrone-tool/IsochroneTool.ts +++ b/src/tools/isochrone-tool/IsochroneTool.ts @@ -4,7 +4,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; -import { fetchClient } from '../../utils/fetchRequest.js'; +import type { HttpRequest } from '../../utils/types.js'; import { IsochroneInputSchema } from './IsochroneTool.input.schema.js'; import { IsochroneResponseSchema, @@ -29,14 +29,12 @@ export class IsochroneTool extends MapboxApiBasedTool< openWorldHint: true }; - private fetch: typeof globalThis.fetch; - - constructor(fetch: typeof globalThis.fetch = fetchClient) { + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: IsochroneInputSchema, - outputSchema: IsochroneResponseSchema + outputSchema: IsochroneResponseSchema, + httpRequest: params.httpRequest }); - this.fetch = fetch; } private formatIsochroneResponse(data: IsochroneResponse): string { @@ -135,7 +133,7 @@ export class IsochroneTool extends MapboxApiBasedTool< url.searchParams.append('depart_at', input.depart_at); } - const response = await this.fetch(url); + const response = await this.httpRequest(url); if (!response.ok) { return { diff --git a/src/tools/matrix-tool/MatrixTool.ts b/src/tools/matrix-tool/MatrixTool.ts index 3a307c4..f002dde 100644 --- a/src/tools/matrix-tool/MatrixTool.ts +++ b/src/tools/matrix-tool/MatrixTool.ts @@ -5,7 +5,7 @@ import type { z } from 'zod'; import { URLSearchParams } from 'node:url'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; -import { fetchClient } from '../../utils/fetchRequest.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MatrixInputSchema } from './MatrixTool.input.schema.js'; import { MatrixResponseSchema, @@ -29,14 +29,12 @@ export class MatrixTool extends MapboxApiBasedTool< openWorldHint: true }; - private fetch: typeof globalThis.fetch; - - constructor(fetch: typeof globalThis.fetch = fetchClient) { + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: MatrixInputSchema, - outputSchema: MatrixResponseSchema + outputSchema: MatrixResponseSchema, + httpRequest: params.httpRequest }); - this.fetch = fetch; } protected async execute( @@ -278,7 +276,7 @@ export class MatrixTool extends MapboxApiBasedTool< const url = `${MapboxApiBasedTool.mapboxApiEndpoint}directions-matrix/v1/mapbox/${input.profile}/${joined}?${queryParams.toString()}`; // Make the request - const response = await this.fetch(url); + const response = await this.httpRequest(url); if (!response.ok) { return { diff --git a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts index c2f5dbc..875703b 100644 --- a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts +++ b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts @@ -4,7 +4,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; -import { fetchClient } from '../../utils/fetchRequest.js'; +import type { HttpRequest } from '../../utils/types.js'; import { ReverseGeocodeInputSchema } from './ReverseGeocodeTool.input.schema.js'; import { GeocodingResponseSchema } from './ReverseGeocodeTool.output.schema.js'; import type { @@ -29,10 +29,11 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< openWorldHint: true }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: ReverseGeocodeInputSchema, - outputSchema: GeocodingResponseSchema + outputSchema: GeocodingResponseSchema, + httpRequest: params.httpRequest }); } @@ -132,7 +133,7 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< url.searchParams.append('types', input.types.join(',')); } - const response = await this.fetch(url.toString()); + const response = await this.httpRequest(url.toString()); if (!response.ok) { return { diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts index eb2d1cc..0819f60 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts @@ -4,7 +4,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; -import { fetchClient } from '../../utils/fetchRequest.js'; +import type { HttpRequest } from '../../utils/types.js'; import { SearchAndGeocodeInputSchema } from './SearchAndGeocodeTool.input.schema.js'; import { SearchBoxResponseSchema, @@ -32,10 +32,11 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< openWorldHint: true }; - constructor(private fetchImpl: typeof fetch = fetchClient) { + constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: SearchAndGeocodeInputSchema, - outputSchema: SearchBoxResponseSchema + outputSchema: SearchBoxResponseSchema, + httpRequest: params.httpRequest }); } @@ -178,7 +179,7 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< `SearchAndGeocodeTool: Fetching from URL: ${url.toString().replace(accessToken, '[REDACTED]')}` ); - const response = await this.fetchImpl(url.toString()); + const response = await this.httpRequest(url.toString()); if (!response.ok) { const errorBody = await response.text(); diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index e260aac..f9c454b 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -4,7 +4,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; -import { fetchClient } from '../../utils/fetchRequest.js'; +import type { HttpRequest } from '../../utils/types.js'; import { StaticMapImageInputSchema } from './StaticMapImageTool.input.schema.js'; import type { OverlaySchema } from './StaticMapImageTool.input.schema.js'; @@ -22,8 +22,11 @@ export class StaticMapImageTool extends MapboxApiBasedTool< openWorldHint: true }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: StaticMapImageInputSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: StaticMapImageInputSchema, + httpRequest: params.httpRequest + }); } private encodeOverlay(overlay: z.infer): string { @@ -95,7 +98,7 @@ export class StaticMapImageTool extends MapboxApiBasedTool< const density = input.highDensity ? '@2x' : ''; const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${input.style}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}?access_token=${accessToken}`; - const response = await this.fetch(url); + const response = await this.httpRequest(url); if (!response.ok) { return { diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 92bd4c1..de6681a 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -11,19 +11,20 @@ import { ReverseGeocodeTool } from './reverse-geocode-tool/ReverseGeocodeTool.js import { StaticMapImageTool } from './static-map-image-tool/StaticMapImageTool.js'; import { SearchAndGeocodeTool } from './search-and-geocode-tool/SearchAndGeocodeTool.js'; import { VersionTool } from './version-tool/VersionTool.js'; +import { httpRequest } from '../utils/httpPipeline.js'; // Central registry of all tools export const ALL_TOOLS = [ // INSERT NEW TOOL INSTANCE HERE new VersionTool(), - new CategoryListTool(), - new CategorySearchTool(), - new DirectionsTool(), - new IsochroneTool(), - new MatrixTool(), - new ReverseGeocodeTool(), - new StaticMapImageTool(), - new SearchAndGeocodeTool() + new CategoryListTool({ httpRequest }), + new CategorySearchTool({ httpRequest }), + new DirectionsTool({ httpRequest }), + new IsochroneTool({ httpRequest }), + new MatrixTool({ httpRequest }), + new ReverseGeocodeTool({ httpRequest }), + new StaticMapImageTool({ httpRequest }), + new SearchAndGeocodeTool({ httpRequest }) ] as const; export type ToolInstance = (typeof ALL_TOOLS)[number]; diff --git a/src/utils/fetchRequest.ts b/src/utils/httpPipeline.ts similarity index 76% rename from src/utils/fetchRequest.ts rename to src/utils/httpPipeline.ts index ebf7250..22afc0e 100644 --- a/src/utils/fetchRequest.ts +++ b/src/utils/httpPipeline.ts @@ -2,33 +2,34 @@ // 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 { @@ -36,7 +37,7 @@ export class PolicyPipeline { } } - findPolicyById(id: string): FetchPolicy | undefined { + findPolicyById(id: string): HttpPolicy | undefined { return this.policies.find((p) => p.id === id); } @@ -44,7 +45,7 @@ export class PolicyPipeline { return this.policies; } - async fetch( + async execute( input: string | URL | Request, init: RequestInit = {} ): Promise { @@ -54,17 +55,20 @@ 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( @@ -76,7 +80,7 @@ export class UserAgentPolicy implements FetchPolicy { async handle( input: string | URL | Request, init: RequestInit, - next: typeof fetch + next: HttpRequest ): Promise { let headers: Headers | Record; @@ -111,7 +115,7 @@ export class UserAgentPolicy implements FetchPolicy { } } -export class RetryPolicy implements FetchPolicy { +export class RetryPolicy implements HttpPolicy { id: string; constructor( @@ -126,7 +130,7 @@ export class RetryPolicy implements FetchPolicy { async handle( input: string | URL | Request, init: RequestInit, - next: typeof fetch + next: HttpRequest ): Promise { let attempt = 0; let lastError: Response | undefined; @@ -156,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/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/test/tools/MapboxApiBasedTool.test.ts b/test/tools/MapboxApiBasedTool.test.ts index 4f048f3..6b92fc3 100644 --- a/test/tools/MapboxApiBasedTool.test.ts +++ b/test/tools/MapboxApiBasedTool.test.ts @@ -4,9 +4,18 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { z } from 'zod'; import { MapboxApiBasedTool } from '../../src/tools/MapboxApiBasedTool.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 { + static readonly annotations = { + title: 'Test Tool for MapboxApiBasedTool', + readOnlyHint: true, + idempotentHint: true + }; + + readonly annotations = TestTool.annotations; readonly name = 'test-tool'; readonly description = 'Tool for testing MapboxApiBasedTool error handling'; @@ -14,12 +23,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 + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { throw new Error('Test error message'); } @@ -34,7 +47,8 @@ describe('MapboxApiBasedTool', () => { 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' ); - 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(); }); @@ -54,7 +68,8 @@ describe('MapboxApiBasedTool', () => { try { // 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(); diff --git a/test/tools/category-list-tool/CategoryListTool.output.schema.test.ts b/test/tools/category-list-tool/CategoryListTool.output.schema.test.ts index db4e211..cddf8d7 100644 --- a/test/tools/category-list-tool/CategoryListTool.output.schema.test.ts +++ b/test/tools/category-list-tool/CategoryListTool.output.schema.test.ts @@ -6,16 +6,19 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, vi } from 'vitest'; import { CategoryListTool } from '../../../src/tools/category-list-tool/CategoryListTool.js'; +import { setupHttpRequest } from 'test/utils/httpPipelineUtils.js'; describe('CategoryListTool output schema registration', () => { it('should have an output schema defined', () => { - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); it('should register output schema with MCP server', () => { - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); // Mock the installTo method to verify it gets called with output schema const mockInstallTo = vi.fn().mockImplementation(() => { @@ -44,7 +47,8 @@ describe('CategoryListTool output schema registration', () => { ] }; - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); // This should not throw if the schema is correct expect(() => { @@ -59,7 +63,8 @@ describe('CategoryListTool output schema registration', () => { listItems: ['food'] }; - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -73,7 +78,8 @@ describe('CategoryListTool output schema registration', () => { listItems: [] }; - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -98,7 +104,8 @@ describe('CategoryListTool output schema registration', () => { ] }; - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -113,7 +120,8 @@ describe('CategoryListTool output schema registration', () => { someOtherField: 'value' }; - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -131,7 +139,8 @@ describe('CategoryListTool output schema registration', () => { ] }; - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -147,7 +156,8 @@ describe('CategoryListTool output schema registration', () => { version: '1.0.0' }; - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(() => { if (tool.outputSchema) { diff --git a/test/tools/category-list-tool/CategoryListTool.test.ts b/test/tools/category-list-tool/CategoryListTool.test.ts index 312ce96..95983d1 100644 --- a/test/tools/category-list-tool/CategoryListTool.test.ts +++ b/test/tools/category-list-tool/CategoryListTool.test.ts @@ -5,9 +5,9 @@ process.env.MAPBOX_ACCESS_TOKEN = 'pk.eyJzdWIiOiJ0ZXN0In0.signature'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { CategoryListTool } from '../../../src/tools/category-list-tool/CategoryListTool.js'; describe('CategoryListTool', () => { @@ -16,7 +16,7 @@ describe('CategoryListTool', () => { }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: async () => ({ listItems: [ { @@ -28,22 +28,22 @@ describe('CategoryListTool', () => { }) }); - await new CategoryListTool(fetch).run({}); + await new CategoryListTool({ httpRequest }).run({}); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('constructs correct URL with access token', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: async () => ({ listItems: [], version: '25:test' }) }); - await new CategoryListTool(fetch).run({}); + await new CategoryListTool({ httpRequest }).run({}); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain( 'https://api.mapbox.com/search/searchbox/v1/list/category' ); @@ -51,29 +51,31 @@ describe('CategoryListTool', () => { }); it('includes language parameter when provided', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: async () => ({ listItems: [], version: '25:test' }) }); - await new CategoryListTool(fetch).run({ + await new CategoryListTool({ httpRequest }).run({ language: 'es' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('language=es'); }); it('handles fetch errors gracefully', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: false, status: 403, statusText: 'Forbidden' }); - await expect(new CategoryListTool(fetch).run({})).resolves.toMatchObject({ + await expect( + new CategoryListTool({ httpRequest }).run({}) + ).resolves.toMatchObject({ isError: true }); }); @@ -93,11 +95,11 @@ describe('CategoryListTool', () => { version: '25:test' }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategoryListTool(fetch).run({}); + const result = await new CategoryListTool({ httpRequest }).run({}); expect(result.isError).toBe(false); expect(result.content[0].type).toBe('text'); @@ -121,11 +123,11 @@ describe('CategoryListTool', () => { version: '25:test' }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategoryListTool(fetch).run({ + const result = await new CategoryListTool({ httpRequest }).run({ limit: 2, offset: 1 }); @@ -140,14 +142,14 @@ describe('CategoryListTool', () => { }); it('handles empty results', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => ({ listItems: [], version: '25:test' }) }); - const result = await new CategoryListTool(fetch).run({}); + const result = await new CategoryListTool({ httpRequest }).run({}); expect(result.isError).toBe(false); const text = (result.content[0] as { type: 'text'; text: string }).text; @@ -158,7 +160,8 @@ describe('CategoryListTool', () => { }); it('validates input parameters correctly', async () => { - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(() => tool.inputSchema.parse({})).not.toThrow(); expect(() => tool.inputSchema.parse({ language: 'en' })).not.toThrow(); @@ -175,7 +178,8 @@ describe('CategoryListTool', () => { }); it('should have output schema defined', () => { - const tool = new CategoryListTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategoryListTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); diff --git a/test/tools/category-search-tool/CategorySearchTool.output.schema.test.ts b/test/tools/category-search-tool/CategorySearchTool.output.schema.test.ts index 4b413ff..f7ac614 100644 --- a/test/tools/category-search-tool/CategorySearchTool.output.schema.test.ts +++ b/test/tools/category-search-tool/CategorySearchTool.output.schema.test.ts @@ -6,16 +6,19 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, vi } from 'vitest'; import { CategorySearchTool } from '../../../src/tools/category-search-tool/CategorySearchTool.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('CategorySearchTool output schema registration', () => { it('should have an output schema defined', () => { - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); it('should register output schema with MCP server', () => { - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); // Mock the installTo method to verify it gets called with output schema const mockInstallTo = vi.fn().mockImplementation(() => { @@ -112,7 +115,8 @@ describe('CategorySearchTool output schema registration', () => { attribution: 'Mapbox' }; - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); // This should not throw if the schema is correct expect(() => { @@ -225,7 +229,8 @@ describe('CategorySearchTool output schema registration', () => { attribution: 'Mapbox' }; - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -259,7 +264,8 @@ describe('CategorySearchTool output schema registration', () => { attribution: 'Mapbox' }; - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -287,7 +293,8 @@ describe('CategorySearchTool output schema registration', () => { // Missing attribution field }; - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -303,7 +310,8 @@ describe('CategorySearchTool output schema registration', () => { attribution: 'Mapbox' }; - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); expect(() => { if (tool.outputSchema) { diff --git a/test/tools/category-search-tool/CategorySearchTool.test.ts b/test/tools/category-search-tool/CategorySearchTool.test.ts index 967cc95..299d0bd 100644 --- a/test/tools/category-search-tool/CategorySearchTool.test.ts +++ b/test/tools/category-search-tool/CategorySearchTool.test.ts @@ -6,9 +6,9 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, afterEach, vi } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { CategorySearchTool } from '../../../src/tools/category-search-tool/CategorySearchTool.js'; describe('CategorySearchTool', () => { @@ -17,31 +17,31 @@ describe('CategorySearchTool', () => { }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'restaurant' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('constructs correct URL with required parameters', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'cafe' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('search/searchbox/v1/category/cafe'); expect(calledUrl).toContain('access_token='); }); it('includes all optional parameters in URL', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'hotel', language: 'es', limit: 15, @@ -56,7 +56,7 @@ describe('CategorySearchTool', () => { poi_category_exclusions: ['motel', 'hostel'] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('category/hotel'); expect(calledUrl).toContain('language=es'); expect(calledUrl).toContain('limit=15'); @@ -67,72 +67,72 @@ describe('CategorySearchTool', () => { }); it('handles IP-based proximity', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'gas_station', proximity: 'ip' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('proximity=ip'); }); it('handles string format proximity coordinates', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'restaurant', proximity: '-82.451668,27.942976' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('proximity=-82.451668%2C27.942976'); }); it('handles array-like string format proximity', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'restaurant', proximity: '[-82.451668, 27.942964]' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('proximity=-82.451668%2C27.942964'); }); it('handles JSON-stringified object format proximity', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'taco_shop', proximity: '{"longitude": -82.458107, "latitude": 27.937259}' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('proximity=-82.458107%2C27.937259'); }); it('uses default limit when not specified', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'pharmacy' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('limit=10'); }); it('handles fetch errors gracefully', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' }); - const result = await new CategorySearchTool(fetch).run({ + const result = await new CategorySearchTool({ httpRequest }).run({ category: 'restaurant' }); @@ -144,7 +144,8 @@ describe('CategorySearchTool', () => { }); it('validates limit constraints', async () => { - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); // Test limit too high await expect( @@ -168,7 +169,8 @@ describe('CategorySearchTool', () => { }); it('validates coordinate constraints', async () => { - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); // Test invalid longitude in proximity await expect( @@ -197,13 +199,13 @@ describe('CategorySearchTool', () => { }); it('encodes special characters in category', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new CategorySearchTool(fetch).run({ + await new CategorySearchTool({ httpRequest }).run({ category: 'shopping mall' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('category/shopping%20mall'); }); @@ -227,11 +229,11 @@ describe('CategorySearchTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategorySearchTool(fetch).run({ + const result = await new CategorySearchTool({ httpRequest }).run({ category: 'cafe' }); @@ -266,11 +268,11 @@ describe('CategorySearchTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategorySearchTool(fetch).run({ + const result = await new CategorySearchTool({ httpRequest }).run({ category: 'fast_food' }); @@ -314,11 +316,11 @@ describe('CategorySearchTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategorySearchTool(fetch).run({ + const result = await new CategorySearchTool({ httpRequest }).run({ category: 'department_store', limit: 2 }); @@ -339,11 +341,11 @@ describe('CategorySearchTool', () => { features: [] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategorySearchTool(fetch).run({ + const result = await new CategorySearchTool({ httpRequest }).run({ category: 'nonexistent_category' }); @@ -371,11 +373,11 @@ describe('CategorySearchTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategorySearchTool(fetch).run({ + const result = await new CategorySearchTool({ httpRequest }).run({ category: 'gas_station' }); @@ -406,11 +408,11 @@ describe('CategorySearchTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategorySearchTool(fetch).run({ + const result = await new CategorySearchTool({ httpRequest }).run({ category: 'restaurant', format: 'json_string' }); @@ -440,11 +442,11 @@ describe('CategorySearchTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new CategorySearchTool(fetch).run({ + const result = await new CategorySearchTool({ httpRequest }).run({ category: 'cafe' }); @@ -456,7 +458,8 @@ describe('CategorySearchTool', () => { }); it('should have output schema defined', () => { - const tool = new CategorySearchTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CategorySearchTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); diff --git a/test/tools/directions-tool/DirectionsTool.output.schema.test.ts b/test/tools/directions-tool/DirectionsTool.output.schema.test.ts index 4c7d381..2f95f35 100644 --- a/test/tools/directions-tool/DirectionsTool.output.schema.test.ts +++ b/test/tools/directions-tool/DirectionsTool.output.schema.test.ts @@ -1,34 +1,44 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + import { describe, it, expect, vi } from 'vitest'; import { DirectionsTool } from '../../../src/tools/directions-tool/DirectionsTool.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('DirectionsTool output schema registration', () => { it('should have an output schema defined', () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); it('should register output schema with MCP server', () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Mock the installTo method to verify it gets called with output schema - const installToSpy = vi.spyOn(tool, 'installTo').mockImplementation(() => { + const mockInstallTo = vi.fn().mockImplementation(() => { // Verify that the tool has an output schema when being installed expect(tool.outputSchema).toBeDefined(); - return undefined; + return tool; }); - const mockServer = {} as Parameters[0]; - tool.installTo(mockServer); + Object.defineProperty(tool, 'installTo', { + value: mockInstallTo + }); - expect(installToSpy).toHaveBeenCalledWith(mockServer); + // Simulate server registration + tool.installTo({} as never); + expect(mockInstallTo).toHaveBeenCalled(); }); it('should validate response structure matches schema', () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); const mockResponse = { routes: [ { diff --git a/test/tools/directions-tool/DirectionsTool.test.ts b/test/tools/directions-tool/DirectionsTool.test.ts index ad6460c..913c530 100644 --- a/test/tools/directions-tool/DirectionsTool.test.ts +++ b/test/tools/directions-tool/DirectionsTool.test.ts @@ -5,9 +5,9 @@ process.env.MAPBOX_ACCESS_TOKEN = 'test.token.signature'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { DirectionsTool } from '../../../src/tools/directions-tool/DirectionsTool.js'; import * as cleanResponseModule from '../../../src/tools/directions-tool/cleanResponseData.js'; @@ -25,39 +25,39 @@ describe('DirectionsTool', () => { }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.102094, latitude: 40.692815 }, { longitude: -74.1022094, latitude: 40.792815 } ] }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('constructs correct URL with required parameters', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -73.989, latitude: 40.733 }, { longitude: -73.979, latitude: 40.743 } ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('directions/v5/mapbox/driving-traffic'); expect(calledUrl).toContain('-73.989%2C40.733%3B-73.979%2C40.743'); expect(calledUrl).toContain('access_token='); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('includes all optional parameters in URL', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, { longitude: -122.4, latitude: 37.79 }, @@ -69,7 +69,7 @@ describe('DirectionsTool', () => { exclude: 'ferry' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('directions/v5/mapbox/walking'); expect(calledUrl).toContain( '-122.42%2C37.78%3B-122.4%2C37.79%3B-122.39%2C37.77' @@ -79,32 +79,32 @@ describe('DirectionsTool', () => { expect(calledUrl).toContain('annotations=distance%2Cspeed'); expect(calledUrl).toContain('overview=full'); expect(calledUrl).toContain('exclude=ferry'); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('uses default parameters when not specified', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -118.24, latitude: 34.05 }, { longitude: -118.3, latitude: 34.02 } ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('directions/v5/mapbox/driving-traffic'); expect(calledUrl).not.toContain('geometries='); expect(calledUrl).toContain('alternatives=false'); expect(calledUrl).toContain('annotations=distance%2Ccongestion%2Cspeed'); expect(calledUrl).not.toContain('exclude='); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles geometries=none', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -118.24, latitude: 34.05 }, { longitude: -118.3, latitude: 34.02 } @@ -112,19 +112,19 @@ describe('DirectionsTool', () => { geometries: 'none' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('directions/v5/mapbox/driving-traffic'); expect(calledUrl).not.toContain('geometries='); expect(calledUrl).toContain('alternatives=false'); expect(calledUrl).toContain('annotations=distance%2Ccongestion%2Cspeed'); expect(calledUrl).not.toContain('exclude='); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles exclude parameter with point format', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.0, latitude: 40.7 }, { longitude: -73.9, latitude: 40.8 } @@ -132,7 +132,7 @@ describe('DirectionsTool', () => { exclude: 'toll,point(-73.95 40.75)' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; const comma = '%2C'; const space = '%20'; const openPar = '%28'; @@ -140,17 +140,17 @@ describe('DirectionsTool', () => { expect(calledUrl).toContain( `exclude=toll${comma}point${openPar}-73.95${space}40.75${closePar}` ); - 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' }); - const result = await new DirectionsTool(fetch).run({ + const result = await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -73.989, latitude: 40.733 }, { longitude: -73.979, latitude: 40.743 } @@ -162,11 +162,12 @@ describe('DirectionsTool', () => { type: 'text', text: 'Request failed with status 404: Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('validates coordinates constraints - minimum required', async () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Test with only one coordinate (invalid) await expect( @@ -188,7 +189,8 @@ describe('DirectionsTool', () => { }); it('validates coordinates constraints - maximum allowed', async () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Create an array of 26 coordinates (one more than allowed) const tooManyCoords = Array(26).fill({ @@ -206,33 +208,33 @@ describe('DirectionsTool', () => { }); it('successfully processes exactly 2 coordinates (minimum allowed)', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -73.989, latitude: 40.733 }, { longitude: -73.979, latitude: 40.743 } ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('-73.989%2C40.733%3B-73.979%2C40.743'); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('successfully processes exactly 25 coordinates (maximum allowed)', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); // Create an array of exactly 25 coordinates (maximum allowed) const maxCoords = Array(25) .fill(0) .map((_, i) => ({ longitude: -74 + i * 0.01, latitude: 40 + i * 0.01 })); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: maxCoords }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; // Check that all coordinates are properly encoded for (let i = 0; i < maxCoords.length; i++) { @@ -242,13 +244,13 @@ describe('DirectionsTool', () => { expect(calledUrl).toContain(expectedCoord); } - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); describe('exclude parameter and routing profile validations', () => { it('accepts driving-specific exclusions with driving profiles', async () => { - const { fetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Test with driving profile await expect( @@ -280,7 +282,8 @@ describe('DirectionsTool', () => { }); it('rejects driving-specific exclusions with non-driving profiles', async () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Test with walking profile await expect( @@ -312,8 +315,8 @@ describe('DirectionsTool', () => { }); it('accepts common exclusions with all routing profiles', async () => { - const { fetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Test with driving profile await expect( @@ -359,8 +362,8 @@ describe('DirectionsTool', () => { }); it('accepts point exclusions with driving profiles and rejects with non-driving profiles', async () => { - const { fetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Test with driving profile - should work await expect( @@ -406,8 +409,8 @@ describe('DirectionsTool', () => { }); it('handles multiple exclusions in a single request correctly', async () => { - const { fetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // All valid exclusions for driving profile await expect( @@ -455,8 +458,8 @@ describe('DirectionsTool', () => { describe('depart_at parameter validations', () => { it('accepts depart_at with driving profiles', async () => { - const { fetch, mockFetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); const validDateTime = '2025-06-05T10:30:00Z'; // Test with driving profile @@ -473,7 +476,7 @@ describe('DirectionsTool', () => { isError: true }); - const calledUrlDriving = mockFetch.mock.calls[0][0]; + const calledUrlDriving = mockHttpRequest.mock.calls[0][0]; expect(calledUrlDriving).toContain( `depart_at=${encodeURIComponent(validDateTime)}` ); @@ -492,7 +495,7 @@ describe('DirectionsTool', () => { isError: true }); - const calledUrlTraffic = mockFetch.mock.calls[1][0]; + const calledUrlTraffic = mockHttpRequest.mock.calls[1][0]; expect(calledUrlTraffic).toContain( `depart_at=${encodeURIComponent(validDateTime)}` ); @@ -500,8 +503,8 @@ describe('DirectionsTool', () => { describe('vehicle dimension parameters validations', () => { it('accepts vehicle dimensions with driving profiles', async () => { - const { fetch, mockFetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Test with driving profile await expect( @@ -519,7 +522,7 @@ describe('DirectionsTool', () => { isError: true }); - const calledUrlDriving = mockFetch.mock.calls[0][0]; + const calledUrlDriving = mockHttpRequest.mock.calls[0][0]; expect(calledUrlDriving).toContain('max_height=4.5'); expect(calledUrlDriving).toContain('max_width=2.5'); expect(calledUrlDriving).toContain('max_weight=7.8'); @@ -538,12 +541,13 @@ describe('DirectionsTool', () => { isError: true }); - const calledUrlTraffic = mockFetch.mock.calls[1][0]; + const calledUrlTraffic = mockHttpRequest.mock.calls[1][0]; expect(calledUrlTraffic).toContain('max_height=3.2'); }); it('rejects vehicle dimensions with non-driving profiles', async () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Test with walking profile await expect( @@ -575,7 +579,8 @@ describe('DirectionsTool', () => { }); it('validates dimension value ranges', async () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); // Test invalid height (too high) await expect( @@ -622,7 +627,8 @@ describe('DirectionsTool', () => { }); it('rejects depart_at with non-driving profiles', async () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); const validDateTime = '2025-06-05T10:30:00Z'; // Test with walking profile @@ -655,8 +661,8 @@ describe('DirectionsTool', () => { }); it('accepts valid date-time formats', async () => { - const { fetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); const baseCoordinates = [ { longitude: -73.989, latitude: 40.733 }, { longitude: -73.979, latitude: 40.743 } @@ -694,7 +700,8 @@ describe('DirectionsTool', () => { }); it('rejects invalid date-time formats', async () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); const baseCoordinates = [ { longitude: -73.989, latitude: 40.733 }, { longitude: -73.979, latitude: 40.743 } @@ -726,7 +733,8 @@ describe('DirectionsTool', () => { }); it('rejects dates with invalid components', async () => { - const tool = new DirectionsTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); const baseCoordinates = [ { longitude: -73.989, latitude: 40.733 }, { longitude: -73.979, latitude: 40.743 } @@ -754,8 +762,8 @@ describe('DirectionsTool', () => { }); it('depart_at accepts and converts YYYY-MM-DDThh:mm:ss format (seconds but no timezone)', async () => { - const { fetch, mockFetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); const dateTimeWithSeconds = '2025-06-05T10:30:45'; const expectedConvertedDateTime = '2025-06-05T10:30'; // Without seconds @@ -773,7 +781,7 @@ describe('DirectionsTool', () => { }); // Verify the seconds were stripped in the API call - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain( `depart_at=${encodeURIComponent(expectedConvertedDateTime)}` ); @@ -783,8 +791,8 @@ describe('DirectionsTool', () => { }); it('arrive_by accepts and converts YYYY-MM-DDThh:mm:ss format (seconds but no timezone)', async () => { - const { fetch, mockFetch } = setupFetch(); - const tool = new DirectionsTool(fetch); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); const dateTimeWithSeconds = '2025-06-05T10:30:45'; const expectedConvertedDateTime = '2025-06-05T10:30'; // Without seconds @@ -803,7 +811,7 @@ describe('DirectionsTool', () => { }); // Verify the seconds were stripped in the API call - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain( `arrive_by=${encodeURIComponent(expectedConvertedDateTime)}` ); @@ -816,10 +824,10 @@ describe('DirectionsTool', () => { describe('arrive_by parameter validations', () => { it('accepts arrive_by with driving profile only', async () => { const validDateTime = '2025-06-05T10:30:00Z'; - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); // Test with driving profile - should work - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -828,8 +836,8 @@ describe('DirectionsTool', () => { arrive_by: validDateTime }); - expect(mockFetch).toHaveBeenCalledTimes(1); - const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; expect(calledUrl).toContain( `arrive_by=${encodeURIComponent(validDateTime)}` ); @@ -838,8 +846,10 @@ describe('DirectionsTool', () => { it('rejects arrive_by with non-driving profiles', async () => { const validDateTime = '2025-06-05T10:30:00Z'; + const { httpRequest } = setupHttpRequest(); + // Test with driving-traffic profile - const result1 = await new DirectionsTool().run({ + const result1 = await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -851,7 +861,7 @@ describe('DirectionsTool', () => { expect(result1.isError).toBe(true); // Test with walking profile - const result2 = await new DirectionsTool().run({ + const result2 = await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -863,7 +873,7 @@ describe('DirectionsTool', () => { expect(result2.isError).toBe(true); // Test with cycling profile - const result3 = await new DirectionsTool().run({ + const result3 = await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -876,7 +886,8 @@ describe('DirectionsTool', () => { }); it('rejects when both arrive_by and depart_at are provided', async () => { - const result = await new DirectionsTool().run({ + const { httpRequest } = setupHttpRequest(); + const result = await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -890,9 +901,9 @@ describe('DirectionsTool', () => { }); it('accepts valid ISO 8601 formats for arrive_by', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -901,11 +912,11 @@ describe('DirectionsTool', () => { arrive_by: '2025-06-05T10:30:00Z' }); - expect(mockFetch).toHaveBeenCalledTimes(1); - mockFetch.mockClear(); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + mockHttpRequest.mockClear(); // Test with timezone offset format - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -914,11 +925,11 @@ describe('DirectionsTool', () => { arrive_by: '2025-06-05T10:30:00+02:00' }); - expect(mockFetch).toHaveBeenCalledTimes(1); - mockFetch.mockClear(); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + mockHttpRequest.mockClear(); // Test with simple time format (no seconds, no timezone) - await new DirectionsTool(fetch).run({ + await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -927,7 +938,7 @@ describe('DirectionsTool', () => { arrive_by: '2025-06-05T10:30' }); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); }); it('rejects invalid formats for arrive_by', async () => { @@ -951,7 +962,8 @@ describe('DirectionsTool', () => { ]; for (const format of invalidFormats) { - const result = await new DirectionsTool().run({ + const { httpRequest } = setupHttpRequest(); + const result = await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -975,7 +987,8 @@ describe('DirectionsTool', () => { ]; for (const date of invalidDates) { - const result = await new DirectionsTool().run({ + const { httpRequest } = setupHttpRequest(); + const result = await new DirectionsTool({ httpRequest }).run({ coordinates: [ { longitude: -74.1, latitude: 40.7 }, { longitude: -74.2, latitude: 40.8 } @@ -990,14 +1003,11 @@ describe('DirectionsTool', () => { }); it('validates geometries enum values', async () => { - const { fetch, mockFetch } = setupFetch(); - const tool = new DirectionsTool(fetch); - - // Mock successful responses for valid values - mockFetch.mockResolvedValue({ + const { httpRequest } = setupHttpRequest({ ok: true, json: async () => ({ routes: [], waypoints: [] }) }); + const tool = new DirectionsTool({ httpRequest }); // Valid values: 'none' and 'geojson' await expect( diff --git a/test/tools/schema-validation.test.ts b/test/tools/input-schema-validation.test.ts similarity index 94% rename from test/tools/schema-validation.test.ts rename to test/tools/input-schema-validation.test.ts index 47664bb..b8f0357 100644 --- a/test/tools/schema-validation.test.ts +++ b/test/tools/input-schema-validation.test.ts @@ -54,18 +54,18 @@ function detectTupleUsage(schema: z.ZodType): string[] { return issues; } -describe('Schema Validation - No Tuples', () => { +describe('Input Schema Validation - No Tuples', () => { // Dynamically get all tools from the central registry const tools = [...getAllTools()]; const schemas = tools.map((tool) => ({ name: tool.constructor.name, - schema: (tool as any).inputSchema + schema: (tool as { inputSchema: z.ZodType }).inputSchema })); it.each(schemas)( '$name should not contain z.tuple() usage', - ({ name, schema }) => { + ({ name: _name, schema }) => { const tupleIssues = detectTupleUsage(schema); expect(tupleIssues).toEqual([]); } diff --git a/test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts b/test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts index f374107..aa6078b 100644 --- a/test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts +++ b/test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts @@ -1,64 +1,39 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -import { describe, it, expect } from 'vitest'; -import { - IsochroneResponseSchema, - IsochroneFeatureSchema -} from '../../../src/tools/isochrone-tool/IsochroneTool.output.schema.js'; +process.env.MAPBOX_ACCESS_TOKEN = 'test-token'; -describe('IsochroneTool Output Schema', () => { - it('should validate a valid isochrone feature', () => { - const validFeature = { - type: 'Feature', - properties: { - contour: 15, - color: '#4286f4', - opacity: 0.33, - fill: '#4286f4', - 'fill-opacity': 0.33, - fillColor: '#4286f4', - fillOpacity: 0.33, - metric: 'time' - }, - geometry: { - type: 'Polygon', - coordinates: [ - [ - [-118.22, 33.99], - [-118.21, 33.99], - [-118.21, 34.0], - [-118.22, 34.0], - [-118.22, 33.99] - ] - ] - } - }; +import { describe, it, expect } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { IsochroneTool } from '../../../src/tools/isochrone-tool/IsochroneTool.js'; - const result = IsochroneFeatureSchema.safeParse(validFeature); - expect(result.success).toBe(true); +describe('IsochroneTool output schema registration', () => { + it('should have an output schema defined', () => { + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneTool({ httpRequest }); + expect(tool.outputSchema).toBeDefined(); + expect(tool.outputSchema).toBeTruthy(); }); - it('should validate a valid isochrone response', () => { + it('should validate valid isochrone GeoJSON FeatureCollection', () => { const validResponse = { type: 'FeatureCollection', features: [ { type: 'Feature', properties: { - contour: 15, - color: '#4286f4', + contour: 10, metric: 'time' }, geometry: { type: 'Polygon', coordinates: [ [ - [-118.22, 33.99], - [-118.21, 33.99], - [-118.21, 34.0], - [-118.22, 34.0], - [-118.22, 33.99] + [-74.01, 40.71], + [-74.005, 40.71], + [-74.005, 40.715], + [-74.01, 40.715], + [-74.01, 40.71] ] ] } @@ -66,64 +41,129 @@ describe('IsochroneTool Output Schema', () => { ] }; - const result = IsochroneResponseSchema.safeParse(validResponse); - expect(result.success).toBe(true); + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneTool({ httpRequest }); + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(validResponse); + } + }).not.toThrow(); }); - it('should validate LineString geometry', () => { - const lineStringFeature = { - type: 'Feature', - properties: { - contour: 10, - color: '#04e813', - opacity: 0.5, - metric: 'time' - }, - geometry: { - type: 'LineString', - coordinates: [ - [-118.22, 33.99], - [-118.21, 33.99], - [-118.21, 34.0] - ] - } + it('should validate multiple contour features', () => { + const multiContourResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { contour: 5, metric: 'time' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-74.01, 40.71], + [-74.005, 40.71], + [-74.005, 40.715], + [-74.01, 40.715], + [-74.01, 40.71] + ] + ] + } + }, + { + type: 'Feature', + properties: { contour: 10, metric: 'time' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-74.02, 40.7], + [-74.0, 40.7], + [-74.0, 40.72], + [-74.02, 40.72], + [-74.02, 40.7] + ] + ] + } + } + ] }; - const result = IsochroneFeatureSchema.safeParse(lineStringFeature); - expect(result.success).toBe(true); + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneTool({ httpRequest }); + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(multiContourResponse); + } + }).not.toThrow(); }); - it('should reject invalid geometry type', () => { - const invalidFeature = { - type: 'Feature', - properties: { - contour: 15, - metric: 'time' - }, - geometry: { - type: 'Point', // Invalid for isochrone - coordinates: [-118.22, 33.99] + it('should validate empty FeatureCollection', () => { + const emptyResponse = { + type: 'FeatureCollection', + features: [] + }; + + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneTool({ httpRequest }); + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(emptyResponse); } + }).not.toThrow(); + }); + + it('should throw validation error for invalid type', () => { + const invalidResponse = { + type: 'InvalidCollection', + features: [] }; - const result = IsochroneFeatureSchema.safeParse(invalidFeature); - expect(result.success).toBe(false); + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneTool({ httpRequest }); + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidResponse); + } + }).toThrow(); }); - it('should require contour property', () => { - const invalidFeature = { - type: 'Feature', - properties: { - color: '#4286f4' - // Missing required contour property - }, - geometry: { - type: 'Polygon', - coordinates: [[]] + it('should throw validation error for missing features array', () => { + const invalidResponse = { + type: 'FeatureCollection' + // Missing features array + }; + + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneTool({ httpRequest }); + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidResponse); } + }).toThrow(); + }); + + it('should throw validation error for invalid geometry type', () => { + const invalidResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { contour: 10, metric: 'time' }, + geometry: { + type: 'InvalidGeometry', + coordinates: [] + } + } + ] }; - const result = IsochroneFeatureSchema.safeParse(invalidFeature); - expect(result.success).toBe(false); + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneTool({ httpRequest }); + expect(() => { + if (tool.outputSchema) { + tool.outputSchema.parse(invalidResponse); + } + }).toThrow(); }); }); diff --git a/test/tools/isochrone-tool/IsochroneTool.registration.test.ts b/test/tools/isochrone-tool/IsochroneTool.registration.test.ts deleted file mode 100644 index ec24f90..0000000 --- a/test/tools/isochrone-tool/IsochroneTool.registration.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { IsochroneTool } from '../../../src/tools/isochrone-tool/IsochroneTool.js'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; - -// Mock the fetchClient -vi.mock('../../../src/utils/fetchRequest.js', () => ({ - fetchClient: vi.fn() -})); - -describe('IsochroneTool Output Schema Registration', () => { - let mockServer: McpServer; - - beforeEach(() => { - vi.stubEnv('MAPBOX_ACCESS_TOKEN', 'test-token'); - - // Create a mock MCP server - mockServer = { - registerTool: vi.fn().mockReturnValue({ - name: 'isochrone_tool', - description: 'Test tool' - }), - server: { - sendLoggingMessage: vi.fn() - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - }); - - it('should register tool with output schema', () => { - const tool = new IsochroneTool(); - - // Install the tool to the mock server - tool.installTo(mockServer); - - // Verify that registerTool was called - expect(mockServer.registerTool).toHaveBeenCalledTimes(1); - - // Get the call arguments - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [name, config, callback] = (mockServer.registerTool as any).mock - .calls[0]; - - // Verify basic tool registration - expect(name).toBe('isochrone_tool'); - expect(config.title).toBe('Isochrone Tool'); - expect(config.description).toContain('Computes areas that are reachable'); - expect(config.inputSchema).toBeDefined(); - expect(callback).toBeInstanceOf(Function); - - // Verify that outputSchema is registered - expect(config.outputSchema).toBeDefined(); - - // The outputSchema should have the expected structure for FeatureCollection - expect(config.outputSchema.type).toBeDefined(); - expect(config.outputSchema.features).toBeDefined(); - }); - - it('should register output schema as Zod schema objects', () => { - const tool = new IsochroneTool(); - tool.installTo(mockServer); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [, config] = (mockServer.registerTool as any).mock.calls[0]; - const outputSchema = config.outputSchema; - - // The outputSchema should be Zod schema objects - expect(outputSchema.type).toBeDefined(); - expect(outputSchema.type._def).toBeDefined(); - expect(outputSchema.type._def.typeName).toBe('ZodLiteral'); - expect(outputSchema.type._def.value).toBe('FeatureCollection'); - - // Features should be a ZodArray - expect(outputSchema.features).toBeDefined(); - expect(outputSchema.features._def).toBeDefined(); - expect(outputSchema.features._def.typeName).toBe('ZodArray'); - - // The MCP server will convert these Zod schemas to JSON Schema for the protocol - // This validates that we're providing the schemas in the correct format - }); -}); diff --git a/test/tools/isochrone-tool/IsochroneTool.test.ts b/test/tools/isochrone-tool/IsochroneTool.test.ts index a36070f..d91eedb 100644 --- a/test/tools/isochrone-tool/IsochroneTool.test.ts +++ b/test/tools/isochrone-tool/IsochroneTool.test.ts @@ -6,9 +6,9 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, afterEach, vi } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { IsochroneTool } from '../../../src/tools/isochrone-tool/IsochroneTool.js'; describe('IsochroneTool', () => { @@ -17,25 +17,25 @@ describe('IsochroneTool', () => { }); it('sends custom header', async () => { - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new IsochroneTool(fetch).run({ + await new IsochroneTool({ httpRequest }).run({ coordinates: { longitude: -74.006, latitude: 40.7128 }, profile: 'mapbox/driving', contours_minutes: [10], generalize: 1000 }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('sends correct parameters', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => ({ type: 'FeatureCollection', features: [] }) }); - await new IsochroneTool(fetch).run({ + await new IsochroneTool({ httpRequest }).run({ coordinates: { longitude: 27.534527, latitude: 53.9353451 }, profile: 'mapbox/driving', contours_minutes: [10, 20], @@ -47,8 +47,8 @@ describe('IsochroneTool', () => { depart_at: '2025-06-02T12:00:00Z' }); - assertHeadersSent(mockFetch); - const calledUrl = mockFetch.mock.calls[0][0].toString(); + assertHeadersSent(mockHttpRequest); + const calledUrl = mockHttpRequest.mock.calls[0][0].toString(); expect(calledUrl).toContain( 'isochrone/v1/mapbox/driving/27.534527%2C53.9353451' @@ -64,17 +64,17 @@ describe('IsochroneTool', () => { }); it('does not send empty parameters', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => ({ type: 'FeatureCollection', features: [] }) }); - await new IsochroneTool(fetch).run({ + await new IsochroneTool({ httpRequest }).run({ coordinates: { longitude: 27.534527, latitude: 53.9353451 }, profile: 'mapbox/driving', contours_minutes: [10, 20], generalize: 1000 }); - const calledUrl = mockFetch.mock.calls[0][0].toString(); + const calledUrl = mockHttpRequest.mock.calls[0][0].toString(); expect(calledUrl).toContain( 'isochrone/v1/mapbox/driving/27.534527%2C53.9353451' ); @@ -88,19 +88,19 @@ describe('IsochroneTool', () => { it('returns geojson from API', async () => { const geojson = { type: 'FeatureCollection', features: [{ id: 42 }] }; - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => geojson }); - const result = await new IsochroneTool(fetch).run({ + const result = await new IsochroneTool({ httpRequest }).run({ coordinates: { longitude: -74.006, latitude: 40.7128 }, profile: 'mapbox/walking', contours_minutes: [5], generalize: 1000 }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); expect(result.content[0].type).toEqual('text'); if (result.content[0].type == 'text') { expect(result.content[0].text).toEqual(JSON.stringify(geojson, null, 2)); @@ -108,7 +108,8 @@ describe('IsochroneTool', () => { }); it('throws on invalid input', async () => { - const tool = new IsochroneTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneTool({ httpRequest }); const result = await tool.run({ coordinates: { longitude: 0, latitude: 0 }, profile: 'invalid', @@ -120,7 +121,8 @@ describe('IsochroneTool', () => { }); it('throws if neither contours_minutes nor contours_meters is specified', async () => { - const result = await new IsochroneTool().run({ + const { httpRequest } = setupHttpRequest(); + const result = await new IsochroneTool({ httpRequest }).run({ coordinates: { longitude: -74.006, latitude: 40.7128 }, profile: 'mapbox/driving', generalize: 1000 diff --git a/test/tools/matrix-tool/MatrixTool.output.schema.test.ts b/test/tools/matrix-tool/MatrixTool.output.schema.test.ts index ca6e2fa..0eb4a77 100644 --- a/test/tools/matrix-tool/MatrixTool.output.schema.test.ts +++ b/test/tools/matrix-tool/MatrixTool.output.schema.test.ts @@ -3,16 +3,19 @@ import { describe, it, expect, vi } from 'vitest'; import { MatrixTool } from '../../../src/tools/matrix-tool/MatrixTool.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('MatrixTool output schema registration', () => { it('should have an output schema defined', () => { - const tool = new MatrixTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new MatrixTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); it('should register output schema with MCP server', () => { - const tool = new MatrixTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new MatrixTool({ httpRequest }); // Mock the installTo method to verify it gets called with output schema const installToSpy = vi.spyOn(tool, 'installTo').mockImplementation(() => { @@ -28,7 +31,8 @@ describe('MatrixTool output schema registration', () => { }); it('should validate response structure matches schema', () => { - const tool = new MatrixTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new MatrixTool({ httpRequest }); const mockResponse = { code: 'Ok', durations: [ @@ -86,7 +90,8 @@ describe('MatrixTool output schema registration', () => { }); it('should handle null values in durations and distances matrices', () => { - const tool = new MatrixTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new MatrixTool({ httpRequest }); const mockResponseWithNulls = { code: 'Ok', durations: [ @@ -124,7 +129,8 @@ describe('MatrixTool output schema registration', () => { }); it('should handle error responses with message field', () => { - const tool = new MatrixTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new MatrixTool({ httpRequest }); const errorResponse = { code: 'InvalidInput', message: 'Invalid coordinates provided', diff --git a/test/tools/matrix-tool/MatrixTool.test.ts b/test/tools/matrix-tool/MatrixTool.test.ts index 7ba2e93..7af4427 100644 --- a/test/tools/matrix-tool/MatrixTool.test.ts +++ b/test/tools/matrix-tool/MatrixTool.test.ts @@ -6,9 +6,9 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { MatrixTool } from '../../../src/tools/matrix-tool/MatrixTool.js'; const sampleMatrixResponse = { @@ -69,9 +69,9 @@ describe('MatrixTool', () => { }); it('sends custom header', async () => { - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new MatrixTool(fetch).run({ + await new MatrixTool({ httpRequest }).run({ coordinates: [ { longitude: -74.102094, latitude: 40.692815 }, { longitude: -74.1022094, latitude: 40.792815 } @@ -79,15 +79,15 @@ describe('MatrixTool', () => { profile: 'walking' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('sends request with correct parameters', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -98,24 +98,24 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(false); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); // Check that URL contains correct profile and coordinates - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain( 'directions-matrix/v1/mapbox/driving/-122.42,37.78;-122.45,37.91;-122.48,37.73' ); expect(url).toContain('access_token='); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('properly includes annotations parameter when specified', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixWithDistanceResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -125,16 +125,16 @@ describe('MatrixTool', () => { annotations: 'duration,distance' }); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('annotations=duration%2Cdistance'); }); it('properly includes approaches parameter when specified', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -145,16 +145,16 @@ describe('MatrixTool', () => { approaches: 'curb;unrestricted;curb' }); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('approaches=curb%3Bunrestricted%3Bcurb'); }); it('properly includes bearings parameter when specified', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -164,16 +164,16 @@ describe('MatrixTool', () => { bearings: '45,90;120,45' }); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('bearings=45%2C90%3B120%2C45'); }); it('properly includes destinations parameter when specified', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -184,16 +184,16 @@ describe('MatrixTool', () => { destinations: '0;2' }); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('destinations=0%3B2'); }); it('properly includes sources parameter when specified', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -204,16 +204,16 @@ describe('MatrixTool', () => { sources: '1' }); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('sources=1'); }); it('handles all optional parameters together', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixWithDistanceResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -229,7 +229,7 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(false); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('annotations=distance%2Cduration'); expect(url).toContain('approaches=curb%3Bunrestricted%3Bcurb'); expect(url).toContain('bearings=45%2C90%3B120%2C45%3B180%2C90'); @@ -238,13 +238,13 @@ describe('MatrixTool', () => { }); it('handles fetch errors gracefully', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -259,13 +259,13 @@ describe('MatrixTool', () => { text: 'Request failed with status 404: Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('validates driving-traffic profile coordinate limit', async () => { - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const coordinates = Array(11).fill({ longitude: -122.42, latitude: 37.78 }); const result = await tool.run({ @@ -274,7 +274,7 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(true); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockHttpRequest).not.toHaveBeenCalled(); // Test for specific error message by calling execute directly const errorResult = await tool['execute']( @@ -299,8 +299,8 @@ describe('MatrixTool', () => { let tool: MatrixTool; beforeEach(() => { - const { fetch } = setupFetch(); - tool = new MatrixTool(fetch); + const { httpRequest } = setupHttpRequest(); + tool = new MatrixTool({ httpRequest }); }); it('validates coordinates - minimum count', async () => { @@ -685,11 +685,11 @@ describe('MatrixTool', () => { }); it('accepts valid "all" value for sources', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const localTool = new MatrixTool(fetch); + const localTool = new MatrixTool({ httpRequest }); await localTool.run({ coordinates: [ @@ -700,16 +700,16 @@ describe('MatrixTool', () => { sources: 'all' }); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('sources=all'); }); it('accepts valid "all" value for destinations', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const localTool = new MatrixTool(fetch); + const localTool = new MatrixTool({ httpRequest }); await localTool.run({ coordinates: [ @@ -720,7 +720,7 @@ describe('MatrixTool', () => { destinations: 'all' }); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('destinations=all'); }); }); @@ -728,10 +728,10 @@ describe('MatrixTool', () => { // Parameter edge cases describe('parameter edge cases', () => { it('accepts approaches with skipped values', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -743,16 +743,16 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(false); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('approaches=curb%3B%3Bunrestricted'); }); it('accepts bearings with skipped values', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ @@ -765,16 +765,16 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(false); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('bearings=45%2C90%3B%3B120%2C45'); }); it('validates empty values correctly in approaches', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const resultWithSuccess1 = await tool.run({ coordinates: [ @@ -801,10 +801,10 @@ describe('MatrixTool', () => { }); it('rejects sources and destinations with unused coordinates', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -816,7 +816,7 @@ describe('MatrixTool', () => { destinations: '2' }); expect(result.isError).toBe(true); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockHttpRequest).not.toHaveBeenCalled(); // Test direct error message for unused coordinates const unusedCoordsResult = await tool['execute']( @@ -843,10 +843,10 @@ describe('MatrixTool', () => { }); it('accepts sources and destinations with single indices when all coordinates are used', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); await tool.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -856,16 +856,17 @@ describe('MatrixTool', () => { sources: '0', destinations: '1' }); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('sources=0'); expect(url).toContain('destinations=1'); }); it('accepts both annotations orders', async () => { - const { mockFetch: mockFetch1, fetch: fetch1 } = setupFetch({ - json: () => Promise.resolve(sampleMatrixWithDistanceResponse) - }); - const tool1 = new MatrixTool(fetch1); + const { mockHttpRequest: mockHttpRequest1, httpRequest: httpRequest1 } = + setupHttpRequest({ + json: () => Promise.resolve(sampleMatrixWithDistanceResponse) + }); + const tool1 = new MatrixTool({ httpRequest: httpRequest1 }); await tool1.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -874,13 +875,14 @@ describe('MatrixTool', () => { profile: 'driving', annotations: 'duration,distance' }); - const url1 = mockFetch1.mock.calls[0][0]; + const url1 = mockHttpRequest1.mock.calls[0][0]; expect(url1).toContain('annotations=duration%2Cdistance'); - const { mockFetch: mockFetch2, fetch: fetch2 } = setupFetch({ - json: () => Promise.resolve(sampleMatrixWithDistanceResponse) - }); - const tool2 = new MatrixTool(fetch2); + const { mockHttpRequest: mockHttpRequest2, httpRequest: httpRequest2 } = + setupHttpRequest({ + json: () => Promise.resolve(sampleMatrixWithDistanceResponse) + }); + const tool2 = new MatrixTool({ httpRequest: httpRequest2 }); await tool2.run({ coordinates: [ { longitude: -122.42, latitude: 37.78 }, @@ -889,7 +891,7 @@ describe('MatrixTool', () => { profile: 'driving', annotations: 'distance,duration' }); - const url2 = mockFetch2.mock.calls[0][0]; + const url2 = mockHttpRequest2.mock.calls[0][0]; expect(url2).toContain('annotations=distance%2Cduration'); }); }); @@ -897,10 +899,10 @@ describe('MatrixTool', () => { // Large input tests describe('large input', () => { it('accepts 25 coordinates for non-driving-traffic profiles', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const coordinates: { longitude: number; latitude: number }[] = Array.from( { length: 25 }, @@ -914,14 +916,14 @@ describe('MatrixTool', () => { profile: 'driving' }); expect(result.isError).toBe(false); - expect(mockFetch).toHaveBeenCalled(); + expect(mockHttpRequest).toHaveBeenCalled(); }); it('accepts 10 coordinates for driving-traffic profile', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const coordinates: { longitude: number; latitude: number }[] = Array.from( { length: 10 }, (_, i) => ({ @@ -934,12 +936,12 @@ describe('MatrixTool', () => { profile: 'driving-traffic' }); expect(result.isError).toBe(false); - expect(mockFetch).toHaveBeenCalled(); + expect(mockHttpRequest).toHaveBeenCalled(); }); it('rejects 11 coordinates for driving-traffic profile', async () => { - const { mockFetch, fetch } = setupFetch(); - const tool = new MatrixTool(fetch); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const tool = new MatrixTool({ httpRequest }); const coordinates: { longitude: number; latitude: number }[] = Array.from( { length: 11 }, (_, i) => ({ @@ -952,7 +954,7 @@ describe('MatrixTool', () => { profile: 'driving-traffic' }); expect(result.isError).toBe(true); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockHttpRequest).not.toHaveBeenCalled(); // Test direct error message for exceeding coordinate limit const trafficErrorResult = await tool['execute']( @@ -976,10 +978,10 @@ describe('MatrixTool', () => { // Test for different profiles describe('profiles', () => { it('works with driving-traffic profile', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ @@ -990,16 +992,16 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(false); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('directions-matrix/v1/mapbox/driving-traffic'); }); it('works with driving profile', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ @@ -1010,16 +1012,16 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(false); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('directions-matrix/v1/mapbox/driving'); }); it('works with walking profile', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ @@ -1030,16 +1032,16 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(false); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('directions-matrix/v1/mapbox/walking'); }); it('works with cycling profile', async () => { - const { mockFetch, fetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: () => Promise.resolve(sampleMatrixResponse) }); - const tool = new MatrixTool(fetch); + const tool = new MatrixTool({ httpRequest }); const result = await tool.run({ coordinates: [ @@ -1050,7 +1052,7 @@ describe('MatrixTool', () => { }); expect(result.isError).toBe(false); - const url = mockFetch.mock.calls[0][0]; + const url = mockHttpRequest.mock.calls[0][0]; expect(url).toContain('directions-matrix/v1/mapbox/cycling'); }); }); diff --git a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.test.ts b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.test.ts index f82f1a2..c7131ea 100644 --- a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.test.ts +++ b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.output.schema.test.ts @@ -6,16 +6,19 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, vi } from 'vitest'; import { ReverseGeocodeTool } from '../../../src/tools/reverse-geocode-tool/ReverseGeocodeTool.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('ReverseGeocodeTool output schema registration', () => { it('should have an output schema defined', () => { - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); it('should register output schema with MCP server', () => { - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); // Mock the installTo method to verify it gets called with output schema const mockInstallTo = vi.fn().mockImplementation(() => { @@ -103,7 +106,8 @@ describe('ReverseGeocodeTool output schema registration', () => { attribution: 'Mapbox' }; - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); // This should not throw if the schema is correct expect(() => { @@ -201,7 +205,8 @@ describe('ReverseGeocodeTool output schema registration', () => { attribution: 'Mapbox' }; - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -236,7 +241,8 @@ describe('ReverseGeocodeTool output schema registration', () => { attribution: 'Mapbox' }; - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -265,7 +271,8 @@ describe('ReverseGeocodeTool output schema registration', () => { // Missing attribution field }; - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { diff --git a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts index c7ef375..864cfb9 100644 --- a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts +++ b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts @@ -6,9 +6,9 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, afterEach, vi } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { ReverseGeocodeTool } from '../../../src/tools/reverse-geocode-tool/ReverseGeocodeTool.js'; describe('ReverseGeocodeTool', () => { @@ -17,25 +17,25 @@ describe('ReverseGeocodeTool', () => { }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new ReverseGeocodeTool(fetch).run({ + await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.989, latitude: 40.733 }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('constructs correct URL for reverse geocoding', async () => { - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new ReverseGeocodeTool(fetch).run({ + await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.989, latitude: 40.733 }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('search/geocode/v6/reverse'); expect(calledUrl).toContain('longitude=-73.989'); expect(calledUrl).toContain('latitude=40.733'); @@ -43,9 +43,9 @@ describe('ReverseGeocodeTool', () => { }); it('includes all optional parameters', async () => { - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new ReverseGeocodeTool(fetch).run({ + await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -74.006, latitude: 40.7128, permanent: true, @@ -56,7 +56,7 @@ describe('ReverseGeocodeTool', () => { worldview: 'jp' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('longitude=-74.006'); expect(calledUrl).toContain('latitude=40.7128'); expect(calledUrl).toContain('permanent=true'); @@ -68,21 +68,22 @@ describe('ReverseGeocodeTool', () => { }); it('uses default values', async () => { - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new ReverseGeocodeTool(fetch).run({ + await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.989, latitude: 40.733 }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('permanent=false'); expect(calledUrl).toContain('limit=1'); expect(calledUrl).toContain('worldview=us'); }); it('validates limit constraints', async () => { - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); // Test limit too high await expect( @@ -108,7 +109,8 @@ describe('ReverseGeocodeTool', () => { }); it('validates coordinate constraints', async () => { - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); // Test invalid longitude await expect( @@ -150,9 +152,9 @@ describe('ReverseGeocodeTool', () => { }); it('enforces types constraint when limit > 1', async () => { - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - const tool = new ReverseGeocodeTool(fetch); + const tool = new ReverseGeocodeTool({ httpRequest }); await tool.run({ longitude: -73.989, @@ -160,7 +162,7 @@ describe('ReverseGeocodeTool', () => { limit: 3, types: ['address'] }); - expect(mockFetch).toHaveBeenCalled(); + expect(mockHttpRequest).toHaveBeenCalled(); // Should fail without types when limit > 1 await expect( @@ -187,34 +189,34 @@ describe('ReverseGeocodeTool', () => { }); it('allows limit of 1 without types constraint', async () => { - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); // Should succeed with limit=1 and no types - await new ReverseGeocodeTool(fetch).run({ + await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.989, latitude: 40.733, limit: 1 }); - expect(mockFetch).toHaveBeenCalled(); + expect(mockHttpRequest).toHaveBeenCalled(); // Should also succeed with limit=1 and multiple types - await new ReverseGeocodeTool(fetch).run({ + await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.989, latitude: 40.733, limit: 1, types: ['address', 'place'] }); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockHttpRequest).toHaveBeenCalledTimes(2); }); it('handles fetch errors gracefully', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' }); - const result = await new ReverseGeocodeTool(fetch).run({ + const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.989, latitude: 40.733 }); @@ -227,7 +229,8 @@ describe('ReverseGeocodeTool', () => { }); it('validates country code format', async () => { - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); // Should fail with invalid country code length await expect( @@ -260,11 +263,11 @@ describe('ReverseGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new ReverseGeocodeTool(fetch).run({ + const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.989, latitude: 40.733 }); @@ -301,11 +304,11 @@ describe('ReverseGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new ReverseGeocodeTool(fetch).run({ + const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.971, latitude: 40.776 }); @@ -352,11 +355,11 @@ describe('ReverseGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new ReverseGeocodeTool(fetch).run({ + const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -73.99, latitude: 40.694, limit: 2, @@ -383,11 +386,11 @@ describe('ReverseGeocodeTool', () => { features: [] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new ReverseGeocodeTool(fetch).run({ + const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: 0.0, latitude: 0.0 }); @@ -417,11 +420,11 @@ describe('ReverseGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new ReverseGeocodeTool(fetch).run({ + const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -100.123, latitude: 35.456 }); @@ -453,11 +456,11 @@ describe('ReverseGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new ReverseGeocodeTool(fetch).run({ + const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -122.676, latitude: 45.515, format: 'json_string' @@ -488,11 +491,11 @@ describe('ReverseGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new ReverseGeocodeTool(fetch).run({ + const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -122.676, latitude: 45.515 }); @@ -505,7 +508,8 @@ describe('ReverseGeocodeTool', () => { }); it('should have output schema defined', () => { - const tool = new ReverseGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ReverseGeocodeTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); diff --git a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.test.ts b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.test.ts index dc6524d..83b050f 100644 --- a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.test.ts +++ b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.test.ts @@ -6,16 +6,19 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, vi } from 'vitest'; import { SearchAndGeocodeTool } from '../../../src/tools/search-and-geocode-tool/SearchAndGeocodeTool.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('SearchAndGeocodeTool output schema registration', () => { it('should have an output schema defined', () => { - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); it('should register output schema with MCP server', () => { - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); // Mock the installTo method to verify it gets called with output schema const mockInstallTo = vi.fn().mockImplementation(() => { @@ -78,7 +81,8 @@ describe('SearchAndGeocodeTool output schema registration', () => { attribution: 'Mapbox' }; - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); // This should not throw if the schema is correct expect(() => { @@ -103,7 +107,8 @@ describe('SearchAndGeocodeTool output schema registration', () => { ] }; - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -118,7 +123,8 @@ describe('SearchAndGeocodeTool output schema registration', () => { features: [] }; - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -206,7 +212,8 @@ describe('SearchAndGeocodeTool output schema registration', () => { attribution: '© 2021 Mapbox and its suppliers. All rights reserved.' }; - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -230,7 +237,8 @@ describe('SearchAndGeocodeTool output schema registration', () => { ] }; - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -257,7 +265,8 @@ describe('SearchAndGeocodeTool output schema registration', () => { ] }; - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -281,7 +290,8 @@ describe('SearchAndGeocodeTool output schema registration', () => { ] }; - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { @@ -296,7 +306,8 @@ describe('SearchAndGeocodeTool output schema registration', () => { features: 'not an array' }; - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); expect(() => { if (tool.outputSchema) { diff --git a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts index 7839e49..f7e09ea 100644 --- a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts +++ b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts @@ -5,9 +5,9 @@ process.env.MAPBOX_ACCESS_TOKEN = 'pk.eyJzdWIiOiJ0ZXN0In0.signature'; import { describe, it, expect, afterEach, vi } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { SearchAndGeocodeTool } from '../../../src/tools/search-and-geocode-tool/SearchAndGeocodeTool.js'; describe('SearchAndGeocodeTool', () => { @@ -16,32 +16,32 @@ describe('SearchAndGeocodeTool', () => { }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'coffee shop' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('constructs correct URL with required parameters', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'starbucks' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('search/searchbox/v1/forward'); expect(calledUrl).toContain('q=starbucks'); expect(calledUrl).toContain('access_token='); }); it('includes all optional parameters in URL', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'restaurant', language: 'es', proximity: { longitude: -74.006, latitude: 40.7128 }, @@ -59,7 +59,7 @@ describe('SearchAndGeocodeTool', () => { navigation_profile: 'driving', origin: { longitude: -74.0, latitude: 40.7 } }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('q=restaurant'); expect(calledUrl).toContain('language=es'); expect(calledUrl).toContain('limit=10'); // Hard-coded limit @@ -75,61 +75,61 @@ describe('SearchAndGeocodeTool', () => { }); it('handles IP-based proximity', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'pizza', proximity: 'ip' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('proximity=ip'); }); it('handles string format proximity coordinates', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'museum', proximity: '-82.451668,27.942976' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('proximity=-82.451668%2C27.942976'); }); it('handles array-like string format proximity', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'bank', proximity: '[-82.451668, 27.942964]' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('proximity=-82.451668%2C27.942964'); }); it('uses hard-coded limit of 10', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'pharmacy' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('limit=10'); }); it('handles fetch errors gracefully', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found', text: async () => 'Not Found' }); - const result = await new SearchAndGeocodeTool(fetch).run({ + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'test query' }); @@ -141,7 +141,8 @@ describe('SearchAndGeocodeTool', () => { }); it('validates query length constraint', async () => { - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); const longQuery = 'a'.repeat(257); // 257 characters, exceeds limit await expect( @@ -154,7 +155,8 @@ describe('SearchAndGeocodeTool', () => { }); it('validates coordinate constraints', async () => { - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); // Test invalid longitude in proximity await expect( @@ -183,27 +185,27 @@ describe('SearchAndGeocodeTool', () => { }); it('encodes special characters in query', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'coffee & tea shop' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('q=coffee+%26+tea+shop'); }); it('validates navigation profile can be used with eta_type', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); // navigation_profile should work when eta_type is set - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'test', eta_type: 'navigation', navigation_profile: 'driving' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('eta_type=navigation'); expect(calledUrl).toContain('navigation_profile=driving'); }); @@ -227,11 +229,11 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new SearchAndGeocodeTool(fetch).run({ + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'Central Park' }); @@ -259,16 +261,16 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: async () => mockResponse }); - await new SearchAndGeocodeTool(fetch).run({ + await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'Starbucks', proximity: 'ip' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('proximity=ip'); }); @@ -292,11 +294,11 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new SearchAndGeocodeTool(fetch).run({ + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'Starbucks' }); @@ -343,11 +345,11 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new SearchAndGeocodeTool(fetch).run({ + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'Starbucks' }); @@ -367,11 +369,11 @@ describe('SearchAndGeocodeTool', () => { features: [] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new SearchAndGeocodeTool(fetch).run({ + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'NonexistentPlace' }); @@ -399,11 +401,11 @@ describe('SearchAndGeocodeTool', () => { ] }; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ json: async () => mockResponse }); - const result = await new SearchAndGeocodeTool(fetch).run({ + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ q: 'location' }); @@ -417,7 +419,8 @@ describe('SearchAndGeocodeTool', () => { }); it('validates country code format', async () => { - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); // Test invalid country code (not 2 letters) await expect( @@ -431,7 +434,8 @@ describe('SearchAndGeocodeTool', () => { }); it('handles invalid proximity format', async () => { - const tool = new SearchAndGeocodeTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new SearchAndGeocodeTool({ httpRequest }); // Test invalid proximity string format await expect( diff --git a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts index b400685..e0c252f 100644 --- a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts +++ b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts @@ -6,9 +6,9 @@ process.env.MAPBOX_ACCESS_TOKEN = import { describe, it, expect, afterEach, vi } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { StaticMapImageTool } from '../../../src/tools/static-map-image-tool/StaticMapImageTool.js'; describe('StaticMapImageTool', () => { @@ -17,16 +17,16 @@ describe('StaticMapImageTool', () => { }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, style: 'mapbox/streets-v12' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('returns image content with base64 data', async () => { @@ -37,11 +37,11 @@ describe('StaticMapImageTool', () => { mockImageBuffer.byteOffset + mockImageBuffer.byteLength ); - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer) }); - const result = await new StaticMapImageTool(fetch).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 10, size: { width: 800, height: 600 }, @@ -58,16 +58,16 @@ describe('StaticMapImageTool', () => { }); it('constructs correct Mapbox Static API URL', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -122.4194, latitude: 37.7749 }, zoom: 15, size: { width: 1024, height: 768 }, style: 'mapbox/dark-v10' }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('styles/v1/mapbox/dark-v10/static/'); expect(calledUrl).toContain('-122.4194,37.7749,15'); expect(calledUrl).toContain('1024x768'); @@ -75,26 +75,26 @@ describe('StaticMapImageTool', () => { }); it('uses default style when not specified', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: 0, latitude: 0 }, zoom: 1, size: { width: 300, height: 200 } }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('styles/v1/mapbox/streets-v12/static/'); }); it('handles fetch errors gracefully', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' }); - const result = await new StaticMapImageTool(fetch).run({ + const result = await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 } @@ -108,7 +108,8 @@ describe('StaticMapImageTool', () => { }); it('validates coordinate constraints', async () => { - const tool = new StaticMapImageTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new StaticMapImageTool({ httpRequest }); // Test invalid longitude await expect( @@ -134,7 +135,8 @@ describe('StaticMapImageTool', () => { }); it('validates size constraints', async () => { - const tool = new StaticMapImageTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new StaticMapImageTool({ httpRequest }); // Test size too large await expect( @@ -160,24 +162,24 @@ describe('StaticMapImageTool', () => { }); it('supports high density parameter', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, highDensity: true }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('600x400@2x'); }); describe('overlay support', () => { it('adds marker overlay to URL', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -193,14 +195,14 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('pin-l-a+ff0000(-74.006,40.7128)/'); }); it('adds custom marker overlay to URL', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -214,16 +216,16 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain( `url-${encodeURIComponent('https://example.com/marker.png')}(-74.006,40.7128)/` ); }); it('adds path overlay to URL', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -240,21 +242,21 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain( `path-3+0000ff-0.8+ff0000-0.5(${encodeURIComponent('u{~vFvyys@fS]')})/` ); }); it('adds GeoJSON overlay to URL', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); const geoJsonData = { type: 'Point', coordinates: [-74.006, 40.7128] }; - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -266,16 +268,16 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain( `geojson(${encodeURIComponent(JSON.stringify(geoJsonData))})/` ); }); it('supports multiple overlays in order', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -298,30 +300,30 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain( 'pin-s+00ff00(-74.01,40.71),pin-l-b+ff0000(-74.002,40.715)/' ); }); it('works without overlays', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 } }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; // Should not have overlay string before coordinates expect(calledUrl).toMatch(/static\/-74\.006,40\.7128,12/); }); it('transforms uppercase labels to lowercase', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -337,15 +339,15 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; // Should contain lowercase 'z' even though 'Z' was provided expect(calledUrl).toContain('pin-s-z+0000ff(-74.006,40.7128)/'); }); it('supports Maki icon names as labels', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -361,14 +363,14 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('pin-l-embassy+ff0000(-74.006,40.7128)/'); }); it('transforms uppercase Maki icon names to lowercase', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -384,15 +386,15 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; // Should contain lowercase 'airport' even though 'AIRPORT' was provided expect(calledUrl).toContain('pin-s-airport+00ff00(-74.01,40.71)/'); }); it('supports numeric labels', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -408,14 +410,14 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain('pin-l-42+0000ff(-74.006,40.7128)/'); }); it('handles complex overlay combination with paths and markers', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -80.278, latitude: 25.796 }, zoom: 15, size: { width: 800, height: 600 }, @@ -447,7 +449,7 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; // Check that markers have lowercase labels expect(calledUrl).toContain('pin-l-a+ff0000(-80.2793529,25.7950805)'); expect(calledUrl).toContain( @@ -462,9 +464,9 @@ describe('StaticMapImageTool', () => { }); it('truncates non-Maki multi-character labels to first character', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -488,16 +490,16 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; // Should contain only the first character in lowercase expect(calledUrl).toContain('pin-s-h+0000ff(-74.006,40.7128)'); expect(calledUrl).toContain('pin-l-x+ff0000(-74.01,40.71)'); }); it('preserves full Maki icon names that are in the supported list', async () => { - const { fetch, mockFetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); - await new StaticMapImageTool(fetch).run({ + await new StaticMapImageTool({ httpRequest }).run({ center: { longitude: -74.006, latitude: 40.7128 }, zoom: 12, size: { width: 600, height: 400 }, @@ -529,7 +531,7 @@ describe('StaticMapImageTool', () => { ] }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; // Should preserve the full Maki icon names expect(calledUrl).toContain('pin-s-restaurant+0000ff(-74.006,40.7128)'); expect(calledUrl).toContain('pin-l-hospital+ff0000(-74.01,40.71)'); diff --git a/test/utils/fetchRequest.test.ts b/test/utils/httpPipeline.test.ts similarity index 84% rename from test/utils/fetchRequest.test.ts rename to test/utils/httpPipeline.test.ts index 2320cf8..1d52b56 100644 --- a/test/utils/fetchRequest.test.ts +++ b/test/utils/httpPipeline.test.ts @@ -4,9 +4,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { RetryPolicy, - PolicyPipeline, + HttpPipeline, UserAgentPolicy -} from '../../src/utils/fetchRequest.js'; +} from '../../src/utils/httpPipeline.js'; import type { Mock } from 'vitest'; function createMockFetch( @@ -25,7 +25,7 @@ function createMockFetch( }) as typeof fetch; } -describe('PolicyPipeline', () => { +describe('HttpPipeline', () => { describe('RetryPolicy', () => { afterEach(() => { vi.restoreAllMocks(); @@ -38,10 +38,10 @@ describe('PolicyPipeline', () => { { status: 500 }, { status: 500 } ]); - const pipeline = new PolicyPipeline(mockFetch); + const pipeline = new HttpPipeline(mockFetch); pipeline.usePolicy(new RetryPolicy(3, 1, 10)); // Use small delays for test speed - const response = await pipeline.fetch('http://test', {}); + const response = await pipeline.execute('http://test', {}); expect(mockFetch).toHaveBeenCalledTimes(4); expect(response.status).toBe(500); @@ -53,10 +53,10 @@ describe('PolicyPipeline', () => { { status: 429 }, { status: 200, ok: true } ]); - const pipeline = new PolicyPipeline(mockFetch); + const pipeline = new HttpPipeline(mockFetch); pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - const response = await pipeline.fetch('http://test', {}); + const response = await pipeline.execute('http://test', {}); expect(mockFetch).toHaveBeenCalledTimes(3); expect(response.status).toBe(200); @@ -65,10 +65,10 @@ describe('PolicyPipeline', () => { it('does not retry on 400 errors', async () => { const mockFetch = createMockFetch([{ status: 400 }]); - const pipeline = new PolicyPipeline(mockFetch); + const pipeline = new HttpPipeline(mockFetch); pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - const response = await pipeline.fetch('http://test', {}); + const response = await pipeline.execute('http://test', {}); expect(mockFetch).toHaveBeenCalledTimes(1); expect(response.status).toBe(400); @@ -76,10 +76,10 @@ describe('PolicyPipeline', () => { it('returns immediately on first success', async () => { const mockFetch = createMockFetch([{ status: 200, ok: true }]); - const pipeline = new PolicyPipeline(mockFetch); + const pipeline = new HttpPipeline(mockFetch); pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - const response = await pipeline.fetch('http://test', {}); + const response = await pipeline.execute('http://test', {}); expect(mockFetch).toHaveBeenCalledTimes(1); expect(response.status).toBe(200); @@ -101,10 +101,10 @@ describe('PolicyPipeline', () => { } ) as Mock; - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); - await pipeline.fetch('http://test', {}); + await pipeline.execute('http://test', {}); const headers = mockFetch.mock.calls[0][1]?.headers as Record< string, @@ -126,10 +126,10 @@ describe('PolicyPipeline', () => { } ) as Mock; - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); - await pipeline.fetch('http://test', { + await pipeline.execute('http://test', { headers: { 'User-Agent': 'CustomAgent/2.0' } @@ -155,11 +155,11 @@ describe('PolicyPipeline', () => { } ) as Mock; - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); const headers = new Headers(); - await pipeline.fetch('http://test', { headers }); + await pipeline.execute('http://test', { headers }); expect(headers.get('User-Agent')).toBe('TestAgent/1.0'); }); @@ -168,7 +168,7 @@ describe('PolicyPipeline', () => { describe('Policy Management', () => { it('can add and list policies', () => { const mockFetch = vi.fn(); - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); const userAgentPolicy = new UserAgentPolicy( 'TestAgent/1.0', @@ -187,7 +187,7 @@ describe('PolicyPipeline', () => { it('can find policy by ID', () => { const mockFetch = vi.fn(); - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); const userAgentPolicy = new UserAgentPolicy( 'TestAgent/1.0', @@ -204,7 +204,7 @@ describe('PolicyPipeline', () => { it('can remove policy by ID', () => { const mockFetch = vi.fn(); - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); const userAgentPolicy = new UserAgentPolicy( 'TestAgent/1.0', @@ -226,7 +226,7 @@ describe('PolicyPipeline', () => { it('can remove policy by reference', () => { const mockFetch = vi.fn(); - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); const userAgentPolicy = new UserAgentPolicy( 'TestAgent/1.0', 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) { From 207cbc1d10b4eca499fd9d933417d55856fbc911 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 13 Oct 2025 13:34:37 -0400 Subject: [PATCH 4/9] [tools] Update tools to use structuredContent with schema --- CHANGELOG.md | 11 +++++++++++ manifest.json | 2 +- package.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de376c..23e4ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.6.0 (Unreleased) + +### Features Added + +- Support for `structuredContent` for all applicable tools +- Registers output schemas with the MCP server and validates schemas + +### Other Features + +- Refactored `fetchClient` to be generic `httpRequest`. + ## 0.5.5 - Add server.json for MCP registry diff --git a/manifest.json b/manifest.json index 8c734c6..c2ff8f3 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "dxt_version": "0.1", "display_name": "Mapbox MCP Server", "name": "@mapbox/mcp-server", - "version": "0.5.5", + "version": "0.6.0", "description": "Mapbox MCP server.", "author": { "name": "Mapbox, Inc." diff --git a/package.json b/package.json index 509984f..b310f0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mapbox/mcp-server", - "version": "0.5.5", + "version": "0.6.0", "description": "Mapbox MCP server.", "mcpName": "io.github.mapbox/mcp-server", "main": "./dist/commonjs/index.js", From 3c394ca9e22b461abebd30fe0339137ef41af2f1 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 13 Oct 2025 13:34:56 -0400 Subject: [PATCH 5/9] [tools] Update tools to use structuredContent with schema --- server.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.json b/server.json index 56330fc..6c09375 100644 --- a/server.json +++ b/server.json @@ -6,13 +6,13 @@ "url": "https://github.com/mapbox/mcp-server", "source": "github" }, - "version": "0.5.5", + "version": "0.6.0", "packages": [ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "runtimeHint": "npx", - "version": "0.5.5", + "version": "0.6.0", "identifier": "@mapbox/mcp-server", "transport": { "type": "stdio" From a10583e86dc5cd16bc21993bbcc678972a927450 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 13 Oct 2025 13:45:22 -0400 Subject: [PATCH 6/9] [tools] Update tools to use structuredContent with schema --- .../category-search-tool/CategorySearchTool.input.schema.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/tools/category-search-tool/CategorySearchTool.input.schema.ts b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts index 9d38e5a..6462a2b 100644 --- a/src/tools/category-search-tool/CategorySearchTool.input.schema.ts +++ b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts @@ -31,12 +31,6 @@ export const CategorySearchInputSchema = z.object({ } // Handle JSON-stringified object: "{\"longitude\": -82.458107, \"latitude\": 27.937259}" if (val.startsWith('{') && val.endsWith('}')) { - // Reject large payloads (should only be a lat/lng pair) - if (val.length > 200) { - throw new Error( - 'Proximity JSON string too large. Only latitude/longitude pairs are allowed.' - ); - } const parsed = JSON.parse(val); if ( typeof parsed === 'object' && From 39e7059ce17957ea6bc2c9c6cfa659750698e8c6 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 13 Oct 2025 15:59:33 -0400 Subject: [PATCH 7/9] [tools] Update tools to use structuredContent with schema --- .../CategorySearchTool.input.schema.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tools/category-search-tool/CategorySearchTool.input.schema.ts b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts index 6462a2b..40d8ee3 100644 --- a/src/tools/category-search-tool/CategorySearchTool.input.schema.ts +++ b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts @@ -31,6 +31,13 @@ export const CategorySearchInputSchema = z.object({ } // Handle JSON-stringified object: "{\"longitude\": -82.458107, \"latitude\": 27.937259}" if (val.startsWith('{') && val.endsWith('}')) { + // Reject if over 200 characters + if (val.length > 200) { + throw new Error( + 'Proximity JSON string too large. Only latitude/longitude pairs are allowed.' + ); + } + const parsed = JSON.parse(val); if ( typeof parsed === 'object' && From 47a0bff88bed85ff46475aaf890663b4246d7cb8 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 13 Oct 2025 22:36:40 -0400 Subject: [PATCH 8/9] [tools] Update tools to use structuredContent with schema --- src/tools/MapboxApiBasedTool.output.schema.ts | 28 ------------------- .../category-list-tool/CategoryListTool.ts | 5 ++-- .../CategorySearchTool.ts | 4 +-- src/tools/directions-tool/DirectionsTool.ts | 4 +-- src/tools/isochrone-tool/IsochroneTool.ts | 4 +-- src/tools/matrix-tool/MatrixTool.ts | 4 +-- .../ReverseGeocodeTool.ts | 4 +-- .../SearchAndGeocodeTool.ts | 4 +-- .../StaticMapImageTool.ts | 4 +-- src/tools/version-tool/VersionTool.ts | 7 ++--- 10 files changed, 19 insertions(+), 49 deletions(-) delete mode 100644 src/tools/MapboxApiBasedTool.output.schema.ts diff --git a/src/tools/MapboxApiBasedTool.output.schema.ts b/src/tools/MapboxApiBasedTool.output.schema.ts deleted file mode 100644 index e822a08..0000000 --- a/src/tools/MapboxApiBasedTool.output.schema.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { z } from 'zod'; - -export const OutputSchema = z.object({ - content: z.array( - z.union([ - z.object({ - type: z.literal('text'), - text: z.string() - }), - z.object({ - type: z.literal('image'), - data: z.string(), - mimeType: z.string() - }) - ]) - ), - /** - * An object containing structured tool output. - * - * If the Tool defines an outputSchema, this field MUST be present in the result, - * and contain a JSON object that matches the schema. - */ - structuredContent: z.object({}).passthrough().optional(), - isError: z.boolean().default(false) -}); diff --git a/src/tools/category-list-tool/CategoryListTool.ts b/src/tools/category-list-tool/CategoryListTool.ts index 1644e50..f4ad1b9 100644 --- a/src/tools/category-list-tool/CategoryListTool.ts +++ b/src/tools/category-list-tool/CategoryListTool.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../../utils/types.js'; import type { CategoryListInput } from './CategoryListTool.input.schema.js'; import { CategoryListInputSchema } from './CategoryListTool.input.schema.js'; @@ -20,7 +20,6 @@ interface MapboxApiResponse { attribution: string; version: string; } -import type { z } from 'zod'; // API Documentation: https://docs.mapbox.com/api/search/search-box/#list-categories @@ -53,7 +52,7 @@ export class CategoryListTool extends MapboxApiBasedTool< protected async execute( input: CategoryListInput, accessToken: string - ): Promise> { + ): Promise { const url = new URL( 'https://api.mapbox.com/search/searchbox/v1/list/category' ); diff --git a/src/tools/category-search-tool/CategorySearchTool.ts b/src/tools/category-search-tool/CategorySearchTool.ts index d190042..1b02fbb 100644 --- a/src/tools/category-search-tool/CategorySearchTool.ts +++ b/src/tools/category-search-tool/CategorySearchTool.ts @@ -3,7 +3,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../../utils/types.js'; import { CategorySearchInputSchema } from './CategorySearchTool.input.schema.js'; import { CategorySearchResponseSchema } from './CategorySearchTool.output.schema.js'; @@ -96,7 +96,7 @@ export class CategorySearchTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise> { + ): Promise { // Build URL with required parameters const url = new URL( `${MapboxApiBasedTool.mapboxApiEndpoint}search/searchbox/v1/category/${encodeURIComponent(input.category)}` diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index 3ccf910..c5f1c5d 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -4,7 +4,7 @@ import { URLSearchParams } from 'node:url'; import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { cleanResponseData } from './cleanResponseData.js'; import { formatIsoDateTime } from '../../utils/dateUtils.js'; import { DirectionsInputSchema } from './DirectionsTool.input.schema.js'; @@ -41,7 +41,7 @@ export class DirectionsTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise> { + ): Promise { // Validate exclude parameter against the actual routing_profile // This is needed because some exclusions are only driving specific if (input.exclude) { diff --git a/src/tools/isochrone-tool/IsochroneTool.ts b/src/tools/isochrone-tool/IsochroneTool.ts index 8a35c20..5f88ce7 100644 --- a/src/tools/isochrone-tool/IsochroneTool.ts +++ b/src/tools/isochrone-tool/IsochroneTool.ts @@ -3,7 +3,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../../utils/types.js'; import { IsochroneInputSchema } from './IsochroneTool.input.schema.js'; import { @@ -80,7 +80,7 @@ export class IsochroneTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise> { + ): Promise { const url = new URL( `${MapboxApiBasedTool.mapboxApiEndpoint}isochrone/v1/${input.profile}/${input.coordinates.longitude}%2C${input.coordinates.latitude}` ); diff --git a/src/tools/matrix-tool/MatrixTool.ts b/src/tools/matrix-tool/MatrixTool.ts index f002dde..37945bf 100644 --- a/src/tools/matrix-tool/MatrixTool.ts +++ b/src/tools/matrix-tool/MatrixTool.ts @@ -4,7 +4,7 @@ import type { z } from 'zod'; import { URLSearchParams } from 'node:url'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../../utils/types.js'; import { MatrixInputSchema } from './MatrixTool.input.schema.js'; import { @@ -40,7 +40,7 @@ export class MatrixTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise> { + ): Promise { // Validate input based on profile type if (input.profile === 'driving-traffic' && input.coordinates.length > 10) { return { diff --git a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts index 875703b..5f8cd21 100644 --- a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts +++ b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts @@ -3,7 +3,7 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../../utils/types.js'; import { ReverseGeocodeInputSchema } from './ReverseGeocodeTool.input.schema.js'; import { GeocodingResponseSchema } from './ReverseGeocodeTool.output.schema.js'; @@ -89,7 +89,7 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise> { + ): Promise { // When limit > 1, must specify exactly one type if ( input.limit && diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts index 0819f60..d8b1846 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts @@ -3,7 +3,6 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import type { HttpRequest } from '../../utils/types.js'; import { SearchAndGeocodeInputSchema } from './SearchAndGeocodeTool.input.schema.js'; import { @@ -14,6 +13,7 @@ import type { MapboxFeatureCollection, MapboxFeature } from '../../schemas/geojson.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; // API Documentation: https://docs.mapbox.com/api/search/search-box/#search-request @@ -99,7 +99,7 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise> { + ): Promise { this.log( 'info', `SearchAndGeocodeTool: Starting search with input: ${JSON.stringify(input)}` diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index f9c454b..5262e18 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -3,10 +3,10 @@ import type { z } from 'zod'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; import type { HttpRequest } from '../../utils/types.js'; import { StaticMapImageInputSchema } from './StaticMapImageTool.input.schema.js'; import type { OverlaySchema } from './StaticMapImageTool.input.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; export class StaticMapImageTool extends MapboxApiBasedTool< typeof StaticMapImageInputSchema @@ -82,7 +82,7 @@ export class StaticMapImageTool extends MapboxApiBasedTool< protected async execute( input: z.infer, accessToken: string - ): Promise> { + ): Promise { const { longitude: lng, latitude: lat } = input.center; const { width, height } = input.size; diff --git a/src/tools/version-tool/VersionTool.ts b/src/tools/version-tool/VersionTool.ts index 96b27ba..c11f261 100644 --- a/src/tools/version-tool/VersionTool.ts +++ b/src/tools/version-tool/VersionTool.ts @@ -8,8 +8,7 @@ import { VersionResponseSchema, type VersionResponse } from './VersionTool.output.schema.js'; -import type { z } from 'zod'; -import type { OutputSchema } from '../MapboxApiBasedTool.output.schema.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; export class VersionTool extends BaseTool< typeof VersionSchema, @@ -34,7 +33,7 @@ export class VersionTool extends BaseTool< } // eslint-disable-next-line @typescript-eslint/no-unused-vars - async run(_rawInput: unknown): Promise> { + async run(_rawInput: unknown): Promise { const versionInfo = getVersionInfo(); const versionText = `MCP Server Version Information: @@ -58,7 +57,7 @@ export class VersionTool extends BaseTool< } return { - content: [{ type: 'text', text: versionText }], + content: [{ type: 'text' as const, text: versionText }], structuredContent: validatedVersionInfo, isError: false }; From 885716c3802dccfa30f7aa3b274c6f3a198b6f98 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 14 Oct 2025 09:18:47 -0400 Subject: [PATCH 9/9] [tools] Update tools to use structuredContent with schema --- package-lock.json | 4 ++-- src/tools/MapboxApiBasedTool.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0a0d91..62eba67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mapbox/mcp-server", - "version": "0.5.5", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mapbox/mcp-server", - "version": "0.5.5", + "version": "0.6.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.2", diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 626a045..eb261b3 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -4,7 +4,10 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { ZodTypeAny, z } from 'zod'; import { BaseTool } from './BaseTool.js'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { + CallToolResult, + ToolAnnotations +} from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../utils/types.js'; export abstract class MapboxApiBasedTool< @@ -13,7 +16,7 @@ export abstract class MapboxApiBasedTool< > extends BaseTool { abstract readonly name: string; abstract readonly description: string; - abstract readonly annotations: import('@modelcontextprotocol/sdk/types.js').ToolAnnotations; + abstract readonly annotations: ToolAnnotations; static get mapboxAccessToken() { return process.env.MAPBOX_ACCESS_TOKEN;