From 1e72d21a350609109de0f998187a851cef976db7 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 12 Jan 2026 16:18:47 -0500 Subject: [PATCH 01/12] Add server-level icons with light and dark theme support Implements MCP server icons at the correct architectural level (server initialization) instead of at the tool level. Adds both light and dark theme variants of the Mapbox logo using base64-encoded SVG data URIs. - Add mapbox-logo-black.svg for light theme backgrounds - Add mapbox-logo-white.svg for dark theme backgrounds - Update server initialization to include icons array with theme property - Use 800x180 SVG logos embedded as base64 data URIs This replaces the previous incorrect approach of adding icons to individual tools, which was not aligned with the MCP specification. Co-Authored-By: Claude Sonnet 4.5 --- assets/mapbox-logo-black.svg | 38 ++++++++++++++++++++++++++++++++ assets/mapbox-logo-white.svg | 42 ++++++++++++++++++++++++++++++++++++ src/index.ts | 16 +++++++++++++- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 assets/mapbox-logo-black.svg create mode 100644 assets/mapbox-logo-white.svg diff --git a/assets/mapbox-logo-black.svg b/assets/mapbox-logo-black.svg new file mode 100644 index 00000000..a0cbd94d --- /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 00000000..8d62aef0 --- /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 50d53a7e..c4313fee 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: { From c20582133bba072c7d879d77d1650a06c301bc07 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 12 Jan 2026 16:24:43 -0500 Subject: [PATCH 02/12] Update @modelcontextprotocol/sdk to 1.25.2 Updates the MCP SDK from 1.25.1 to 1.25.2 and recreates the output validation patch for the new version. The patch continues to convert strict output schema validation errors to warnings, allowing tools to gracefully handle schema mismatches. Changes: - Update @modelcontextprotocol/sdk from ^1.25.1 to ^1.25.2 - Recreate SDK patch for version 1.25.2 - Remove obsolete 1.25.1 patch file - All 397 tests pass with new SDK version Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 8 ++++---- package.json | 2 +- ....25.1.patch => @modelcontextprotocol+sdk+1.25.2.patch} | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename patches/{@modelcontextprotocol+sdk+1.25.1.patch => @modelcontextprotocol+sdk+1.25.2.patch} (100%) diff --git a/package-lock.json b/package-lock.json index 87d6481d..1fae1526 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@mcp-ui/server": "^5.13.1", - "@modelcontextprotocol/sdk": "^1.25.1", + "@modelcontextprotocol/sdk": "^1.25.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.0", "@opentelemetry/exporter-trace-otlp-http": "^0.56.0", @@ -1913,9 +1913,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", - "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.7", diff --git a/package.json b/package.json index 5c5e77a5..304046d7 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ ], "dependencies": { "@mcp-ui/server": "^5.13.1", - "@modelcontextprotocol/sdk": "^1.25.1", + "@modelcontextprotocol/sdk": "^1.25.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.0", "@opentelemetry/exporter-trace-otlp-http": "^0.56.0", diff --git a/patches/@modelcontextprotocol+sdk+1.25.1.patch b/patches/@modelcontextprotocol+sdk+1.25.2.patch similarity index 100% rename from patches/@modelcontextprotocol+sdk+1.25.1.patch rename to patches/@modelcontextprotocol+sdk+1.25.2.patch From 188f51afa854374f66571e8d34809613836d4a45 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 1 Apr 2026 14:16:36 -0400 Subject: [PATCH 03/12] chore: add CVE-2026-4926 entry to CHANGELOG Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 565eb52e..8d2c3eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Security +- **CVE-2026-4926**: Upgraded `@modelcontextprotocol/sdk` to `^1.29.0`, resolving `path-to-regexp` to `8.4.1` and fixing the ReDoS vulnerability [GHSA-j3q9-mxjg-w52f](https://github.com/advisories/GHSA-j3q9-mxjg-w52f); regenerated output-validation patch for the new version - **static_map_image_tool**: Validate `style` parameter against `username/style-id` format to prevent path traversal attacks where a crafted style value (e.g., `../../tokens/v2`) could escape the `/styles/v1/` URL path and access arbitrary Mapbox API endpoints using the server operator's token - **static_map_image_tool**: Remove access token from URL returned in text content — the token is only used internally for the HTTP fetch and the MCP Apps iframe URL, not exposed to the model context From c554cc91b8de1c14a5f29d3406086bb0c7b59cc9 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 10:09:52 -0400 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20add=20directions=5Fapp=5Ftool=20?= =?UTF-8?q?=E2=80=94=20interactive=20GLJS=20map=20as=20MCP=20App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof-of-concept tool that renders a directions route on a live Mapbox GL JS map inside an MCP App iframe. Returns a self-contained rawHtml UI resource with the route line, start/end markers, and camera fit. Uses a separate MAPBOX_PUBLIC_TOKEN env var (public pk.* token) so the secret MAPBOX_ACCESS_TOKEN never leaves the server. The point of this tool is to probe whether GLJS works inside the sandboxed iframes that MCP App hosts (Claude Desktop, VS Code, Cursor) provide — when it works, the agent gets a fully interactive map; when it doesn't, the error surfaces in the iframe console rather than silently failing. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../DirectionsAppTool.input.schema.ts | 24 ++ .../directions-app-tool/DirectionsAppTool.ts | 237 ++++++++++++++++++ src/tools/index.ts | 5 + src/tools/toolRegistry.ts | 2 + .../DirectionsAppTool.test.ts | 158 ++++++++++++ 6 files changed, 427 insertions(+) create mode 100644 src/tools/directions-app-tool/DirectionsAppTool.input.schema.ts create mode 100644 src/tools/directions-app-tool/DirectionsAppTool.ts create mode 100644 test/tools/directions-app-tool/DirectionsAppTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a5cfd7..9851a1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`directions_app_tool`**: New tool that renders a route on an interactive Mapbox GL JS map as a self-contained MCP App (rawHtml UI resource). Useful when the user wants a visual, interactive map rather than turn-by-turn data. Requires a `MAPBOX_PUBLIC_TOKEN` (public `pk.*` token) env var separate from the existing secret `MAPBOX_ACCESS_TOKEN`. - **MCP Completions capability**: Add auto-completion support for prompt arguments per MCP spec (2025-11-25). Clients can now suggest values when users fill in prompt parameters (#176) - `category` argument on `find-places-nearby` — 482 Mapbox Search API categories - `mode` argument on `get-directions`, `search-along-route`, `show-reachable-areas` — driving, driving-traffic, walking, cycling diff --git a/src/tools/directions-app-tool/DirectionsAppTool.input.schema.ts b/src/tools/directions-app-tool/DirectionsAppTool.input.schema.ts new file mode 100644 index 00000000..f0ef8613 --- /dev/null +++ b/src/tools/directions-app-tool/DirectionsAppTool.input.schema.ts @@ -0,0 +1,24 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { coordinateSchema } from '../../schemas/shared.js'; + +export const DirectionsAppInputSchema = z.object({ + coordinates: z + .array(coordinateSchema) + .min(2) + .max(25) + .describe('Ordered list of waypoints (start, optional vias, end).'), + routing_profile: z + .enum([ + 'mapbox/driving', + 'mapbox/driving-traffic', + 'mapbox/walking', + 'mapbox/cycling' + ]) + .default('mapbox/driving') + .describe('Mapbox routing profile to use.') +}); + +export type DirectionsAppInput = z.infer; diff --git a/src/tools/directions-app-tool/DirectionsAppTool.ts b/src/tools/directions-app-tool/DirectionsAppTool.ts new file mode 100644 index 00000000..63a19894 --- /dev/null +++ b/src/tools/directions-app-tool/DirectionsAppTool.ts @@ -0,0 +1,237 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import type { z } from 'zod'; +import { createUIResource } from '@mcp-ui/server'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { DirectionsAppInputSchema } from './DirectionsAppTool.input.schema.js'; + +// Docs: https://docs.mapbox.com/api/navigation/directions/ + +interface RouteFeature { + geometry?: { type: string; coordinates?: [number, number][] }; + distance?: number; + duration?: number; +} + +interface DirectionsResponse { + routes?: RouteFeature[]; +} + +export class DirectionsAppTool extends MapboxApiBasedTool< + typeof DirectionsAppInputSchema +> { + name = 'directions_app_tool'; + description = + 'Render a directions route on an interactive Mapbox GL JS map as an MCP App. ' + + 'Returns a self-contained HTML UI resource with the route drawn, start/end markers, ' + + 'and camera fit to the route bounds. Requires the MAPBOX_PUBLIC_TOKEN env var ' + + '(a public Mapbox access token). Use this when the user asks for a visual, ' + + 'interactive map of a route rather than just turn-by-turn data.'; + annotations = { + title: 'Directions App Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: DirectionsAppInputSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: z.infer, + accessToken: string + ): Promise { + const publicToken = process.env.MAPBOX_PUBLIC_TOKEN; + if (!publicToken) { + return { + content: [ + { + type: 'text', + text: 'MAPBOX_PUBLIC_TOKEN environment variable is not set. Provide a public (pk.*) Mapbox access token to render the interactive map.' + } + ], + isError: true + }; + } + + const coords = input.coordinates + .map((c) => `${c.longitude},${c.latitude}`) + .join(';'); + + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}directions/v5/${input.routing_profile}/${encodeURIComponent(coords)}` + ); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('geometries', 'geojson'); + url.searchParams.set('overview', 'full'); + + const response = await this.httpRequest(url.toString()); + if (!response.ok) { + const errorText = await this.getErrorMessage(response); + return { + content: [{ type: 'text', text: `Directions API error: ${errorText}` }], + isError: true + }; + } + + const data = (await response.json()) as DirectionsResponse; + const route = data.routes?.[0]; + if (!route?.geometry?.coordinates?.length) { + return { + content: [ + { type: 'text', text: 'No route found for the given coordinates.' } + ], + isError: true + }; + } + + const distanceMiles = route.distance + ? `${(route.distance / 1609.34).toFixed(1)} mi` + : 'unknown'; + const durationMin = route.duration + ? `${Math.round(route.duration / 60)} min` + : 'unknown'; + + const summary = `Route: ${distanceMiles}, ${durationMin}`; + + const htmlString = buildRouteAppHtml({ + publicToken, + geometry: route.geometry as { + type: string; + coordinates: [number, number][]; + }, + profile: input.routing_profile, + summary + }); + + const uiResource = createUIResource({ + uri: `ui://mapbox/directions/${randomUUID()}`, + content: { type: 'rawHtml', htmlString }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '480px'] + } + }); + + return { + content: [{ type: 'text', text: summary }, uiResource], + isError: false, + _meta: { + viewUUID: randomUUID() + } + }; + } +} + +interface RouteAppParams { + publicToken: string; + geometry: { type: string; coordinates: [number, number][] }; + profile: string; + summary: string; +} + +function buildRouteAppHtml(params: RouteAppParams): string { + const { publicToken, geometry, profile, summary } = params; + + // Compute bounding box for camera fit + const lngs = geometry.coordinates.map((c) => c[0]); + const lats = geometry.coordinates.map((c) => c[1]); + const bounds: [[number, number], [number, number]] = [ + [Math.min(...lngs), Math.min(...lats)], + [Math.max(...lngs), Math.max(...lats)] + ]; + + const startCoord = geometry.coordinates[0]; + const endCoord = geometry.coordinates[geometry.coordinates.length - 1]; + + const data = { + publicToken, + profile, + summary, + geometry, + bounds, + startCoord, + endCoord + }; + + // Escape in the JSON payload to prevent breaking out of the script tag + const dataJson = JSON.stringify(data).replace(/<\/script>/gi, '<\\/script>'); + + return ` + + + + +Mapbox Directions + + + + +
+
+ + + + +`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index b265d26d..e9eb48e9 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -37,6 +37,7 @@ export { CategoryListTool } from './category-list-tool/CategoryListTool.js'; export { CategorySearchTool } from './category-search-tool/CategorySearchTool.js'; export { CentroidTool } from './centroid-tool/CentroidTool.js'; export { DirectionsTool } from './directions-tool/DirectionsTool.js'; +export { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; export { DistanceTool } from './distance-tool/DistanceTool.js'; export { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; export { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -59,6 +60,7 @@ import { CategoryListTool } from './category-list-tool/CategoryListTool.js'; import { CategorySearchTool } from './category-search-tool/CategorySearchTool.js'; import { CentroidTool } from './centroid-tool/CentroidTool.js'; import { DirectionsTool } from './directions-tool/DirectionsTool.js'; +import { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; import { DistanceTool } from './distance-tool/DistanceTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -97,6 +99,9 @@ export const centroid = new CentroidTool(); /** Get directions between waypoints */ export const directions = new DirectionsTool({ httpRequest }); +/** Render a directions route on an interactive Mapbox GL JS map (MCP App) */ +export const directionsApp = new DirectionsAppTool({ httpRequest }); + /** Calculate distance between points */ export const distance = new DistanceTool(); diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 75babfd1..a056bde0 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -24,6 +24,7 @@ import { DistanceTool } from './distance-tool/DistanceTool.js'; import { CategoryListTool } from './category-list-tool/CategoryListTool.js'; import { CategorySearchTool } from './category-search-tool/CategorySearchTool.js'; import { DirectionsTool } from './directions-tool/DirectionsTool.js'; +import { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; @@ -61,6 +62,7 @@ export const CORE_TOOLS = [ new DistanceTool(), new CategorySearchTool({ httpRequest }), new DirectionsTool({ httpRequest }), + new DirectionsAppTool({ httpRequest }), new IsochroneTool({ httpRequest }), new MapMatchingTool({ httpRequest }), new MatrixTool({ httpRequest }), diff --git a/test/tools/directions-app-tool/DirectionsAppTool.test.ts b/test/tools/directions-app-tool/DirectionsAppTool.test.ts new file mode 100644 index 00000000..9d8aed42 --- /dev/null +++ b/test/tools/directions-app-tool/DirectionsAppTool.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, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { DirectionsAppTool } from '../../../src/tools/directions-app-tool/DirectionsAppTool.js'; + +const fakeRouteResponse = { + routes: [ + { + geometry: { + type: 'LineString', + coordinates: [ + [-122.4194, 37.7749], + [-122.42, 37.78], + [-122.43, 37.79] + ] + }, + distance: 5000, + duration: 600 + } + ] +}; + +function makeOkResponse(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('DirectionsAppTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + it('returns an error when MAPBOX_PUBLIC_TOKEN is missing', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + + const result = await new DirectionsAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.43, latitude: 37.79 } + ] + }); + + expect(result.isError).toBe(true); + expect(mockHttpRequest).not.toHaveBeenCalled(); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('MAPBOX_PUBLIC_TOKEN'); + }); + + it('fetches a route and returns a rawHtml UI resource', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.testpublictoken'; + + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeRouteResponse) + ); + + const result = await new DirectionsAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.43, latitude: 37.79 } + ] + }); + + expect(result.isError).toBe(false); + + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('directions/v5/mapbox/driving/'); + expect(calledUrl).toContain('geometries=geojson'); + + // Summary text first, UI resource second + expect(result.content).toHaveLength(2); + expect(result.content[0].type).toBe('text'); + const summary = (result.content[0] as { type: 'text'; text: string }).text; + expect(summary).toMatch(/Route: 3\.1 mi, 10 min/); + + const uiResource = result.content[1] as { + type: 'resource'; + resource: { mimeType: string; text: string; uri: string }; + }; + expect(uiResource.type).toBe('resource'); + expect(uiResource.resource.uri).toMatch(/^ui:\/\/mapbox\/directions\//); + expect(uiResource.resource.mimeType).toContain('text/html'); + expect(uiResource.resource.text).toContain('mapbox-gl.js'); + expect(uiResource.resource.text).toContain('pk.testpublictoken'); + // Route data should be embedded as JSON + expect(uiResource.resource.text).toContain('"profile":"mapbox/driving"'); + }); + + it('returns an error when the API returns a non-2xx response', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.testpublictoken'; + + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ message: 'Invalid coordinates' }), + text: async () => '{"message":"Invalid coordinates"}' + }); + + const result = await new DirectionsAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: 0, latitude: 0 }, + { longitude: 0, latitude: 0 } + ] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Directions API error'); + }); + + it('returns an error when no route is in the response', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.testpublictoken'; + + const { httpRequest } = setupHttpRequest(makeOkResponse({ routes: [] })); + + const result = await new DirectionsAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.43, latitude: 37.79 } + ] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('No route found'); + }); + + it('respects the chosen routing_profile', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.testpublictoken'; + + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeRouteResponse) + ); + + await new DirectionsAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.43, latitude: 37.79 } + ], + routing_profile: 'mapbox/walking' + }); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('directions/v5/mapbox/walking/'); + }); +}); From a137f78903735403620dcdcfe36d5d3ba89b9308 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 10:32:08 -0400 Subject: [PATCH 05/12] feat: directions_app_tool resolves public token via Tokens API Replace the MAPBOX_PUBLIC_TOKEN env-var requirement with a smart token resolver that calls GET /tokens/v2/{user}?default=true to fetch the customer's default public (pk.*) token, caches it for an hour, and falls back to the env var if the API call fails. Lets the tool work under OAuth (tokens:read scope) without requiring operators to pre-configure a separate public token. Also adds _meta.ui.csp on the UI resource so MCP App hosts know to allow blob workers and the Mapbox API connect/resource domains the GLJS bundle needs to load tiles, fonts, sprites, and styles. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- .../directions-app-tool/DirectionsAppTool.ts | 113 ++++++++++- src/utils/jwtUtils.ts | 22 ++ .../DirectionsAppTool.test.ts | 192 +++++++++++++----- 4 files changed, 269 insertions(+), 60 deletions(-) create mode 100644 src/utils/jwtUtils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9851a1af..f13f6376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ ### New Features -- **`directions_app_tool`**: New tool that renders a route on an interactive Mapbox GL JS map as a self-contained MCP App (rawHtml UI resource). Useful when the user wants a visual, interactive map rather than turn-by-turn data. Requires a `MAPBOX_PUBLIC_TOKEN` (public `pk.*` token) env var separate from the existing secret `MAPBOX_ACCESS_TOKEN`. +- **`directions_app_tool`**: New tool that renders a route on an interactive Mapbox GL JS map as a self-contained MCP App (rawHtml UI resource). Useful when the user wants a visual, interactive map rather than turn-by-turn data. The required public (`pk.*`) token is resolved automatically: it first calls `GET /tokens/v2/{user}?default=true` to fetch the user's default public token (requires `tokens:read` scope on the server's `sk.*` access token), and falls back to the optional `MAPBOX_PUBLIC_TOKEN` env var. Includes `_meta.ui.csp` metadata so MCP App hosts can grant the right iframe sandbox permissions (blob workers, mapbox API connect/resource domains). - **MCP Completions capability**: Add auto-completion support for prompt arguments per MCP spec (2025-11-25). Clients can now suggest values when users fill in prompt parameters (#176) - `category` argument on `find-places-nearby` — 482 Mapbox Search API categories - `mode` argument on `get-directions`, `search-along-route`, `show-reachable-areas` — driving, driving-traffic, walking, cycling diff --git a/src/tools/directions-app-tool/DirectionsAppTool.ts b/src/tools/directions-app-tool/DirectionsAppTool.ts index 63a19894..6bc652d0 100644 --- a/src/tools/directions-app-tool/DirectionsAppTool.ts +++ b/src/tools/directions-app-tool/DirectionsAppTool.ts @@ -7,6 +7,7 @@ import { createUIResource } from '@mcp-ui/server'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { HttpRequest } from '../../utils/types.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { DirectionsAppInputSchema } from './DirectionsAppTool.input.schema.js'; // Docs: https://docs.mapbox.com/api/navigation/directions/ @@ -21,16 +22,34 @@ interface DirectionsResponse { routes?: RouteFeature[]; } +interface TokenListEntry { + token?: string; + usage?: string; + default?: boolean; +} + +// Cache the resolved pk.* token for an hour to avoid an extra Tokens API call +// on every directions_app_tool invocation. +interface CachedToken { + token: string; + expiresAt: number; +} +const PUBLIC_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h + export class DirectionsAppTool extends MapboxApiBasedTool< typeof DirectionsAppInputSchema > { + private cachedPublicToken: CachedToken | null = null; + name = 'directions_app_tool'; description = 'Render a directions route on an interactive Mapbox GL JS map as an MCP App. ' + 'Returns a self-contained HTML UI resource with the route drawn, start/end markers, ' + - 'and camera fit to the route bounds. Requires the MAPBOX_PUBLIC_TOKEN env var ' + - '(a public Mapbox access token). Use this when the user asks for a visual, ' + - 'interactive map of a route rather than just turn-by-turn data.'; + 'and camera fit to the route bounds. The required public (pk.*) token is fetched ' + + "from the user's Mapbox account via the Tokens API (requires the tokens:read scope) " + + 'or read from the optional MAPBOX_PUBLIC_TOKEN env var as a fallback. ' + + 'Use this when the user asks for a visual, interactive map of a route rather than ' + + 'just turn-by-turn data.'; annotations = { title: 'Directions App Tool', readOnlyHint: true, @@ -50,13 +69,13 @@ export class DirectionsAppTool extends MapboxApiBasedTool< input: z.infer, accessToken: string ): Promise { - const publicToken = process.env.MAPBOX_PUBLIC_TOKEN; + const publicToken = await this.resolvePublicToken(accessToken); if (!publicToken) { return { content: [ { type: 'text', - text: 'MAPBOX_PUBLIC_TOKEN environment variable is not set. Provide a public (pk.*) Mapbox access token to render the interactive map.' + text: 'Unable to resolve a public Mapbox token. The server token does not have tokens:read scope and MAPBOX_PUBLIC_TOKEN is not set. Either grant tokens:read to the OAuth client or set MAPBOX_PUBLIC_TOKEN (a pk.* token).' } ], isError: true @@ -119,6 +138,20 @@ export class DirectionsAppTool extends MapboxApiBasedTool< encoding: 'text', uiMetadata: { 'preferred-frame-size': ['100%', '480px'] + }, + resourceProps: { + _meta: { + ui: { + csp: { + connectDomains: [ + 'https://*.mapbox.com', + 'https://events.mapbox.com' + ], + resourceDomains: ['https://api.mapbox.com'], + workerDomains: ['blob:'] + } + } + } } }); @@ -130,6 +163,76 @@ export class DirectionsAppTool extends MapboxApiBasedTool< } }; } + + /** + * Resolve a public (pk.*) token suitable for embedding in client-side HTML. + * + * Resolution order: + * 1. If the server token is already a pk.* token, use it directly. + * 2. If we have a cached pk.* token with >5 min TTL remaining, reuse it. + * 3. If the server token is an sk.* token, call GET /tokens/v2/{user}?default=true + * to fetch the user's default public token (requires tokens:read scope). + * 4. Fall back to the MAPBOX_PUBLIC_TOKEN env var. + * + * Returns undefined if none of the above produces a pk.* token. + */ + private async resolvePublicToken( + accessToken: string + ): Promise { + if (accessToken.startsWith('pk.')) { + return accessToken; + } + + const now = Date.now(); + if ( + this.cachedPublicToken && + this.cachedPublicToken.expiresAt - now > 5 * 60 * 1000 + ) { + return this.cachedPublicToken.token; + } + + if (accessToken.startsWith('sk.')) { + const username = getUserNameFromToken(accessToken); + if (username) { + try { + const tokensUrl = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${username}` + ); + tokensUrl.searchParams.set('default', 'true'); + tokensUrl.searchParams.set('access_token', accessToken); + + const response = await this.httpRequest(tokensUrl.toString()); + if (response.ok) { + const body = (await response.json()) as unknown; + const entries: TokenListEntry[] = Array.isArray(body) + ? (body as TokenListEntry[]) + : ((body as { tokens?: TokenListEntry[] })?.tokens ?? []); + const defaultPk = entries.find( + (entry) => + entry?.usage === 'pk' && typeof entry.token === 'string' + ); + if (defaultPk?.token) { + this.cachedPublicToken = { + token: defaultPk.token, + expiresAt: now + PUBLIC_TOKEN_TTL_MS + }; + return defaultPk.token; + } + } + } catch (err) { + this.log( + 'debug', + `directions_app_tool: failed to fetch default public token, falling back to env var: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } + + const envFallback = process.env.MAPBOX_PUBLIC_TOKEN; + return envFallback && envFallback.startsWith('pk.') + ? envFallback + : undefined; + } } interface RouteAppParams { diff --git a/src/utils/jwtUtils.ts b/src/utils/jwtUtils.ts new file mode 100644 index 00000000..ede70b97 --- /dev/null +++ b/src/utils/jwtUtils.ts @@ -0,0 +1,22 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Extract the Mapbox username from a JWT access token. + * + * Mapbox tokens are JWTs whose payload contains the username under the `u` key. + * Returns undefined if the token is malformed or missing the `u` field — callers + * can decide whether to surface an error or fall back to another auth path. + */ +export function getUserNameFromToken(token: string): string | undefined { + const parts = token.split('.'); + if (parts.length !== 3) return undefined; + try { + const payload = JSON.parse( + Buffer.from(parts[1], 'base64').toString('utf-8') + ) as { u?: unknown }; + return typeof payload.u === 'string' ? payload.u : undefined; + } catch { + return undefined; + } +} diff --git a/test/tools/directions-app-tool/DirectionsAppTool.test.ts b/test/tools/directions-app-tool/DirectionsAppTool.test.ts index 9d8aed42..22bb27d9 100644 --- a/test/tools/directions-app-tool/DirectionsAppTool.test.ts +++ b/test/tools/directions-app-tool/DirectionsAppTool.test.ts @@ -1,11 +1,11 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +// JWT with payload { sub: 'test', u: 'testuser' } — base64 of {"sub":"test","u":"testuser"} process.env.MAPBOX_ACCESS_TOKEN = - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { DirectionsAppTool } from '../../../src/tools/directions-app-tool/DirectionsAppTool.js'; const fakeRouteResponse = { @@ -25,7 +25,17 @@ const fakeRouteResponse = { ] }; -function makeOkResponse(body: unknown): Partial { +const fakeTokenListResponse = [ + { + id: 'cktest123', + usage: 'pk', + default: true, + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token', + scopes: ['styles:read', 'styles:tiles', 'fonts:read'] + } +]; + +function makeOkJsonResponse(body: unknown): Partial { return { ok: true, status: 200, @@ -35,14 +45,38 @@ function makeOkResponse(body: unknown): Partial { }; } +/** + * Build an httpRequest that routes calls to the right mock response based on URL. + * The Tokens API call is matched by `tokens/v2/`; everything else is treated as + * the Directions API call. + */ +function buildRoutingMock(opts: { + tokensResponse?: Partial; + directionsResponse?: Partial; +}) { + const mock = vi.fn(async (url: string) => { + if (url.includes('tokens/v2/')) { + return (opts.tokensResponse ?? { ok: false, status: 403 }) as Response; + } + return (opts.directionsResponse ?? + makeOkJsonResponse(fakeRouteResponse)) as Response; + }); + return { httpRequest: mock, mock }; +} + describe('DirectionsAppTool', () => { + beforeEach(() => { + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + afterEach(() => { vi.restoreAllMocks(); - delete process.env.MAPBOX_PUBLIC_TOKEN; }); - it('returns an error when MAPBOX_PUBLIC_TOKEN is missing', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest(); + it('uses the default public token from the Tokens API', async () => { + const { httpRequest, mock } = buildRoutingMock({ + tokensResponse: makeOkJsonResponse(fakeTokenListResponse) + }); const result = await new DirectionsAppTool({ httpRequest }).run({ coordinates: [ @@ -51,18 +85,31 @@ describe('DirectionsAppTool', () => { ] }); - expect(result.isError).toBe(true); - expect(mockHttpRequest).not.toHaveBeenCalled(); - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('MAPBOX_PUBLIC_TOKEN'); - }); + expect(result.isError).toBe(false); - it('fetches a route and returns a rawHtml UI resource', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.testpublictoken'; + // Should have called tokens/v2 once + directions/v5 once + expect(mock).toHaveBeenCalledTimes(2); + const tokensCall = mock.mock.calls.find((c) => + (c[0] as string).includes('tokens/v2/') + ); + expect(tokensCall?.[0]).toContain('tokens/v2/testuser'); + expect(tokensCall?.[0]).toContain('default=true'); - const { httpRequest, mockHttpRequest } = setupHttpRequest( - makeOkResponse(fakeRouteResponse) + const uiResource = result.content[1] as { + type: 'resource'; + resource: { text: string }; + }; + expect(uiResource.resource.text).toContain( + 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token' ); + }); + + it('falls back to MAPBOX_PUBLIC_TOKEN when the Tokens API call fails', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; + + const { httpRequest } = buildRoutingMock({ + tokensResponse: { ok: false, status: 403 } as Response + }); const result = await new DirectionsAppTool({ httpRequest }).run({ coordinates: [ @@ -72,40 +119,41 @@ describe('DirectionsAppTool', () => { }); expect(result.isError).toBe(false); - - expect(mockHttpRequest).toHaveBeenCalledTimes(1); - const calledUrl = mockHttpRequest.mock.calls[0][0] as string; - expect(calledUrl).toContain('directions/v5/mapbox/driving/'); - expect(calledUrl).toContain('geometries=geojson'); - - // Summary text first, UI resource second - expect(result.content).toHaveLength(2); - expect(result.content[0].type).toBe('text'); - const summary = (result.content[0] as { type: 'text'; text: string }).text; - expect(summary).toMatch(/Route: 3\.1 mi, 10 min/); - const uiResource = result.content[1] as { type: 'resource'; - resource: { mimeType: string; text: string; uri: string }; + resource: { text: string }; }; - expect(uiResource.type).toBe('resource'); - expect(uiResource.resource.uri).toMatch(/^ui:\/\/mapbox\/directions\//); - expect(uiResource.resource.mimeType).toContain('text/html'); - expect(uiResource.resource.text).toContain('mapbox-gl.js'); - expect(uiResource.resource.text).toContain('pk.testpublictoken'); - // Route data should be embedded as JSON - expect(uiResource.resource.text).toContain('"profile":"mapbox/driving"'); + expect(uiResource.resource.text).toContain('pk.fallback-public-token'); }); - it('returns an error when the API returns a non-2xx response', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.testpublictoken'; + it('errors when neither Tokens API nor MAPBOX_PUBLIC_TOKEN is available', async () => { + const { httpRequest } = buildRoutingMock({ + tokensResponse: { ok: false, status: 403 } as Response + }); - const { httpRequest } = setupHttpRequest({ - ok: false, - status: 422, - statusText: 'Unprocessable Entity', - json: async () => ({ message: 'Invalid coordinates' }), - text: async () => '{"message":"Invalid coordinates"}' + const result = await new DirectionsAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.43, latitude: 37.79 } + ] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Unable to resolve a public Mapbox token'); + }); + + it('returns an error when the Directions API returns a non-2xx response', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; + + const { httpRequest } = buildRoutingMock({ + directionsResponse: { + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ message: 'Invalid coordinates' }), + text: async () => '{"message":"Invalid coordinates"}' + } as Response }); const result = await new DirectionsAppTool({ httpRequest }).run({ @@ -120,10 +168,12 @@ describe('DirectionsAppTool', () => { expect(text).toContain('Directions API error'); }); - it('returns an error when no route is in the response', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.testpublictoken'; + it('returns an error when no route is found', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; - const { httpRequest } = setupHttpRequest(makeOkResponse({ routes: [] })); + const { httpRequest } = buildRoutingMock({ + directionsResponse: makeOkJsonResponse({ routes: [] }) + }); const result = await new DirectionsAppTool({ httpRequest }).run({ coordinates: [ @@ -137,12 +187,10 @@ describe('DirectionsAppTool', () => { expect(text).toContain('No route found'); }); - it('respects the chosen routing_profile', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.testpublictoken'; + it('honors a non-default routing_profile', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; - const { httpRequest, mockHttpRequest } = setupHttpRequest( - makeOkResponse(fakeRouteResponse) - ); + const { httpRequest, mock } = buildRoutingMock({}); await new DirectionsAppTool({ httpRequest }).run({ coordinates: [ @@ -152,7 +200,43 @@ describe('DirectionsAppTool', () => { routing_profile: 'mapbox/walking' }); - const calledUrl = mockHttpRequest.mock.calls[0][0] as string; - expect(calledUrl).toContain('directions/v5/mapbox/walking/'); + const directionsCall = mock.mock.calls.find((c) => + (c[0] as string).includes('directions/v5/') + ); + expect(directionsCall?.[0]).toContain('directions/v5/mapbox/walking/'); + }); + + it('includes CSP metadata on the UI resource for the iframe sandbox', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; + + const { httpRequest } = buildRoutingMock({}); + + const result = await new DirectionsAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.43, latitude: 37.79 } + ] + }); + + const uiResource = result.content[1] as { + type: 'resource'; + resource: { + _meta?: { + ui?: { + csp?: { + connectDomains?: string[]; + resourceDomains?: string[]; + workerDomains?: string[]; + }; + }; + }; + }; + }; + expect(uiResource.resource._meta?.ui?.csp?.workerDomains).toContain( + 'blob:' + ); + expect(uiResource.resource._meta?.ui?.csp?.resourceDomains).toContain( + 'https://api.mapbox.com' + ); }); }); From ae448813acebfafea8bf91098d5287cd2a8f9bd7 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 10:40:59 -0400 Subject: [PATCH 06/12] refactor: directions_app_tool uses MCP Apps resource pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Desktop and other MCP hosts only render HTML that's served via a properly-registered MCP App resource (mimeType: text/html;profile=mcp-app), not inline rawHtml on a tool result. Switch to that pattern: - DirectionsAppUIResource serves the GLJS HTML at ui://mapbox/directions-app/index.html with the public token baked in server-side. HTML implements the MCP App postMessage protocol and parses the route GeoJSON out of the tool result. - DirectionsAppTool drops the inline HTML / createUIResource entirely; it returns the route data as text and structured content, and points hosts to the resource via readonly meta.ui.resourceUri. - Public token resolution (Tokens API → env-var fallback) extracted to a shared mapboxPublicToken helper so both the resource and any future tool can reuse it. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- src/resources/resourceRegistry.ts | 2 + .../ui-apps/DirectionsAppUIResource.ts | 351 ++++++++++++++++++ .../directions-app-tool/DirectionsAppTool.ts | 269 ++------------ src/utils/mapboxPublicToken.ts | 91 +++++ .../ui-apps/DirectionsAppUIResource.test.ts | 124 +++++++ .../DirectionsAppTool.test.ts | 190 +++------- 7 files changed, 638 insertions(+), 391 deletions(-) create mode 100644 src/resources/ui-apps/DirectionsAppUIResource.ts create mode 100644 src/utils/mapboxPublicToken.ts create mode 100644 test/resources/ui-apps/DirectionsAppUIResource.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f13f6376..2f31772a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ ### New Features -- **`directions_app_tool`**: New tool that renders a route on an interactive Mapbox GL JS map as a self-contained MCP App (rawHtml UI resource). Useful when the user wants a visual, interactive map rather than turn-by-turn data. The required public (`pk.*`) token is resolved automatically: it first calls `GET /tokens/v2/{user}?default=true` to fetch the user's default public token (requires `tokens:read` scope on the server's `sk.*` access token), and falls back to the optional `MAPBOX_PUBLIC_TOKEN` env var. Includes `_meta.ui.csp` metadata so MCP App hosts can grant the right iframe sandbox permissions (blob workers, mapbox API connect/resource domains). +- **`directions_app_tool`**: New tool that renders a route on an interactive Mapbox GL JS map as an MCP App. Returns the route GeoJSON plus a `_meta.ui.resourceUri` reference to a separately-registered MCP App resource (`ui://mapbox/directions-app/index.html`) that hosts (Claude Desktop, VS Code, Cursor) render as a live map with the route drawn, start/end markers, and camera fit to the route bounds. The required public (`pk.*`) token is resolved server-side by the resource: it first calls `GET /tokens/v2/{user}?default=true` to fetch the user's default public token (requires `tokens:read` scope on the `sk.*` access token), and falls back to the optional `MAPBOX_PUBLIC_TOKEN` env var. Includes `_meta.ui.csp` with `workerDomains: ['blob:']` so MCP App hosts grant Mapbox GL JS the iframe sandbox permissions it needs. - **MCP Completions capability**: Add auto-completion support for prompt arguments per MCP spec (2025-11-25). Clients can now suggest values when users fill in prompt parameters (#176) - `category` argument on `find-places-nearby` — 482 Mapbox Search API categories - `mode` argument on `get-directions`, `search-along-route`, `show-reachable-areas` — driving, driving-traffic, walking, cycling diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 8d0c0ed1..69a81f03 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -5,6 +5,7 @@ import { CategoryListResource } from './category-list/CategoryListResource.js'; import { TemporaryDataResource } from './temporary/TemporaryDataResource.js'; import { StaticMapUIResource } from './ui-apps/StaticMapUIResource.js'; +import { DirectionsAppUIResource } from './ui-apps/DirectionsAppUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -14,6 +15,7 @@ export const ALL_RESOURCES = [ new CategoryListResource({ httpRequest }), new TemporaryDataResource(), new StaticMapUIResource(), + new DirectionsAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/DirectionsAppUIResource.ts b/src/resources/ui-apps/DirectionsAppUIResource.ts new file mode 100644 index 00000000..3803c957 --- /dev/null +++ b/src/resources/ui-apps/DirectionsAppUIResource.ts @@ -0,0 +1,351 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { + ReadResourceResult, + ServerNotification, + ServerRequest +} from '@modelcontextprotocol/sdk/types.js'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; +import { BaseResource } from '../BaseResource.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; + +const MAPBOX_GL_VERSION = '3.12.0'; + +/** + * Serves the HTML for the Directions App MCP App. + * + * The HTML implements the MCP Apps postMessage protocol: on initialize it + * receives the tool result via `ui/notifications/tool-result`, extracts the + * route GeoJSON from the structured content, and renders it on a live Mapbox + * GL JS map. + * + * A short-lived public (`pk.*`) Mapbox token is resolved server-side and baked + * into the response so the iframe never sees the customer's `sk.*` token. + */ +export class DirectionsAppUIResource extends BaseResource { + readonly name = 'Directions App UI'; + readonly uri = 'ui://mapbox/directions-app/index.html'; + readonly description = + 'Interactive UI for visualizing a Mapbox directions route with Mapbox GL JS (MCP Apps)'; + readonly mimeType = RESOURCE_MIME_TYPE; + + private readonly httpRequest: HttpRequest; + private readonly apiEndpoint: () => string; + + constructor(params: { + httpRequest: HttpRequest; + apiEndpoint?: () => string; + }) { + super(); + this.httpRequest = params.httpRequest; + this.apiEndpoint = + params.apiEndpoint ?? + (() => process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'); + } + + async read( + _uri: string, + extra?: RequestHandlerExtra + ): Promise { + const accessToken = + (extra?.authInfo?.token as string | undefined) || + process.env.MAPBOX_ACCESS_TOKEN || + ''; + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: this.apiEndpoint(), + httpRequest: this.httpRequest + }); + + const html = renderDirectionsAppHtml({ + publicToken: publicToken ?? '', + glVersion: MAPBOX_GL_VERSION + }); + + return { + contents: [ + { + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + connectDomains: [ + 'https://*.mapbox.com', + 'https://events.mapbox.com' + ], + resourceDomains: ['https://api.mapbox.com'], + workerDomains: ['blob:'] + }, + preferredSize: { width: 1000, height: 600 } + } + } + } + ] + }; + } +} + +function renderDirectionsAppHtml(params: { + publicToken: string; + glVersion: string; +}): string { + const { publicToken, glVersion } = params; + + return ` + + + + +Directions Preview + + + + + +
+ +
Loading directions…
+ + + + +`; +} diff --git a/src/tools/directions-app-tool/DirectionsAppTool.ts b/src/tools/directions-app-tool/DirectionsAppTool.ts index 6bc652d0..0db53b7c 100644 --- a/src/tools/directions-app-tool/DirectionsAppTool.ts +++ b/src/tools/directions-app-tool/DirectionsAppTool.ts @@ -3,11 +3,9 @@ import { randomUUID } from 'node:crypto'; import type { z } from 'zod'; -import { createUIResource } from '@mcp-ui/server'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { HttpRequest } from '../../utils/types.js'; -import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { DirectionsAppInputSchema } from './DirectionsAppTool.input.schema.js'; // Docs: https://docs.mapbox.com/api/navigation/directions/ @@ -22,34 +20,16 @@ interface DirectionsResponse { routes?: RouteFeature[]; } -interface TokenListEntry { - token?: string; - usage?: string; - default?: boolean; -} - -// Cache the resolved pk.* token for an hour to avoid an extra Tokens API call -// on every directions_app_tool invocation. -interface CachedToken { - token: string; - expiresAt: number; -} -const PUBLIC_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h - export class DirectionsAppTool extends MapboxApiBasedTool< typeof DirectionsAppInputSchema > { - private cachedPublicToken: CachedToken | null = null; - name = 'directions_app_tool'; description = 'Render a directions route on an interactive Mapbox GL JS map as an MCP App. ' + - 'Returns a self-contained HTML UI resource with the route drawn, start/end markers, ' + - 'and camera fit to the route bounds. The required public (pk.*) token is fetched ' + - "from the user's Mapbox account via the Tokens API (requires the tokens:read scope) " + - 'or read from the optional MAPBOX_PUBLIC_TOKEN env var as a fallback. ' + - 'Use this when the user asks for a visual, interactive map of a route rather than ' + - 'just turn-by-turn data.'; + 'Returns the route geometry plus an MCP App reference that hosts (Claude Desktop, ' + + 'VS Code, Cursor) render as a live map with the route drawn, start/end markers, ' + + 'and camera fit to the route bounds. Use this when the user asks for a visual, ' + + 'interactive map of a route rather than just turn-by-turn data.'; annotations = { title: 'Directions App Tool', readOnlyHint: true, @@ -57,6 +37,15 @@ export class DirectionsAppTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/directions-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -69,19 +58,6 @@ export class DirectionsAppTool extends MapboxApiBasedTool< input: z.infer, accessToken: string ): Promise { - const publicToken = await this.resolvePublicToken(accessToken); - if (!publicToken) { - return { - content: [ - { - type: 'text', - text: 'Unable to resolve a public Mapbox token. The server token does not have tokens:read scope and MAPBOX_PUBLIC_TOKEN is not set. Either grant tokens:read to the OAuth client or set MAPBOX_PUBLIC_TOKEN (a pk.* token).' - } - ], - isError: true - }; - } - const coords = input.coordinates .map((c) => `${c.longitude},${c.latitude}`) .join(';'); @@ -122,219 +98,26 @@ export class DirectionsAppTool extends MapboxApiBasedTool< const summary = `Route: ${distanceMiles}, ${durationMin}`; - const htmlString = buildRouteAppHtml({ - publicToken, - geometry: route.geometry as { - type: string; - coordinates: [number, number][]; - }, + // The MCP App resource at ui://mapbox/directions-app/index.html parses the + // route from this text content (JSON-encoded) via the postMessage protocol. + const routePayload = { + summary, profile: input.routing_profile, - summary - }); - - const uiResource = createUIResource({ - uri: `ui://mapbox/directions/${randomUUID()}`, - content: { type: 'rawHtml', htmlString }, - encoding: 'text', - uiMetadata: { - 'preferred-frame-size': ['100%', '480px'] - }, - resourceProps: { - _meta: { - ui: { - csp: { - connectDomains: [ - 'https://*.mapbox.com', - 'https://events.mapbox.com' - ], - resourceDomains: ['https://api.mapbox.com'], - workerDomains: ['blob:'] - } - } - } - } - }); + geometry: route.geometry, + distance_meters: route.distance, + duration_seconds: route.duration + }; return { - content: [{ type: 'text', text: summary }, uiResource], + content: [ + { type: 'text', text: summary }, + { type: 'text', text: JSON.stringify(routePayload) } + ], + structuredContent: { route: routePayload }, isError: false, _meta: { viewUUID: randomUUID() } }; } - - /** - * Resolve a public (pk.*) token suitable for embedding in client-side HTML. - * - * Resolution order: - * 1. If the server token is already a pk.* token, use it directly. - * 2. If we have a cached pk.* token with >5 min TTL remaining, reuse it. - * 3. If the server token is an sk.* token, call GET /tokens/v2/{user}?default=true - * to fetch the user's default public token (requires tokens:read scope). - * 4. Fall back to the MAPBOX_PUBLIC_TOKEN env var. - * - * Returns undefined if none of the above produces a pk.* token. - */ - private async resolvePublicToken( - accessToken: string - ): Promise { - if (accessToken.startsWith('pk.')) { - return accessToken; - } - - const now = Date.now(); - if ( - this.cachedPublicToken && - this.cachedPublicToken.expiresAt - now > 5 * 60 * 1000 - ) { - return this.cachedPublicToken.token; - } - - if (accessToken.startsWith('sk.')) { - const username = getUserNameFromToken(accessToken); - if (username) { - try { - const tokensUrl = new URL( - `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${username}` - ); - tokensUrl.searchParams.set('default', 'true'); - tokensUrl.searchParams.set('access_token', accessToken); - - const response = await this.httpRequest(tokensUrl.toString()); - if (response.ok) { - const body = (await response.json()) as unknown; - const entries: TokenListEntry[] = Array.isArray(body) - ? (body as TokenListEntry[]) - : ((body as { tokens?: TokenListEntry[] })?.tokens ?? []); - const defaultPk = entries.find( - (entry) => - entry?.usage === 'pk' && typeof entry.token === 'string' - ); - if (defaultPk?.token) { - this.cachedPublicToken = { - token: defaultPk.token, - expiresAt: now + PUBLIC_TOKEN_TTL_MS - }; - return defaultPk.token; - } - } - } catch (err) { - this.log( - 'debug', - `directions_app_tool: failed to fetch default public token, falling back to env var: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - } - - const envFallback = process.env.MAPBOX_PUBLIC_TOKEN; - return envFallback && envFallback.startsWith('pk.') - ? envFallback - : undefined; - } -} - -interface RouteAppParams { - publicToken: string; - geometry: { type: string; coordinates: [number, number][] }; - profile: string; - summary: string; -} - -function buildRouteAppHtml(params: RouteAppParams): string { - const { publicToken, geometry, profile, summary } = params; - - // Compute bounding box for camera fit - const lngs = geometry.coordinates.map((c) => c[0]); - const lats = geometry.coordinates.map((c) => c[1]); - const bounds: [[number, number], [number, number]] = [ - [Math.min(...lngs), Math.min(...lats)], - [Math.max(...lngs), Math.max(...lats)] - ]; - - const startCoord = geometry.coordinates[0]; - const endCoord = geometry.coordinates[geometry.coordinates.length - 1]; - - const data = { - publicToken, - profile, - summary, - geometry, - bounds, - startCoord, - endCoord - }; - - // Escape in the JSON payload to prevent breaking out of the script tag - const dataJson = JSON.stringify(data).replace(/<\/script>/gi, '<\\/script>'); - - return ` - - - - -Mapbox Directions - - - - -
-
- - - - -`; } diff --git a/src/utils/mapboxPublicToken.ts b/src/utils/mapboxPublicToken.ts new file mode 100644 index 00000000..8f012f6a --- /dev/null +++ b/src/utils/mapboxPublicToken.ts @@ -0,0 +1,91 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { getUserNameFromToken } from './jwtUtils.js'; +import type { HttpRequest } from './types.js'; + +interface TokenListEntry { + token?: string; + usage?: string; + default?: boolean; +} + +interface CachedToken { + token: string; + expiresAt: number; +} + +const PUBLIC_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h +let cachedPublicToken: CachedToken | null = null; + +/** + * Resolve a public (pk.*) Mapbox token suitable for embedding in client-side + * HTML (e.g. an MCP App iframe that initializes Mapbox GL JS). + * + * Resolution order: + * 1. If the access token is already a pk.* token, use it directly. + * 2. Reuse a cached pk.* token while it has >5 min TTL remaining. + * 3. If the access token is an sk.* token, call + * GET /tokens/v2/{user}?default=true to fetch the user's default public + * token (requires `tokens:read` scope on the bearer). + * 4. Fall back to the MAPBOX_PUBLIC_TOKEN env var. + * + * Returns undefined if none of the above produces a pk.* token. + */ +export async function resolveMapboxPublicToken(params: { + accessToken: string; + apiEndpoint: string; + httpRequest: HttpRequest; +}): Promise { + const { accessToken, apiEndpoint, httpRequest } = params; + + if (accessToken.startsWith('pk.')) { + return accessToken; + } + + const now = Date.now(); + if (cachedPublicToken && cachedPublicToken.expiresAt - now > 5 * 60 * 1000) { + return cachedPublicToken.token; + } + + if (accessToken.startsWith('sk.')) { + const username = getUserNameFromToken(accessToken); + if (username) { + try { + const tokensUrl = new URL(`${apiEndpoint}tokens/v2/${username}`); + tokensUrl.searchParams.set('default', 'true'); + tokensUrl.searchParams.set('access_token', accessToken); + + const response = await httpRequest(tokensUrl.toString()); + if (response.ok) { + const body = (await response.json()) as unknown; + const entries: TokenListEntry[] = Array.isArray(body) + ? (body as TokenListEntry[]) + : ((body as { tokens?: TokenListEntry[] })?.tokens ?? []); + const defaultPk = entries.find( + (entry) => entry?.usage === 'pk' && typeof entry.token === 'string' + ); + if (defaultPk?.token) { + cachedPublicToken = { + token: defaultPk.token, + expiresAt: now + PUBLIC_TOKEN_TTL_MS + }; + return defaultPk.token; + } + } + } catch { + // Fall through to env-var fallback + } + } + } + + const envFallback = process.env.MAPBOX_PUBLIC_TOKEN; + return envFallback && envFallback.startsWith('pk.') ? envFallback : undefined; +} + +/** + * Reset the cached public token. For tests only. + */ +export function __resetMapboxPublicTokenCache(): void { + cachedPublicToken = null; +} diff --git a/test/resources/ui-apps/DirectionsAppUIResource.test.ts b/test/resources/ui-apps/DirectionsAppUIResource.test.ts new file mode 100644 index 00000000..135c4631 --- /dev/null +++ b/test/resources/ui-apps/DirectionsAppUIResource.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +// JWT with payload {"sub":"test","u":"testuser"} (base64) +const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { DirectionsAppUIResource } from '../../../src/resources/ui-apps/DirectionsAppUIResource.js'; +import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js'; + +const fakeTokenList = [ + { + id: 'cktest123', + usage: 'pk', + default: true, + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token', + scopes: ['styles:read', 'styles:tiles', 'fonts:read'] + } +]; + +function makeOkJson(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('DirectionsAppUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serves HTML with the mcp-app mime type and the public token baked in', async () => { + const httpRequest = vi.fn(async (url: string) => { + if (url.includes('tokens/v2/')) + return makeOkJson(fakeTokenList) as Response; + throw new Error(`Unexpected URL: ${url}`); + }); + + const resource = new DirectionsAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/directions-app/index.html', + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authInfo: { token: SK_TOKEN } as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ); + + expect(result.contents).toHaveLength(1); + const entry = result.contents[0]; + expect(entry.mimeType).toBe('text/html;profile=mcp-app'); + expect(entry.uri).toBe('ui://mapbox/directions-app/index.html'); + expect(typeof entry.text).toBe('string'); + expect(entry.text as string).toContain( + 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token' + ); + expect(entry.text as string).toContain('mapbox-gl.js'); + + const meta = (entry as { _meta?: unknown })._meta as + | { ui?: { csp?: { workerDomains?: string[] } } } + | undefined; + expect(meta?.ui?.csp?.workerDomains).toContain('blob:'); + + // Confirm the tokens endpoint was hit with default=true + const tokensCall = httpRequest.mock.calls.find((c) => + (c[0] as string).includes('tokens/v2/') + ); + expect(tokensCall?.[0]).toContain('tokens/v2/testuser'); + expect(tokensCall?.[0]).toContain('default=true'); + }); + + it('falls back to MAPBOX_PUBLIC_TOKEN when the Tokens API call fails', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-token'; + + const httpRequest = vi.fn( + async () => ({ ok: false, status: 403 }) as Response + ); + + const resource = new DirectionsAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/directions-app/index.html', + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authInfo: { token: SK_TOKEN } as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ); + + expect(result.contents[0].text as string).toContain('pk.fallback-token'); + }); + + it('still returns HTML (with empty token) when no token can be resolved', async () => { + const httpRequest = vi.fn( + async () => ({ ok: false, status: 403 }) as Response + ); + + const resource = new DirectionsAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/directions-app/index.html', + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authInfo: { token: SK_TOKEN } as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ); + + // HTML is still served — the iframe will surface a friendly error to the user + expect(result.contents[0].text as string).toContain( + 'No Mapbox public token available' + ); + }); +}); diff --git a/test/tools/directions-app-tool/DirectionsAppTool.test.ts b/test/tools/directions-app-tool/DirectionsAppTool.test.ts index 22bb27d9..073a0f3d 100644 --- a/test/tools/directions-app-tool/DirectionsAppTool.test.ts +++ b/test/tools/directions-app-tool/DirectionsAppTool.test.ts @@ -1,11 +1,11 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -// JWT with payload { sub: 'test', u: 'testuser' } — base64 of {"sub":"test","u":"testuser"} process.env.MAPBOX_ACCESS_TOKEN = - 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; -import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; import { DirectionsAppTool } from '../../../src/tools/directions-app-tool/DirectionsAppTool.js'; const fakeRouteResponse = { @@ -25,17 +25,7 @@ const fakeRouteResponse = { ] }; -const fakeTokenListResponse = [ - { - id: 'cktest123', - usage: 'pk', - default: true, - token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token', - scopes: ['styles:read', 'styles:tiles', 'fonts:read'] - } -]; - -function makeOkJsonResponse(body: unknown): Partial { +function makeOkResponse(body: unknown): Partial { return { ok: true, status: 200, @@ -45,38 +35,15 @@ function makeOkJsonResponse(body: unknown): Partial { }; } -/** - * Build an httpRequest that routes calls to the right mock response based on URL. - * The Tokens API call is matched by `tokens/v2/`; everything else is treated as - * the Directions API call. - */ -function buildRoutingMock(opts: { - tokensResponse?: Partial; - directionsResponse?: Partial; -}) { - const mock = vi.fn(async (url: string) => { - if (url.includes('tokens/v2/')) { - return (opts.tokensResponse ?? { ok: false, status: 403 }) as Response; - } - return (opts.directionsResponse ?? - makeOkJsonResponse(fakeRouteResponse)) as Response; - }); - return { httpRequest: mock, mock }; -} - describe('DirectionsAppTool', () => { - beforeEach(() => { - delete process.env.MAPBOX_PUBLIC_TOKEN; - }); - afterEach(() => { vi.restoreAllMocks(); }); - it('uses the default public token from the Tokens API', async () => { - const { httpRequest, mock } = buildRoutingMock({ - tokensResponse: makeOkJsonResponse(fakeTokenListResponse) - }); + it('returns route summary, route JSON, and structuredContent.route', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeRouteResponse) + ); const result = await new DirectionsAppTool({ httpRequest }).run({ coordinates: [ @@ -86,74 +53,43 @@ describe('DirectionsAppTool', () => { }); expect(result.isError).toBe(false); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); - // Should have called tokens/v2 once + directions/v5 once - expect(mock).toHaveBeenCalledTimes(2); - const tokensCall = mock.mock.calls.find((c) => - (c[0] as string).includes('tokens/v2/') - ); - expect(tokensCall?.[0]).toContain('tokens/v2/testuser'); - expect(tokensCall?.[0]).toContain('default=true'); - - const uiResource = result.content[1] as { - type: 'resource'; - resource: { text: string }; - }; - expect(uiResource.resource.text).toContain( - 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token' - ); - }); - - it('falls back to MAPBOX_PUBLIC_TOKEN when the Tokens API call fails', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('directions/v5/mapbox/driving/'); + expect(calledUrl).toContain('geometries=geojson'); - const { httpRequest } = buildRoutingMock({ - tokensResponse: { ok: false, status: 403 } as Response - }); + expect(result.content).toHaveLength(2); + const summary = (result.content[0] as { type: 'text'; text: string }).text; + expect(summary).toMatch(/Route: 3\.1 mi, 10 min/); - const result = await new DirectionsAppTool({ httpRequest }).run({ - coordinates: [ - { longitude: -122.4194, latitude: 37.7749 }, - { longitude: -122.43, latitude: 37.79 } - ] - }); + const routeJson = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(routeJson.geometry.coordinates).toHaveLength(3); + expect(routeJson.profile).toBe('mapbox/driving'); - expect(result.isError).toBe(false); - const uiResource = result.content[1] as { - type: 'resource'; - resource: { text: string }; - }; - expect(uiResource.resource.text).toContain('pk.fallback-public-token'); + const structuredContent = ( + result as unknown as { structuredContent?: { route?: unknown } } + ).structuredContent; + expect(structuredContent?.route).toBeDefined(); }); - it('errors when neither Tokens API nor MAPBOX_PUBLIC_TOKEN is available', async () => { - const { httpRequest } = buildRoutingMock({ - tokensResponse: { ok: false, status: 403 } as Response - }); - - const result = await new DirectionsAppTool({ httpRequest }).run({ - coordinates: [ - { longitude: -122.4194, latitude: 37.7749 }, - { longitude: -122.43, latitude: 37.79 } - ] - }); - - expect(result.isError).toBe(true); - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('Unable to resolve a public Mapbox token'); + it('declares the MCP App resourceUri on its meta', () => { + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsAppTool({ httpRequest }); + expect(tool.meta?.ui?.resourceUri).toBe( + 'ui://mapbox/directions-app/index.html' + ); }); it('returns an error when the Directions API returns a non-2xx response', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; - - const { httpRequest } = buildRoutingMock({ - directionsResponse: { - ok: false, - status: 422, - statusText: 'Unprocessable Entity', - json: async () => ({ message: 'Invalid coordinates' }), - text: async () => '{"message":"Invalid coordinates"}' - } as Response + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ message: 'Invalid coordinates' }), + text: async () => '{"message":"Invalid coordinates"}' }); const result = await new DirectionsAppTool({ httpRequest }).run({ @@ -169,11 +105,7 @@ describe('DirectionsAppTool', () => { }); it('returns an error when no route is found', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; - - const { httpRequest } = buildRoutingMock({ - directionsResponse: makeOkJsonResponse({ routes: [] }) - }); + const { httpRequest } = setupHttpRequest(makeOkResponse({ routes: [] })); const result = await new DirectionsAppTool({ httpRequest }).run({ coordinates: [ @@ -187,10 +119,10 @@ describe('DirectionsAppTool', () => { expect(text).toContain('No route found'); }); - it('honors a non-default routing_profile', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; - - const { httpRequest, mock } = buildRoutingMock({}); + it('respects a non-default routing_profile', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeRouteResponse) + ); await new DirectionsAppTool({ httpRequest }).run({ coordinates: [ @@ -200,43 +132,7 @@ describe('DirectionsAppTool', () => { routing_profile: 'mapbox/walking' }); - const directionsCall = mock.mock.calls.find((c) => - (c[0] as string).includes('directions/v5/') - ); - expect(directionsCall?.[0]).toContain('directions/v5/mapbox/walking/'); - }); - - it('includes CSP metadata on the UI resource for the iframe sandbox', async () => { - process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-public-token'; - - const { httpRequest } = buildRoutingMock({}); - - const result = await new DirectionsAppTool({ httpRequest }).run({ - coordinates: [ - { longitude: -122.4194, latitude: 37.7749 }, - { longitude: -122.43, latitude: 37.79 } - ] - }); - - const uiResource = result.content[1] as { - type: 'resource'; - resource: { - _meta?: { - ui?: { - csp?: { - connectDomains?: string[]; - resourceDomains?: string[]; - workerDomains?: string[]; - }; - }; - }; - }; - }; - expect(uiResource.resource._meta?.ui?.csp?.workerDomains).toContain( - 'blob:' - ); - expect(uiResource.resource._meta?.ui?.csp?.resourceDomains).toContain( - 'https://api.mapbox.com' - ); + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('directions/v5/mapbox/walking/'); }); }); From fb12b892838363ade52ce047affaff52a5bd9f5a Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 10:58:47 -0400 Subject: [PATCH 07/12] fix(directions_app): tighten camera padding so the route fills the viewport The 60px symmetric padding left a lot of unused space around the route line. Switch to directional padding (top: 70, bottom/left/right: 30) so the summary chip still has room at the top but the route fills the rest of the viewport. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/DirectionsAppUIResource.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/resources/ui-apps/DirectionsAppUIResource.ts b/src/resources/ui-apps/DirectionsAppUIResource.ts index 3803c957..87dfc738 100644 --- a/src/resources/ui-apps/DirectionsAppUIResource.ts +++ b/src/resources/ui-apps/DirectionsAppUIResource.ts @@ -339,7 +339,10 @@ function renderDirectionsAppHtml(params: { [Math.min.apply(null, lngs), Math.min.apply(null, lats)], [Math.max.apply(null, lngs), Math.max.apply(null, lats)] ]; - map.fitBounds(bounds, { padding: 60, duration: 600 }); + map.fitBounds(bounds, { + padding: { top: 70, bottom: 30, left: 30, right: 30 }, + duration: 600 + }); loadingEl.style.display = 'none'; requestSizeToFit(); From f653375d5394d988ec4e9ac117c29f95598e2c4c Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:42:50 -0400 Subject: [PATCH 08/12] fix(directions_app): defer fitBounds until after iframe resize Previously fitBounds was called before requestSizeToFit, so the camera was computed against the iframe's initial (often smaller) size and ended up zoomed too far out. Send the size-changed notification first, wait a tick for the host to apply the resize, then map.resize() + fitBounds against the final viewport. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/DirectionsAppUIResource.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/resources/ui-apps/DirectionsAppUIResource.ts b/src/resources/ui-apps/DirectionsAppUIResource.ts index 87dfc738..5c499424 100644 --- a/src/resources/ui-apps/DirectionsAppUIResource.ts +++ b/src/resources/ui-apps/DirectionsAppUIResource.ts @@ -339,13 +339,19 @@ function renderDirectionsAppHtml(params: { [Math.min.apply(null, lngs), Math.min.apply(null, lats)], [Math.max.apply(null, lngs), Math.max.apply(null, lats)] ]; - map.fitBounds(bounds, { - padding: { top: 70, bottom: 30, left: 30, right: 30 }, - duration: 600 - }); - loadingEl.style.display = 'none'; + + // Request the final iframe size FIRST so fitBounds computes against the + // post-resize viewport. Wait a tick for the host to apply the change, + // then force a map.resize() so Mapbox re-measures before fitting. requestSizeToFit(); + setTimeout(function() { + map.resize(); + map.fitBounds(bounds, { + padding: { top: 70, bottom: 30, left: 30, right: 30 }, + duration: 600 + }); + }, 60); } })(); From 277d2677d964bfe446ed117db2c0638f7922ccd0 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 10:57:05 -0400 Subject: [PATCH 09/12] refactor: fold MCP App support into directions_tool (drops directions_app_tool) Replaces the standalone directions_app_tool with a single source of truth: directions_tool now serves the live GL JS map for both MCP Apps (via _meta.ui.resourceUri pointing to DirectionsAppUIResource) and legacy MCP-UI (via inline rawHtml UIResource on the tool result, gated by ENABLE_MCP_UI). - Extract HTML template into directionsAppHtml.ts shared by both pathways - MCP Apps: resource serves generic HTML, iframe receives geometry via postMessage from ui/notifications/tool-result - MCP-UI: tool inlines the same HTML with geometry baked in via an initial-data - - - -
- -
Loading directions…
- - - - -`; -} diff --git a/src/resources/ui-apps/directionsAppHtml.ts b/src/resources/ui-apps/directionsAppHtml.ts new file mode 100644 index 00000000..c2b95bc0 --- /dev/null +++ b/src/resources/ui-apps/directionsAppHtml.ts @@ -0,0 +1,325 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Render the directions MCP App HTML. + * + * The same template is consumed by two ingress paths: + * + * 1. **MCP Apps spec** — `DirectionsAppUIResource` reads this resource at + * `ui://mapbox/directions-app/index.html`. The iframe loads, the agent's + * tool result is delivered via the `ui/notifications/tool-result` + * postMessage event, and `extractRoute()` pulls the route out of + * `structuredContent.routes[0]`. + * + * 2. **Legacy MCP-UI spec** — `directions_tool` inlines a `rawHtml` + * UIResource into its content array (gated by `isMcpUiEnabled()`). The + * HTML is generated at tool-execute time with the route geometry already + * baked in as an `initialData` script block, so the iframe renders + * immediately without needing the host to deliver the tool result. + * + * One source of truth for the rendering logic; two slim entry conditions. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export interface DirectionsAppInitialData { + geometry: { type: string; coordinates: [number, number][] }; + summary?: string; +} + +export function renderDirectionsAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: DirectionsAppInitialData; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Directions Preview + + + + + +
+ +
Loading directions…
+ +${initialDataScript} + + + +`; +} + +function escapeForScript(s: string): string { + // Prevent inside JSON from breaking out of the script tag. + return s.replace(/<\/script>/gi, '<\\/script>'); +} diff --git a/src/tools/directions-app-tool/DirectionsAppTool.input.schema.ts b/src/tools/directions-app-tool/DirectionsAppTool.input.schema.ts deleted file mode 100644 index f0ef8613..00000000 --- a/src/tools/directions-app-tool/DirectionsAppTool.input.schema.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { z } from 'zod'; -import { coordinateSchema } from '../../schemas/shared.js'; - -export const DirectionsAppInputSchema = z.object({ - coordinates: z - .array(coordinateSchema) - .min(2) - .max(25) - .describe('Ordered list of waypoints (start, optional vias, end).'), - routing_profile: z - .enum([ - 'mapbox/driving', - 'mapbox/driving-traffic', - 'mapbox/walking', - 'mapbox/cycling' - ]) - .default('mapbox/driving') - .describe('Mapbox routing profile to use.') -}); - -export type DirectionsAppInput = z.infer; diff --git a/src/tools/directions-app-tool/DirectionsAppTool.ts b/src/tools/directions-app-tool/DirectionsAppTool.ts deleted file mode 100644 index 0db53b7c..00000000 --- a/src/tools/directions-app-tool/DirectionsAppTool.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { randomUUID } from 'node:crypto'; -import type { z } from 'zod'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { HttpRequest } from '../../utils/types.js'; -import { DirectionsAppInputSchema } from './DirectionsAppTool.input.schema.js'; - -// Docs: https://docs.mapbox.com/api/navigation/directions/ - -interface RouteFeature { - geometry?: { type: string; coordinates?: [number, number][] }; - distance?: number; - duration?: number; -} - -interface DirectionsResponse { - routes?: RouteFeature[]; -} - -export class DirectionsAppTool extends MapboxApiBasedTool< - typeof DirectionsAppInputSchema -> { - name = 'directions_app_tool'; - description = - 'Render a directions route on an interactive Mapbox GL JS map as an MCP App. ' + - 'Returns the route geometry plus an MCP App reference that hosts (Claude Desktop, ' + - 'VS Code, Cursor) render as a live map with the route drawn, start/end markers, ' + - 'and camera fit to the route bounds. Use this when the user asks for a visual, ' + - 'interactive map of a route rather than just turn-by-turn data.'; - annotations = { - title: 'Directions App Tool', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true - }; - readonly meta = { - ui: { - resourceUri: 'ui://mapbox/directions-app/index.html', - csp: { - connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], - resourceDomains: ['https://api.mapbox.com'] - } - } - }; - - constructor(params: { httpRequest: HttpRequest }) { - super({ - inputSchema: DirectionsAppInputSchema, - httpRequest: params.httpRequest - }); - } - - protected async execute( - input: z.infer, - accessToken: string - ): Promise { - const coords = input.coordinates - .map((c) => `${c.longitude},${c.latitude}`) - .join(';'); - - const url = new URL( - `${MapboxApiBasedTool.mapboxApiEndpoint}directions/v5/${input.routing_profile}/${encodeURIComponent(coords)}` - ); - url.searchParams.set('access_token', accessToken); - url.searchParams.set('geometries', 'geojson'); - url.searchParams.set('overview', 'full'); - - const response = await this.httpRequest(url.toString()); - if (!response.ok) { - const errorText = await this.getErrorMessage(response); - return { - content: [{ type: 'text', text: `Directions API error: ${errorText}` }], - isError: true - }; - } - - const data = (await response.json()) as DirectionsResponse; - const route = data.routes?.[0]; - if (!route?.geometry?.coordinates?.length) { - return { - content: [ - { type: 'text', text: 'No route found for the given coordinates.' } - ], - isError: true - }; - } - - const distanceMiles = route.distance - ? `${(route.distance / 1609.34).toFixed(1)} mi` - : 'unknown'; - const durationMin = route.duration - ? `${Math.round(route.duration / 60)} min` - : 'unknown'; - - const summary = `Route: ${distanceMiles}, ${durationMin}`; - - // The MCP App resource at ui://mapbox/directions-app/index.html parses the - // route from this text content (JSON-encoded) via the postMessage protocol. - const routePayload = { - summary, - profile: input.routing_profile, - geometry: route.geometry, - distance_meters: route.distance, - duration_seconds: route.duration - }; - - return { - content: [ - { type: 'text', text: summary }, - { type: 'text', text: JSON.stringify(routePayload) } - ], - structuredContent: { route: routePayload }, - isError: false, - _meta: { - viewUUID: randomUUID() - } - }; - } -} diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index 080d222b..1735023f 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -2,8 +2,9 @@ // Licensed under the MIT License. import { URLSearchParams } from 'node:url'; -import { randomBytes } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import type { z } from 'zod'; +import { createUIResource } from '@mcp-ui/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { cleanResponseData } from './cleanResponseData.js'; @@ -15,6 +16,9 @@ import { } from './DirectionsTool.output.schema.js'; import type { HttpRequest } from '../..//utils/types.js'; import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import { renderDirectionsAppHtml } from '../../resources/ui-apps/directionsAppHtml.js'; // Docs: https://docs.mapbox.com/api/navigation/directions/ @@ -35,6 +39,15 @@ export class DirectionsTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/directions-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -333,10 +346,92 @@ ${responseSize > RESPONSE_SIZE_THRESHOLD ? `\n⚠️ Full response (${Math.round } // Small response - return normally + const content: CallToolResult['content'] = [ + { type: 'text', text: responseText } + ]; + + // Legacy MCP-UI: inline a rawHtml UIResource so non-MCP-Apps clients + // also get a live GL JS map. Only works when the response actually + // carries a renderable GeoJSON geometry — i.e. geometries=geojson. + if (isMcpUiEnabled()) { + const inlineHtml = await tryRenderInlineUiHtml( + validatedData, + accessToken, + this.httpRequest + ); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/directions/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + return { - content: [{ type: 'text', text: responseText }], + content, structuredContent: validatedData, isError: false }; } } + +/** + * Try to render the same DirectionsAppHtml as the MCP Apps resource, but + * with the route geometry baked in so MCP-UI clients (which don't fetch + * external resources) can render inline. Returns undefined when the + * response has no GeoJSON geometry to render or no public token can be + * resolved — the caller falls back to text-only output. + */ +async function tryRenderInlineUiHtml( + data: DirectionsResponse, + accessToken: string, + httpRequest: HttpRequest +): Promise { + const route = data.routes?.[0]; + const geometry = route?.geometry; + // The GeoJSON branch is the only one we can render without decoding work. + if ( + !geometry || + typeof geometry !== 'object' || + geometry === null || + (geometry as { type?: string }).type !== 'LineString' || + !Array.isArray((geometry as { coordinates?: unknown }).coordinates) + ) { + return undefined; + } + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, + httpRequest + }); + if (!publicToken) return undefined; + + const summaryParts: string[] = []; + if (typeof route?.distance === 'number') { + summaryParts.push(`${(route.distance / 1609.34).toFixed(1)} mi`); + } + if (typeof route?.duration === 'number') { + summaryParts.push(`${Math.round(route.duration / 60)} min`); + } + const summary = summaryParts.length + ? `Route: ${summaryParts.join(', ')}` + : 'Route'; + + return renderDirectionsAppHtml({ + publicToken, + initialData: { + geometry: geometry as { + type: string; + coordinates: [number, number][]; + }, + summary + } + }); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index e9eb48e9..b265d26d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -37,7 +37,6 @@ export { CategoryListTool } from './category-list-tool/CategoryListTool.js'; export { CategorySearchTool } from './category-search-tool/CategorySearchTool.js'; export { CentroidTool } from './centroid-tool/CentroidTool.js'; export { DirectionsTool } from './directions-tool/DirectionsTool.js'; -export { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; export { DistanceTool } from './distance-tool/DistanceTool.js'; export { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; export { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -60,7 +59,6 @@ import { CategoryListTool } from './category-list-tool/CategoryListTool.js'; import { CategorySearchTool } from './category-search-tool/CategorySearchTool.js'; import { CentroidTool } from './centroid-tool/CentroidTool.js'; import { DirectionsTool } from './directions-tool/DirectionsTool.js'; -import { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; import { DistanceTool } from './distance-tool/DistanceTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -99,9 +97,6 @@ export const centroid = new CentroidTool(); /** Get directions between waypoints */ export const directions = new DirectionsTool({ httpRequest }); -/** Render a directions route on an interactive Mapbox GL JS map (MCP App) */ -export const directionsApp = new DirectionsAppTool({ httpRequest }); - /** Calculate distance between points */ export const distance = new DistanceTool(); diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index a056bde0..75babfd1 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -24,7 +24,6 @@ import { DistanceTool } from './distance-tool/DistanceTool.js'; import { CategoryListTool } from './category-list-tool/CategoryListTool.js'; import { CategorySearchTool } from './category-search-tool/CategorySearchTool.js'; import { DirectionsTool } from './directions-tool/DirectionsTool.js'; -import { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; @@ -62,7 +61,6 @@ export const CORE_TOOLS = [ new DistanceTool(), new CategorySearchTool({ httpRequest }), new DirectionsTool({ httpRequest }), - new DirectionsAppTool({ httpRequest }), new IsochroneTool({ httpRequest }), new MapMatchingTool({ httpRequest }), new MatrixTool({ httpRequest }), diff --git a/test/tools/directions-app-tool/DirectionsAppTool.test.ts b/test/tools/directions-app-tool/DirectionsAppTool.test.ts deleted file mode 100644 index 073a0f3d..00000000 --- a/test/tools/directions-app-tool/DirectionsAppTool.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -process.env.MAPBOX_ACCESS_TOKEN = - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; -import { DirectionsAppTool } from '../../../src/tools/directions-app-tool/DirectionsAppTool.js'; - -const fakeRouteResponse = { - routes: [ - { - geometry: { - type: 'LineString', - coordinates: [ - [-122.4194, 37.7749], - [-122.42, 37.78], - [-122.43, 37.79] - ] - }, - distance: 5000, - duration: 600 - } - ] -}; - -function makeOkResponse(body: unknown): Partial { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => body, - text: async () => JSON.stringify(body) - }; -} - -describe('DirectionsAppTool', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('returns route summary, route JSON, and structuredContent.route', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest( - makeOkResponse(fakeRouteResponse) - ); - - const result = await new DirectionsAppTool({ httpRequest }).run({ - coordinates: [ - { longitude: -122.4194, latitude: 37.7749 }, - { longitude: -122.43, latitude: 37.79 } - ] - }); - - expect(result.isError).toBe(false); - expect(mockHttpRequest).toHaveBeenCalledTimes(1); - - const calledUrl = mockHttpRequest.mock.calls[0][0] as string; - expect(calledUrl).toContain('directions/v5/mapbox/driving/'); - expect(calledUrl).toContain('geometries=geojson'); - - expect(result.content).toHaveLength(2); - const summary = (result.content[0] as { type: 'text'; text: string }).text; - expect(summary).toMatch(/Route: 3\.1 mi, 10 min/); - - const routeJson = JSON.parse( - (result.content[1] as { type: 'text'; text: string }).text - ); - expect(routeJson.geometry.coordinates).toHaveLength(3); - expect(routeJson.profile).toBe('mapbox/driving'); - - const structuredContent = ( - result as unknown as { structuredContent?: { route?: unknown } } - ).structuredContent; - expect(structuredContent?.route).toBeDefined(); - }); - - it('declares the MCP App resourceUri on its meta', () => { - const { httpRequest } = setupHttpRequest(); - const tool = new DirectionsAppTool({ httpRequest }); - expect(tool.meta?.ui?.resourceUri).toBe( - 'ui://mapbox/directions-app/index.html' - ); - }); - - it('returns an error when the Directions API returns a non-2xx response', async () => { - const { httpRequest } = setupHttpRequest({ - ok: false, - status: 422, - statusText: 'Unprocessable Entity', - json: async () => ({ message: 'Invalid coordinates' }), - text: async () => '{"message":"Invalid coordinates"}' - }); - - const result = await new DirectionsAppTool({ httpRequest }).run({ - coordinates: [ - { longitude: 0, latitude: 0 }, - { longitude: 0, latitude: 0 } - ] - }); - - expect(result.isError).toBe(true); - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('Directions API error'); - }); - - it('returns an error when no route is found', async () => { - const { httpRequest } = setupHttpRequest(makeOkResponse({ routes: [] })); - - const result = await new DirectionsAppTool({ httpRequest }).run({ - coordinates: [ - { longitude: -122.4194, latitude: 37.7749 }, - { longitude: -122.43, latitude: 37.79 } - ] - }); - - expect(result.isError).toBe(true); - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('No route found'); - }); - - it('respects a non-default routing_profile', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest( - makeOkResponse(fakeRouteResponse) - ); - - await new DirectionsAppTool({ httpRequest }).run({ - coordinates: [ - { longitude: -122.4194, latitude: 37.7749 }, - { longitude: -122.43, latitude: 37.79 } - ], - routing_profile: 'mapbox/walking' - }); - - const calledUrl = mockHttpRequest.mock.calls[0][0] as string; - expect(calledUrl).toContain('directions/v5/mapbox/walking/'); - }); -}); diff --git a/test/tools/directions-tool/DirectionsTool.test.ts b/test/tools/directions-tool/DirectionsTool.test.ts index a1fcf25f..027de31b 100644 --- a/test/tools/directions-tool/DirectionsTool.test.ts +++ b/test/tools/directions-tool/DirectionsTool.test.ts @@ -1064,4 +1064,98 @@ describe('DirectionsTool', () => { isError: true }); }); + + describe('MCP App + MCP-UI integration', () => { + it('declares meta.ui.resourceUri pointing to the directions-app resource', () => { + const { httpRequest } = setupHttpRequest(); + const tool = new DirectionsTool({ httpRequest }); + expect(tool.meta?.ui?.resourceUri).toBe( + 'ui://mapbox/directions-app/index.html' + ); + }); + + it('adds an inline MCP-UI rawHtml resource for small geojson responses', async () => { + const fakeResponse = { + routes: [ + { + geometry: { + type: 'LineString', + coordinates: [ + [-74.0, 40.7], + [-74.01, 40.71] + ] + }, + distance: 1500, + duration: 180, + legs: [] + } + ], + waypoints: [ + { location: [-74.0, 40.7], name: '' }, + { location: [-74.01, 40.71], name: '' } + ], + code: 'Ok' + }; + const tokensListResponse = [ + { + usage: 'pk', + default: true, + token: 'pk.fake-public-token' + } + ]; + + const httpRequestFn = vi.fn(async (url: string) => { + if (url.includes('tokens/v2/')) { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => tokensListResponse, + text: async () => JSON.stringify(tokensListResponse) + } as Response; + } + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => fakeResponse, + text: async () => JSON.stringify(fakeResponse) + } as Response; + }); + + // Mapbox sk.* tokens are 3 dot-segments: sk.. + const realToken = process.env.MAPBOX_ACCESS_TOKEN; + const payload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( + 'base64' + ); + process.env.MAPBOX_ACCESS_TOKEN = `sk.${payload}.signature`; + + try { + const result = await new DirectionsTool({ + httpRequest: httpRequestFn + }).run({ + coordinates: [ + { longitude: -74.0, latitude: 40.7 }, + { longitude: -74.01, latitude: 40.71 } + ], + geometries: 'geojson' + }); + + expect(result.isError).toBe(false); + // Expect at least the response text + the MCP-UI resource block + expect(result.content.length).toBeGreaterThanOrEqual(2); + const uiBlock = result.content.find( + (c) => (c as { type?: string }).type === 'resource' + ) as { resource?: { text?: string; mimeType?: string } } | undefined; + expect(uiBlock).toBeDefined(); + expect(uiBlock?.resource?.text).toContain('mapbox-gl.js'); + expect(uiBlock?.resource?.text).toContain('pk.fake-public-token'); + // Initial-data block should carry the baked-in geometry + expect(uiBlock?.resource?.text).toContain('initial-data'); + expect(uiBlock?.resource?.text).toContain('LineString'); + } finally { + process.env.MAPBOX_ACCESS_TOKEN = realToken; + } + }); + }); }); From 9fc4467bbc5b1dfab0050b747eae7de7c321b8fe Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 11:01:14 -0400 Subject: [PATCH 10/12] fix(directions_app): decode polyline geometries in the iframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version only rendered when geometry was a GeoJSON LineString. Mapbox's default is a polyline string (geometries=polyline), and the agent often doesn't pass geometries=geojson explicitly — leaving the iframe with "Could not find route data in tool result" despite a valid response. Add an inline polyline decoder (precision 5 + 6 fallback with bounds check) and route both the postMessage path and the MCP-UI initial-data path through a shared pickRouteGeometry that normalizes either shape to GeoJSON before rendering. The directions_tool's inline UI block also relaxes its gate to accept polyline strings. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/directionsAppHtml.ts | 98 +++++++++++++++++++-- src/tools/directions-tool/DirectionsTool.ts | 19 ++-- 2 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/resources/ui-apps/directionsAppHtml.ts b/src/resources/ui-apps/directionsAppHtml.ts index c2b95bc0..fcfa2734 100644 --- a/src/resources/ui-apps/directionsAppHtml.ts +++ b/src/resources/ui-apps/directionsAppHtml.ts @@ -190,14 +190,23 @@ ${initialDataScript} // ------------------------------------------------------------------------- // Initial data path (MCP-UI rawHtml): geometry was baked in server-side. + // Accepts the same geometry shapes as extractRoute (GeoJSON or polyline). // ------------------------------------------------------------------------- function consumeInitialData() { var el = document.getElementById('initial-data'); if (!el || !el.textContent) return; try { var data = JSON.parse(el.textContent); - if (data && data.geometry && data.geometry.coordinates) { - drawRoute(data); + if (!data || !data.geometry) return; + // Reuse pickRouteGeometry by wrapping the baked-in payload as a route. + var route = pickRouteGeometry({ + geometry: data.geometry, + distance: data.distance, + duration: data.duration + }); + if (route) { + if (data.summary) route.summary = data.summary; + drawRoute(route); } } catch (_) { /* ignore */ } } @@ -222,10 +231,8 @@ ${initialDataScript} function extractRoute(result) { var sc = result && result.structuredContent; if (sc && Array.isArray(sc.routes) && sc.routes.length > 0) { - var r = sc.routes[0]; - if (r && r.geometry && r.geometry.coordinates) { - return { geometry: r.geometry, summary: buildSummary(r) }; - } + var picked = pickRouteGeometry(sc.routes[0]); + if (picked) return picked; } if (result && Array.isArray(result.content)) { for (var i = 0; i < result.content.length; i++) { @@ -233,9 +240,9 @@ ${initialDataScript} if (c && c.type === 'text' && typeof c.text === 'string') { try { var parsed = JSON.parse(c.text); - if (parsed && Array.isArray(parsed.routes) && parsed.routes[0] && parsed.routes[0].geometry) { - var r2 = parsed.routes[0]; - return { geometry: r2.geometry, summary: buildSummary(r2) }; + if (parsed && Array.isArray(parsed.routes) && parsed.routes[0]) { + var picked2 = pickRouteGeometry(parsed.routes[0]); + if (picked2) return picked2; } if (parsed && parsed.geometry && parsed.geometry.coordinates) { return parsed; @@ -247,6 +254,79 @@ ${initialDataScript} return null; } + // Mapbox Directions can return geometry in 3 shapes: + // - GeoJSON : { type: 'LineString', coordinates: [[lng,lat], ...] } + // - polyline : "encoded_string" (precision 5, default) + // - polyline6: "encoded_string" (precision 6) + // We accept all three and normalize to GeoJSON for rendering. + function pickRouteGeometry(route) { + if (!route || !route.geometry) return null; + var g = route.geometry; + if (typeof g === 'object' && Array.isArray(g.coordinates) && g.coordinates.length) { + return { geometry: g, summary: buildSummary(route) }; + } + if (typeof g === 'string' && g.length > 0) { + // Pick precision: polyline6 strings often contain '_' / '@'-like density + // but the response usually carries a hint at top level. Try 6 first if + // the response object says so, else 5. + var coords = decodePolyline(g, 5); + if (coords.length > 0 && coordsLookSane(coords)) { + return { + geometry: { type: 'LineString', coordinates: coords }, + summary: buildSummary(route) + }; + } + coords = decodePolyline(g, 6); + if (coords.length > 0 && coordsLookSane(coords)) { + return { + geometry: { type: 'LineString', coordinates: coords }, + summary: buildSummary(route) + }; + } + } + return null; + } + + function coordsLookSane(coords) { + for (var i = 0; i < coords.length; i++) { + var c = coords[i]; + if (!Array.isArray(c) || c.length !== 2) return false; + if (c[0] < -180 || c[0] > 180 || c[1] < -90 || c[1] > 90) return false; + } + return true; + } + + // Standard Google/Mapbox polyline decoder. Precision = 5 (default) or 6. + function decodePolyline(str, precision) { + precision = precision || 5; + var factor = Math.pow(10, precision); + var coords = []; + var lat = 0; + var lng = 0; + var i = 0; + while (i < str.length) { + var shift = 0; + var result = 0; + var b; + do { + b = str.charCodeAt(i++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20 && i < str.length); + lat += (result & 1) ? ~(result >> 1) : (result >> 1); + shift = 0; + result = 0; + do { + b = str.charCodeAt(i++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20 && i < str.length); + lng += (result & 1) ? ~(result >> 1) : (result >> 1); + coords.push([lng / factor, lat / factor]); + } + return coords; + } + function buildSummary(route) { var parts = []; if (typeof route.distance === 'number') { diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index 1735023f..e7490706 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -395,14 +395,15 @@ async function tryRenderInlineUiHtml( ): Promise { const route = data.routes?.[0]; const geometry = route?.geometry; - // The GeoJSON branch is the only one we can render without decoding work. - if ( - !geometry || - typeof geometry !== 'object' || - geometry === null || - (geometry as { type?: string }).type !== 'LineString' || - !Array.isArray((geometry as { coordinates?: unknown }).coordinates) - ) { + // Accept either a GeoJSON LineString object or a polyline string — the + // iframe normalizes both shapes before rendering. + const hasGeojson = + geometry && + typeof geometry === 'object' && + (geometry as { type?: string }).type === 'LineString' && + Array.isArray((geometry as { coordinates?: unknown }).coordinates); + const hasPolyline = typeof geometry === 'string' && geometry.length > 0; + if (!hasGeojson && !hasPolyline) { return undefined; } @@ -427,7 +428,7 @@ async function tryRenderInlineUiHtml( return renderDirectionsAppHtml({ publicToken, initialData: { - geometry: geometry as { + geometry: geometry as unknown as { type: string; coordinates: [number, number][]; }, From b8fffa2968903c2ab1e46d7780b997f002690cab Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 11:06:51 -0400 Subject: [PATCH 11/12] fix(directions_app): read temp resource when geometry is offloaded For responses >50KB, directions_tool stores the full data as a mapbox://temp/directions-{id} resource and strips geometry from structuredContent to keep the agent context light. The iframe was showing 'Could not find route data' because the geometry wasn't where it normally lives. Fall back to calling resources/read via the MCP Apps host bridge when extractRoute fails on the initial tool result. Scan the text content for the mapbox://temp/directions-* URI, fetch the full data, and re-extract. (Also fixes a template-literal escape bug: backticks in a code comment inside the HTML template were closing the template prematurely.) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/directionsAppHtml.ts | 63 ++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/src/resources/ui-apps/directionsAppHtml.ts b/src/resources/ui-apps/directionsAppHtml.ts index fcfa2734..a6018327 100644 --- a/src/resources/ui-apps/directionsAppHtml.ts +++ b/src/resources/ui-apps/directionsAppHtml.ts @@ -213,12 +213,39 @@ ${initialDataScript} function handleToolResult(result) { var route = extractRoute(result); - if (!route) { - loadingEl.style.display = 'none'; - errorEl.textContent = 'Could not find route data in tool result.'; - errorEl.style.display = 'block'; + if (route) { + renderRoute(route); + return; + } + + // Fallback: large directions responses are stored as a temporary resource + // (mapbox://temp/directions-id) and the structuredContent is stripped of + // geometry to keep the agent context light. Read it back via the + // MCP Apps host resources/read bridge. + var tempUri = findTempResourceUri(result); + if (tempUri) { + loadingEl.textContent = 'Fetching full route…'; + sendRequest('resources/read', { uri: tempUri }).then( + function(rr) { + var fetched = readResourceJson(rr); + var fetchedRoute = fetched ? extractRoute({ structuredContent: fetched }) : null; + if (fetchedRoute) { + renderRoute(fetchedRoute); + } else { + showError('Could not parse route from the temporary resource.'); + } + }, + function(err) { + showError('Could not read temporary resource: ' + (err && err.message ? err.message : err)); + } + ); return; } + + showError('Could not find route data in tool result.'); + } + + function renderRoute(route) { if (route.summary) { summaryEl.textContent = route.summary; summaryEl.style.display = 'block'; @@ -228,6 +255,34 @@ ${initialDataScript} else pendingRoute = route; } + function showError(message) { + loadingEl.style.display = 'none'; + errorEl.textContent = message; + errorEl.style.display = 'block'; + } + + // Mapbox MCP server emits the URI in the summary text content of large + // responses: "Resource URI: mapbox://temp/directions-". + function findTempResourceUri(result) { + if (!result || !Array.isArray(result.content)) return null; + for (var i = 0; i < result.content.length; i++) { + var c = result.content[i]; + if (c && c.type === 'text' && typeof c.text === 'string') { + var m = c.text.match(/mapbox:\\/\\/temp\\/directions-[0-9a-fA-F]+/); + if (m) return m[0]; + } + } + return null; + } + + // resources/read responses have shape: { contents: [{ text: "", ... }] } + function readResourceJson(rr) { + if (!rr || !Array.isArray(rr.contents) || rr.contents.length === 0) return null; + var first = rr.contents[0]; + if (!first || typeof first.text !== 'string') return null; + try { return JSON.parse(first.text); } catch (_) { return null; } + } + function extractRoute(result) { var sc = result && result.structuredContent; if (sc && Array.isArray(sc.routes) && sc.routes.length > 0) { From e8602f3db12229f5387e66edcac9ee41f63f769d Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 16:00:21 -0400 Subject: [PATCH 12/12] fix(directions_app): review feedback from PR #189 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track route markers across re-renders so a second tool-result delivery doesn't stack pins on the previous start/end (reported by @zmofei) - Surface a console.warn when the Tokens API call fails so the env-var fallback path is diagnosable from logs rather than silently masking network or JSON parse errors (reported by @zmofei) - Clean up docs/importing-tools.md references to the deleted pointInPolygon / PointInPolygonTool — now pointsWithinPolygon / PointsWithinPolygonTool (out-of-PR regression noted by @zmofei) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/importing-tools.md | 4 ++-- src/resources/ui-apps/directionsAppHtml.ts | 28 +++++++++++++++------- src/utils/mapboxPublicToken.ts | 9 +++++-- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/importing-tools.md b/docs/importing-tools.md index b45c63cc..25c6637c 100644 --- a/docs/importing-tools.md +++ b/docs/importing-tools.md @@ -116,7 +116,7 @@ import { centroid, distance, midpoint, - pointInPolygon, + pointsWithinPolygon, simplify, // API tools (HTTP pre-configured) @@ -149,7 +149,7 @@ import { CentroidTool, DistanceTool, MidpointTool, - PointInPolygonTool, + PointsWithinPolygonTool, SimplifyTool, // API tools diff --git a/src/resources/ui-apps/directionsAppHtml.ts b/src/resources/ui-apps/directionsAppHtml.ts index a6018327..6883ea7a 100644 --- a/src/resources/ui-apps/directionsAppHtml.ts +++ b/src/resources/ui-apps/directionsAppHtml.ts @@ -93,6 +93,9 @@ ${initialDataScript} var mapLoaded = false; var pendingRoute = null; var currentDisplayMode = 'inline'; + // Track markers across re-renders so we can remove them before drawing the + // next route — otherwise a second tool-result delivery stacks pins. + var routeMarkers = []; // ------------------------------------------------------------------------- // MCP App postMessage protocol (skipped silently in MCP-UI rawHtml mode) @@ -408,6 +411,11 @@ ${initialDataScript} if (map.getLayer('route-line')) map.removeLayer('route-line'); if (map.getSource('route')) map.removeSource('route'); + // Remove any markers from a prior render so we don't stack pins. + for (var mi = 0; mi < routeMarkers.length; mi++) { + routeMarkers[mi].remove(); + } + routeMarkers = []; map.addSource('route', { type: 'geojson', @@ -422,14 +430,18 @@ ${initialDataScript} }); var coords = route.geometry.coordinates; - new mapboxgl.Marker({ color: '#22c55e' }) - .setLngLat(coords[0]) - .setPopup(new mapboxgl.Popup().setText('Start')) - .addTo(map); - new mapboxgl.Marker({ color: '#ef4444' }) - .setLngLat(coords[coords.length - 1]) - .setPopup(new mapboxgl.Popup().setText('End')) - .addTo(map); + routeMarkers.push( + new mapboxgl.Marker({ color: '#22c55e' }) + .setLngLat(coords[0]) + .setPopup(new mapboxgl.Popup().setText('Start')) + .addTo(map) + ); + routeMarkers.push( + new mapboxgl.Marker({ color: '#ef4444' }) + .setLngLat(coords[coords.length - 1]) + .setPopup(new mapboxgl.Popup().setText('End')) + .addTo(map) + ); var lngs = coords.map(function(c) { return c[0]; }); var lats = coords.map(function(c) { return c[1]; }); diff --git a/src/utils/mapboxPublicToken.ts b/src/utils/mapboxPublicToken.ts index 8f012f6a..f01a366e 100644 --- a/src/utils/mapboxPublicToken.ts +++ b/src/utils/mapboxPublicToken.ts @@ -73,8 +73,13 @@ export async function resolveMapboxPublicToken(params: { return defaultPk.token; } } - } catch { - // Fall through to env-var fallback + } catch (err) { + // Network failures and JSON parse errors land here. Surface a warning + // so the cause is diagnosable from logs rather than masked behind the + // generic env-var fallback path. + console.warn( + `resolveMapboxPublicToken: Tokens API call failed, falling back to MAPBOX_PUBLIC_TOKEN env var: ${err instanceof Error ? err.message : String(err)}` + ); } } }