diff --git a/assets/mapbox-logo-black.svg b/assets/mapbox-logo-black.svg new file mode 100644 index 0000000..a0cbd94 --- /dev/null +++ b/assets/mapbox-logo-black.svg @@ -0,0 +1,38 @@ + + + +Mapbox_Logo_08 + + + + + + + + + + + + + + + diff --git a/assets/mapbox-logo-white.svg b/assets/mapbox-logo-white.svg new file mode 100644 index 0000000..8d62aef --- /dev/null +++ b/assets/mapbox-logo-white.svg @@ -0,0 +1,42 @@ + + + + +Mapbox_Logo_08 + + + + + + + + + + + + + + + diff --git a/src/index.ts b/src/index.ts index 50d53a7..c4313fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,21 @@ const allResources = getAllResources(); const server = new McpServer( { name: versionInfo.name, - version: versionInfo.version + version: versionInfo.version, + icons: [ + { + src: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDIxLjAuMiwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9Im5ldyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDgwMCAxODAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDgwMCAxODA7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHRpdGxlPk1hcGJveF9Mb2dvXzA4PC90aXRsZT4KPGc+Cgk8Zz4KCQk8cGF0aCBkPSJNNTk0LjYsNDkuOGMtOS45LDAtMTkuNCw0LjEtMjYuMywxMS4zVjIzYzAtMS4yLTEtMi4yLTIuMi0yLjJsMCwwaC0xMy40Yy0xLjIsMC0yLjIsMS0yLjIsMi4ydjEwM2MwLDEuMiwxLDIuMiwyLjIsMi4yCgkJCWgxMy40YzEuMiwwLDIuMi0xLDIuMi0yLjJ2MHYtNy4xYzYuOSw3LjIsMTYuMywxMS4zLDI2LjMsMTEuM2MyMC45LDAsMzcuOC0xOCwzNy44LTQwLjJTNjE1LjUsNDkuOCw1OTQuNiw0OS44eiBNNTkxLjUsMTE0LjEKCQkJYy0xMi43LDAtMjMtMTAuNi0yMy4xLTIzLjh2LTAuNmMwLjItMTMuMiwxMC40LTIzLjgsMjMuMS0yMy44YzEyLjgsMCwyMy4xLDEwLjgsMjMuMSwyNC4xUzYwNC4yLDExNC4xLDU5MS41LDExNC4xTDU5MS41LDExNC4xeiIKCQkJLz4KCQk8cGF0aCBkPSJNNjgxLjcsNDkuOGMtMjIuNiwwLTQwLjksMTgtNDAuOSw0MC4yczE4LjMsNDAuMiw0MC45LDQwLjJjMjIuNiwwLDQwLjktMTgsNDAuOS00MC4yUzcwNC4zLDQ5LjgsNjgxLjcsNDkuOHoKCQkJIE02ODEuNiwxMTQuMWMtMTIuOCwwLTIzLjEtMTAuOC0yMy4xLTI0LjFzMTAuNC0yNC4xLDIzLjEtMjQuMXMyMy4xLDEwLjgsMjMuMSwyNC4xUzY5NC4zLDExNC4xLDY4MS42LDExNC4xTDY4MS42LDExNC4xeiIvPgoJCTxwYXRoIGQ9Ik00MzEuNiw1MS44aC0xMy40Yy0xLjIsMC0yLjIsMS0yLjIsMi4yYzAsMCwwLDAsMCwwdjcuMWMtNi45LTcuMi0xNi4zLTExLjMtMjYuMy0xMS4zYy0yMC45LDAtMzcuOCwxOC0zNy44LDQwLjIKCQkJczE2LjksNDAuMiwzNy44LDQwLjJjOS45LDAsMTkuNC00LjEsMjYuMy0xMS4zdjcuMWMwLDEuMiwxLDIuMiwyLjIsMi4ybDAsMGgxMy40YzEuMiwwLDIuMi0xLDIuMi0yLjJ2MFY1NAoJCQlDNDMzLjgsNTIuOCw0MzIuOCw1MS44LDQzMS42LDUxLjh6IE0zOTIuOCwxMTQuMWMtMTIuOCwwLTIzLjEtMTAuOC0yMy4xLTI0LjFzMTAuNC0yNC4xLDIzLjEtMjQuMWMxMi43LDAsMjMsMTAuNiwyMy4xLDIzLjh2MC42CgkJCUM0MTUuOCwxMDMuNSw0MDUuNSwxMTQuMSwzOTIuOCwxMTQuMUwzOTIuOCwxMTQuMXoiLz4KCQk8cGF0aCBkPSJNNDk4LjUsNDkuOGMtOS45LDAtMTkuNCw0LjEtMjYuMywxMS4zVjU0YzAtMS4yLTEtMi4yLTIuMi0yLjJsMCwwaC0xMy40Yy0xLjIsMC0yLjIsMS0yLjIsMi4yYzAsMCwwLDAsMCwwdjEwMwoJCQljMCwxLjIsMSwyLjIsMi4yLDIuMmwwLDBoMTMuNGMxLjIsMCwyLjItMSwyLjItMi4ydjB2LTM4LjFjNi45LDcuMiwxNi4zLDExLjMsMjYuMywxMS4zYzIwLjksMCwzNy44LTE4LDM3LjgtNDAuMgoJCQlTNTE5LjQsNDkuOCw0OTguNSw0OS44eiBNNDk1LjQsMTE0LjFjLTEyLjcsMC0yMy0xMC42LTIzLjEtMjMuOHYtMC42YzAuMi0xMy4yLDEwLjQtMjMuOCwyMy4xLTIzLjhjMTIuOCwwLDIzLjEsMTAuOCwyMy4xLDI0LjEKCQkJUzUwOC4yLDExNC4xLDQ5NS40LDExNC4xTDQ5NS40LDExNC4xeiIvPgoJCTxwYXRoIGQ9Ik0zMTEuOCw0OS44Yy0xMCwwLjEtMTkuMSw1LjktMjMuNCwxNWMtNC45LTkuMy0xNC43LTE1LjEtMjUuMi0xNWMtOC4yLDAtMTUuOSw0LTIwLjcsMTAuNlY1NGMwLTEuMi0xLTIuMi0yLjItMi4ybDAsMAoJCQloLTEzLjRjLTEuMiwwLTIuMiwxLTIuMiwyLjJjMCwwLDAsMCwwLDB2NzJjMCwxLjIsMSwyLjIsMi4yLDIuMmgwaDEzLjRjMS4yLDAsMi4yLTEsMi4yLTIuMnYwVjgyLjljMC41LTkuNiw3LjItMTcuMywxNS40LTE3LjMKCQkJYzguNSwwLDE1LjYsNy4xLDE1LjYsMTYuNHY0NGMwLDEuMiwxLDIuMiwyLjIsMi4ybDEzLjUsMGMxLjIsMCwyLjItMSwyLjItMi4yYzAsMCwwLDAsMCwwbC0wLjEtNDQuOGMxLjItOC44LDcuNS0xNS42LDE1LjItMTUuNgoJCQljOC41LDAsMTUuNiw3LjEsMTUuNiwxNi40djQ0YzAsMS4yLDEsMi4yLDIuMiwyLjJsMTMuNSwwYzEuMiwwLDIuMi0xLDIuMi0yLjJjMCwwLDAsMCwwLDBsLTAuMS00OS41CgkJCUMzMzkuOSw2MS43LDMyNy4zLDQ5LjgsMzExLjgsNDkuOHoiLz4KCQk8cGF0aCBkPSJNNzk0LjcsMTI1LjFsLTIzLjItMzUuM2wyMy0zNWMwLjYtMC45LDAuMy0yLjItMC42LTIuOGMtMC4zLTAuMi0wLjctMC4zLTEuMS0wLjNoLTE1LjVjLTEuMiwwLTIuMywwLjYtMi45LDEuNkw3NjAuOSw3NgoJCQlsLTEzLjUtMjIuNmMtMC42LTEtMS43LTEuNi0yLjktMS42aC0xNS41Yy0xLjEsMC0yLDAuOS0yLDJjMCwwLjQsMC4xLDAuOCwwLjMsMS4xbDIzLDM1bC0yMy4yLDM1LjNjLTAuNiwwLjktMC4zLDIuMiwwLjYsMi44CgkJCWMwLjMsMC4yLDAuNywwLjMsMS4xLDAuM2gxNS41YzEuMiwwLDIuMy0wLjYsMi45LTEuNmwxMy44LTIzbDEzLjgsMjNjMC42LDEsMS43LDEuNiwyLjksMS42SDc5M2MxLjEsMCwyLTAuOSwyLTIKCQkJQzc5NSwxMjUuOSw3OTQuOSwxMjUuNSw3OTQuNywxMjUuMXoiLz4KCTwvZz4KCTxnPgoJCTxwYXRoIGQ9Ik05My45LDEuMUM0NC44LDEuMSw1LDQwLjksNSw5MHMzOS44LDg4LjksODguOSw4OC45czg4LjktMzkuOCw4OC45LTg4LjlDMTgyLjgsNDAuOSwxNDMsMS4xLDkzLjksMS4xeiBNMTM2LjEsMTExLjgKCQkJYy0zMC40LDMwLjQtODQuNywyMC43LTg0LjcsMjAuN3MtOS44LTU0LjIsMjAuNy04NC43Qzg5LDMwLjksMTE3LDMxLjYsMTM0LjcsNDkuMlMxNTMsOTQuOSwxMzYuMSwxMTEuOEwxMzYuMSwxMTEuOHoiLz4KCQk8cG9seWdvbiBwb2ludHM9IjEwNC4xLDUzLjIgOTUuNCw3MS4xIDc3LjUsNzkuOCA5NS40LDg4LjUgMTA0LjEsMTA2LjQgMTEyLjgsODguNSAxMzAuNyw3OS44IDExMi44LDcxLjEgCQkiLz4KCTwvZz4KPC9nPgo8L3N2Zz4K', + mimeType: 'image/svg+xml', + sizes: ['800x180'], + theme: 'light' + }, + { + src: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDIxLjAuMiwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9Im5ldyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDgwMCAxODAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDgwMCAxODA7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkZGRkZGO30KPC9zdHlsZT4KPHRpdGxlPk1hcGJveF9Mb2dvXzA4PC90aXRsZT4KPGc+Cgk8Zz4KCQk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNNTk0LjYsNDkuOGMtOS45LDAtMTkuNCw0LjEtMjYuMywxMS4zVjIzYzAtMS4yLTEtMi4yLTIuMi0yLjJsMCwwaC0xMy40Yy0xLjIsMC0yLjIsMS0yLjIsMi4ydjEwMwoJCQljMCwxLjIsMSwyLjIsMi4yLDIuMmgxMy40YzEuMiwwLDIuMi0xLDIuMi0yLjJ2MHYtNy4xYzYuOSw3LjIsMTYuMywxMS4zLDI2LjMsMTEuM2MyMC45LDAsMzcuOC0xOCwzNy44LTQwLjIKCQkJUzYxNS41LDQ5LjgsNTk0LjYsNDkuOHogTTU5MS41LDExNC4xYy0xMi43LDAtMjMtMTAuNi0yMy4xLTIzLjh2LTAuNmMwLjItMTMuMiwxMC40LTIzLjgsMjMuMS0yMy44YzEyLjgsMCwyMy4xLDEwLjgsMjMuMSwyNC4xCgkJCVM2MDQuMiwxMTQuMSw1OTEuNSwxMTQuMUw1OTEuNSwxMTQuMXoiLz4KCQk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNNjgxLjcsNDkuOGMtMjIuNiwwLTQwLjksMTgtNDAuOSw0MC4yczE4LjMsNDAuMiw0MC45LDQwLjJjMjIuNiwwLDQwLjktMTgsNDAuOS00MC4yUzcwNC4zLDQ5LjgsNjgxLjcsNDkuOHoKCQkJIE02ODEuNiwxMTQuMWMtMTIuOCwwLTIzLjEtMTAuOC0yMy4xLTI0LjFzMTAuNC0yNC4xLDIzLjEtMjQuMXMyMy4xLDEwLjgsMjMuMSwyNC4xUzY5NC4zLDExNC4xLDY4MS42LDExNC4xTDY4MS42LDExNC4xeiIvPgoJCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik00MzEuNiw1MS44aC0xMy40Yy0xLjIsMC0yLjIsMS0yLjIsMi4yYzAsMCwwLDAsMCwwdjcuMWMtNi45LTcuMi0xNi4zLTExLjMtMjYuMy0xMS4zCgkJCWMtMjAuOSwwLTM3LjgsMTgtMzcuOCw0MC4yczE2LjksNDAuMiwzNy44LDQwLjJjOS45LDAsMTkuNC00LjEsMjYuMy0xMS4zdjcuMWMwLDEuMiwxLDIuMiwyLjIsMi4ybDAsMGgxMy40YzEuMiwwLDIuMi0xLDIuMi0yLjIKCQkJdjBWNTRDNDMzLjgsNTIuOCw0MzIuOCw1MS44LDQzMS42LDUxLjh6IE0zOTIuOCwxMTQuMWMtMTIuOCwwLTIzLjEtMTAuOC0yMy4xLTI0LjFzMTAuNC0yNC4xLDIzLjEtMjQuMWMxMi43LDAsMjMsMTAuNiwyMy4xLDIzLjgKCQkJdjAuNkM0MTUuOCwxMDMuNSw0MDUuNSwxMTQuMSwzOTIuOCwxMTQuMUwzOTIuOCwxMTQuMXoiLz4KCQk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNNDk4LjUsNDkuOGMtOS45LDAtMTkuNCw0LjEtMjYuMywxMS4zVjU0YzAtMS4yLTEtMi4yLTIuMi0yLjJsMCwwaC0xMy40Yy0xLjIsMC0yLjIsMS0yLjIsMi4yYzAsMCwwLDAsMCwwCgkJCXYxMDNjMCwxLjIsMSwyLjIsMi4yLDIuMmwwLDBoMTMuNGMxLjIsMCwyLjItMSwyLjItMi4ydjB2LTM4LjFjNi45LDcuMiwxNi4zLDExLjMsMjYuMywxMS4zYzIwLjksMCwzNy44LTE4LDM3LjgtNDAuMgoJCQlTNTE5LjQsNDkuOCw0OTguNSw0OS44eiBNNDk1LjQsMTE0LjFjLTEyLjcsMC0yMy0xMC42LTIzLjEtMjMuOHYtMC42YzAuMi0xMy4yLDEwLjQtMjMuOCwyMy4xLTIzLjhjMTIuOCwwLDIzLjEsMTAuOCwyMy4xLDI0LjEKCQkJUzUwOC4yLDExNC4xLDQ5NS40LDExNC4xTDQ5NS40LDExNC4xeiIvPgoJCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0zMTEuOCw0OS44Yy0xMCwwLjEtMTkuMSw1LjktMjMuNCwxNWMtNC45LTkuMy0xNC43LTE1LjEtMjUuMi0xNWMtOC4yLDAtMTUuOSw0LTIwLjcsMTAuNlY1NAoJCQljMC0xLjItMS0yLjItMi4yLTIuMmwwLDBoLTEzLjRjLTEuMiwwLTIuMiwxLTIuMiwyLjJjMCwwLDAsMCwwLDB2NzJjMCwxLjIsMSwyLjIsMi4yLDIuMmgwaDEzLjRjMS4yLDAsMi4yLTEsMi4yLTIuMnYwVjgyLjkKCQkJYzAuNS05LjYsNy4yLTE3LjMsMTUuNC0xNy4zYzguNSwwLDE1LjYsNy4xLDE1LjYsMTYuNHY0NGMwLDEuMiwxLDIuMiwyLjIsMi4ybDEzLjUsMGMxLjIsMCwyLjItMSwyLjItMi4yYzAsMCwwLDAsMCwwbC0wLjEtNDQuOAoJCQljMS4yLTguOCw3LjUtMTUuNiwxNS4yLTE1LjZjOC41LDAsMTUuNiw3LjEsMTUuNiwxNi40djQ0YzAsMS4yLDEsMi4yLDIuMiwyLjJsMTMuNSwwYzEuMiwwLDIuMi0xLDIuMi0yLjJjMCwwLDAsMCwwLDBsLTAuMS00OS41CgkJCUMzMzkuOSw2MS43LDMyNy4zLDQ5LjgsMzExLjgsNDkuOHoiLz4KCQk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNNzk0LjcsMTI1LjFsLTIzLjItMzUuM2wyMy0zNWMwLjYtMC45LDAuMy0yLjItMC42LTIuOGMtMC4zLTAuMi0wLjctMC4zLTEuMS0wLjNoLTE1LjUKCQkJYy0xLjIsMC0yLjMsMC42LTIuOSwxLjZMNzYwLjksNzZsLTEzLjUtMjIuNmMtMC42LTEtMS43LTEuNi0yLjktMS42aC0xNS41Yy0xLjEsMC0yLDAuOS0yLDJjMCwwLjQsMC4xLDAuOCwwLjMsMS4xbDIzLDM1CgkJCWwtMjMuMiwzNS4zYy0wLjYsMC45LTAuMywyLjIsMC42LDIuOGMwLjMsMC4yLDAuNywwLjMsMS4xLDAuM2gxNS41YzEuMiwwLDIuMy0wLjYsMi45LTEuNmwxMy44LTIzbDEzLjgsMjNjMC42LDEsMS43LDEuNiwyLjksMS42CgkJCUg3OTNjMS4xLDAsMi0wLjksMi0yQzc5NSwxMjUuOSw3OTQuOSwxMjUuNSw3OTQuNywxMjUuMXoiLz4KCTwvZz4KCTxnPgoJCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik05My45LDEuMUM0NC44LDEuMSw1LDQwLjksNSw5MHMzOS44LDg4LjksODguOSw4OC45czg4LjktMzkuOCw4OC45LTg4LjlDMTgyLjgsNDAuOSwxNDMsMS4xLDkzLjksMS4xegoJCQkgTTEzNi4xLDExMS44Yy0zMC40LDMwLjQtODQuNywyMC43LTg0LjcsMjAuN3MtOS44LTU0LjIsMjAuNy04NC43Qzg5LDMwLjksMTE3LDMxLjYsMTM0LjcsNDkuMlMxNTMsOTQuOSwxMzYuMSwxMTEuOEwxMzYuMSwxMTEuOAoJCQl6Ii8+CgkJPHBvbHlnb24gY2xhc3M9InN0MCIgcG9pbnRzPSIxMDQuMSw1My4yIDk1LjQsNzEuMSA3Ny41LDc5LjggOTUuNCw4OC41IDEwNC4xLDEwNi40IDExMi44LDg4LjUgMTMwLjcsNzkuOCAxMTIuOCw3MS4xIAkJIi8+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==', + mimeType: 'image/svg+xml', + sizes: ['800x180'], + theme: 'dark' + } + ] }, { capabilities: { diff --git a/src/tools/directions-tool/DirectionsTool.output.schema.ts b/src/tools/directions-tool/DirectionsTool.output.schema.ts index 0523025..3e4016d 100644 --- a/src/tools/directions-tool/DirectionsTool.output.schema.ts +++ b/src/tools/directions-tool/DirectionsTool.output.schema.ts @@ -284,8 +284,8 @@ const RouteStepSchema = z.object({ distance: z.number(), duration: z.number(), weight: z.number(), - duration_typical: z.number().optional(), - weight_typical: z.number().optional(), + duration_typical: z.number().nullable().optional(), + weight_typical: z.number().nullable().optional(), geometry: z.union([z.string(), GeoJSONLineStringSchema]), name: z.string(), ref: z.string().optional(), @@ -315,8 +315,8 @@ const RouteLegSchema = z.object({ distance: z.number(), duration: z.number(), weight: z.number(), - duration_typical: z.number().optional(), - weight_typical: z.number().optional(), + duration_typical: z.number().nullable().optional(), + weight_typical: z.number().nullable().optional(), steps: z.array(RouteStepSchema), summary: z.string(), admins: z.array(AdminSchema), @@ -333,8 +333,8 @@ const RouteSchema = z.object({ 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(), + duration_typical: z.number().nullable().optional(), + weight_typical: z.number().nullable().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(), diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index 16fa0bc..37fd31f 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -38,10 +38,117 @@ export class DirectionsTool extends MapboxApiBasedTool< httpRequest: params.httpRequest }); } + private formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.round((seconds % 3600) / 60); + if (hours > 0) { + return `${hours}h ${minutes}min`; + } + return `${minutes}min`; + } + + private formatDistance(meters: number): string { + const km = (meters / 1000).toFixed(1); + const miles = (meters / 1609.34).toFixed(1); + return `${km}km (${miles}mi)`; + } + protected async execute( input: z.infer, accessToken: string ): Promise { + // Stage 1: Elicit routing preferences if server available and no preferences set + const excludeOptions: string[] = []; + if ( + this.server && + !input.exclude && + input.coordinates.length === 2 // Only for simple A-to-B routes + ) { + try { + const isDrivingProfile = + input.routing_profile === 'mapbox/driving-traffic' || + input.routing_profile === 'mapbox/driving'; + + const preferenceOptions = [ + { + value: 'fastest', + label: 'Fastest route (tolls OK)' + }, + { + value: 'avoid_tolls', + label: 'Avoid tolls (may be slower)' + } + ]; + + if (isDrivingProfile) { + preferenceOptions.push({ + value: 'avoid_highways', + label: 'Avoid highways/motorways' + }); + } + + preferenceOptions.push({ + value: 'avoid_ferries', + label: 'Avoid ferries' + }); + + const preferencesResult = await this.server.server.elicitInput({ + mode: 'form', + message: 'Choose your routing preferences:', + requestedSchema: { + type: 'object', + properties: { + routePreference: { + type: 'string', + title: 'Route Preference', + description: 'Select your routing priorities', + enum: preferenceOptions.map((o) => o.value), + enumNames: preferenceOptions.map((o) => o.label) + } + }, + required: ['routePreference'] + } + }); + + if ( + preferencesResult.action === 'accept' && + preferencesResult.content?.routePreference + ) { + const preference = + typeof preferencesResult.content.routePreference === 'string' + ? preferencesResult.content.routePreference + : String(preferencesResult.content.routePreference); + + // Map preferences to exclude options + if (preference === 'avoid_tolls') { + excludeOptions.push('toll', 'cash_only_tolls'); + } else if (preference === 'avoid_highways' && isDrivingProfile) { + excludeOptions.push('motorway'); + } else if (preference === 'avoid_ferries') { + excludeOptions.push('ferry'); + } + + // Force alternatives=true to get multiple routes for Stage 2 + input.alternatives = true; + + this.log( + 'info', + `DirectionsTool: User selected preference: ${preference}, exclude: ${excludeOptions.join(',')}` + ); + } + } catch (elicitError) { + this.log( + 'warning', + `DirectionsTool: Stage 1 elicitation failed: ${elicitError instanceof Error ? elicitError.message : 'Unknown error'}` + ); + } + } + + // Apply collected exclusions + if (excludeOptions.length > 0 && !input.exclude) { + input.exclude = excludeOptions.join(','); + } + // Validate exclude parameter against the actual routing_profile // This is needed because some exclusions are only driving specific if (input.exclude) { @@ -268,6 +375,116 @@ export class DirectionsTool extends MapboxApiBasedTool< validatedData = cleanedData as DirectionsResponse; } + // Stage 2: Elicit route selection if multiple routes returned + if ( + this.server && + validatedData.routes && + validatedData.routes.length >= 2 + ) { + try { + const routeOptions = validatedData.routes.map((route, index) => { + const duration = this.formatDuration(route.duration); + const distance = this.formatDistance(route.distance); + const roads = + route.leg_summaries && route.leg_summaries.length > 0 + ? route.leg_summaries[0] + : 'Route'; + + // Build traffic/congestion summary + let trafficInfo = ''; + if (route.congestion_information) { + const congestion = route.congestion_information; + const totalLength = + congestion.length_low + + congestion.length_moderate + + congestion.length_heavy + + congestion.length_severe; + const heavyPercent = Math.round( + ((congestion.length_heavy + congestion.length_severe) / + totalLength) * + 100 + ); + if (heavyPercent > 20) { + trafficInfo = ` ⚠️ Heavy traffic (${heavyPercent}%)`; + } else if (congestion.length_moderate > 0) { + trafficInfo = ' 🟡 Moderate traffic'; + } else { + trafficInfo = ' ✅ Light traffic'; + } + } + + // Count incidents + const incidentCount = route.incidents_summary?.length || 0; + const incidentInfo = + incidentCount > 0 ? ` • ${incidentCount} incident(s)` : ''; + + return { + value: String(index), + label: `${duration} via ${roads} • ${distance}${trafficInfo}${incidentInfo}` + }; + }); + + const routeSelectionResult = await this.server.server.elicitInput({ + mode: 'form', + message: `Found ${validatedData.routes.length} routes. Choose your preferred route:`, + requestedSchema: { + type: 'object', + properties: { + selectedRoute: { + type: 'string', + title: 'Select Route', + description: 'Choose the route that best fits your needs', + enum: routeOptions.map((o) => o.value), + enumNames: routeOptions.map((o) => o.label) + } + }, + required: ['selectedRoute'] + } + }); + + if ( + routeSelectionResult.action === 'accept' && + routeSelectionResult.content?.selectedRoute + ) { + const selectedIndexStr = + typeof routeSelectionResult.content.selectedRoute === 'string' + ? routeSelectionResult.content.selectedRoute + : String(routeSelectionResult.content.selectedRoute); + const selectedIndex = parseInt(selectedIndexStr, 10); + const selectedRoute = validatedData.routes[selectedIndex]; + + // Return only the selected route + const singleRouteResult: DirectionsResponse = { + ...validatedData, + routes: [selectedRoute] + }; + + this.log( + 'info', + `DirectionsTool: User selected route ${selectedIndex}: ${this.formatDuration(selectedRoute.duration)} via ${selectedRoute.leg_summaries?.[0] || 'route'}` + ); + + return { + content: [ + { type: 'text', text: JSON.stringify(singleRouteResult, null, 2) } + ], + structuredContent: singleRouteResult, + isError: false + }; + } else if (routeSelectionResult.action === 'decline') { + this.log( + 'info', + 'DirectionsTool: User declined to select a specific route' + ); + } + } catch (elicitError) { + this.log( + 'warning', + `DirectionsTool: Stage 2 elicitation failed: ${elicitError instanceof Error ? elicitError.message : 'Unknown error'}` + ); + } + } + return { content: [{ type: 'text', text: JSON.stringify(validatedData, null, 2) }], structuredContent: validatedData, diff --git a/src/tools/directions-tool/cleanResponseData.ts b/src/tools/directions-tool/cleanResponseData.ts index 6cbefbb..9078724 100644 --- a/src/tools/directions-tool/cleanResponseData.ts +++ b/src/tools/directions-tool/cleanResponseData.ts @@ -67,8 +67,8 @@ interface RawRoute { distance?: number; weight_name?: string; weight?: number; - duration_typical?: number; - weight_typical?: number; + duration_typical?: number | null; + weight_typical?: number | null; geometry?: unknown; legs?: RawLeg[]; [key: string]: unknown; diff --git a/test/tools/directions-tool/DirectionsTool.test.ts b/test/tools/directions-tool/DirectionsTool.test.ts index 888d088..37c41b7 100644 --- a/test/tools/directions-tool/DirectionsTool.test.ts +++ b/test/tools/directions-tool/DirectionsTool.test.ts @@ -1064,4 +1064,155 @@ describe('DirectionsTool', () => { isError: true }); }); + + describe('elicitation behavior', () => { + it('does not elicit when exclude parameter is already provided', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + + await new DirectionsTool({ httpRequest }).run({ + coordinates: [ + { longitude: -74.006, latitude: 40.7128 }, + { longitude: -71.0589, latitude: 42.3601 } + ], + exclude: 'toll' + }); + + const calledUrl = mockHttpRequest.mock.calls[0][0]; + // Should use the provided exclude parameter + expect(calledUrl).toContain('exclude=toll'); + // Should not modify alternatives (defaults to false) + expect(calledUrl).toContain('alternatives=false'); + }); + + it('does not elicit when more than 2 coordinates provided', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + + await new DirectionsTool({ httpRequest }).run({ + coordinates: [ + { longitude: -74.006, latitude: 40.7128 }, + { longitude: -73.9, latitude: 41.0 }, + { longitude: -71.0589, latitude: 42.3601 } + ] + }); + + const calledUrl = mockHttpRequest.mock.calls[0][0]; + // Should not set alternatives=true (elicitation skipped for multi-waypoint) + expect(calledUrl).toContain('alternatives=false'); + }); + + it('returns multiple routes when alternatives=true and no elicitation', async () => { + const mockResponse = { + code: 'Ok', + routes: [ + { + duration: 17280, + distance: 366800, + legs: [ + { + summary: 'I-84 East, I-90 East', + duration: 17280, + distance: 366800, + steps: [] + } + ], + leg_summaries: ['I-84 East, I-90 East'], + congestion_information: { + length_low: 300000, + length_moderate: 50000, + length_heavy: 16800, + length_severe: 0 + }, + incidents_summary: [{ type: 'construction' }] + }, + { + duration: 17880, + distance: 348000, + legs: [ + { + summary: 'CT-15 North, I-90 East', + duration: 17880, + distance: 348000, + steps: [] + } + ], + leg_summaries: ['CT-15 North, I-90 East'], + congestion_information: { + length_low: 280000, + length_moderate: 60000, + length_heavy: 8000, + length_severe: 0 + }, + incidents_summary: [ + { type: 'construction' }, + { type: 'accident' }, + { type: 'construction' }, + { type: 'construction' }, + { type: 'construction' } + ] + } + ], + waypoints: [] + }; + + const { httpRequest } = setupHttpRequest({ + json: async () => mockResponse + }); + + const result = await new DirectionsTool({ httpRequest }).run({ + coordinates: [ + { longitude: -74.006, latitude: 40.7128 }, + { longitude: -71.0589, latitude: 42.3601 } + ], + alternatives: true + }); + + expect(result.isError).toBe(false); + // Without elicitation, should return all routes + const response = JSON.parse( + (result.content[0] as { type: 'text'; text: string }).text + ); + expect(response.routes).toHaveLength(2); + }); + + it('returns single route when only one route available (no Stage 2 elicitation)', async () => { + const mockResponse = { + code: 'Ok', + routes: [ + { + duration: 17280, + distance: 366800, + legs: [ + { + summary: 'I-84 East, I-90 East', + duration: 17280, + distance: 366800, + steps: [] + } + ], + leg_summaries: ['I-84 East, I-90 East'] + } + ], + waypoints: [] + }; + + const { httpRequest } = setupHttpRequest({ + json: async () => mockResponse + }); + + const result = await new DirectionsTool({ httpRequest }).run({ + coordinates: [ + { longitude: -74.006, latitude: 40.7128 }, + { longitude: -71.0589, latitude: 42.3601 } + ], + alternatives: true + }); + + expect(result.isError).toBe(false); + // Should return the single route + const response = JSON.parse( + (result.content[0] as { type: 'text'; text: string }).text + ); + expect(response.routes).toHaveLength(1); + }); + }); });