From a8625811bc13fcc2c42f81cac863f415961bb99c Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 26 Oct 2026 12:50:25 -0400 Subject: [PATCH 1/4] feat: list_public_tokens_tool for pages built outside the chat Lists the account's public (pk.) tokens with their URL restrictions and recommends one: a token restricted to site_url when there is one, otherwise the default with advice to use a URL-restricted token for a published site. Never returns secret or temporary tokens and can't create tokens. Server instructions point page building at it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + README.md | 12 + .../ListPublicTokensTool.input.schema.ts | 24 ++ .../ListPublicTokensTool.output.schema.ts | 35 +++ .../ListPublicTokensTool.ts | 242 ++++++++++++++++++ src/tools/toolRegistry.ts | 2 + src/utils/serverInstructions.ts | 5 + .../ListPublicTokensTool.test.ts | 215 ++++++++++++++++ test/utils/serverInstructions.test.ts | 11 + 9 files changed, 551 insertions(+) create mode 100644 src/tools/list-public-tokens-tool/ListPublicTokensTool.input.schema.ts create mode 100644 src/tools/list-public-tokens-tool/ListPublicTokensTool.output.schema.ts create mode 100644 src/tools/list-public-tokens-tool/ListPublicTokensTool.ts create mode 100644 test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 45fe772..1e4d90d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ ### New Features +- **`list_public_tokens_tool`: a public token for pages built outside the chat (#PR, part of #281).** An agent building a site or app with a Mapbox map had to ask the user for a token. The tool lists the account's public (`pk.`) tokens with their names, default flag, URL restrictions and scopes, and recommends one. + - **Safer token first:** pass `site_url` and it recommends a token restricted to that URL when one exists. Otherwise it recommends the default public token and says a published site should use a URL-restricted one, with the link to create it. + - **Public tokens only:** it asks the Tokens API for `pk` tokens and drops anything else, so secret and temporary tokens never come back. It can't create tokens (that needs `tokens:write`; DevKit's `create_token_tool` does it). + - **No new scope:** it needs `tokens:read`, which the in-chat map already uses to find the default public token. With a `pk.` access token, or when listing fails and `MAPBOX_PUBLIC_TOKEN` is set, it returns that token. + - **Server instructions** now tell the model to use it instead of asking the user for a token when it builds a page outside the chat. - **`feedback_tool`: report wrong map, place or routing data to Mapbox (#291).** When someone says a place has closed, a road is closed, or a route took an illegal turn, the agent can send it to the [Feedback API](https://docs.mapbox.com/api/feedback/) (`POST /user-feedback/v1/feedback`, Public Preview) with the text, a category and the location, usually taken from an earlier search or directions result. It returns the report's id and status (`received`). - **Confirm first:** the report goes to Mapbox under the user's account, so the description tells the model to show the user what it will send and get a yes. Clients that support elicitation also get a confirmation prompt from the tool itself. Declining sends nothing, and so does a failed prompt. - **Categories:** an enum of the Feedback Agent's default categories that make sense from chat (`poi_closed`, `poi_details`, `road_closure_issue`, `illegal_turn` and so on), leaving out the navigation-app ones such as voice guidance and rerouting. diff --git a/README.md b/README.md index 4494629..655123c 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,18 @@ Each report may be billed ($0.02 per item once the Feedback API leaves Public Pr **Example Usage**: "Blue Bottle on Mint St closed last year, can you report that to Mapbox?" +#### List public tokens tool + +Lists the account's public (`pk.`) access tokens, for an agent building a web page or app outside the chat that loads Mapbox GL JS or calls Mapbox APIs from the browser. In the chat, `render_map_tool` needs no token. Each token comes with its name, whether it's the default, its URL restrictions and its scopes. + +Pass `site_url` (where the page will run) and the tool recommends a token restricted to that URL when the account has one. Otherwise it recommends the default public token and says that a published site should use a URL-restricted token instead, with a link to create one. Anyone who copies an unrestricted public token can use it, and the usage bills to the account. + +It never returns secret or temporary tokens, and it can't create tokens (that needs `tokens:write`; the [DevKit server](https://github.com/mapbox/mcp-devkit-server)'s `create_token_tool` does it). + +**Requirements**: listing needs the `tokens:read` scope, the same one the in-chat map already uses to find the default public token. With a `pk.` access token, or when listing fails and `MAPBOX_PUBLIC_TOKEN` is set, it returns that token instead. To leave the tool out, start the server with `--disable-tools list_public_tokens_tool`. + +**Example Usage**: "Build me a single-page site that shows our three store locations on a Mapbox map." + # Development ## Inspecting server diff --git a/src/tools/list-public-tokens-tool/ListPublicTokensTool.input.schema.ts b/src/tools/list-public-tokens-tool/ListPublicTokensTool.input.schema.ts new file mode 100644 index 0000000..77cdd84 --- /dev/null +++ b/src/tools/list-public-tokens-tool/ListPublicTokensTool.input.schema.ts @@ -0,0 +1,24 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import z from 'zod'; + +/** + * Input schema for ListPublicTokensTool + */ +export const ListPublicTokensInputSchema = z.object({ + site_url: z + .string() + .url() + .optional() + .describe( + 'Where the page or app that will use the token is served from, for example ' + + '"https://example.com" or "http://localhost:3000". Used to pick a public token ' + + "that is restricted to that URL. Leave it out if you don't know yet." + ) +}); + +/** + * Type inference for ListPublicTokensInput + */ +export type ListPublicTokensInput = z.infer; diff --git a/src/tools/list-public-tokens-tool/ListPublicTokensTool.output.schema.ts b/src/tools/list-public-tokens-tool/ListPublicTokensTool.output.schema.ts new file mode 100644 index 0000000..9343bb6 --- /dev/null +++ b/src/tools/list-public-tokens-tool/ListPublicTokensTool.output.schema.ts @@ -0,0 +1,35 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const PublicTokenSchema = z.object({ + token: z.string().describe('The public (pk.) token'), + note: z.string().nullable().describe('The name given to the token'), + default: z + .boolean() + .describe("Whether this is the account's default public token"), + allowedUrls: z + .array(z.string()) + .describe( + 'URLs the token is restricted to. Empty means it works from anywhere.' + ), + scopes: z.array(z.string()).describe('Scopes granted to the token') +}); + +export const ListPublicTokensOutputSchema = z.object({ + tokens: z.array(PublicTokenSchema), + recommended: z + .object({ + token: z.string(), + reason: z.string() + }) + .optional() + .describe('The token to use for the given site_url, and why'), + guidance: z.string().describe('How to use the token safely') +}); + +export type PublicToken = z.infer; +export type ListPublicTokensOutput = z.infer< + typeof ListPublicTokensOutputSchema +>; diff --git a/src/tools/list-public-tokens-tool/ListPublicTokensTool.ts b/src/tools/list-public-tokens-tool/ListPublicTokensTool.ts new file mode 100644 index 0000000..90b670b --- /dev/null +++ b/src/tools/list-public-tokens-tool/ListPublicTokensTool.ts @@ -0,0 +1,242 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import { + ListPublicTokensInputSchema, + type ListPublicTokensInput +} from './ListPublicTokensTool.input.schema.js'; +import { + ListPublicTokensOutputSchema, + type ListPublicTokensOutput, + type PublicToken +} from './ListPublicTokensTool.output.schema.js'; + +// Docs: https://docs.mapbox.com/api/accounts/tokens/#list-tokens + +const CREATE_TOKEN_URL = 'https://account.mapbox.com/access-tokens/create'; + +const GUIDANCE = + 'Public (pk.) tokens are meant for client-side code, but anyone who copies one can use it, and the ' + + "usage bills to this account. For a site you publish, use a token restricted to the site's URL; " + + `if none matches, ask the user to create one at ${CREATE_TOKEN_URL} (add the site's URL under URL ` + + 'restrictions) and use that instead. The default token is fine for local prototypes.'; + +/** What the Tokens API returns for each token; only pk entries are kept. */ +interface ApiToken { + usage?: string; + token?: string; + note?: string | null; + default?: boolean; + allowedUrls?: string[]; + scopes?: string[]; +} + +/** + * Host and path, lowercased, without scheme or trailing slash: how the + * restriction and the site are compared. + */ +function normalizeUrl(url: string): string { + return url + .trim() + .toLowerCase() + .replace(/^[a-z][a-z0-9+.-]*:\/\//, '') + .replace(/\/+$/, ''); +} + +/** + * Whether a token restricted to `allowed` would work on `site`. Best effort: + * Mapbox makes the final call when the page loads. + */ +export function urlMatches(allowed: string, site: string): boolean { + const a = normalizeUrl(allowed); + const s = normalizeUrl(site); + if (a.startsWith('*.')) { + const host = s.split('/')[0]; + return host.endsWith(a.slice(1)); + } + return s === a || s.startsWith(`${a}/`); +} + +function pickToken( + tokens: PublicToken[], + siteUrl: string | undefined +): ListPublicTokensOutput['recommended'] { + if (siteUrl) { + const restricted = tokens.find((t) => + t.allowedUrls.some((allowed) => urlMatches(allowed, siteUrl)) + ); + if (restricted) { + return { + token: restricted.token, + reason: `Restricted to URLs that include ${siteUrl}, so it can't be reused elsewhere.` + }; + } + } + const fallback = tokens.find((t) => t.default) ?? tokens[0]; + if (!fallback) return undefined; + const which = fallback.default + ? "The account's default public token" + : 'A public token on the account'; + return { + token: fallback.token, + reason: siteUrl + ? `${which}. No public token is restricted to ${siteUrl}, so this one works from any site; ` + + 'for a published site, create a restricted one and use it instead.' + : `${which}. Pass site_url to look for a token restricted to where the page will run.` + }; +} + +/** + * Lists the account's public tokens so an agent building a page or app + * outside the chat (where render_map_tool can't help) can use Mapbox GL JS. + * Never returns secret or temporary tokens, and never creates tokens: + * that needs `tokens:write` (see mcp-devkit-server's create_token_tool). + */ +export class ListPublicTokensTool extends MapboxApiBasedTool< + typeof ListPublicTokensInputSchema, + typeof ListPublicTokensOutputSchema +> { + name = 'list_public_tokens_tool'; + description = + "Lists the Mapbox account's public (pk.) access tokens, for when you build a web page or app " + + 'outside this chat that loads Mapbox GL JS or calls Mapbox APIs from the browser. ' + + 'To show a map in the chat, use render_map_tool instead; it needs no token. ' + + 'Pass site_url (where the page will run) to get a token restricted to that URL when the account ' + + "has one. Returns each token's name, whether it is the default, its URL restrictions and scopes, " + + 'a recommended token, and guidance: for a published site, use a URL-restricted token, and tell ' + + 'the user the default token can be reused by anyone who copies it. Secret tokens are never listed.'; + annotations = { + title: 'List Public Tokens', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + + constructor({ httpRequest }: { httpRequest: HttpRequest }) { + super({ + inputSchema: ListPublicTokensInputSchema, + outputSchema: ListPublicTokensOutputSchema, + httpRequest + }); + } + + protected async execute( + input: ListPublicTokensInput, + accessToken: string + ): Promise { + // A public token can't list tokens (that takes tokens:read), but it is + // itself a public token the page can use. + if (accessToken.startsWith('pk.')) { + return this.result({ + tokens: [], + recommended: { + token: accessToken, + reason: + "The public token this server is configured with. Listing the account's other tokens needs a token with the tokens:read scope." + }, + guidance: GUIDANCE + }); + } + + const username = getUserNameFromToken(accessToken); + if (!username) { + return this.envFallbackOr( + "Couldn't read the account name from the access token." + ); + } + + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${username}` + ); + url.searchParams.set('usage', 'pk'); + url.searchParams.set('limit', '100'); + url.searchParams.set('access_token', accessToken); + + const response = await this.httpRequest(url.toString()); + if (!response.ok) { + const scopeHint = + response.status === 401 || response.status === 403 + ? ' Listing tokens needs an access token with the tokens:read scope.' + : ''; + return this.envFallbackOr( + `Listing tokens failed: ${await this.getErrorMessage(response)}.${scopeHint}` + ); + } + + const body = (await response.json()) as unknown; + const entries: ApiToken[] = Array.isArray(body) + ? body + : ((body as { tokens?: ApiToken[] })?.tokens ?? []); + + // usage=pk already filters, but never pass anything else through. + const tokens: PublicToken[] = entries + .filter( + (t): t is ApiToken & { token: string } => + t?.usage === 'pk' && + typeof t.token === 'string' && + t.token.startsWith('pk.') + ) + .map((t) => ({ + token: t.token, + note: t.note ?? null, + default: t.default === true, + allowedUrls: t.allowedUrls ?? [], + scopes: t.scopes ?? [] + })); + + return this.result({ + tokens, + recommended: pickToken(tokens, input.site_url), + guidance: GUIDANCE + }); + } + + /** MAPBOX_PUBLIC_TOKEN when it's set, otherwise the error. */ + private envFallbackOr(message: string): CallToolResult { + const envToken = process.env.MAPBOX_PUBLIC_TOKEN; + if (envToken?.startsWith('pk.')) { + return this.result({ + tokens: [], + recommended: { + token: envToken, + reason: `The public token this server is configured with (MAPBOX_PUBLIC_TOKEN). ${message}` + }, + guidance: GUIDANCE + }); + } + return { content: [{ type: 'text', text: message }], isError: true }; + } + + private result(output: ListPublicTokensOutput): CallToolResult { + const lines = output.tokens.map((t) => { + const name = t.note ? `"${t.note}"` : '(unnamed)'; + const restriction = + t.allowedUrls.length > 0 + ? `restricted to ${t.allowedUrls.join(', ')}` + : 'no URL restrictions'; + return `- ${name}${t.default ? ' (default)' : ''}: ${t.token} (${restriction})`; + }); + const text = [ + output.tokens.length > 0 + ? `Public tokens (${output.tokens.length}):\n${lines.join('\n')}` + : undefined, + output.recommended + ? `Recommended: ${output.recommended.token}\n${output.recommended.reason}` + : 'This account has no public tokens.', + output.guidance + ] + .filter(Boolean) + .join('\n\n'); + + return { + content: [{ type: 'text', text }], + structuredContent: output, + isError: false + }; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 0b7339e..f17376d 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. // INSERT NEW TOOL IMPORT HERE +import { ListPublicTokensTool } from './list-public-tokens-tool/ListPublicTokensTool.js'; import { FeedbackTool } from './feedback-tool/FeedbackTool.js'; import { GroundLocationTool } from './ground-location-tool/GroundLocationTool.js'; import { PointsWithinPolygonTool } from './points-within-polygon-tool/PointsWithinPolygonTool.js'; @@ -42,6 +43,7 @@ import { httpRequest } from '../utils/httpPipeline.js'; */ export const CORE_TOOLS = [ // INSERT NEW TOOL INSTANCE HERE + new ListPublicTokensTool({ httpRequest }), new FeedbackTool({ httpRequest }), new GroundLocationTool({ httpRequest }), new PointsWithinPolygonTool(), diff --git a/src/utils/serverInstructions.ts b/src/utils/serverInstructions.ts index dfaf5b8..fbf9b9a 100644 --- a/src/utils/serverInstructions.ts +++ b/src/utils/serverInstructions.ts @@ -24,5 +24,10 @@ export function buildServerInstructions( 'Use static_map_image_tool only when the user explicitly asks for a static image file (PNG/JPEG), never as the default way to show a map.' ); } + if (enabledToolNames.includes('list_public_tokens_tool')) { + lines.push( + 'When building a web page or app outside this chat that needs a Mapbox token, get one with list_public_tokens_tool instead of asking the user for it.' + ); + } return lines.join('\n'); } diff --git a/test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts b/test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts new file mode 100644 index 0000000..406aafa --- /dev/null +++ b/test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts @@ -0,0 +1,215 @@ +// 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 type { ServerContext } from '@modelcontextprotocol/server'; +import { + setupHttpRequest, + assertHeadersSent +} from '../../utils/httpPipelineUtils.js'; +import { + ListPublicTokensTool, + urlMatches +} from '../../../src/tools/list-public-tokens-tool/ListPublicTokensTool.js'; +import type { ListPublicTokensOutput } from '../../../src/tools/list-public-tokens-tool/ListPublicTokensTool.output.schema.js'; +import { structuredOf, textOf } from '../../utils/resultTestUtils.js'; + +/** A secret-token-shaped bearer for account "test-user". */ +const secretToken = `sk.${Buffer.from(JSON.stringify({ u: 'test-user' })).toString('base64')}.sig`; +const withToken = (token: string) => + ({ http: { authInfo: { token } } }) as unknown as ServerContext; + +const defaultPk = { + usage: 'pk', + token: 'pk.default.sig', + note: 'Default public token', + default: true, + scopes: ['styles:tiles', 'styles:read'], + id: 'ckdefault' +}; +const restrictedPk = { + usage: 'pk', + token: 'pk.restricted.sig', + note: 'example.com site', + default: false, + allowedUrls: ['https://example.com'], + scopes: ['styles:tiles'] +}; + +function setup(body: unknown, overrides: Partial = {}) { + return setupHttpRequest({ json: async () => body, ...overrides }); +} + +async function run( + httpRequest: ReturnType['httpRequest'], + input: Record = {}, + token = secretToken +) { + return new ListPublicTokensTool({ httpRequest }).run(input, withToken(token)); +} + +describe('ListPublicTokensTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('sends custom header', async () => { + const { httpRequest, mockHttpRequest } = setup([defaultPk]); + await run(httpRequest); + assertHeadersSent(mockHttpRequest); + }); + + it("asks for only the account's public tokens", async () => { + const { httpRequest, mockHttpRequest } = setup([defaultPk]); + await run(httpRequest); + const url = new URL(mockHttpRequest.mock.calls[0][0]); + expect(url.pathname).toBe('/tokens/v2/test-user'); + expect(url.searchParams.get('usage')).toBe('pk'); + }); + + it('lists public tokens and recommends the default without a site_url', async () => { + const { httpRequest } = setup([defaultPk, restrictedPk]); + const result = await run(httpRequest); + + expect(result.isError).toBe(false); + const output = structuredOf(result); + expect(output.tokens).toEqual([ + { + token: 'pk.default.sig', + note: 'Default public token', + default: true, + allowedUrls: [], + scopes: ['styles:tiles', 'styles:read'] + }, + { + token: 'pk.restricted.sig', + note: 'example.com site', + default: false, + allowedUrls: ['https://example.com'], + scopes: ['styles:tiles'] + } + ]); + expect(output.recommended?.token).toBe('pk.default.sig'); + expect(output.guidance).toContain( + 'account.mapbox.com/access-tokens/create' + ); + expect(textOf(result)).toContain('Recommended: pk.default.sig'); + }); + + it('recommends a token restricted to the site_url', async () => { + const { httpRequest } = setup([defaultPk, restrictedPk]); + const result = await run(httpRequest, { + site_url: 'https://example.com/maps/' + }); + const output = structuredOf(result); + expect(output.recommended?.token).toBe('pk.restricted.sig'); + expect(output.recommended?.reason).toContain('Restricted'); + }); + + it('falls back to the default and says why when nothing matches the site_url', async () => { + const { httpRequest } = setup([defaultPk, restrictedPk]); + const result = await run(httpRequest, { + site_url: 'https://other.org' + }); + const output = structuredOf(result); + expect(output.recommended?.token).toBe('pk.default.sig'); + expect(output.recommended?.reason).toContain( + 'No public token is restricted to https://other.org' + ); + }); + + it('never passes through anything but pk tokens', async () => { + const { httpRequest } = setup([ + defaultPk, + { usage: 'sk', note: 'secret', default: false, scopes: [] }, + { usage: 'sk', token: 'sk.leak.sig', default: false }, + { usage: 'pk', token: 'tk.wrong-prefix.sig', default: false }, + { usage: 'tk', token: 'tk.temp.sig', default: false } + ]); + const result = await run(httpRequest); + const output = structuredOf(result); + expect(output.tokens.map((t) => t.token)).toEqual(['pk.default.sig']); + expect(JSON.stringify(result)).not.toMatch(/sk\.leak|tk\./); + }); + + it('accepts the object form of the response', async () => { + const { httpRequest } = setup({ tokens: [defaultPk] }); + const output = structuredOf(await run(httpRequest)); + expect(output.tokens).toHaveLength(1); + }); + + it('returns a pk access token itself without calling the API', async () => { + const { httpRequest, mockHttpRequest } = setup([]); + const pk = `pk.${Buffer.from(JSON.stringify({ u: 'test-user' })).toString('base64')}.sig`; + const result = await run(httpRequest, {}, pk); + expect(mockHttpRequest).not.toHaveBeenCalled(); + expect( + structuredOf(result).recommended?.token + ).toBe(pk); + }); + + it('explains the missing scope on a 403', async () => { + const { httpRequest } = setup( + {}, + { + ok: false, + status: 403, + statusText: 'Forbidden', + text: async () => + JSON.stringify({ message: 'Not Authorized - No Token' }) + } + ); + const result = await run(httpRequest); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('tokens:read'); + }); + + it('falls back to MAPBOX_PUBLIC_TOKEN when listing fails', async () => { + vi.stubEnv('MAPBOX_PUBLIC_TOKEN', 'pk.env.sig'); + const { httpRequest } = setup( + {}, + { + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => '{}' + } + ); + const result = await run(httpRequest); + expect(result.isError).toBe(false); + const output = structuredOf(result); + expect(output.recommended?.token).toBe('pk.env.sig'); + expect(output.recommended?.reason).toContain('tokens:read'); + }); + + it('says so when the account has no public tokens', async () => { + const { httpRequest } = setup([]); + const result = await run(httpRequest); + expect(structuredOf(result).recommended).toBe( + undefined + ); + expect(textOf(result)).toContain('no public tokens'); + }); +}); + +describe('urlMatches', () => { + it.each([ + ['https://example.com', 'https://example.com', true], + ['https://example.com', 'http://EXAMPLE.com/', true], + ['https://example.com', 'https://example.com/maps/page', true], + ['https://example.com/maps', 'https://example.com/maps/page', true], + ['https://example.com/maps', 'https://example.com/other', false], + ['https://example.com', 'https://example.com.evil.org', false], + ['https://example.com', 'https://notexample.com', false], + ['*.example.com', 'https://docs.example.com/x', true], + ['*.example.com', 'https://example.org', false], + ['http://localhost:3000', 'http://localhost:3000/index.html', true], + ['http://localhost:3000', 'http://localhost:4000', false] + ])('%s allows %s: %s', (allowed, site, expected) => { + expect(urlMatches(allowed, site)).toBe(expected); + }); +}); diff --git a/test/utils/serverInstructions.test.ts b/test/utils/serverInstructions.test.ts index 95b503d..8bafe8c 100644 --- a/test/utils/serverInstructions.test.ts +++ b/test/utils/serverInstructions.test.ts @@ -19,6 +19,17 @@ describe('buildServerInstructions', () => { ); }); + // Agents building a site with a map asked the user for a token instead + // of knowing the server could provide one (#281). + it('points page building at list_public_tokens_tool when it is enabled', () => { + expect(buildServerInstructions(getAllTools().map((t) => t.name))).toContain( + 'list_public_tokens_tool' + ); + expect(buildServerInstructions(['render_map_tool'])).not.toContain( + 'list_public_tokens_tool' + ); + }); + it('omits the static image rule when that tool is disabled', () => { const text = buildServerInstructions(['render_map_tool', 'isochrone_tool']); expect(text).toContain('use render_map_tool'); From 18207412f00ce9f3d3f9d5ebaae977451fb7b5e0 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 26 Oct 2026 12:50:50 -0400 Subject: [PATCH 2/4] docs: add PR number to CHANGELOG Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e4d90d..ab1c307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ ### New Features -- **`list_public_tokens_tool`: a public token for pages built outside the chat (#PR, part of #281).** An agent building a site or app with a Mapbox map had to ask the user for a token. The tool lists the account's public (`pk.`) tokens with their names, default flag, URL restrictions and scopes, and recommends one. +- **`list_public_tokens_tool`: a public token for pages built outside the chat (#304, part of #281).** An agent building a site or app with a Mapbox map had to ask the user for a token. The tool lists the account's public (`pk.`) tokens with their names, default flag, URL restrictions and scopes, and recommends one. - **Safer token first:** pass `site_url` and it recommends a token restricted to that URL when one exists. Otherwise it recommends the default public token and says a published site should use a URL-restricted one, with the link to create it. - **Public tokens only:** it asks the Tokens API for `pk` tokens and drops anything else, so secret and temporary tokens never come back. It can't create tokens (that needs `tokens:write`; DevKit's `create_token_tool` does it). - **No new scope:** it needs `tokens:read`, which the in-chat map already uses to find the default public token. With a `pk.` access token, or when listing fails and `MAPBOX_PUBLIC_TOKEN` is set, it returns that token. From 6564c757882ceefe086d0b3e657ba3785388a03d Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 27 Oct 2026 14:13:28 -0400 Subject: [PATCH 3/4] fix: never hand a hosted caller the server's MAPBOX_PUBLIC_TOKEN Only fall back to MAPBOX_PUBLIC_TOKEN when the call runs on the server's own MAPBOX_ACCESS_TOKEN, and never recommend a token restricted to other URLs. From review on #304. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- src/skills/agentSkills.generated.ts | 64 +++++++++++++++++++ .../ListPublicTokensTool.ts | 31 +++++++-- .../ListPublicTokensTool.test.ts | 36 ++++++++++- 5 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 src/skills/agentSkills.generated.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1c307..f73ebcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ - **`list_public_tokens_tool`: a public token for pages built outside the chat (#304, part of #281).** An agent building a site or app with a Mapbox map had to ask the user for a token. The tool lists the account's public (`pk.`) tokens with their names, default flag, URL restrictions and scopes, and recommends one. - **Safer token first:** pass `site_url` and it recommends a token restricted to that URL when one exists. Otherwise it recommends the default public token and says a published site should use a URL-restricted one, with the link to create it. - **Public tokens only:** it asks the Tokens API for `pk` tokens and drops anything else, so secret and temporary tokens never come back. It can't create tokens (that needs `tokens:write`; DevKit's `create_token_tool` does it). - - **No new scope:** it needs `tokens:read`, which the in-chat map already uses to find the default public token. With a `pk.` access token, or when listing fails and `MAPBOX_PUBLIC_TOKEN` is set, it returns that token. + - **No new scope:** it needs `tokens:read`, which the in-chat map already uses to find the default public token. With a `pk.` access token, or when listing fails on the server's own `MAPBOX_ACCESS_TOKEN` and `MAPBOX_PUBLIC_TOKEN` is set, it returns that token. A caller with its own bearer token never gets the server's `MAPBOX_PUBLIC_TOKEN`, and a token restricted to other URLs is never recommended. - **Server instructions** now tell the model to use it instead of asking the user for a token when it builds a page outside the chat. - **`feedback_tool`: report wrong map, place or routing data to Mapbox (#291).** When someone says a place has closed, a road is closed, or a route took an illegal turn, the agent can send it to the [Feedback API](https://docs.mapbox.com/api/feedback/) (`POST /user-feedback/v1/feedback`, Public Preview) with the text, a category and the location, usually taken from an earlier search or directions result. It returns the report's id and status (`received`). - **Confirm first:** the report goes to Mapbox under the user's account, so the description tells the model to show the user what it will send and get a yes. Clients that support elicitation also get a confirmation prompt from the tool itself. Declining sends nothing, and so does a failed prompt. diff --git a/README.md b/README.md index 655123c..35ddc81 100644 --- a/README.md +++ b/README.md @@ -541,7 +541,7 @@ Pass `site_url` (where the page will run) and the tool recommends a token restri It never returns secret or temporary tokens, and it can't create tokens (that needs `tokens:write`; the [DevKit server](https://github.com/mapbox/mcp-devkit-server)'s `create_token_tool` does it). -**Requirements**: listing needs the `tokens:read` scope, the same one the in-chat map already uses to find the default public token. With a `pk.` access token, or when listing fails and `MAPBOX_PUBLIC_TOKEN` is set, it returns that token instead. To leave the tool out, start the server with `--disable-tools list_public_tokens_tool`. +**Requirements**: listing needs the `tokens:read` scope, the same one the in-chat map already uses to find the default public token. With a `pk.` access token, or when listing fails on the server's own `MAPBOX_ACCESS_TOKEN` and `MAPBOX_PUBLIC_TOKEN` is set, it returns that token instead. A caller with its own bearer token (the hosted server) never gets `MAPBOX_PUBLIC_TOKEN`, and a token restricted to other URLs is never recommended. To leave the tool out, start the server with `--disable-tools list_public_tokens_tool`. **Example Usage**: "Build me a single-page site that shows our three store locations on a Mapbox map." diff --git a/src/skills/agentSkills.generated.ts b/src/skills/agentSkills.generated.ts new file mode 100644 index 0000000..59244a2 --- /dev/null +++ b/src/skills/agentSkills.generated.ts @@ -0,0 +1,64 @@ +// Generated by scripts/fetch-agent-skills.mjs from mapbox/mapbox-agent-skills@b993fef50efedf4cb1ed51d18b412cc7368a5403. +// Do not edit: change the skills there and the pin in agent-skills.json. +// agent-skills.json sha256: 622abca7b10a6e52078c6de201495a7c84d50b07b912cf4460fc78e7f47d1de6 + +import type { Skill } from './Skill.js'; + +export const AGENT_SKILLS_SOURCE = + 'mapbox/mapbox-agent-skills@b993fef50efedf4cb1ed51d18b412cc7368a5403'; + +export const AGENT_SKILLS: readonly Skill[] = [ + { + name: 'mapbox-map-display', + tools: ['render_map_tool'], + files: { + 'SKILL.md': + '---\nname: mapbox-map-display\ndescription: Use this skill when the user wants to see Mapbox MCP Server tool results on a map. Covers passing refs to render_map_tool, combining several results on one map, hand-placed markers, restyling the base map, and when a static image is the right call instead.\n---\n\n# Showing results on a Mapbox map\n\nUse `render_map_tool` whenever the user wants to see something on a map. It\nrenders a live, interactive Mapbox GL JS map inline in the conversation.\n\n## Pass refs, not geometry\n\nEvery geo tool (search, directions, isochrone, elevation and so on) returns\n`structuredContent.mapboxRender.ref`. Pass it to `render_map_tool` unchanged:\n\n```json\n{ "payload_refs": [""] }\n```\n\n- Never write, edit or shorten a ref. Copy it exactly.\n- Don\'t copy coordinates out of a tool result into `layers` or `markers`\n when a ref exists. The ref keeps thousands of coordinates out of the\n conversation and the map fetches the data itself.\n- The camera fits all the data on its own. Only set `camera` when the user\n asks for a particular view.\n\n## Several results on one map\n\nPut every ref in the same call to answer one question on one map, for example\na route plus places along it, or a drive-time area plus the places inside it:\n\n```json\n{ "payload_refs": ["", ""], "summary": "Coffee within a 10 min walk" }\n```\n\n`summary` is the short chip in the map\'s top-left corner. Keep it under about\n40 characters.\n\n## Hand-placed markers\n\nUse `markers` only for points no tool returned a ref for, or for a filtered\nsubset of a result (see the mapbox-reachability-analysis skill).\n\n- For a place from a search tool, set `id` to its `mapbox_id` and `name` to\n its name (`category` and `distanceMeters` are optional). The map then lists\n it in its results panel with a photo and popularity when Mapbox has them,\n the same as a search ref would.\n- `style` is `pin` (default), `numbered` (with `label`, e.g. visit order),\n `start` or `end`.\n- Coordinates are `[longitude, latitude]`.\n\n## Restyling the base map\n\n- `baseMapConfig: { "lightPreset": "night" }` (also `dawn`, `dusk`) for\n lighting.\n- `baseStyle: "standard-satellite"` for imagery.\n- Only restyle when the user asks, or when it clearly helps (night lighting for\n a question about the evening).\n\n## Static images\n\nUse `static_map_image_tool` only when the user explicitly asks for an image\nfile (PNG or JPEG), for example to paste into a document or email. It is never\nthe default way to show a map.\n' + } + }, + { + name: 'mapbox-reachability-analysis', + tools: [ + 'isochrone_tool', + 'bbox_tool', + 'category_search_tool', + 'points_within_polygon_tool', + 'render_map_tool' + ], + files: { + 'SKILL.md': + '---\nname: mapbox-reachability-analysis\ndescription: Use this skill to find places of a kind within a travel time or distance of a location with the Mapbox MCP Server (coffee within a 10 minute walk, hospitals within 20 minutes by car) and map them with the reachable area. Also fits site selection and coverage questions.\n---\n\n# Places within reach\n\nAnswers questions like "what\'s within a 10 minute walk of my hotel?" with the\narea a person can actually reach, not a straight-line radius. Rivers, highways\nand one-way streets make the two very different.\n\n## Steps\n\n1. **Get the origin.** If you only have an address or a name, look it up with\n `search_and_geocode_tool` first.\n\n2. **Compute the reachable area** with `isochrone_tool`:\n - `profile`: `mapbox/walking`, `mapbox/cycling`, `mapbox/driving` or\n `mapbox/driving-traffic`.\n - `contours_minutes` (1 to 60) or `contours_meters`. One contour is\n usually enough.\n - `polygons: true`, so the result is an area you can test points against.\n - `generalize` keeps the polygon small: about 100 meters for a short walk,\n 2000 for a drive.\n\n Keep the result\'s `mapboxRender.ref` for the map. The polygon is the\n feature\'s `geometry.coordinates`. If the result is too large to include\n the polygon, call it again with a larger `generalize`.\n\n3. **Bound the search.** Pass the polygon to `bbox_tool`. It returns\n `[minLon, minLat, maxLon, maxLat]`.\n\n4. **Search inside the box** with `category_search_tool`:\n - `category`: a canonical category such as `cafe`, `restaurant`,\n `pharmacy` or `hospital` (the `mapbox://categories` resource has the\n full list).\n - `bbox`: `{ minLongitude, minLatitude, maxLongitude, maxLatitude }`\n from step 3.\n - `proximity`: the origin, so the closest places come first.\n - `limit: 25`, the most it returns.\n\n5. **Drop places outside the area.** The box is a rectangle and the area isn\'t,\n so some results fall in its corners. Pass every result\'s coordinates with\n the polygon to `points_within_polygon_tool` and keep only the points it\n returns.\n\n6. **Show it.** Call `render_map_tool` with the isochrone ref in\n `payload_refs` and one marker per place you kept, with `id` set to its\n Mapbox ID and `name` to its name. Don\'t pass the search ref: it would also\n show the places you dropped.\n\n## Answering\n\n- Say how many places are within reach, then list them nearest first.\n- Name the mode and time ("a 10 minute walk"), since the area depends on both.\n- If all 25 results are inside the area, there are probably more. Say so, and\n offer a narrower category or a shorter time.\n- If nothing is inside, say that plainly and offer a longer time or another\n mode of travel.\n- For exact travel times to each place, call `matrix_tool` with the origin\n first in `coordinates` and `sources: "0"`. One call covers every place.\n' + } + }, + { + name: 'mapbox-location-grounding', + tools: [ + 'reverse_geocode_tool', + 'category_search_tool', + 'directions_tool', + 'isochrone_tool' + ], + files: { + 'SKILL.md': + '---\nname: mapbox-location-grounding\ndescription: Compose Mapbox MCP tools to produce grounded, cited location-aware responses from live data instead of training data\n---\n\n# Mapbox Location Grounding Skill\n\nTeaches AI assistants how to ground location-aware responses in live Mapbox data by composing MCP tools into a structured, cited answer. Use this instead of relying on training data for place names, POIs, ratings, or travel times — which are stale and prone to hallucination.\n\n## When to Use Grounding\n\nGround responses when the user asks about:\n\n- "What\'s near [location]?" or "What\'s around [coordinate]?"\n- "Describe this neighborhood / area"\n- "Find [category] within walking/driving distance"\n- "What can I do near [address]?"\n- "How long does it take to get from A to B?"\n- "What\'s within a 10-minute walk of here?"\n- "How far is it between these locations?"\n- Real estate, travel, mobility, or local discovery use cases\n- Any question where place accuracy, recency, or travel time matters\n\n**Never answer location questions from training data alone.** Always retrieve live data.\n\n## Grounding Tool Composition\n\n### Preferred: single tool call\n\nIf `ground_location_tool` is available, use it — it handles reverse geocoding, POI search, place details enrichment and isochrone in one call:\n\n```\nground_location_tool(\n longitude, latitude,\n query: "restaurant", // optional — category or subcategory of nearby places to find\n profile: "mapbox/walking", // optional — travel profile for isochrone\n contours_minutes: [5, 10, 15]\n)\n```\n\nReturns:\n\n- Neighborhood/place name from reverse geocoding\n- Nearby POIs with distances, ratings, price levels, and popularity (when available)\n- Travel-time reachability from isochrone\n- A `mapboxRender.ref`: pass it to `render_map_tool` to show the results on a map\n- Citations for all data sources\n\nDo not call `reverse_geocode_tool`, `category_search_tool`, `place_details_tool`, or `isochrone_tool` separately — they are already composed inside this tool.\n\n### Query parameter\n\nThe `query` parameter accepts **category or subcategory terms** — not attribute preferences:\n\n- Supported: `"restaurant"`, `"coffee"`, `"park"`, `"Italian restaurant"`, `"EV charging station"`\n- Not supported: `"family-friendly"`, `"fast charging"`, `"outdoor seating"` — these are not filterable attributes in Mapbox data\n\nTo help users find places matching a preference (e.g. "family-friendly"), search by category (`"restaurant"`) and use the returned rating and price data to inform the recommendation.\n\n### Fallback: manual composition\n\nIf `ground_location_tool` is not available, build the grounded response by composing these tools in order:\n\n#### Step 1 — Establish place context\n\n```\nreverse_geocode_tool(longitude, latitude, types: "neighborhood,locality,place")\n```\n\nReturns: neighborhood, city, region, country. This is the anchor for the response.\n\n#### Step 2 — Retrieve nearby POIs\n\nFor specific names or brands:\n\n```\nsearch_and_geocode_tool(query, proximity: {longitude, latitude}, limit: 10)\n```\n\nFor generic categories:\n\n```\ncategory_search_tool(category, proximity: {longitude, latitude}, limit: 10)\n```\n\n#### Step 3 — Enrich POIs with ratings and price (optional but high-value)\n\nFor each POI with a `mapbox_id`, call in parallel:\n\n```\nplace_details_tool(mapbox_id, attribute_sets: ["visit"])\n```\n\nReturns: rating, price level, popularity, and opening hours per place.\n\n#### Step 4 — Add travel-time context (optional but high-value)\n\n```\nisochrone_tool(\n coordinates: {longitude, latitude},\n profile: "mapbox/walking", // or "mapbox/driving", "mapbox/cycling", "mapbox/driving-traffic"\n contours_minutes: [5, 10, 15]\n)\n```\n\nReturns a polygon showing what\'s reachable within each time threshold.\n\n#### Step 5 — Visual grounding (optional)\n\n```\nrender_map_tool(payload_refs: [])\n```\n\nShows the places, routes and areas you found on one interactive map. Pass each result\'s `mapboxRender.ref` unchanged. Use `static_map_image_tool` only when the user explicitly asks for an image file.\n\n## Grounded Response Structure\n\nAlways structure grounded responses with explicit citations:\n\n```\nPlace: [neighborhood, city from reverse_geocode]\nNearby [category]: [list from search/category tool, with names, ratings, prices, and distances]\nTravel context: [X min walk / Y min drive from isochrone]\nSources: Mapbox Search, Mapbox Directions (live data)\n```\n\nExample grounded response:\n\n> **SoMa, San Francisco, CA** (live Mapbox data)\n>\n> Restaurants within walking distance:\n>\n> - Bix Restaurant $$ ★8.4 — 56 Gold St (180m)\n> - The Bird $ ★7.9 — 115 New Montgomery St (320m)\n> - Oren\'s Hummus $$ ★8.1 — 131 Townsend St (510m)\n>\n> Reachable by walking: 5 min, 10 min, 15 min\n>\n> _Sources: Mapbox Geocoding API, Mapbox Search API, Mapbox Place Details API, Mapbox Isochrone API, Mapbox Static Images API_\n\n## Routing Grounding\n\nFor questions about travel between two locations, use `directions_tool`. For questions about reachable area, use `isochrone_tool`. For comparing travel times across multiple locations, use `matrix_tool`.\n\n### Point-to-point travel time and distance\n\n> "How long does it take to drive from Pike Place Market to Capitol Hill?"\n> "What\'s the fastest route from the hotel to the airport?"\n\n```\ndirections_tool(\n waypoints: [{longitude, latitude}, {longitude, latitude}],\n profile: "mapbox/driving-traffic" // live traffic; or "mapbox/walking", "mapbox/cycling", "mapbox/driving"\n)\n```\n\nReturns: duration (with live traffic), distance, turn-by-turn steps, and an encoded polyline for map rendering. Supports up to 25 waypoints for multi-stop routes.\n\n### Area reachability ("within X minutes")\n\n> "What\'s reachable within a 10-minute walk?"\n> "Show me what I can reach in 15 minutes by car from here"\n\n```\nisochrone_tool(\n coordinates: {longitude, latitude},\n profile: "mapbox/walking",\n contours_minutes: [5, 10, 15]\n)\n```\n\nReturns a polygon showing the area reachable within each time threshold. Combine with `category_search_tool` or `ground_location_tool` to answer "what restaurants are within a 10-minute walk?" — use the isochrone to define the boundary, then search within it.\n\n### Multi-location travel time matrix\n\n> "Which of these three offices is closest to my location?"\n> "What are travel times from this warehouse to each of our delivery zones?"\n> "Compare commute times to multiple locations"\n\nUse `matrix_tool` — **do NOT call `directions_tool` separately for each pair**. Matrix batches all pairs in a single API call.\n\n```\nmatrix_tool(\n sources: [{longitude, latitude}, ...],\n destinations: [{longitude, latitude}, ...],\n profile: "mapbox/driving-traffic"\n)\n```\n\nReturns a matrix of durations and distances between every source/destination pair. Rank results by duration to find the nearest location. One `matrix_tool` call replaces N separate `directions_tool` calls.\n\n### Proximity search with routing context\n\nFor "find [category] within X minutes of [location]":\n\n1. Geocode the origin if needed: `search_and_geocode_tool(address)`\n2. Get the reachable area: `isochrone_tool(coordinates, profile, contours_minutes)`\n3. Search for POIs: `ground_location_tool(longitude, latitude, query: "category")` — the isochrone\n contours tell the user what\'s within each time band\n\nOr more directly: `ground_location_tool` returns both nearby POIs and isochrone reachability in one\ncall, which covers most proximity + routing use cases without additional composition.\n\n### Example routing response\n\n> **Pike Place Market → Capitol Hill, Seattle**\n>\n> By car (with traffic): 12 min, 3.2 km via Pine St\n> By walking: 28 min, 2.1 km\n> By cycling: 14 min, 2.3 km\n>\n> _Source: Mapbox Directions API (live traffic)_\n\n## What Mapbox Grounding Offers vs. Training Data\n\n| | Training Data | Mapbox Grounding |\n| ------------------ | ------------------- | --------------------------- |\n| POI accuracy | Stale, hallucinated | Live, verified |\n| Ratings/price | Often wrong | Live via Place Details |\n| Business hours | Often wrong | Live via Place Details |\n| Travel times | Estimated | Live traffic via Directions |\n| Reachable area | Not available | Isochrone polygons |\n| Multi-stop routing | Not available | Up to 25 waypoints |\n| New places | Missing | Indexed |\n| Map image | None | Inline static map |\n| Citations | None | Tool + API source |\n\n## Important Limitations\n\n- **Attribute filtering** (family-friendly, outdoor seating, fast charging) is not supported by the category search API. Use category terms and let rating/price data inform preference-based recommendations.\n- **POI coverage** varies by region. If search returns few results, say so rather than padding with guesses.\n- **Coordinates required** for proximity search — if the user provides an address, geocode it first with `search_and_geocode_tool` before running category search.\n\n## Anti-Patterns to Avoid\n\n- Answering "what\'s near X?" from training data without calling search tools\n- Estimating travel times without calling `directions_tool` or `isochrone_tool`\n- Hallucinating business names, hours, or ratings\n- Calling `reverse_geocode_tool` + `category_search_tool` + `place_details_tool` separately when `ground_location_tool` is available\n- Using attribute terms like "family-friendly" as the query parameter — use the category instead\n- Returning raw tool output without synthesizing into a readable response\n- Omitting citations — always indicate the response is grounded in live Mapbox data\n' + } + }, + { + name: 'mapbox-search-patterns', + tools: [ + 'search_and_geocode_tool', + 'category_search_tool', + 'reverse_geocode_tool' + ], + files: { + 'references/advanced-params.md': + '# Advanced Parameters Reference\n\n## poi_category Parameter\n\n**search_and_geocode_tool:** Narrow generic searches\n\n```json\n{\n "q": "lunch",\n "poi_category": ["restaurant", "cafe"],\n "proximity": { "longitude": -122.4194, "latitude": 37.7749 }\n}\n```\n\n**When to use:**\n\n- Generic query that could match multiple categories\n- Want to focus search within category\n- User specifies type implicitly\n\n**category_search_tool:** Use `poi_category_exclusions` instead\n\n```json\n{\n "category": "food_and_drink",\n "poi_category_exclusions": ["bar", "nightclub"]\n}\n```\n\n**When to use:**\n\n- Broad category but want to exclude subcategories\n- "Restaurants but not fast food"\n\n## ETA Parameters (search_and_geocode_tool)\n\n**Request estimated time of arrival to results**\n\n**Parameters:**\n\n- `eta_type`: Set to `"navigation"`\n- `navigation_profile`: `"driving"` | `"walking"` | `"cycling"`\n- `origin`: Starting coordinates\n\n**Use when:**\n\n- User asks "how long to get there?"\n- Sorting by travel time, not distance\n- Need route time, not straight-line distance\n\n**Example:**\n\n```json\n{\n "q": "grocery stores",\n "proximity": { "longitude": -122.4194, "latitude": 37.7749 },\n "eta_type": "navigation",\n "navigation_profile": "driving",\n "origin": { "longitude": -122.4194, "latitude": 37.7749 }\n}\n```\n\n**Returns:** Results with `eta` (travel time in seconds)\n\n**Warning:** Requires routing calculation per result (counts toward API quota)\n\n**When NOT to use:**\n\n- Just need straight-line distance (use distance_tool offline after search)\n- Budget-conscious (adds API cost)\n\n## format Parameter (category_search_tool)\n\n**Choose output format:**\n\n| Format | Returns | Use When |\n| -------------------------- | ---------------------- | ----------------------------- |\n| `formatted_text` (default) | Human-readable text | Displaying to user directly |\n| `json_string` | GeoJSON as JSON string | Need to parse/process results |\n\n**Example:**\n\n**formatted_text:**\n\n```\n1. Blue Bottle Coffee\n Address: 66 Mint St, San Francisco, CA\n Coordinates: 37.7825, -122.4052\n Type: poi\n```\n\n**json_string:**\n\n```json\n{\n "type": "FeatureCollection",\n "features": [{\n "type": "Feature",\n "geometry": {"type": "Point", "coordinates": [-122.4052, 37.7825]},\n "properties": {"name": "Blue Bottle Coffee", ...}\n }]\n}\n```\n\n**Decision:**\n\n- Showing list to user -> `formatted_text`\n- Plotting on map -> `json_string` (parse and use coordinates)\n- Further processing -> `json_string`\n\n## language Parameter\n\n**ISO language codes** (e.g., "en", "es", "fr", "de", "ja", "zh")\n\n**Use when:**\n\n- Building multilingual app\n- User\'s language preference known\n- Need localized names\n\n**Example:**\n\n```json\n{\n "q": "東京タワー",\n "language": "ja"\n}\n// Returns results in Japanese\n```\n\n**Default:** English (if not specified)\n\n**Tip:** Match user\'s locale for best experience\n', + 'references/optimization-combining.md': + '# Performance, Combining Tools, and Troubleshooting\n\n## Performance Optimization\n\n### Minimize API Calls\n\n**Pattern: Geocode once, reuse coordinates**\n\n```\n// GOOD\n1. User enters "Seattle"\n2. Geocode "Seattle" → (lng, lat)\n3. Use those coordinates for multiple category searches\n4. Cache coordinates for session\n\n// BAD\n1. Geocode "Seattle" for coffee search\n2. Geocode "Seattle" again for restaurant search\n3. Geocode "Seattle" again for hotel search\n```\n\n### Set Appropriate Limits\n\n| UI Context | Recommended Limit |\n| --------------------- | ----------------- |\n| Autocomplete dropdown | 5 |\n| List view | 10 |\n| Map view | 25 |\n| Export/download | 25 (or paginate) |\n\n### Use Offline Tools When Possible\n\n**After getting search results:**\n\n```\n1. category_search_tool → Get POIs\n2. distance_tool (offline) → Calculate distances\n3. bearing_tool (offline) → Get directions\n```\n\n**Why:** Search once (API), then use offline tools for calculations (free, fast)\n\n## Combining Search with Other Tools\n\n### Search → Distance Calculation\n\n```\n1. category_search_tool({category: "hospital", proximity: user_location})\n → Returns 10 hospitals with coordinates\n2. distance_tool(user_location, each_hospital)\n → Calculate exact distances offline\n3. Sort by distance\n```\n\n### Search → Directions\n\n```\n1. search_and_geocode_tool({q: "Space Needle"})\n → Get destination coordinates\n2. directions_tool({from: user_location, to: space_needle_coords})\n → Get turn-by-turn directions\n```\n\n### Search → Isochrone → Containment Check\n\n```\n1. search_and_geocode_tool({q: "warehouse"})\n → Get warehouse coordinates\n2. isochrone_tool({coordinates: warehouse, time: 30, profile: "driving"})\n → Get 30-minute delivery zone polygon\n3. points_within_polygon_tool(customer_coordinates, delivery_zone)\n → Check if customer is in delivery zone\n```\n\n### Search → Map Visualization\n\n```\n1. category_search_tool({category: "restaurant", limit: 10})\n → Get restaurants, plus a mapboxRender.ref\n2. render_map_tool({payload_refs: []})\n → Interactive map showing all restaurants\n```\n\nUse `static_map_image_tool` instead only when the user explicitly asks for an image file.\n\n## Handling No Results\n\n### If category_search returns no results:\n\n**Possible reasons:**\n\n1. Invalid category → Use `resource_reader_tool` with `mapbox://categories` to see valid categories\n2. Too restrictive bbox → Expand area or use proximity instead\n3. No POIs in area → Try broader category or remove spatial filters\n4. Wrong country filter → Check country codes\n\n**Example recovery:**\n\n```\n1. category_search_tool({category: "taco"}) → No results\n2. Check: Is "taco" a valid category?\n → Use category_list_tool → See "mexican_restaurant" is valid\n3. Retry: category_search_tool({category: "mexican_restaurant"}) → Success\n```\n\n### If search_and_geocode returns no results:\n\n**Possible reasons:**\n\n1. Typo in query → Retry with `auto_complete: true`\n2. Too specific → Broaden search (remove address numbers, try nearby city)\n3. Wrong types filter → Remove or expand types\n4. Not a recognized place → Check spelling, try alternative names\n\n## Category List Resource\n\n**Get valid categories:** Use `resource_reader_tool` or `category_list_tool`\n\n```\nresource_reader_tool({uri: "mapbox://categories"})\n```\n\n**Returns:** All valid category IDs (e.g., "restaurant", "hotel", "gas_station")\n\n**When to use:**\n\n- User enters free-text category\n- Need to map user terms to Mapbox categories\n- Validating category before search\n\n**Example mapping:**\n\n- User: "places to eat" → Category: "restaurant"\n- User: "gas" → Category: "gas_station"\n- User: "lodging" → Category: "hotel"\n\n## Integration with Other Skills\n\n**Works with:**\n\n- **mapbox-geospatial-operations**: After search, use offline distance/bearing calculations\n- **mapbox-web-integration-patterns**: Display search results on map in web app\n- **mapbox-token-security**: Ensure search requests use properly scoped tokens\n\n## Resources\n\n- [Mapbox Search Box API Docs](https://docs.mapbox.com/api/search/search-box/)\n- [Category Search API](https://docs.mapbox.com/api/search/search-box/#category-search)\n- [Geocoding API](https://docs.mapbox.com/api/search/geocoding/)\n- [Category List Resource](https://docs.mapbox.com/api/search/search-box/#category-list)\n', + 'references/workflows.md': + '# Common Patterns and Workflows\n\n## Pattern 1: "Near Me" Search\n\n**User:** "Find coffee shops near me"\n\n**Optimal approach:**\n\n```\n1. Get user\'s location (from app/browser)\n2. Use category_search_tool:\n - category: "coffee_shop"\n - proximity: user\'s coordinates\n - limit: 10\n```\n\n**Why:** Category tool for generic "coffee shops", proximity for "near me"\n\n## Pattern 2: Branded Chain Lookup\n\n**User:** "Find all Starbucks in Seattle"\n\n**Optimal approach:**\n\n```\n1. Use search_and_geocode_tool:\n - q: "Starbucks"\n - proximity: Seattle coordinates\n - country: ["US"]\n2. Or if need strict boundary:\n - bbox: Seattle city bounds\n```\n\n**Why:** Brand name = search_and_geocode_tool; proximity biases to Seattle\n\n## Pattern 3: Address Geocoding\n\n**User:** "What are the coordinates of 1600 Pennsylvania Ave?"\n\n**Optimal approach:**\n\n```\nUse search_and_geocode_tool:\n- q: "1600 Pennsylvania Ave, Washington DC"\n- types: ["address"] // Focus on addresses\n- country: ["US"] // Narrow to US\n```\n\n**Why:** Specific address with country context for disambiguation\n\n## Pattern 4: Category Search with Area Restriction\n\n**User:** "Show me all hotels in downtown Portland"\n\n**Optimal approach:**\n\n```\n1. Geocode "downtown Portland" → get center point\n2. Define downtown bbox (or use 1-2 mile radius)\n3. Use category_search_tool:\n - category: "hotel"\n - bbox: downtown bounds (or proximity + filter by distance)\n - limit: 25 // Get comprehensive list\n```\n\n**Why:** Category for "hotels", bbox for "in downtown" hard boundary\n\n## Pattern 5: Reverse Geocoding\n\n**User:** "What\'s at these GPS coordinates?"\n\n**Optimal approach:**\n\n```\nUse reverse_geocode_tool:\n- longitude: -122.4194\n- latitude: 37.7749\n- types: ["address"] // Get address (can also use place, locality, postcode, etc.)\n```\n\n**Why:** Coordinates → address is exactly what reverse geocoding does\n\n## Pattern 6: Route-Based Search\n\n**User:** "Find gas stations along my route"\n\n**Optimal approach:**\n\n```\n1. Get route geometry from directions_tool\n2. Create bbox around route (use bbox_tool on the route geometry)\n3. Use category_search_tool:\n - category: "gas_station"\n - bbox: route bounding box\n4. Filter results to those within X meters of route (use distance_tool)\n```\n\n**Why:** Bbox for rough filter, then distance calculation for precision\n\n## Pattern 7: Multilingual POI Search\n\n**User:** "Find ramen shops" (user locale: ja)\n\n**Optimal approach:**\n\n```\nUse category_search_tool:\n- category: "ramen_restaurant" (or "restaurant")\n- language: "ja"\n- proximity: user location\n```\n\n**Why:** Returns Japanese names/addresses for better UX\n', + 'SKILL.md': + '---\nname: mapbox-search-patterns\ndescription: Expert guidance on choosing the right Mapbox search tool and parameters for geocoding, POI search, and location discovery\n---\n\n# Mapbox Search Patterns Skill\n\nExpert guidance for AI assistants on using Mapbox search tools effectively. Covers tool selection, parameter optimization, and best practices for geocoding, POI search, and location discovery.\n\n## Available Search Tools\n\n### 1. search_and_geocode_tool\n\n**Best for:** Specific places, addresses, brands, named locations\n\n**Use when query contains:**\n\n- Specific names: "Starbucks on 5th Avenue", "Empire State Building"\n- Brand names: "McDonald\'s", "Whole Foods"\n- Addresses: "123 Main Street, Seattle", "1 Times Square"\n- Chain stores: "Target"\n- Cities/places: "San Francisco", "Portland"\n\n**Don\'t use for:** Generic categories ("coffee shops", "museums")\n\n### 2. category_search_tool\n\n**Best for:** Generic place types, categories, plural queries\n\n**Use when query contains:**\n\n- Generic types: "coffee shops", "restaurants", "gas stations"\n- Plural forms: "museums", "hotels", "parks"\n- Is-a phrases: "any coffee shop", "all restaurants", "nearby pharmacies"\n- Industry terms: "electric vehicle chargers", "ATMs"\n\n**Don\'t use for:** Specific names or brands\n\n### 3. reverse_geocode_tool\n\n**Best for:** Converting coordinates to addresses, cities, towns, postcodes\n\n**Use when:**\n\n- Have GPS coordinates, need human-readable address\n- Need to identify what\'s at a specific location\n- Converting user location to address\n\n## Tool Selection Decision Matrix\n\n| User Query | Tool | Reasoning |\n| ------------------------------- | ----------------------- | ------------------------ |\n| "Find Starbucks on Main Street" | search_and_geocode_tool | Specific brand name |\n| "Find coffee shops nearby" | category_search_tool | Generic category, plural |\n| "What\'s at 37.7749, -122.4194?" | reverse_geocode_tool | Coordinates to address |\n| "Empire State Building" | search_and_geocode_tool | Specific named POI |\n| "hotels in downtown Seattle" | category_search_tool | Generic type + location |\n| "Target store locations" | search_and_geocode_tool | Brand name (even plural) |\n| "any restaurant near me" | category_search_tool | Generic + "any" phrase |\n| "123 Main St, Boston, MA" | search_and_geocode_tool | Specific address |\n| "electric vehicle chargers" | category_search_tool | Industry category |\n| "McDonald\'s" | search_and_geocode_tool | Brand name |\n\n## Parameter Guidance\n\n### Proximity vs Bbox vs Country\n\n**Three ways to spatially constrain search results:**\n\n#### 1. proximity (STRONGLY RECOMMENDED)\n\n**What it does:** Biases results toward a location, but doesn\'t exclude distant matches\n\n**Use when:**\n\n- User says "near me", "nearby", "close to"\n- Have a reference point but want some flexibility\n- Want results sorted by relevance to a point\n\n**Example:**\n\n```json\n{\n "q": "pizza",\n "proximity": {\n "longitude": -122.4194,\n "latitude": 37.7749\n }\n}\n```\n\n**Why this works:** API returns SF pizza places first, but might include famous NYC pizzerias if highly relevant\n\n**Critical:** Always set proximity when you have a reference location! Without it, results are IP-based or global.\n\n#### 2. bbox (Bounding Box)\n\n**What it does:** Hard constraint - ONLY returns results within the box\n\n**Use when:**\n\n- User specifies an area: "in downtown", "within this neighborhood"\n- Have a defined service area\n- Need to guarantee results are within bounds\n\n**Example:**\n\n```json\n{\n "q": "hotel",\n "bbox": [-122.51, 37.7, -122.35, 37.83] // [minLon, minLat, maxLon, maxLat]\n}\n```\n\n**Why this works:** Guarantees all hotels are within SF\'s downtown area\n\n**Watch out:** Too small = no results; too large = irrelevant results\n\n#### 3. country\n\n**What it does:** Limits results to specific countries\n\n**Use when:**\n\n- User specifies country: "restaurants in France"\n- Building country-specific features\n- Need to respect regional boundaries\n- Or it is otherwise clear they want results within a specific country\n\n**Example:**\n\n```json\n{\n "q": "Paris",\n "country": ["FR"] // ISO 3166 alpha-2 codes\n}\n```\n\n**Why this works:** Finds Paris, France (not Paris, Texas)\n\n**Can combine:** `proximity` + `country` + `bbox` or any combination of the three\n\n### Decision Matrix: Spatial Filters\n\n| Scenario | Use | Why |\n| ---------------------------------- | ----------------------------------- | --------------------------------- |\n| "Find coffee near me" | proximity | Bias toward user location |\n| "Coffee shops in downtown Seattle" | proximity + bbox | Center on downtown, limit to area |\n| "Hotels in France" | country | Hard country boundary |\n| "Best pizza in San Francisco" | proximity + country ["US"] | Bias to SF, limit to US |\n| "Gas stations along this route" | bbox around route | Hard constraint to route corridor |\n| "Restaurants within 5 miles" | proximity (then filter by distance) | Bias nearby, filter results |\n\n### Setting limit Parameter\n\n**category_search_tool only** (1-25, default 10)\n\n| Use Case | Limit | Reasoning |\n| --------------------- | ----- | ----------------------- |\n| Quick suggestions | 5 | Fast, focused results |\n| Standard list | 10 | Default, good balance |\n| Comprehensive search | 25 | Maximum allowed |\n| Map visualization | 25 | Show all nearby options |\n| Dropdown/autocomplete | 5 | Don\'t overwhelm UI |\n\n**Performance tip:** Lower limits = faster responses\n\n### types Parameter (search_and_geocode_tool)\n\n**Filter by feature type:**\n\n| Type | What It Includes | Use When |\n| ---------- | ------------------------------------------ | --------------------------------- |\n| `poi` | Points of interest (businesses, landmarks) | Looking for POIs, not addresses |\n| `address` | Street addresses | Need specific address |\n| `place` | Cities, neighborhoods, regions | Looking for area/region |\n| `street` | Street names without numbers | Need street, not specific address |\n| `postcode` | Postal codes | Searching by ZIP/postal code |\n| `district` | Districts, neighborhoods | Area-based search |\n| `locality` | Towns, villages | Municipality search |\n| `country` | Country names | Country-level search |\n\n**Example combinations:**\n\n```json\n// Only POIs and addresses, no cities\n{"q": "Paris", "types": ["poi", "address"]}\n// Returns Paris Hotel, Paris Street, not Paris, France\n\n// Only places (cities)\n{"q": "Paris", "types": ["place"]}\n// Returns Paris, France; Paris, Texas; etc.\n```\n\n**Default behavior:** All types included (usually what you want)\n\n### auto_complete Parameter (search_and_geocode_tool)\n\n**What it does:** Enables partial/fuzzy matching\n\n| Setting | Behavior | Use When |\n| ----------------- | ---------------------------- | ----------------------------- |\n| `true` | Matches partial words, typos | User typing in real-time |\n| `false` (default) | Exact matching | Final query, not autocomplete |\n\n**Example:**\n\n\n\n```json\n// User types "starb"\n{ "q": "starb", "auto_complete": true }\n// Returns: Starbucks, Starboard Tavern, etc.\n```\n\n**Use for:**\n\n- Search-as-you-type interfaces\n- Handling typos ("mcdonalds" -> McDonald\'s)\n\n- Incomplete queries\n\n**Don\'t use for:**\n\n- Final/submitted queries (less precise)\n- When you need exact matches\n\n## Anti-Patterns to Avoid\n\n### Don\'t: Use category_search for brands\n\n```javascript\n// BAD\ncategory_search_tool({ category: \'starbucks\' });\n// "starbucks" is not a category, returns error\n\n// GOOD\nsearch_and_geocode_tool({ q: \'Starbucks\' });\n```\n\n### Don\'t: Use search_and_geocode for generic categories\n\n```javascript\n// BAD\nsearch_and_geocode_tool({ q: \'coffee shops\' });\n// Less precise, may return unrelated results\n\n// GOOD\ncategory_search_tool({ category: \'coffee_shop\' });\n```\n\n### Don\'t: Forget proximity for local searches\n\n```javascript\n// BAD - Results may be anywhere globally\ncategory_search_tool({ category: \'restaurant\' });\n\n// GOOD - Biased to user location\ncategory_search_tool({\n category: \'restaurant\',\n proximity: { longitude: -122.4194, latitude: 37.7749 }\n});\n```\n\n### Don\'t: Geocode ambiguous place names without proximity (REST too)\n\nThis applies to Mapbox Geocoding API v5 / Search Box in browser apps — not only MCP tools.\n\n```javascript\n// BAD — limit=1 without proximity can resolve "Lincoln Memorial" to Illinois\nfetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json?access_token=${token}&limit=1`);\n\n// GOOD — bias to map center (and optional bbox)\nfetch(\n `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json` +\n `?access_token=${token}&proximity=-77.0369,38.9072&bbox=-77.15,38.79,-76.90,38.99&limit=1`\n);\n```\n\nAlso debounce search inputs (`clearTimeout` + `setTimeout`) so every keystroke does not fire a geocode.\n\n### Don\'t: Use bbox when you mean proximity\n\n```javascript\n// BAD - Hard boundary may exclude good nearby results\nsearch_and_geocode_tool({\n q: \'pizza\',\n bbox: [-122.42, 37.77, -122.41, 37.78] // Tiny box\n});\n\n// GOOD - Bias toward point, but flexible\nsearch_and_geocode_tool({\n q: \'pizza\',\n proximity: { longitude: -122.4194, latitude: 37.7749 }\n});\n```\n\n### Don\'t: Request ETA unnecessarily\n\n```javascript\n// BAD - Costs API quota for routing calculations\nsearch_and_geocode_tool({\n q: \'museums\',\n eta_type: \'navigation\',\n navigation_profile: \'driving\'\n});\n// User didn\'t ask for travel time!\n\n// GOOD - Only add ETA when needed\nsearch_and_geocode_tool({ q: \'museums\' });\n// If user asks "how long to get there?", then add ETA\n```\n\n### Don\'t: Set limit too high for UI display\n\n```javascript\n// BAD - Overwhelming for simple dropdown\ncategory_search_tool({\n category: \'restaurant\',\n limit: 25\n});\n// Returns 25 restaurants for a 5-item dropdown\n\n// GOOD - Match UI needs\ncategory_search_tool({\n category: \'restaurant\',\n limit: 5\n});\n```\n\n## Quick Reference\n\n### Tool Selection Flowchart\n\n```\nUser query contains...\n\n-> Specific name/brand (Starbucks, Empire State Building)\n -> search_and_geocode_tool\n\n-> Generic category/plural (coffee shops, museums, any restaurant)\n -> category_search_tool\n\n-> Coordinates -> Address\n -> reverse_geocode_tool\n\n-> Address -> Coordinates\n -> search_and_geocode_tool with types: ["address"]\n```\n\n### Essential Parameters Checklist\n\n**For local searches, ALWAYS set:**\n\n- `proximity` (or bbox if strict boundary needed)\n\n**For category searches, consider:**\n\n- `limit` (match UI needs)\n- `format` (json_string if plotting on map)\n\n**For disambiguation, use:**\n\n- `country` (when geographic context matters)\n- `types` (when feature type matters)\n\n**For travel-time ranking:**\n\n- `eta_type`, `navigation_profile`, `origin` (costs API quota)\n\n## Common Mistakes\n\n1. **Forgetting proximity** -> Results are global/IP-based (or wrong state for ambiguous memorial/park names)\n2. **Using wrong tool** -> category_search for "Starbucks" (use search_and_geocode)\n3. **Invalid category** -> Check category_list first\n4. **Bbox too small** -> No results; use proximity instead\n5. **Requesting ETA unnecessarily** -> Adds API cost\n6. **Limit too high for UI** -> Overwhelming user\n7. **Not filtering types** -> Get cities when you want POIs\n8. **No debounce on typeahead** -> Quota burn and racy UI\n\n## Reference Files\n\nLoad these for deeper guidance on specific topics:\n\n- **`references/advanced-params.md`** — poi_category, ETA, format, and language parameters\n- **`references/workflows.md`** — Common patterns: Near Me, Branded, Geocoding, Category+Area, Reverse, Route-Based, Multilingual\n- **`references/optimization-combining.md`** — Performance optimization, combining tools, handling no results, category list resource\n' + } + } +]; diff --git a/src/tools/list-public-tokens-tool/ListPublicTokensTool.ts b/src/tools/list-public-tokens-tool/ListPublicTokensTool.ts index 90b670b..4cde8cc 100644 --- a/src/tools/list-public-tokens-tool/ListPublicTokensTool.ts +++ b/src/tools/list-public-tokens-tool/ListPublicTokensTool.ts @@ -76,7 +76,10 @@ function pickToken( }; } } - const fallback = tokens.find((t) => t.default) ?? tokens[0]; + // Never fall back to a token restricted to some other site: it wouldn't + // load there anyway. + const unrestricted = tokens.filter((t) => t.allowedUrls.length === 0); + const fallback = unrestricted.find((t) => t.default) ?? unrestricted[0]; if (!fallback) return undefined; const which = fallback.default ? "The account's default public token" @@ -143,9 +146,18 @@ export class ListPublicTokensTool extends MapboxApiBasedTool< }); } + // MAPBOX_PUBLIC_TOKEN belongs to whoever runs the server. Only hand it out + // when the caller is running on the server's own access token too (a + // local install); a hosted caller with its own token must never get it. + const envFallback = + accessToken === MapboxApiBasedTool.mapboxAccessToken + ? process.env.MAPBOX_PUBLIC_TOKEN + : undefined; + const username = getUserNameFromToken(accessToken); if (!username) { - return this.envFallbackOr( + return this.fallbackOr( + envFallback, "Couldn't read the account name from the access token." ); } @@ -163,7 +175,8 @@ export class ListPublicTokensTool extends MapboxApiBasedTool< response.status === 401 || response.status === 403 ? ' Listing tokens needs an access token with the tokens:read scope.' : ''; - return this.envFallbackOr( + return this.fallbackOr( + envFallback, `Listing tokens failed: ${await this.getErrorMessage(response)}.${scopeHint}` ); } @@ -196,9 +209,11 @@ export class ListPublicTokensTool extends MapboxApiBasedTool< }); } - /** MAPBOX_PUBLIC_TOKEN when it's set, otherwise the error. */ - private envFallbackOr(message: string): CallToolResult { - const envToken = process.env.MAPBOX_PUBLIC_TOKEN; + /** The server's MAPBOX_PUBLIC_TOKEN when allowed and set, otherwise the error. */ + private fallbackOr( + envToken: string | undefined, + message: string + ): CallToolResult { if (envToken?.startsWith('pk.')) { return this.result({ tokens: [], @@ -227,7 +242,9 @@ export class ListPublicTokensTool extends MapboxApiBasedTool< : undefined, output.recommended ? `Recommended: ${output.recommended.token}\n${output.recommended.reason}` - : 'This account has no public tokens.', + : output.tokens.length > 0 + ? 'Every public token on this account is restricted to other URLs, so none is recommended.' + : 'This account has no public tokens.', output.guidance ] .filter(Boolean) diff --git a/test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts b/test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts index 406aafa..e7c5d0a 100644 --- a/test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts +++ b/test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts @@ -168,7 +168,8 @@ describe('ListPublicTokensTool', () => { expect(textOf(result)).toContain('tokens:read'); }); - it('falls back to MAPBOX_PUBLIC_TOKEN when listing fails', async () => { + it('falls back to MAPBOX_PUBLIC_TOKEN when listing fails on the server token', async () => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', secretToken); vi.stubEnv('MAPBOX_PUBLIC_TOKEN', 'pk.env.sig'); const { httpRequest } = setup( {}, @@ -186,6 +187,39 @@ describe('ListPublicTokensTool', () => { expect(output.recommended?.reason).toContain('tokens:read'); }); + it("never hands a hosted caller the server's MAPBOX_PUBLIC_TOKEN", async () => { + vi.stubEnv('MAPBOX_PUBLIC_TOKEN', 'pk.env.sig'); + const { httpRequest } = setup( + {}, + { + ok: false, + status: 403, + statusText: 'Forbidden', + text: async () => '{}' + } + ); + // The caller's bearer token differs from the server's MAPBOX_ACCESS_TOKEN. + const result = await run(httpRequest); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain('pk.env.sig'); + }); + + it("doesn't recommend a token restricted to another site", async () => { + const { httpRequest } = setup([restrictedPk]); + const result = await run(httpRequest, { site_url: 'https://other.org' }); + const output = structuredOf(result); + expect(output.tokens).toHaveLength(1); + expect(output.recommended).toBe(undefined); + expect(textOf(result)).toContain('restricted to other URLs'); + }); + + it('falls back to an unrestricted token when there is no default', async () => { + const plainPk = { ...defaultPk, token: 'pk.plain.sig', default: false }; + const { httpRequest } = setup([restrictedPk, plainPk]); + const output = structuredOf(await run(httpRequest)); + expect(output.recommended?.token).toBe('pk.plain.sig'); + }); + it('says so when the account has no public tokens', async () => { const { httpRequest } = setup([]); const result = await run(httpRequest); From 6e1448a95b7ddea57afd962bc6845f9b61f01a53 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 27 Oct 2026 14:13:39 -0400 Subject: [PATCH 4/4] chore: drop generated skills file committed by mistake Co-Authored-By: Claude Opus 5.5 --- src/skills/agentSkills.generated.ts | 64 ----------------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/skills/agentSkills.generated.ts diff --git a/src/skills/agentSkills.generated.ts b/src/skills/agentSkills.generated.ts deleted file mode 100644 index 59244a2..0000000 --- a/src/skills/agentSkills.generated.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Generated by scripts/fetch-agent-skills.mjs from mapbox/mapbox-agent-skills@b993fef50efedf4cb1ed51d18b412cc7368a5403. -// Do not edit: change the skills there and the pin in agent-skills.json. -// agent-skills.json sha256: 622abca7b10a6e52078c6de201495a7c84d50b07b912cf4460fc78e7f47d1de6 - -import type { Skill } from './Skill.js'; - -export const AGENT_SKILLS_SOURCE = - 'mapbox/mapbox-agent-skills@b993fef50efedf4cb1ed51d18b412cc7368a5403'; - -export const AGENT_SKILLS: readonly Skill[] = [ - { - name: 'mapbox-map-display', - tools: ['render_map_tool'], - files: { - 'SKILL.md': - '---\nname: mapbox-map-display\ndescription: Use this skill when the user wants to see Mapbox MCP Server tool results on a map. Covers passing refs to render_map_tool, combining several results on one map, hand-placed markers, restyling the base map, and when a static image is the right call instead.\n---\n\n# Showing results on a Mapbox map\n\nUse `render_map_tool` whenever the user wants to see something on a map. It\nrenders a live, interactive Mapbox GL JS map inline in the conversation.\n\n## Pass refs, not geometry\n\nEvery geo tool (search, directions, isochrone, elevation and so on) returns\n`structuredContent.mapboxRender.ref`. Pass it to `render_map_tool` unchanged:\n\n```json\n{ "payload_refs": [""] }\n```\n\n- Never write, edit or shorten a ref. Copy it exactly.\n- Don\'t copy coordinates out of a tool result into `layers` or `markers`\n when a ref exists. The ref keeps thousands of coordinates out of the\n conversation and the map fetches the data itself.\n- The camera fits all the data on its own. Only set `camera` when the user\n asks for a particular view.\n\n## Several results on one map\n\nPut every ref in the same call to answer one question on one map, for example\na route plus places along it, or a drive-time area plus the places inside it:\n\n```json\n{ "payload_refs": ["", ""], "summary": "Coffee within a 10 min walk" }\n```\n\n`summary` is the short chip in the map\'s top-left corner. Keep it under about\n40 characters.\n\n## Hand-placed markers\n\nUse `markers` only for points no tool returned a ref for, or for a filtered\nsubset of a result (see the mapbox-reachability-analysis skill).\n\n- For a place from a search tool, set `id` to its `mapbox_id` and `name` to\n its name (`category` and `distanceMeters` are optional). The map then lists\n it in its results panel with a photo and popularity when Mapbox has them,\n the same as a search ref would.\n- `style` is `pin` (default), `numbered` (with `label`, e.g. visit order),\n `start` or `end`.\n- Coordinates are `[longitude, latitude]`.\n\n## Restyling the base map\n\n- `baseMapConfig: { "lightPreset": "night" }` (also `dawn`, `dusk`) for\n lighting.\n- `baseStyle: "standard-satellite"` for imagery.\n- Only restyle when the user asks, or when it clearly helps (night lighting for\n a question about the evening).\n\n## Static images\n\nUse `static_map_image_tool` only when the user explicitly asks for an image\nfile (PNG or JPEG), for example to paste into a document or email. It is never\nthe default way to show a map.\n' - } - }, - { - name: 'mapbox-reachability-analysis', - tools: [ - 'isochrone_tool', - 'bbox_tool', - 'category_search_tool', - 'points_within_polygon_tool', - 'render_map_tool' - ], - files: { - 'SKILL.md': - '---\nname: mapbox-reachability-analysis\ndescription: Use this skill to find places of a kind within a travel time or distance of a location with the Mapbox MCP Server (coffee within a 10 minute walk, hospitals within 20 minutes by car) and map them with the reachable area. Also fits site selection and coverage questions.\n---\n\n# Places within reach\n\nAnswers questions like "what\'s within a 10 minute walk of my hotel?" with the\narea a person can actually reach, not a straight-line radius. Rivers, highways\nand one-way streets make the two very different.\n\n## Steps\n\n1. **Get the origin.** If you only have an address or a name, look it up with\n `search_and_geocode_tool` first.\n\n2. **Compute the reachable area** with `isochrone_tool`:\n - `profile`: `mapbox/walking`, `mapbox/cycling`, `mapbox/driving` or\n `mapbox/driving-traffic`.\n - `contours_minutes` (1 to 60) or `contours_meters`. One contour is\n usually enough.\n - `polygons: true`, so the result is an area you can test points against.\n - `generalize` keeps the polygon small: about 100 meters for a short walk,\n 2000 for a drive.\n\n Keep the result\'s `mapboxRender.ref` for the map. The polygon is the\n feature\'s `geometry.coordinates`. If the result is too large to include\n the polygon, call it again with a larger `generalize`.\n\n3. **Bound the search.** Pass the polygon to `bbox_tool`. It returns\n `[minLon, minLat, maxLon, maxLat]`.\n\n4. **Search inside the box** with `category_search_tool`:\n - `category`: a canonical category such as `cafe`, `restaurant`,\n `pharmacy` or `hospital` (the `mapbox://categories` resource has the\n full list).\n - `bbox`: `{ minLongitude, minLatitude, maxLongitude, maxLatitude }`\n from step 3.\n - `proximity`: the origin, so the closest places come first.\n - `limit: 25`, the most it returns.\n\n5. **Drop places outside the area.** The box is a rectangle and the area isn\'t,\n so some results fall in its corners. Pass every result\'s coordinates with\n the polygon to `points_within_polygon_tool` and keep only the points it\n returns.\n\n6. **Show it.** Call `render_map_tool` with the isochrone ref in\n `payload_refs` and one marker per place you kept, with `id` set to its\n Mapbox ID and `name` to its name. Don\'t pass the search ref: it would also\n show the places you dropped.\n\n## Answering\n\n- Say how many places are within reach, then list them nearest first.\n- Name the mode and time ("a 10 minute walk"), since the area depends on both.\n- If all 25 results are inside the area, there are probably more. Say so, and\n offer a narrower category or a shorter time.\n- If nothing is inside, say that plainly and offer a longer time or another\n mode of travel.\n- For exact travel times to each place, call `matrix_tool` with the origin\n first in `coordinates` and `sources: "0"`. One call covers every place.\n' - } - }, - { - name: 'mapbox-location-grounding', - tools: [ - 'reverse_geocode_tool', - 'category_search_tool', - 'directions_tool', - 'isochrone_tool' - ], - files: { - 'SKILL.md': - '---\nname: mapbox-location-grounding\ndescription: Compose Mapbox MCP tools to produce grounded, cited location-aware responses from live data instead of training data\n---\n\n# Mapbox Location Grounding Skill\n\nTeaches AI assistants how to ground location-aware responses in live Mapbox data by composing MCP tools into a structured, cited answer. Use this instead of relying on training data for place names, POIs, ratings, or travel times — which are stale and prone to hallucination.\n\n## When to Use Grounding\n\nGround responses when the user asks about:\n\n- "What\'s near [location]?" or "What\'s around [coordinate]?"\n- "Describe this neighborhood / area"\n- "Find [category] within walking/driving distance"\n- "What can I do near [address]?"\n- "How long does it take to get from A to B?"\n- "What\'s within a 10-minute walk of here?"\n- "How far is it between these locations?"\n- Real estate, travel, mobility, or local discovery use cases\n- Any question where place accuracy, recency, or travel time matters\n\n**Never answer location questions from training data alone.** Always retrieve live data.\n\n## Grounding Tool Composition\n\n### Preferred: single tool call\n\nIf `ground_location_tool` is available, use it — it handles reverse geocoding, POI search, place details enrichment and isochrone in one call:\n\n```\nground_location_tool(\n longitude, latitude,\n query: "restaurant", // optional — category or subcategory of nearby places to find\n profile: "mapbox/walking", // optional — travel profile for isochrone\n contours_minutes: [5, 10, 15]\n)\n```\n\nReturns:\n\n- Neighborhood/place name from reverse geocoding\n- Nearby POIs with distances, ratings, price levels, and popularity (when available)\n- Travel-time reachability from isochrone\n- A `mapboxRender.ref`: pass it to `render_map_tool` to show the results on a map\n- Citations for all data sources\n\nDo not call `reverse_geocode_tool`, `category_search_tool`, `place_details_tool`, or `isochrone_tool` separately — they are already composed inside this tool.\n\n### Query parameter\n\nThe `query` parameter accepts **category or subcategory terms** — not attribute preferences:\n\n- Supported: `"restaurant"`, `"coffee"`, `"park"`, `"Italian restaurant"`, `"EV charging station"`\n- Not supported: `"family-friendly"`, `"fast charging"`, `"outdoor seating"` — these are not filterable attributes in Mapbox data\n\nTo help users find places matching a preference (e.g. "family-friendly"), search by category (`"restaurant"`) and use the returned rating and price data to inform the recommendation.\n\n### Fallback: manual composition\n\nIf `ground_location_tool` is not available, build the grounded response by composing these tools in order:\n\n#### Step 1 — Establish place context\n\n```\nreverse_geocode_tool(longitude, latitude, types: "neighborhood,locality,place")\n```\n\nReturns: neighborhood, city, region, country. This is the anchor for the response.\n\n#### Step 2 — Retrieve nearby POIs\n\nFor specific names or brands:\n\n```\nsearch_and_geocode_tool(query, proximity: {longitude, latitude}, limit: 10)\n```\n\nFor generic categories:\n\n```\ncategory_search_tool(category, proximity: {longitude, latitude}, limit: 10)\n```\n\n#### Step 3 — Enrich POIs with ratings and price (optional but high-value)\n\nFor each POI with a `mapbox_id`, call in parallel:\n\n```\nplace_details_tool(mapbox_id, attribute_sets: ["visit"])\n```\n\nReturns: rating, price level, popularity, and opening hours per place.\n\n#### Step 4 — Add travel-time context (optional but high-value)\n\n```\nisochrone_tool(\n coordinates: {longitude, latitude},\n profile: "mapbox/walking", // or "mapbox/driving", "mapbox/cycling", "mapbox/driving-traffic"\n contours_minutes: [5, 10, 15]\n)\n```\n\nReturns a polygon showing what\'s reachable within each time threshold.\n\n#### Step 5 — Visual grounding (optional)\n\n```\nrender_map_tool(payload_refs: [])\n```\n\nShows the places, routes and areas you found on one interactive map. Pass each result\'s `mapboxRender.ref` unchanged. Use `static_map_image_tool` only when the user explicitly asks for an image file.\n\n## Grounded Response Structure\n\nAlways structure grounded responses with explicit citations:\n\n```\nPlace: [neighborhood, city from reverse_geocode]\nNearby [category]: [list from search/category tool, with names, ratings, prices, and distances]\nTravel context: [X min walk / Y min drive from isochrone]\nSources: Mapbox Search, Mapbox Directions (live data)\n```\n\nExample grounded response:\n\n> **SoMa, San Francisco, CA** (live Mapbox data)\n>\n> Restaurants within walking distance:\n>\n> - Bix Restaurant $$ ★8.4 — 56 Gold St (180m)\n> - The Bird $ ★7.9 — 115 New Montgomery St (320m)\n> - Oren\'s Hummus $$ ★8.1 — 131 Townsend St (510m)\n>\n> Reachable by walking: 5 min, 10 min, 15 min\n>\n> _Sources: Mapbox Geocoding API, Mapbox Search API, Mapbox Place Details API, Mapbox Isochrone API, Mapbox Static Images API_\n\n## Routing Grounding\n\nFor questions about travel between two locations, use `directions_tool`. For questions about reachable area, use `isochrone_tool`. For comparing travel times across multiple locations, use `matrix_tool`.\n\n### Point-to-point travel time and distance\n\n> "How long does it take to drive from Pike Place Market to Capitol Hill?"\n> "What\'s the fastest route from the hotel to the airport?"\n\n```\ndirections_tool(\n waypoints: [{longitude, latitude}, {longitude, latitude}],\n profile: "mapbox/driving-traffic" // live traffic; or "mapbox/walking", "mapbox/cycling", "mapbox/driving"\n)\n```\n\nReturns: duration (with live traffic), distance, turn-by-turn steps, and an encoded polyline for map rendering. Supports up to 25 waypoints for multi-stop routes.\n\n### Area reachability ("within X minutes")\n\n> "What\'s reachable within a 10-minute walk?"\n> "Show me what I can reach in 15 minutes by car from here"\n\n```\nisochrone_tool(\n coordinates: {longitude, latitude},\n profile: "mapbox/walking",\n contours_minutes: [5, 10, 15]\n)\n```\n\nReturns a polygon showing the area reachable within each time threshold. Combine with `category_search_tool` or `ground_location_tool` to answer "what restaurants are within a 10-minute walk?" — use the isochrone to define the boundary, then search within it.\n\n### Multi-location travel time matrix\n\n> "Which of these three offices is closest to my location?"\n> "What are travel times from this warehouse to each of our delivery zones?"\n> "Compare commute times to multiple locations"\n\nUse `matrix_tool` — **do NOT call `directions_tool` separately for each pair**. Matrix batches all pairs in a single API call.\n\n```\nmatrix_tool(\n sources: [{longitude, latitude}, ...],\n destinations: [{longitude, latitude}, ...],\n profile: "mapbox/driving-traffic"\n)\n```\n\nReturns a matrix of durations and distances between every source/destination pair. Rank results by duration to find the nearest location. One `matrix_tool` call replaces N separate `directions_tool` calls.\n\n### Proximity search with routing context\n\nFor "find [category] within X minutes of [location]":\n\n1. Geocode the origin if needed: `search_and_geocode_tool(address)`\n2. Get the reachable area: `isochrone_tool(coordinates, profile, contours_minutes)`\n3. Search for POIs: `ground_location_tool(longitude, latitude, query: "category")` — the isochrone\n contours tell the user what\'s within each time band\n\nOr more directly: `ground_location_tool` returns both nearby POIs and isochrone reachability in one\ncall, which covers most proximity + routing use cases without additional composition.\n\n### Example routing response\n\n> **Pike Place Market → Capitol Hill, Seattle**\n>\n> By car (with traffic): 12 min, 3.2 km via Pine St\n> By walking: 28 min, 2.1 km\n> By cycling: 14 min, 2.3 km\n>\n> _Source: Mapbox Directions API (live traffic)_\n\n## What Mapbox Grounding Offers vs. Training Data\n\n| | Training Data | Mapbox Grounding |\n| ------------------ | ------------------- | --------------------------- |\n| POI accuracy | Stale, hallucinated | Live, verified |\n| Ratings/price | Often wrong | Live via Place Details |\n| Business hours | Often wrong | Live via Place Details |\n| Travel times | Estimated | Live traffic via Directions |\n| Reachable area | Not available | Isochrone polygons |\n| Multi-stop routing | Not available | Up to 25 waypoints |\n| New places | Missing | Indexed |\n| Map image | None | Inline static map |\n| Citations | None | Tool + API source |\n\n## Important Limitations\n\n- **Attribute filtering** (family-friendly, outdoor seating, fast charging) is not supported by the category search API. Use category terms and let rating/price data inform preference-based recommendations.\n- **POI coverage** varies by region. If search returns few results, say so rather than padding with guesses.\n- **Coordinates required** for proximity search — if the user provides an address, geocode it first with `search_and_geocode_tool` before running category search.\n\n## Anti-Patterns to Avoid\n\n- Answering "what\'s near X?" from training data without calling search tools\n- Estimating travel times without calling `directions_tool` or `isochrone_tool`\n- Hallucinating business names, hours, or ratings\n- Calling `reverse_geocode_tool` + `category_search_tool` + `place_details_tool` separately when `ground_location_tool` is available\n- Using attribute terms like "family-friendly" as the query parameter — use the category instead\n- Returning raw tool output without synthesizing into a readable response\n- Omitting citations — always indicate the response is grounded in live Mapbox data\n' - } - }, - { - name: 'mapbox-search-patterns', - tools: [ - 'search_and_geocode_tool', - 'category_search_tool', - 'reverse_geocode_tool' - ], - files: { - 'references/advanced-params.md': - '# Advanced Parameters Reference\n\n## poi_category Parameter\n\n**search_and_geocode_tool:** Narrow generic searches\n\n```json\n{\n "q": "lunch",\n "poi_category": ["restaurant", "cafe"],\n "proximity": { "longitude": -122.4194, "latitude": 37.7749 }\n}\n```\n\n**When to use:**\n\n- Generic query that could match multiple categories\n- Want to focus search within category\n- User specifies type implicitly\n\n**category_search_tool:** Use `poi_category_exclusions` instead\n\n```json\n{\n "category": "food_and_drink",\n "poi_category_exclusions": ["bar", "nightclub"]\n}\n```\n\n**When to use:**\n\n- Broad category but want to exclude subcategories\n- "Restaurants but not fast food"\n\n## ETA Parameters (search_and_geocode_tool)\n\n**Request estimated time of arrival to results**\n\n**Parameters:**\n\n- `eta_type`: Set to `"navigation"`\n- `navigation_profile`: `"driving"` | `"walking"` | `"cycling"`\n- `origin`: Starting coordinates\n\n**Use when:**\n\n- User asks "how long to get there?"\n- Sorting by travel time, not distance\n- Need route time, not straight-line distance\n\n**Example:**\n\n```json\n{\n "q": "grocery stores",\n "proximity": { "longitude": -122.4194, "latitude": 37.7749 },\n "eta_type": "navigation",\n "navigation_profile": "driving",\n "origin": { "longitude": -122.4194, "latitude": 37.7749 }\n}\n```\n\n**Returns:** Results with `eta` (travel time in seconds)\n\n**Warning:** Requires routing calculation per result (counts toward API quota)\n\n**When NOT to use:**\n\n- Just need straight-line distance (use distance_tool offline after search)\n- Budget-conscious (adds API cost)\n\n## format Parameter (category_search_tool)\n\n**Choose output format:**\n\n| Format | Returns | Use When |\n| -------------------------- | ---------------------- | ----------------------------- |\n| `formatted_text` (default) | Human-readable text | Displaying to user directly |\n| `json_string` | GeoJSON as JSON string | Need to parse/process results |\n\n**Example:**\n\n**formatted_text:**\n\n```\n1. Blue Bottle Coffee\n Address: 66 Mint St, San Francisco, CA\n Coordinates: 37.7825, -122.4052\n Type: poi\n```\n\n**json_string:**\n\n```json\n{\n "type": "FeatureCollection",\n "features": [{\n "type": "Feature",\n "geometry": {"type": "Point", "coordinates": [-122.4052, 37.7825]},\n "properties": {"name": "Blue Bottle Coffee", ...}\n }]\n}\n```\n\n**Decision:**\n\n- Showing list to user -> `formatted_text`\n- Plotting on map -> `json_string` (parse and use coordinates)\n- Further processing -> `json_string`\n\n## language Parameter\n\n**ISO language codes** (e.g., "en", "es", "fr", "de", "ja", "zh")\n\n**Use when:**\n\n- Building multilingual app\n- User\'s language preference known\n- Need localized names\n\n**Example:**\n\n```json\n{\n "q": "東京タワー",\n "language": "ja"\n}\n// Returns results in Japanese\n```\n\n**Default:** English (if not specified)\n\n**Tip:** Match user\'s locale for best experience\n', - 'references/optimization-combining.md': - '# Performance, Combining Tools, and Troubleshooting\n\n## Performance Optimization\n\n### Minimize API Calls\n\n**Pattern: Geocode once, reuse coordinates**\n\n```\n// GOOD\n1. User enters "Seattle"\n2. Geocode "Seattle" → (lng, lat)\n3. Use those coordinates for multiple category searches\n4. Cache coordinates for session\n\n// BAD\n1. Geocode "Seattle" for coffee search\n2. Geocode "Seattle" again for restaurant search\n3. Geocode "Seattle" again for hotel search\n```\n\n### Set Appropriate Limits\n\n| UI Context | Recommended Limit |\n| --------------------- | ----------------- |\n| Autocomplete dropdown | 5 |\n| List view | 10 |\n| Map view | 25 |\n| Export/download | 25 (or paginate) |\n\n### Use Offline Tools When Possible\n\n**After getting search results:**\n\n```\n1. category_search_tool → Get POIs\n2. distance_tool (offline) → Calculate distances\n3. bearing_tool (offline) → Get directions\n```\n\n**Why:** Search once (API), then use offline tools for calculations (free, fast)\n\n## Combining Search with Other Tools\n\n### Search → Distance Calculation\n\n```\n1. category_search_tool({category: "hospital", proximity: user_location})\n → Returns 10 hospitals with coordinates\n2. distance_tool(user_location, each_hospital)\n → Calculate exact distances offline\n3. Sort by distance\n```\n\n### Search → Directions\n\n```\n1. search_and_geocode_tool({q: "Space Needle"})\n → Get destination coordinates\n2. directions_tool({from: user_location, to: space_needle_coords})\n → Get turn-by-turn directions\n```\n\n### Search → Isochrone → Containment Check\n\n```\n1. search_and_geocode_tool({q: "warehouse"})\n → Get warehouse coordinates\n2. isochrone_tool({coordinates: warehouse, time: 30, profile: "driving"})\n → Get 30-minute delivery zone polygon\n3. points_within_polygon_tool(customer_coordinates, delivery_zone)\n → Check if customer is in delivery zone\n```\n\n### Search → Map Visualization\n\n```\n1. category_search_tool({category: "restaurant", limit: 10})\n → Get restaurants, plus a mapboxRender.ref\n2. render_map_tool({payload_refs: []})\n → Interactive map showing all restaurants\n```\n\nUse `static_map_image_tool` instead only when the user explicitly asks for an image file.\n\n## Handling No Results\n\n### If category_search returns no results:\n\n**Possible reasons:**\n\n1. Invalid category → Use `resource_reader_tool` with `mapbox://categories` to see valid categories\n2. Too restrictive bbox → Expand area or use proximity instead\n3. No POIs in area → Try broader category or remove spatial filters\n4. Wrong country filter → Check country codes\n\n**Example recovery:**\n\n```\n1. category_search_tool({category: "taco"}) → No results\n2. Check: Is "taco" a valid category?\n → Use category_list_tool → See "mexican_restaurant" is valid\n3. Retry: category_search_tool({category: "mexican_restaurant"}) → Success\n```\n\n### If search_and_geocode returns no results:\n\n**Possible reasons:**\n\n1. Typo in query → Retry with `auto_complete: true`\n2. Too specific → Broaden search (remove address numbers, try nearby city)\n3. Wrong types filter → Remove or expand types\n4. Not a recognized place → Check spelling, try alternative names\n\n## Category List Resource\n\n**Get valid categories:** Use `resource_reader_tool` or `category_list_tool`\n\n```\nresource_reader_tool({uri: "mapbox://categories"})\n```\n\n**Returns:** All valid category IDs (e.g., "restaurant", "hotel", "gas_station")\n\n**When to use:**\n\n- User enters free-text category\n- Need to map user terms to Mapbox categories\n- Validating category before search\n\n**Example mapping:**\n\n- User: "places to eat" → Category: "restaurant"\n- User: "gas" → Category: "gas_station"\n- User: "lodging" → Category: "hotel"\n\n## Integration with Other Skills\n\n**Works with:**\n\n- **mapbox-geospatial-operations**: After search, use offline distance/bearing calculations\n- **mapbox-web-integration-patterns**: Display search results on map in web app\n- **mapbox-token-security**: Ensure search requests use properly scoped tokens\n\n## Resources\n\n- [Mapbox Search Box API Docs](https://docs.mapbox.com/api/search/search-box/)\n- [Category Search API](https://docs.mapbox.com/api/search/search-box/#category-search)\n- [Geocoding API](https://docs.mapbox.com/api/search/geocoding/)\n- [Category List Resource](https://docs.mapbox.com/api/search/search-box/#category-list)\n', - 'references/workflows.md': - '# Common Patterns and Workflows\n\n## Pattern 1: "Near Me" Search\n\n**User:** "Find coffee shops near me"\n\n**Optimal approach:**\n\n```\n1. Get user\'s location (from app/browser)\n2. Use category_search_tool:\n - category: "coffee_shop"\n - proximity: user\'s coordinates\n - limit: 10\n```\n\n**Why:** Category tool for generic "coffee shops", proximity for "near me"\n\n## Pattern 2: Branded Chain Lookup\n\n**User:** "Find all Starbucks in Seattle"\n\n**Optimal approach:**\n\n```\n1. Use search_and_geocode_tool:\n - q: "Starbucks"\n - proximity: Seattle coordinates\n - country: ["US"]\n2. Or if need strict boundary:\n - bbox: Seattle city bounds\n```\n\n**Why:** Brand name = search_and_geocode_tool; proximity biases to Seattle\n\n## Pattern 3: Address Geocoding\n\n**User:** "What are the coordinates of 1600 Pennsylvania Ave?"\n\n**Optimal approach:**\n\n```\nUse search_and_geocode_tool:\n- q: "1600 Pennsylvania Ave, Washington DC"\n- types: ["address"] // Focus on addresses\n- country: ["US"] // Narrow to US\n```\n\n**Why:** Specific address with country context for disambiguation\n\n## Pattern 4: Category Search with Area Restriction\n\n**User:** "Show me all hotels in downtown Portland"\n\n**Optimal approach:**\n\n```\n1. Geocode "downtown Portland" → get center point\n2. Define downtown bbox (or use 1-2 mile radius)\n3. Use category_search_tool:\n - category: "hotel"\n - bbox: downtown bounds (or proximity + filter by distance)\n - limit: 25 // Get comprehensive list\n```\n\n**Why:** Category for "hotels", bbox for "in downtown" hard boundary\n\n## Pattern 5: Reverse Geocoding\n\n**User:** "What\'s at these GPS coordinates?"\n\n**Optimal approach:**\n\n```\nUse reverse_geocode_tool:\n- longitude: -122.4194\n- latitude: 37.7749\n- types: ["address"] // Get address (can also use place, locality, postcode, etc.)\n```\n\n**Why:** Coordinates → address is exactly what reverse geocoding does\n\n## Pattern 6: Route-Based Search\n\n**User:** "Find gas stations along my route"\n\n**Optimal approach:**\n\n```\n1. Get route geometry from directions_tool\n2. Create bbox around route (use bbox_tool on the route geometry)\n3. Use category_search_tool:\n - category: "gas_station"\n - bbox: route bounding box\n4. Filter results to those within X meters of route (use distance_tool)\n```\n\n**Why:** Bbox for rough filter, then distance calculation for precision\n\n## Pattern 7: Multilingual POI Search\n\n**User:** "Find ramen shops" (user locale: ja)\n\n**Optimal approach:**\n\n```\nUse category_search_tool:\n- category: "ramen_restaurant" (or "restaurant")\n- language: "ja"\n- proximity: user location\n```\n\n**Why:** Returns Japanese names/addresses for better UX\n', - 'SKILL.md': - '---\nname: mapbox-search-patterns\ndescription: Expert guidance on choosing the right Mapbox search tool and parameters for geocoding, POI search, and location discovery\n---\n\n# Mapbox Search Patterns Skill\n\nExpert guidance for AI assistants on using Mapbox search tools effectively. Covers tool selection, parameter optimization, and best practices for geocoding, POI search, and location discovery.\n\n## Available Search Tools\n\n### 1. search_and_geocode_tool\n\n**Best for:** Specific places, addresses, brands, named locations\n\n**Use when query contains:**\n\n- Specific names: "Starbucks on 5th Avenue", "Empire State Building"\n- Brand names: "McDonald\'s", "Whole Foods"\n- Addresses: "123 Main Street, Seattle", "1 Times Square"\n- Chain stores: "Target"\n- Cities/places: "San Francisco", "Portland"\n\n**Don\'t use for:** Generic categories ("coffee shops", "museums")\n\n### 2. category_search_tool\n\n**Best for:** Generic place types, categories, plural queries\n\n**Use when query contains:**\n\n- Generic types: "coffee shops", "restaurants", "gas stations"\n- Plural forms: "museums", "hotels", "parks"\n- Is-a phrases: "any coffee shop", "all restaurants", "nearby pharmacies"\n- Industry terms: "electric vehicle chargers", "ATMs"\n\n**Don\'t use for:** Specific names or brands\n\n### 3. reverse_geocode_tool\n\n**Best for:** Converting coordinates to addresses, cities, towns, postcodes\n\n**Use when:**\n\n- Have GPS coordinates, need human-readable address\n- Need to identify what\'s at a specific location\n- Converting user location to address\n\n## Tool Selection Decision Matrix\n\n| User Query | Tool | Reasoning |\n| ------------------------------- | ----------------------- | ------------------------ |\n| "Find Starbucks on Main Street" | search_and_geocode_tool | Specific brand name |\n| "Find coffee shops nearby" | category_search_tool | Generic category, plural |\n| "What\'s at 37.7749, -122.4194?" | reverse_geocode_tool | Coordinates to address |\n| "Empire State Building" | search_and_geocode_tool | Specific named POI |\n| "hotels in downtown Seattle" | category_search_tool | Generic type + location |\n| "Target store locations" | search_and_geocode_tool | Brand name (even plural) |\n| "any restaurant near me" | category_search_tool | Generic + "any" phrase |\n| "123 Main St, Boston, MA" | search_and_geocode_tool | Specific address |\n| "electric vehicle chargers" | category_search_tool | Industry category |\n| "McDonald\'s" | search_and_geocode_tool | Brand name |\n\n## Parameter Guidance\n\n### Proximity vs Bbox vs Country\n\n**Three ways to spatially constrain search results:**\n\n#### 1. proximity (STRONGLY RECOMMENDED)\n\n**What it does:** Biases results toward a location, but doesn\'t exclude distant matches\n\n**Use when:**\n\n- User says "near me", "nearby", "close to"\n- Have a reference point but want some flexibility\n- Want results sorted by relevance to a point\n\n**Example:**\n\n```json\n{\n "q": "pizza",\n "proximity": {\n "longitude": -122.4194,\n "latitude": 37.7749\n }\n}\n```\n\n**Why this works:** API returns SF pizza places first, but might include famous NYC pizzerias if highly relevant\n\n**Critical:** Always set proximity when you have a reference location! Without it, results are IP-based or global.\n\n#### 2. bbox (Bounding Box)\n\n**What it does:** Hard constraint - ONLY returns results within the box\n\n**Use when:**\n\n- User specifies an area: "in downtown", "within this neighborhood"\n- Have a defined service area\n- Need to guarantee results are within bounds\n\n**Example:**\n\n```json\n{\n "q": "hotel",\n "bbox": [-122.51, 37.7, -122.35, 37.83] // [minLon, minLat, maxLon, maxLat]\n}\n```\n\n**Why this works:** Guarantees all hotels are within SF\'s downtown area\n\n**Watch out:** Too small = no results; too large = irrelevant results\n\n#### 3. country\n\n**What it does:** Limits results to specific countries\n\n**Use when:**\n\n- User specifies country: "restaurants in France"\n- Building country-specific features\n- Need to respect regional boundaries\n- Or it is otherwise clear they want results within a specific country\n\n**Example:**\n\n```json\n{\n "q": "Paris",\n "country": ["FR"] // ISO 3166 alpha-2 codes\n}\n```\n\n**Why this works:** Finds Paris, France (not Paris, Texas)\n\n**Can combine:** `proximity` + `country` + `bbox` or any combination of the three\n\n### Decision Matrix: Spatial Filters\n\n| Scenario | Use | Why |\n| ---------------------------------- | ----------------------------------- | --------------------------------- |\n| "Find coffee near me" | proximity | Bias toward user location |\n| "Coffee shops in downtown Seattle" | proximity + bbox | Center on downtown, limit to area |\n| "Hotels in France" | country | Hard country boundary |\n| "Best pizza in San Francisco" | proximity + country ["US"] | Bias to SF, limit to US |\n| "Gas stations along this route" | bbox around route | Hard constraint to route corridor |\n| "Restaurants within 5 miles" | proximity (then filter by distance) | Bias nearby, filter results |\n\n### Setting limit Parameter\n\n**category_search_tool only** (1-25, default 10)\n\n| Use Case | Limit | Reasoning |\n| --------------------- | ----- | ----------------------- |\n| Quick suggestions | 5 | Fast, focused results |\n| Standard list | 10 | Default, good balance |\n| Comprehensive search | 25 | Maximum allowed |\n| Map visualization | 25 | Show all nearby options |\n| Dropdown/autocomplete | 5 | Don\'t overwhelm UI |\n\n**Performance tip:** Lower limits = faster responses\n\n### types Parameter (search_and_geocode_tool)\n\n**Filter by feature type:**\n\n| Type | What It Includes | Use When |\n| ---------- | ------------------------------------------ | --------------------------------- |\n| `poi` | Points of interest (businesses, landmarks) | Looking for POIs, not addresses |\n| `address` | Street addresses | Need specific address |\n| `place` | Cities, neighborhoods, regions | Looking for area/region |\n| `street` | Street names without numbers | Need street, not specific address |\n| `postcode` | Postal codes | Searching by ZIP/postal code |\n| `district` | Districts, neighborhoods | Area-based search |\n| `locality` | Towns, villages | Municipality search |\n| `country` | Country names | Country-level search |\n\n**Example combinations:**\n\n```json\n// Only POIs and addresses, no cities\n{"q": "Paris", "types": ["poi", "address"]}\n// Returns Paris Hotel, Paris Street, not Paris, France\n\n// Only places (cities)\n{"q": "Paris", "types": ["place"]}\n// Returns Paris, France; Paris, Texas; etc.\n```\n\n**Default behavior:** All types included (usually what you want)\n\n### auto_complete Parameter (search_and_geocode_tool)\n\n**What it does:** Enables partial/fuzzy matching\n\n| Setting | Behavior | Use When |\n| ----------------- | ---------------------------- | ----------------------------- |\n| `true` | Matches partial words, typos | User typing in real-time |\n| `false` (default) | Exact matching | Final query, not autocomplete |\n\n**Example:**\n\n\n\n```json\n// User types "starb"\n{ "q": "starb", "auto_complete": true }\n// Returns: Starbucks, Starboard Tavern, etc.\n```\n\n**Use for:**\n\n- Search-as-you-type interfaces\n- Handling typos ("mcdonalds" -> McDonald\'s)\n\n- Incomplete queries\n\n**Don\'t use for:**\n\n- Final/submitted queries (less precise)\n- When you need exact matches\n\n## Anti-Patterns to Avoid\n\n### Don\'t: Use category_search for brands\n\n```javascript\n// BAD\ncategory_search_tool({ category: \'starbucks\' });\n// "starbucks" is not a category, returns error\n\n// GOOD\nsearch_and_geocode_tool({ q: \'Starbucks\' });\n```\n\n### Don\'t: Use search_and_geocode for generic categories\n\n```javascript\n// BAD\nsearch_and_geocode_tool({ q: \'coffee shops\' });\n// Less precise, may return unrelated results\n\n// GOOD\ncategory_search_tool({ category: \'coffee_shop\' });\n```\n\n### Don\'t: Forget proximity for local searches\n\n```javascript\n// BAD - Results may be anywhere globally\ncategory_search_tool({ category: \'restaurant\' });\n\n// GOOD - Biased to user location\ncategory_search_tool({\n category: \'restaurant\',\n proximity: { longitude: -122.4194, latitude: 37.7749 }\n});\n```\n\n### Don\'t: Geocode ambiguous place names without proximity (REST too)\n\nThis applies to Mapbox Geocoding API v5 / Search Box in browser apps — not only MCP tools.\n\n```javascript\n// BAD — limit=1 without proximity can resolve "Lincoln Memorial" to Illinois\nfetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json?access_token=${token}&limit=1`);\n\n// GOOD — bias to map center (and optional bbox)\nfetch(\n `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json` +\n `?access_token=${token}&proximity=-77.0369,38.9072&bbox=-77.15,38.79,-76.90,38.99&limit=1`\n);\n```\n\nAlso debounce search inputs (`clearTimeout` + `setTimeout`) so every keystroke does not fire a geocode.\n\n### Don\'t: Use bbox when you mean proximity\n\n```javascript\n// BAD - Hard boundary may exclude good nearby results\nsearch_and_geocode_tool({\n q: \'pizza\',\n bbox: [-122.42, 37.77, -122.41, 37.78] // Tiny box\n});\n\n// GOOD - Bias toward point, but flexible\nsearch_and_geocode_tool({\n q: \'pizza\',\n proximity: { longitude: -122.4194, latitude: 37.7749 }\n});\n```\n\n### Don\'t: Request ETA unnecessarily\n\n```javascript\n// BAD - Costs API quota for routing calculations\nsearch_and_geocode_tool({\n q: \'museums\',\n eta_type: \'navigation\',\n navigation_profile: \'driving\'\n});\n// User didn\'t ask for travel time!\n\n// GOOD - Only add ETA when needed\nsearch_and_geocode_tool({ q: \'museums\' });\n// If user asks "how long to get there?", then add ETA\n```\n\n### Don\'t: Set limit too high for UI display\n\n```javascript\n// BAD - Overwhelming for simple dropdown\ncategory_search_tool({\n category: \'restaurant\',\n limit: 25\n});\n// Returns 25 restaurants for a 5-item dropdown\n\n// GOOD - Match UI needs\ncategory_search_tool({\n category: \'restaurant\',\n limit: 5\n});\n```\n\n## Quick Reference\n\n### Tool Selection Flowchart\n\n```\nUser query contains...\n\n-> Specific name/brand (Starbucks, Empire State Building)\n -> search_and_geocode_tool\n\n-> Generic category/plural (coffee shops, museums, any restaurant)\n -> category_search_tool\n\n-> Coordinates -> Address\n -> reverse_geocode_tool\n\n-> Address -> Coordinates\n -> search_and_geocode_tool with types: ["address"]\n```\n\n### Essential Parameters Checklist\n\n**For local searches, ALWAYS set:**\n\n- `proximity` (or bbox if strict boundary needed)\n\n**For category searches, consider:**\n\n- `limit` (match UI needs)\n- `format` (json_string if plotting on map)\n\n**For disambiguation, use:**\n\n- `country` (when geographic context matters)\n- `types` (when feature type matters)\n\n**For travel-time ranking:**\n\n- `eta_type`, `navigation_profile`, `origin` (costs API quota)\n\n## Common Mistakes\n\n1. **Forgetting proximity** -> Results are global/IP-based (or wrong state for ambiguous memorial/park names)\n2. **Using wrong tool** -> category_search for "Starbucks" (use search_and_geocode)\n3. **Invalid category** -> Check category_list first\n4. **Bbox too small** -> No results; use proximity instead\n5. **Requesting ETA unnecessarily** -> Adds API cost\n6. **Limit too high for UI** -> Overwhelming user\n7. **Not filtering types** -> Get cities when you want POIs\n8. **No debounce on typeahead** -> Quota burn and racy UI\n\n## Reference Files\n\nLoad these for deeper guidance on specific topics:\n\n- **`references/advanced-params.md`** — poi_category, ETA, format, and language parameters\n- **`references/workflows.md`** — Common patterns: Near Me, Branded, Geocoding, Category+Area, Reverse, Route-Based, Multilingual\n- **`references/optimization-combining.md`** — Performance optimization, combining tools, handling no results, category list resource\n' - } - } -];