Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof ListPublicTokensInputSchema>;
Original file line number Diff line number Diff line change
@@ -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<typeof PublicTokenSchema>;
export type ListPublicTokensOutput = z.infer<
typeof ListPublicTokensOutputSchema
>;
259 changes: 259 additions & 0 deletions src/tools/list-public-tokens-tool/ListPublicTokensTool.ts
Original file line number Diff line number Diff line change
@@ -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<CallToolResult> {
// 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
};
}
}
2 changes: 2 additions & 0 deletions src/tools/toolRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
Expand Down
5 changes: 5 additions & 0 deletions src/utils/serverInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Loading
Loading