diff --git a/src/schemas/common.ts b/src/schemas/common.ts new file mode 100644 index 0000000..59f4f8c --- /dev/null +++ b/src/schemas/common.ts @@ -0,0 +1,201 @@ +import { z } from 'zod'; + +/** + * Common Zod schema definitions for reuse across different tools + */ + +/** + * Public Mapbox access token that starts with 'pk.' + */ +export const publicAccessTokenSchema = (description?: string) => + z + .string() + .startsWith( + 'pk.', + 'Invalid access token. Only public tokens (starting with pk.*) are allowed. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.' + ) + .describe( + description || + 'Mapbox public access token (required, must start with pk.* and have styles:read permission). Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs. Please use an existing public token or get one from list_tokens_tool or create one with create_token_tool with styles:read permission.' + ); + +/** + * Latitude coordinate with validation + */ +export const latitudeSchema = ( + description?: string, + optional: boolean = false +) => + numberSchema( + -90, + 90, + description || 'Latitude coordinate (-90 to 90)', + optional + ); + +/** + * Longitude coordinate with validation + */ +export const longitudeSchema = ( + description?: string, + optional: boolean = false +) => + numberSchema( + -180, + 180, + description || 'Longitude coordinate (-180 to 180)', + optional + ); + +/** + * Zoom level for map views + */ +export const zoomSchema = (description?: string) => + z + .number() + .optional() + .describe( + description || + 'Initial zoom level for the map view (0-22). If provided along with latitude and longitude, sets the initial map position.' + ); + +/** + * Complete coordinate set (latitude + longitude + zoom) for map positioning + */ +export const mapPositionSchema = ( + latDescription?: string, + lngDescription?: string, + zoomDescription?: string +) => ({ + latitude: latitudeSchema( + latDescription || + 'Latitude coordinate for the initial map center (-90 to 90). Must be provided together with longitude and zoom.', + true + ), + longitude: longitudeSchema( + lngDescription || + 'Longitude coordinate for the initial map center (-180 to 180). Must be provided together with latitude and zoom.', + true + ), + zoom: zoomSchema(zoomDescription) +}); + +/** + * Generic string schema + */ +export const stringSchema = ( + description: string, + optional: boolean = false +) => { + const schema = z.string().describe(description); + return optional ? schema.optional() : schema; +}; + +/** + * Generic number schema with range validation + */ +export const numberSchema = ( + min?: number, + max?: number, + description?: string, + optional: boolean = false +) => { + let schema = z.number(); + + if (min !== undefined) { + schema = schema.min(min); + } + if (max !== undefined) { + schema = schema.max(max); + } + + schema = schema.describe( + description || + `Number${min !== undefined ? ` (min: ${min}` : ''}${max !== undefined ? `${min !== undefined ? ', ' : ' ('}max: ${max}` : ''}${min !== undefined || max !== undefined ? ')' : ''}` + ); + + return optional ? schema.optional() : schema; +}; + +/** + * Generic boolean schema + */ +export const booleanSchema = ( + description: string, + optional: boolean = false, + defaultValue?: boolean +) => { + const baseSchema = z.boolean().describe(description); + + if (defaultValue !== undefined) { + return baseSchema.default(defaultValue); + } + + if (optional) { + return baseSchema.optional(); + } + + return baseSchema; +}; + +/** + * Generic enum schema + */ +export const enumSchema = ( + values: T, + description: string, + optional: boolean = false +) => { + const schema = z.enum(values).describe(description); + return optional ? schema.optional() : schema; +}; + +/** + * Generic array schema + */ +export const arraySchema = ( + itemSchema: T, + description: string, + optional: boolean = false +) => { + const schema = z.array(itemSchema).describe(description); + return optional ? schema.optional() : schema; +}; + +/** + * Generic record/object schema + */ +export const recordSchema = ( + description?: string, + optional: boolean = false +) => { + const schema = z + .record(z.any()) + .describe(description || 'Object with arbitrary key-value pairs'); + + return optional ? schema.optional() : schema; +}; + +/** + * Mapbox style specification object + */ +export const mapboxStyleSchema = ( + description?: string, + optional: boolean = false +) => recordSchema(description || 'Mapbox style specification object', optional); + +/** + * Pagination limit field + */ +export const limitSchema = ( + min: number = 1, + max: number = 100, + description?: string, + optional: boolean = true +) => + numberSchema( + min, + max, + description || `Maximum number of items to return (${min}-${max})`, + optional + ); diff --git a/src/tools/create-style-tool/CreateStyleTool.schema.ts b/src/tools/create-style-tool/CreateStyleTool.schema.ts index 2e5c606..143abdb 100644 --- a/src/tools/create-style-tool/CreateStyleTool.schema.ts +++ b/src/tools/create-style-tool/CreateStyleTool.schema.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; +import { stringSchema, mapboxStyleSchema } from '../../schemas/common.js'; export const CreateStyleSchema = z.object({ - name: z.string().describe('Name for the new style'), - style: z.record(z.any()).describe('Mapbox style specification object') + name: stringSchema('New name for the style'), + style: mapboxStyleSchema() }); export type CreateStyleInput = z.infer; diff --git a/src/tools/create-token-tool/CreateTokenTool.schema.ts b/src/tools/create-token-tool/CreateTokenTool.schema.ts index d9aa613..56fbc3d 100644 --- a/src/tools/create-token-tool/CreateTokenTool.schema.ts +++ b/src/tools/create-token-tool/CreateTokenTool.schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { stringSchema, arraySchema } from '../../schemas/common.js'; // Public scopes that can be used with public tokens const PUBLIC_SCOPES = [ @@ -44,22 +45,20 @@ const SECRET_SCOPES = [ const ALL_SCOPES = [...PUBLIC_SCOPES, ...SECRET_SCOPES] as const; export const CreateTokenSchema = z.object({ - note: z.string().describe('Description of the token'), - scopes: z - .array(z.enum(ALL_SCOPES)) - .describe( - 'Array of scopes/permissions for the token. PUBLIC scopes (styles:tiles, styles:read, fonts:read, datasets:read, vision:read) create a public token. SECRET scopes (all others) create a secret token. If any secret scope is included, the entire token becomes secret and will only be visible once upon creation.' - ), - allowedUrls: z - .array(z.string()) - .optional() - .describe('Optional array of URLs where the token can be used (max 100)'), - expires: z - .string() - .optional() - .describe( - 'Optional expiration time in ISO 8601 format (maximum 1 hour in the future)' - ) + note: stringSchema('Description of the token'), + scopes: arraySchema( + z.enum(ALL_SCOPES), + 'Array of scopes/permissions for the token. PUBLIC scopes (styles:tiles, styles:read, fonts:read, datasets:read, vision:read) create a public token. SECRET scopes (all others) create a secret token. If any secret scope is included, the entire token becomes secret and will only be visible once upon creation.' + ), + allowedUrls: arraySchema( + z.string(), + 'Optional array of URLs where the token can be used (max 100)', + true + ), + expires: stringSchema( + 'Optional expiration time in ISO 8601 format (maximum 1 hour in the future)', + true + ) }); export type CreateTokenInput = z.infer; diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index 8ecc6ba..88c9ba4 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -28,7 +28,7 @@ export class CreateTokenTool extends MapboxApiBasedTool< ); // Check if any secret scopes are being used - const hasSecretScopes = input.scopes.some((scope) => + const hasSecretScopes = input.scopes?.some((scope) => SECRET_SCOPES.includes(scope as (typeof SECRET_SCOPES)[number]) ); @@ -52,8 +52,8 @@ export class CreateTokenTool extends MapboxApiBasedTool< allowedUrls?: string[]; expires?: string; } = { - note: input.note, - scopes: input.scopes + note: input.note as string, + scopes: input.scopes || [] }; if (input.allowedUrls) { diff --git a/src/tools/delete-style-tool/DeleteStyleTool.schema.ts b/src/tools/delete-style-tool/DeleteStyleTool.schema.ts index c5a0909..99e4945 100644 --- a/src/tools/delete-style-tool/DeleteStyleTool.schema.ts +++ b/src/tools/delete-style-tool/DeleteStyleTool.schema.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import { stringSchema } from '../../schemas/common.js'; export const DeleteStyleSchema = z.object({ - styleId: z.string().describe('Style ID to delete') + styleId: stringSchema('Style ID to delete') }); export type DeleteStyleInput = z.infer; diff --git a/src/tools/list-styles-tool/ListStylesTool.schema.ts b/src/tools/list-styles-tool/ListStylesTool.schema.ts index e2715ba..aaa3780 100644 --- a/src/tools/list-styles-tool/ListStylesTool.schema.ts +++ b/src/tools/list-styles-tool/ListStylesTool.schema.ts @@ -1,18 +1,16 @@ import { z } from 'zod'; +import { limitSchema, stringSchema } from '../../schemas/common.js'; export const ListStylesSchema = z.object({ - limit: z - .number() - .optional() - .describe( - 'Maximum number of styles to return (recommended: 5-10 to avoid token limits, default: no limit)' - ), - start: z - .string() - .optional() - .describe( - 'Start token for pagination (use the "start" value from previous response)' - ) + limit: limitSchema( + 1, + 500, + 'Maximum number of styles to return (recommended: 5-10 to avoid token limits, default: no limit)' + ), + start: stringSchema( + 'Start token for pagination (use the "start" value from previous response)', + true + ) }); export type ListStylesInput = z.infer; diff --git a/src/tools/list-tokens-tool/ListTokensTool.schema.ts b/src/tools/list-tokens-tool/ListTokensTool.schema.ts index f2c646a..ab2c1b8 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.schema.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.schema.ts @@ -1,25 +1,25 @@ import { z } from 'zod'; +import { + limitSchema, + stringSchema, + booleanSchema, + enumSchema +} from '../../schemas/common.js'; export const ListTokensSchema = z.object({ - default: z - .boolean() - .optional() - .describe('Filter to show only the default public token'), - limit: z - .number() - .min(1) - .max(100) - .optional() - .describe('Maximum number of tokens to return (1-100)'), - sortby: z - .enum(['created', 'modified']) - .optional() - .describe('Sort tokens by created or modified timestamp'), - start: z.string().optional().describe('Token ID to start pagination from'), - usage: z - .enum(['pk', 'sk', 'tk']) - .optional() - .describe('Filter by token type: pk (public), sk (secret), tk (temporary)') + default: booleanSchema('Filter to show only the default public token', true), + limit: limitSchema(1, 100, 'Maximum number of tokens to return (1-100)'), + sortby: enumSchema( + ['created', 'modified'], + 'Sort tokens by created or modified timestamp', + true + ), + start: stringSchema('Token ID to start pagination from', true), + usage: enumSchema( + ['pk', 'sk', 'tk'], + 'Filter by token type: pk (public), sk (secret), tk (temporary)', + true + ) }); export type ListTokensInput = z.infer; diff --git a/src/tools/preview-style-tool/PreviewStyleTool.schema.ts b/src/tools/preview-style-tool/PreviewStyleTool.schema.ts index eec52c3..8b6a545 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.schema.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.schema.ts @@ -1,26 +1,15 @@ import { z } from 'zod'; +import { + publicAccessTokenSchema, + stringSchema, + booleanSchema +} from '../../schemas/common.js'; export const PreviewStyleSchema = z.object({ - styleId: z.string().describe('Style ID to preview'), - accessToken: z - .string() - .startsWith( - 'pk.', - 'Invalid access token. Only public tokens (starting with pk.*) are allowed for preview URLs. Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs.' - ) - .describe( - 'Mapbox public access token (required, must start with pk.* and have styles:read permission). Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs. Please use an existing public token or get one from list_tokens_tool or create one with create_token_tool with styles:read permission.' - ), - title: z - .boolean() - .optional() - .default(false) - .describe('Show title in the preview'), - zoomwheel: z - .boolean() - .optional() - .default(true) - .describe('Enable zoom wheel control') + styleId: stringSchema('Style ID to preview'), + accessToken: publicAccessTokenSchema(), + title: booleanSchema('Show title in the preview', true, false), + zoomwheel: booleanSchema('Enable zoom wheel control', true, true) }); export type PreviewStyleInput = z.infer; diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.schema.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.schema.ts index a6e7aa3..b8bc133 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.schema.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.schema.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import { stringSchema } from '../../schemas/common.js'; export const RetrieveStyleSchema = z.object({ - styleId: z.string().describe('Style ID to retrieve') + styleId: stringSchema('Style ID to retrieve') }); export type RetrieveStyleInput = z.infer; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index aa62aec..dbd55f6 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -1,47 +1,19 @@ import { z } from 'zod'; +import { + publicAccessTokenSchema, + mapPositionSchema, + stringSchema +} from '../../schemas/common.js'; export const StyleComparisonSchema = z.object({ - before: z - .string() - .describe( - 'Mapbox style for the "before" side. Accepts: full style URL (mapbox://styles/username/styleId), username/styleId format, or just styleId if using your own styles' - ), - after: z - .string() - .describe( - 'Mapbox style for the "after" side. Accepts: full style URL (mapbox://styles/username/styleId), username/styleId format, or just styleId if using your own styles' - ), - accessToken: z - .string() - .startsWith( - 'pk.', - 'Invalid token type. Style comparison requires a public token (pk.*) that can be used in browser URLs. Secret tokens (sk.*) cannot be exposed in client-side applications. Please provide a public token with styles:read permission.' - ) - .describe( - 'Mapbox public access token (required, must start with pk.* and have styles:read permission). Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs. Please use a public token or create one with styles:read permission.' - ), - zoom: z - .number() - .optional() - .describe( - 'Initial zoom level for the map view (0-22). If provided along with latitude and longitude, sets the initial map position.' - ), - latitude: z - .number() - .min(-90) - .max(90) - .optional() - .describe( - 'Latitude coordinate for the initial map center (-90 to 90). Must be provided together with longitude and zoom.' - ), - longitude: z - .number() - .min(-180) - .max(180) - .optional() - .describe( - 'Longitude coordinate for the initial map center (-180 to 180). Must be provided together with latitude and zoom.' - ) + before: stringSchema( + 'Mapbox style for the "before" side. Accepts: full style URL (mapbox://styles/username/styleId), username/styleId format, or just styleId if using your own styles' + ), + after: stringSchema( + 'Mapbox style for the "after" side. Accepts: full style URL (mapbox://styles/username/styleId), username/styleId format, or just styleId if using your own styles' + ), + accessToken: publicAccessTokenSchema(), + ...mapPositionSchema() }); export type StyleComparisonInput = z.infer; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.test.ts b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts index f57750f..7af17c7 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -93,10 +93,12 @@ describe('StyleComparisonTool', () => { expect(result.isError).toBe(true); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('Invalid token type'); + ).toContain('Invalid access token'); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('Secret tokens (sk.*) cannot be exposed'); + ).toContain( + 'Secret tokens (sk.*) cannot be used as they cannot be exposed in browser URLs' + ); }); it('should reject invalid token formats', async () => { @@ -111,7 +113,7 @@ describe('StyleComparisonTool', () => { expect(result.isError).toBe(true); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('Invalid token type'); + ).toContain('Invalid access token'); }); it('should return error for style ID without valid username in token', async () => { diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 1aec199..9abc24e 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -19,18 +19,25 @@ export class StyleComparisonTool extends BaseTool< /** * Processes style input to extract username/styleId format */ - private processStyleId(style: string, accessToken: string): string { + private processStyleId( + style: string | undefined, + accessToken?: string + ): string { // If it's a full URL, extract the username/styleId part - if (style.startsWith('mapbox://styles/')) { + if (style?.startsWith('mapbox://styles/')) { return style.replace('mapbox://styles/', ''); } // If it contains a slash, assume it's already username/styleId format - if (style.includes('/')) { + if (style?.includes('/')) { return style; } // If it's just a style ID, try to get username from the token + if (!style) { + throw new Error('Style is required'); + } + try { const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); return `${username}/${style}`; diff --git a/src/tools/tilequery-tool/TilequeryTool.schema.ts b/src/tools/tilequery-tool/TilequeryTool.schema.ts index b50a264..bd12215 100644 --- a/src/tools/tilequery-tool/TilequeryTool.schema.ts +++ b/src/tools/tilequery-tool/TilequeryTool.schema.ts @@ -1,43 +1,42 @@ import { z } from 'zod'; +import { + latitudeSchema, + longitudeSchema, + limitSchema, + stringSchema, + numberSchema, + booleanSchema, + enumSchema +} from '../../schemas/common.js'; export const TilequerySchema = z.object({ - tilesetId: z - .string() - .optional() - .default('mapbox.mapbox-streets-v8') - .describe('Tileset ID to query (default: mapbox.mapbox-streets-v8)'), - longitude: z - .number() - .min(-180) - .max(180) - .describe('Longitude coordinate to query'), - latitude: z - .number() - .min(-90) - .max(90) - .describe('Latitude coordinate to query'), - radius: z - .number() - .min(0) - .optional() - .default(0) - .describe('Radius in meters to search for features (default: 0)'), - limit: z - .number() - .min(1) - .max(50) - .optional() - .default(5) - .describe('Number of features to return (1-50, default: 5)'), - dedupe: z - .boolean() - .optional() - .default(true) - .describe('Whether to deduplicate identical features (default: true)'), - geometry: z - .enum(['polygon', 'linestring', 'point']) - .optional() - .describe('Filter results by geometry type'), + tilesetId: stringSchema( + 'Tileset ID to query (default: mapbox.mapbox-streets-v8)', + true + ).default('mapbox.mapbox-streets-v8'), + longitude: longitudeSchema('Longitude coordinate to query'), + latitude: latitudeSchema('Latitude coordinate to query'), + radius: numberSchema( + 0, + undefined, + 'Radius in meters to search for features (default: 0)', + true + ).default(0), + limit: limitSchema( + 1, + 50, + 'Number of features to return (1-50, default: 5)' + ).default(5), + dedupe: booleanSchema( + 'Whether to deduplicate identical features (default: true)', + true, + true + ), + geometry: enumSchema( + ['polygon', 'linestring', 'point'], + 'Filter results by geometry type', + true + ), layers: z .array(z.string()) .optional() diff --git a/src/tools/update-style-tool/UpdateStyleTool.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.schema.ts index 08d8229..7779dcc 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.schema.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.schema.ts @@ -1,12 +1,10 @@ import { z } from 'zod'; +import { stringSchema, mapboxStyleSchema } from '../../schemas/common.js'; export const UpdateStyleSchema = z.object({ - styleId: z.string().describe('Style ID to update'), - name: z.string().optional().describe('New name for the style'), - style: z - .record(z.any()) - .optional() - .describe('Updated Mapbox style specification object') + styleId: stringSchema('Style ID to update'), + name: stringSchema('New name for the style', true), + style: mapboxStyleSchema('Updated Mapbox style specification object', true) }); export type UpdateStyleInput = z.infer;