diff --git a/CHANGELOG.md b/CHANGELOG.md index 45fe772..f73ebcf 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 (#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 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. - **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..35ddc81 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 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." + # 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..4cde8cc --- /dev/null +++ b/src/tools/list-public-tokens-tool/ListPublicTokensTool.ts @@ -0,0 +1,259 @@ +// 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.` + }; + } + } + // 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" + : '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 + }); + } + + // 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.fallbackOr( + envFallback, + "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.fallbackOr( + envFallback, + `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 + }); + } + + /** 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: [], + 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}` + : 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) + .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..e7c5d0a --- /dev/null +++ b/test/tools/list-public-tokens-tool/ListPublicTokensTool.test.ts @@ -0,0 +1,249 @@ +// 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 on the server token', async () => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', secretToken); + 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("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); + 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');