From 1bd33a83613a40ae2a8590159db5f2045e0f6027 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 14 Oct 2025 18:45:05 -0400 Subject: [PATCH 1/9] [tools] Update tools to use structuredContent with schema --- cspell.config.json | 16 + package.json | 17 +- src/index.ts | 3 + src/schemas/style.ts | 238 +++++++++++++ src/tools/BaseTool.ts | 136 +++----- src/tools/MapboxApiBasedTool.ts | 113 +++--- .../tool-naming-convention.test.ts.snap | 86 ----- .../bounding-box-tool/BoundariesData-cjs.cts | 3 + src/tools/bounding-box-tool/BoundariesData.ts | 3 + ...ema.ts => BoundingBoxTool.input.schema.ts} | 3 + .../BoundingBoxTool.output.schema.ts | 18 + .../bounding-box-tool/BoundingBoxTool.ts | 42 ++- ...=> CountryBoundingBoxTool.input.schema.ts} | 3 + .../CountryBoundingBoxTool.output.schema.ts | 20 ++ .../CountryBoundingBoxTool.ts | 31 +- ... CoordinateConversionTool.input.schema.ts} | 3 + .../CoordinateConversionTool.output.schema.ts | 16 + .../CoordinateConversionTool.ts | 73 ++-- .../CreateStyleTool.input.schema.ts | 17 + .../CreateStyleTool.output.schema.ts | 30 ++ .../CreateStyleTool.schema.ts | 8 - .../create-style-tool/CreateStyleTool.ts | 72 ++-- ...ema.ts => CreateTokenTool.input.schema.ts} | 3 + .../CreateTokenTool.output.schema.ts | 21 ++ .../create-token-tool/CreateTokenTool.ts | 96 ++++-- ...ema.ts => DeleteStyleTool.input.schema.ts} | 0 .../delete-style-tool/DeleteStyleTool.ts | 37 +- ....ts => GeojsonPreviewTool.input.schema.ts} | 3 + .../GeojsonPreviewTool.ts | 39 ++- ...=> GetMapboxDocSourceTool.input.schema.ts} | 3 + .../GetMapboxDocSourceTool.ts | 44 ++- ...hema.ts => ListStylesTool.input.schema.ts} | 3 + .../ListStylesTool.output.schema.ts | 10 + src/tools/list-styles-tool/ListStylesTool.ts | 63 +++- ...hema.ts => ListTokensTool.input.schema.ts} | 0 .../ListTokensTool.output.schema.ts | 27 ++ src/tools/list-tokens-tool/ListTokensTool.ts | 88 ++++- ...ma.ts => PreviewStyleTool.input.schema.ts} | 0 .../preview-style-tool/PreviewStyleTool.ts | 19 +- ...a.ts => RetrieveStyleTool.input.schema.ts} | 0 .../RetrieveStyleTool.output.schema.ts | 30 ++ .../retrieve-style-tool/RetrieveStyleTool.ts | 59 +++- ...ma.ts => StyleBuilderTool.input.schema.ts} | 3 + .../style-builder-tool/StyleBuilderTool.ts | 12 +- .../StyleComparisonTool.schema.ts | 3 + .../StyleComparisonTool.ts | 21 +- ...chema.ts => TilequeryTool.input.schema.ts} | 3 + .../TilequeryTool.output.schema.ts | 77 +++++ src/tools/tilequery-tool/TilequeryTool.ts | 81 ++++- src/tools/toolRegistry.ts | 22 +- .../UpdateStyleTool.input.schema.ts | 17 + .../UpdateStyleTool.output.schema.ts | 29 ++ .../UpdateStyleTool.schema.ts | 12 - .../update-style-tool/UpdateStyleTool.ts | 67 +++- .../{fetchRequest.ts => httpPipeline.ts} | 61 ++-- src/utils/jwtUtils.ts | 51 +++ src/utils/styleUtils.ts | 3 + src/utils/types.ts | 9 + src/utils/versionUtils-cjs.cts | 3 + src/utils/versionUtils.ts | 3 + .../tool-naming-convention.test.ts.snap | 45 --- .../bounding-box-tool/BoundingBoxTool.test.ts | 5 +- .../CountryBoundingBoxTool.test.ts | 5 +- .../CoordinateConversionTool.test.ts | 5 +- .../create-style-tool/CreateStyleTool.test.ts | 28 +- .../create-token-tool/CreateTokenTool.test.ts | 76 ++-- .../delete-style-tool/DeleteStyleTool.test.ts | 30 +- .../GeojsonPreviewTool.test.ts | 5 +- .../GetMapboxDocSourceTool.test.ts | 42 ++- .../list-styles-tool/ListStylesTool.test.ts | 65 ++-- .../list-tokens-tool/ListTokensTool.test.ts | 117 ++++--- .../PreviewStyleTool.test.ts | 35 +- .../RetrieveStyleTool.test.ts | 24 +- .../StyleBuilderTool.test.ts | 249 ++++++++------ .../StyleComparisonTool.test.ts | 33 +- .../tilequery-tool/TilequeryTool.test.ts | 9 +- .../update-style-tool/UpdateStyleTool.test.ts | 28 +- test/utils/fetchRequest.test.ts | 325 ------------------ test/utils/httpPipeline.test.ts | 268 +++++++++++++++ ...chRequestUtils.ts => httpPipelineUtils.ts} | 18 +- 80 files changed, 2161 insertions(+), 1224 deletions(-) create mode 100644 cspell.config.json create mode 100644 src/schemas/style.ts delete mode 100644 src/tools/__snapshots__/tool-naming-convention.test.ts.snap rename src/tools/bounding-box-tool/{BoundingBoxTool.schema.ts => BoundingBoxTool.input.schema.ts} (91%) create mode 100644 src/tools/bounding-box-tool/BoundingBoxTool.output.schema.ts rename src/tools/bounding-box-tool/{CountryBoundingBoxTool.schema.ts => CountryBoundingBoxTool.input.schema.ts} (82%) create mode 100644 src/tools/bounding-box-tool/CountryBoundingBoxTool.output.schema.ts rename src/tools/coordinate-conversion-tool/{CoordinateConversionTool.schema.ts => CoordinateConversionTool.input.schema.ts} (90%) create mode 100644 src/tools/coordinate-conversion-tool/CoordinateConversionTool.output.schema.ts create mode 100644 src/tools/create-style-tool/CreateStyleTool.input.schema.ts create mode 100644 src/tools/create-style-tool/CreateStyleTool.output.schema.ts delete mode 100644 src/tools/create-style-tool/CreateStyleTool.schema.ts rename src/tools/create-token-tool/{CreateTokenTool.schema.ts => CreateTokenTool.input.schema.ts} (93%) create mode 100644 src/tools/create-token-tool/CreateTokenTool.output.schema.ts rename src/tools/delete-style-tool/{DeleteStyleTool.schema.ts => DeleteStyleTool.input.schema.ts} (100%) rename src/tools/geojson-preview-tool/{GeojsonPreviewTool.schema.ts => GeojsonPreviewTool.input.schema.ts} (87%) rename src/tools/get-mapbox-doc-source-tool/{GetMapboxDocSourceTool.schema.ts => GetMapboxDocSourceTool.input.schema.ts} (70%) rename src/tools/list-styles-tool/{ListStylesTool.schema.ts => ListStylesTool.input.schema.ts} (87%) create mode 100644 src/tools/list-styles-tool/ListStylesTool.output.schema.ts rename src/tools/list-tokens-tool/{ListTokensTool.schema.ts => ListTokensTool.input.schema.ts} (100%) create mode 100644 src/tools/list-tokens-tool/ListTokensTool.output.schema.ts rename src/tools/preview-style-tool/{PreviewStyleTool.schema.ts => PreviewStyleTool.input.schema.ts} (100%) rename src/tools/retrieve-style-tool/{RetrieveStyleTool.schema.ts => RetrieveStyleTool.input.schema.ts} (100%) create mode 100644 src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts rename src/tools/style-builder-tool/{StyleBuilderTool.schema.ts => StyleBuilderTool.input.schema.ts} (99%) rename src/tools/tilequery-tool/{TilequeryTool.schema.ts => TilequeryTool.input.schema.ts} (95%) create mode 100644 src/tools/tilequery-tool/TilequeryTool.output.schema.ts create mode 100644 src/tools/update-style-tool/UpdateStyleTool.input.schema.ts create mode 100644 src/tools/update-style-tool/UpdateStyleTool.output.schema.ts delete mode 100644 src/tools/update-style-tool/UpdateStyleTool.schema.ts rename src/utils/{fetchRequest.ts => httpPipeline.ts} (69%) create mode 100644 src/utils/jwtUtils.ts create mode 100644 src/utils/types.ts delete mode 100644 test/utils/fetchRequest.test.ts create mode 100644 test/utils/httpPipeline.test.ts rename test/utils/{fetchRequestUtils.ts => httpPipelineUtils.ts} (62%) diff --git a/cspell.config.json b/cspell.config.json new file mode 100644 index 0000000..e8f6d4a --- /dev/null +++ b/cspell.config.json @@ -0,0 +1,16 @@ +{ + "version": "0.2", + "language": "en", + "words": [ + "bbox", + "denoise", + "isochrone", + "mapbox", + "mmss", + "tilequery" + ], + "ignorePaths": [ + "node_modules", + "dist" + ] +} diff --git a/package.json b/package.json index c213241..2f5e32d 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,18 @@ "mapbox-mcp-devkit": "dist/esm/index.js" }, "scripts": { - "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\"", - "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" --fix", - "fix-lint": "npm run lint:fix && npm run format:fix", + "build": "npm run prepare && tshy && npm run generate-version && node scripts/add-shebang.cjs", "format": "prettier --check \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\"", "format:fix": "prettier --write \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\"", - "prepare": "husky && node .husky/setup-hooks.js", - "test": "vitest", - "build": "npm run prepare && npm run sync-manifest && tshy && npm run generate-version && node scripts/build-helpers.cjs copy-json && node scripts/add-shebang.cjs", "generate-version": "node scripts/build-helpers.cjs generate-version", + "inspect:build": "npm run build && npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" node dist/esm/index.js", + "inspect:dev": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" npx -y tsx src/index.ts", + "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\"", + "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" --fix", + "prepare": "husky && node .husky/setup-hooks.js", + "spellcheck": "cspell \"*.md\" \"src/**/*.ts\" \"test/**/*.ts\"", "sync-manifest": "node scripts/sync-manifest-version.cjs", - "dev": "tsc -p tsconfig.json --watch", - "dev:inspect": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_PRIVATE_TOKEN\" npx -y tsx src/index.ts" + "test": "vitest" }, "lint-staged": { "*.{js,jsx,ts,tsx}": "eslint --fix", @@ -52,6 +52,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-istanbul": "^3.2.4", + "cspell": "^9.2.1", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.21.3", diff --git a/src/index.ts b/src/index.ts index 728000a..d38e577 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { parseToolConfigFromArgs, filterTools } from './config/toolConfig.js'; diff --git a/src/schemas/style.ts b/src/schemas/style.ts new file mode 100644 index 0000000..dcf9643 --- /dev/null +++ b/src/schemas/style.ts @@ -0,0 +1,238 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Basic types +const ColorSchema = z + .string() + .describe('Color as hex, rgb, rgba, hsl, or hsla'); +const CoordinatesSchema = z.tuple([z.number(), z.number()]); + +// Transition schema +const TransitionSchema = z + .object({ + duration: z.number().optional(), + delay: z.number().optional() + }) + .passthrough(); + +// Light schema +const LightSchema = z + .object({ + anchor: z.enum(['map', 'viewport']).optional(), + position: z.tuple([z.number(), z.number(), z.number()]).optional(), + color: ColorSchema.optional(), + intensity: z.number().optional() + }) + .passthrough(); + +// Lights (3D) schema +const LightsSchema = z.array( + z + .object({ + id: z.string(), + type: z.enum(['ambient', 'directional']) + }) + .passthrough() +); + +// Terrain schema +const TerrainSchema = z + .object({ + source: z.string(), + exaggeration: z.number().optional() + }) + .passthrough(); + +// Source schemas +const VectorSourceSchema = z + .object({ + type: z.literal('vector'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .optional(), + scheme: z.enum(['xyz', 'tms']).optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + attribution: z.string().optional(), + promoteId: z.union([z.string(), z.record(z.string())]).optional(), + volatile: z.boolean().optional() + }) + .passthrough(); + +const RasterSourceSchema = z + .object({ + type: z.literal('raster'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + tileSize: z.number().optional(), + scheme: z.enum(['xyz', 'tms']).optional(), + attribution: z.string().optional(), + volatile: z.boolean().optional() + }) + .passthrough(); + +const RasterDemSourceSchema = z + .object({ + type: z.literal('raster-dem'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + tileSize: z.number().optional(), + attribution: z.string().optional(), + encoding: z.enum(['terrarium', 'mapbox']).optional(), + volatile: z.boolean().optional() + }) + .passthrough(); + +const GeoJSONSourceSchema = z + .object({ + type: z.literal('geojson'), + data: z.union([z.string(), z.any()]), // URL or inline GeoJSON + maxzoom: z.number().min(0).max(24).optional(), + attribution: z.string().optional(), + buffer: z.number().min(0).max(512).optional(), + tolerance: z.number().optional(), + cluster: z.boolean().optional(), + clusterRadius: z.number().optional(), + clusterMaxZoom: z.number().optional(), + clusterProperties: z.record(z.any()).optional(), + lineMetrics: z.boolean().optional(), + generateId: z.boolean().optional(), + promoteId: z.union([z.string(), z.record(z.string())]).optional() + }) + .passthrough(); + +const ImageSourceSchema = z + .object({ + type: z.literal('image'), + url: z.string(), + coordinates: z.tuple([ + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema + ]) + }) + .passthrough(); + +const VideoSourceSchema = z + .object({ + type: z.literal('video'), + urls: z.array(z.string()), + coordinates: z.tuple([ + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema + ]) + }) + .passthrough(); + +const SourceSchema = z.union([ + VectorSourceSchema, + RasterSourceSchema, + RasterDemSourceSchema, + GeoJSONSourceSchema, + ImageSourceSchema, + VideoSourceSchema +]); + +// Layer schema (simplified - full schema would be very extensive) +const LayerSchema = z + .object({ + id: z.string().describe('Unique layer name'), + type: z.enum([ + 'fill', + 'line', + 'symbol', + 'circle', + 'heatmap', + 'fill-extrusion', + 'raster', + 'hillshade', + 'background', + 'sky', + 'slot', + 'clip', + 'model', + 'raster-particle', + 'building' + ]), + source: z + .string() + .optional() + .describe('Source name (not required for background/sky/slot)'), + 'source-layer': z + .string() + .optional() + .describe('Layer from vector tile source'), + minzoom: z.number().min(0).max(24).optional(), + maxzoom: z.number().min(0).max(24).optional(), + filter: z.any().optional().describe('Expression for filtering features'), + layout: z.record(z.any()).optional(), + paint: z.record(z.any()).optional(), + metadata: z.record(z.any()).optional(), + slot: z.string().optional().describe('Slot this layer is assigned to') + }) + .passthrough(); + +// Style import schema +const StyleImportSchema = z + .object({ + id: z.string(), + url: z.string(), + config: z.record(z.any()).optional() + }) + .passthrough(); + +// Base Style properties (shared between input and output) +export const BaseStylePropertiesSchema = z.object({ + // Required Style Spec properties + version: z + .literal(8) + .describe('Style specification version number. Must be 8'), + sources: z.record(SourceSchema).describe('Data source specifications'), + layers: z.array(LayerSchema).describe('Layers in draw order'), + + // Optional Style Spec properties + metadata: z + .record(z.any()) + .optional() + .describe('Arbitrary properties for tracking'), + center: CoordinatesSchema.optional().describe( + 'Default map center [longitude, latitude]' + ), + zoom: z.number().optional().describe('Default zoom level'), + bearing: z.number().optional().describe('Default bearing in degrees'), + pitch: z.number().optional().describe('Default pitch in degrees'), + sprite: z + .string() + .optional() + .describe('Base URL for sprite image and metadata'), + glyphs: z.string().optional().describe('URL template for glyph sets'), + light: LightSchema.optional().describe( + 'Global light source (deprecated, use lights)' + ), + lights: LightsSchema.optional().describe('Array of 3D light sources'), + terrain: TerrainSchema.optional().describe('Global terrain elevation'), + fog: z.record(z.any()).optional().describe('Fog properties'), + projection: z.record(z.any()).optional().describe('Map projection'), + transition: TransitionSchema.optional().describe('Default transition timing'), + imports: z.array(StyleImportSchema).optional().describe('Imported styles') +}); + +export type MapboxSource = z.infer; +export type MapboxLayer = z.infer; diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index bea2c64..3f48a48 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -1,102 +1,45 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; -import { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolResult, + ToolAnnotations +} from '@modelcontextprotocol/sdk/types.js'; import { z, ZodTypeAny } from 'zod'; -const ContentItemSchema = z.union([ - z.object({ - type: z.literal('text'), - text: z.string() - }), - z.object({ - type: z.literal('image'), - data: z.string(), - mimeType: z.string() - }) -]); - -export const OutputSchema = z.object({ - content: z.array(ContentItemSchema), - isError: z.boolean().default(false) -}); - -export type ContentItem = z.infer; -export type ToolOutput = z.infer; - -export abstract class BaseTool { +export abstract class BaseTool< + InputSchema extends ZodTypeAny, + OutputSchema extends ZodTypeAny = ZodTypeAny +> { abstract readonly name: string; abstract readonly description: string; abstract readonly annotations: ToolAnnotations; readonly inputSchema: InputSchema; + readonly outputSchema?: OutputSchema; protected server: McpServer | null = null; - constructor(params: { inputSchema: InputSchema }) { + constructor(params: { + inputSchema: InputSchema; + outputSchema?: OutputSchema; + }) { this.inputSchema = params.inputSchema; + this.outputSchema = params.outputSchema; } /** - * Validates and runs the tool logic. + * Tool logic to be implemented by subclasses. */ - async run( + abstract run( rawInput: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any extra?: RequestHandlerExtra - ): Promise> { - try { - const input = this.inputSchema.parse(rawInput); - const accessToken = - extra?.authInfo?.token || process.env.MAPBOX_ACCESS_TOKEN; - const result = await this.execute(input, accessToken); - - // Check if result is already a content object (image or text) - if ( - result && - typeof result === 'object' && - 'type' in result && - (result.type === 'image' || result.type === 'text') - ) { - return { - content: [result as ContentItem], - isError: false - }; - } - - // Otherwise return as text - return { - content: [{ type: 'text', text: JSON.stringify(result) }], - isError: false - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - this.log( - 'error', - `${this.name}: Error during execution: ${errorMessage}` - ); - - return { - content: [ - { - type: 'text', - text: errorMessage || 'Internal error has occurred.' - } - ], - isError: true - }; - } - } - - /** - * Tool logic to be implemented by subclasses. - */ - protected abstract execute( - _input: z.infer, - accessToken?: string - ): Promise; + ): Promise; /** * Installs the tool to the given MCP server. @@ -104,18 +47,31 @@ export abstract class BaseTool { installTo(server: McpServer): RegisteredTool { this.server = server; - return server.registerTool( - this.name, - { - description: this.description, - inputSchema: ( - this.inputSchema as unknown as z.ZodObject< - Record - > - ).shape, - annotations: this.annotations - }, - (args: any, extra: any) => this.run(args, extra) + const config: { + title?: string; + description?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputSchema?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputSchema?: any; + annotations?: ToolAnnotations; + } = { + title: this.annotations.title, + description: this.description, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputSchema: (this.inputSchema as unknown as z.ZodObject).shape, + annotations: this.annotations + }; + + // Add outputSchema if provided + if (this.outputSchema) { + config.outputSchema = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.outputSchema as unknown as z.ZodObject).shape; + } + + return server.registerTool(this.name, config, (args, extra) => + this.run(args, extra) ); } diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 2f1a16a..eb261b3 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -1,10 +1,23 @@ -import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; -import { z, ZodTypeAny } from 'zod'; -import { BaseTool, OutputSchema } from './BaseTool.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { ZodTypeAny, z } from 'zod'; +import { BaseTool } from './BaseTool.js'; +import type { + CallToolResult, + ToolAnnotations +} from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../utils/types.js'; export abstract class MapboxApiBasedTool< - InputSchema extends ZodTypeAny -> extends BaseTool { + InputSchema extends ZodTypeAny, + OutputSchema extends ZodTypeAny = ZodTypeAny +> extends BaseTool { + abstract readonly name: string; + abstract readonly description: string; + abstract readonly annotations: ToolAnnotations; + static get mapboxAccessToken() { return process.env.MAPBOX_ACCESS_TOKEN; } @@ -13,51 +26,15 @@ export abstract class MapboxApiBasedTool< return process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'; } - constructor(params: { inputSchema: InputSchema }) { - super(params); - } - - /** - * Extracts the username from the Mapbox access token. - * Mapbox tokens are JWT tokens where the payload contains the username. - * @throws Error if the token is not set, invalid, or doesn't contain username - */ - static getUserNameFromToken(access_token?: string): string { - if (!access_token) { - if (!MapboxApiBasedTool.mapboxAccessToken) { - throw new Error( - 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' - ); - } - access_token = MapboxApiBasedTool.mapboxAccessToken; - } - - try { - // JWT format: header.payload.signature - const parts = access_token.split('.'); - if (parts.length !== 3) { - throw new Error('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); - } + protected httpRequest: HttpRequest; - // Decode the payload (second part) - const payload = JSON.parse( - Buffer.from(parts[1], 'base64').toString('utf-8') - ); - - // The username is stored in the 'u' field - if (!payload.u) { - throw new Error( - 'MAPBOX_ACCESS_TOKEN does not contain username in payload' - ); - } - - return payload.u; - } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error('Failed to parse MAPBOX_ACCESS_TOKEN'); - } + constructor(params: { + inputSchema: InputSchema; + outputSchema?: OutputSchema; + httpRequest: HttpRequest; + }) { + super(params); + this.httpRequest = params.httpRequest; } /** @@ -76,12 +53,13 @@ export abstract class MapboxApiBasedTool< } /** - * Validates Mapbox token and runs the tool logic. + * Validates and runs the tool logic. */ async run( rawInput: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any extra?: RequestHandlerExtra - ): Promise> { + ): Promise { try { // First check if token is provided via authentication context // Check both standard token field and accessToken in extra for compatibility @@ -90,18 +68,28 @@ export abstract class MapboxApiBasedTool< const authToken = extra?.authInfo?.token; const accessToken = authToken || MapboxApiBasedTool.mapboxAccessToken; if (!accessToken) { - throw new Error( - 'No access token available. Please provide via Bearer auth or MAPBOX_ACCESS_TOKEN env var' - ); + const errorMessage = + 'No access token available. Please provide via Bearer auth or MAPBOX_ACCESS_TOKEN env var'; + this.log('error', `${this.name}: ${errorMessage}`); + return { + content: [{ type: 'text', text: errorMessage }], + isError: true + }; } // Validate that the token has the correct JWT format if (!this.isValidJwtFormat(accessToken)) { - throw new Error('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); + const errorMessage = 'Access token is not in valid JWT format'; + this.log('error', `${this.name}: ${errorMessage}`); + return { + content: [{ type: 'text', text: errorMessage }], + isError: true + }; } - // Call parent run method which handles the rest - return await super.run(rawInput, extra); + const input = this.inputSchema.parse(rawInput); + const result = await this.execute(input, accessToken); + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -115,11 +103,20 @@ export abstract class MapboxApiBasedTool< content: [ { type: 'text', - text: errorMessage || 'Internal error has occurred.' + text: errorMessage } ], isError: true }; } } + + /** + * Tool logic to be implemented by subclasses. + * Must return a complete OutputSchema with content and optional structured content. + */ + protected abstract execute( + _input: z.infer, + accessToken: string + ): Promise; } diff --git a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap b/src/tools/__snapshots__/tool-naming-convention.test.ts.snap deleted file mode 100644 index 204a4e5..0000000 --- a/src/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ /dev/null @@ -1,86 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Tool Naming Convention should maintain consistent tool list (snapshot test) 1`] = ` -[ - { - "className": "BoundingBoxTool", - "description": "Calculates bounding box of given GeoJSON content, returns as [minX, minY, maxX, maxY]", - "toolName": "bounding_box_tool", - }, - { - "className": "CoordinateConversionTool", - "description": "Converts coordinates between WGS84 (longitude/latitude) and EPSG:3857 (Web Mercator) coordinate systems", - "toolName": "coordinate_conversion_tool", - }, - { - "className": "CountryBoundingBoxTool", - "description": "Gets bounding box for a country by its ISO 3166-1 country code, returns as [minX, minY, maxX, maxY].", - "toolName": "country_bounding_box_tool", - }, - { - "className": "CreateStyleTool", - "description": "Create a new Mapbox style", - "toolName": "create_style_tool", - }, - { - "className": "CreateTokenTool", - "description": "Create a new Mapbox public access token with specified scopes and optional URL restrictions.", - "toolName": "create_token_tool", - }, - { - "className": "DeleteStyleTool", - "description": "Delete a Mapbox style by ID", - "toolName": "delete_style_tool", - }, - { - "className": "GeojsonPreviewTool", - "description": "Generate a geojson.io URL to visualize GeoJSON data. Returns only the URL link.", - "toolName": "geojson_preview_tool", - }, - { - "className": "GetMapboxDocSourceTool", - "description": "Get the latest official Mapbox documentation, APIs, SDKs, and developer resources directly from Mapbox. Always up-to-date, comprehensive coverage of all current Mapbox services including mapping, navigation, search, geocoding, and mobile SDKs. Use this for accurate, official Mapbox information instead of web search.", - "toolName": "get_latest_mapbox_docs_tool", - }, - { - "className": "ListStylesTool", - "description": "List styles for a Mapbox account. Use limit parameter to avoid large responses (recommended: limit=5-10). Use start parameter for pagination.", - "toolName": "list_styles_tool", - }, - { - "className": "ListTokensTool", - "description": "List Mapbox access tokens for the authenticated user with optional filtering and pagination. When using pagination, the "start" parameter must be obtained from the "next_start" field of the previous response (it is not a token ID)", - "toolName": "list_tokens_tool", - }, - { - "className": "PreviewStyleTool", - "description": "Generate preview URL for a Mapbox style using an existing public token", - "toolName": "preview_style_tool", - }, - { - "className": "RetrieveStyleTool", - "description": "Retrieve a specific Mapbox style by ID", - "toolName": "retrieve_style_tool", - }, - { - "className": "StyleComparisonTool", - "description": "Generate a comparison URL for comparing two Mapbox styles side-by-side", - "toolName": "style_comparison_tool", - }, - { - "className": "StyleHelperTool", - "description": "Interactive helper for creating custom Mapbox styles with specific features and colors", - "toolName": "style_helper_tool", - }, - { - "className": "TilequeryTool", - "description": "Query vector and raster data from Mapbox tilesets at geographic coordinates", - "toolName": "tilequery_tool", - }, - { - "className": "UpdateStyleTool", - "description": "Update an existing Mapbox style", - "toolName": "update_style_tool", - }, -] -`; diff --git a/src/tools/bounding-box-tool/BoundariesData-cjs.cts b/src/tools/bounding-box-tool/BoundariesData-cjs.cts index 732bed9..19cae41 100644 --- a/src/tools/bounding-box-tool/BoundariesData-cjs.cts +++ b/src/tools/bounding-box-tool/BoundariesData-cjs.cts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import boundariesData from './boundaries_v4_country_bbox_min.json'; export default boundariesData; diff --git a/src/tools/bounding-box-tool/BoundariesData.ts b/src/tools/bounding-box-tool/BoundariesData.ts index 1a5ba6a..e64d0c9 100644 --- a/src/tools/bounding-box-tool/BoundariesData.ts +++ b/src/tools/bounding-box-tool/BoundariesData.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import boundariesData from './boundaries_v4_country_bbox_min.json' with { type: 'json' }; diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.schema.ts b/src/tools/bounding-box-tool/BoundingBoxTool.input.schema.ts similarity index 91% rename from src/tools/bounding-box-tool/BoundingBoxTool.schema.ts rename to src/tools/bounding-box-tool/BoundingBoxTool.input.schema.ts index 03cd826..d986f32 100644 --- a/src/tools/bounding-box-tool/BoundingBoxTool.schema.ts +++ b/src/tools/bounding-box-tool/BoundingBoxTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; // Define a loose GeoJSON schema that accepts any valid GeoJSON structure diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.output.schema.ts b/src/tools/bounding-box-tool/BoundingBoxTool.output.schema.ts new file mode 100644 index 0000000..c8616b5 --- /dev/null +++ b/src/tools/bounding-box-tool/BoundingBoxTool.output.schema.ts @@ -0,0 +1,18 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const BoundingBoxOutputSchema = z.object({ + bbox: z + .tuple([ + z.number().describe('minX (west longitude)'), + z.number().describe('minY (south latitude)'), + z.number().describe('maxX (east longitude)'), + z.number().describe('maxY (north latitude)') + ]) + .describe('Bounding box as [minX, minY, maxX, maxY]'), + message: z.string().optional().describe('Status or error message') +}); + +export type BoundingBoxOutput = z.infer; diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.ts b/src/tools/bounding-box-tool/BoundingBoxTool.ts index 2cbaa3c..c094acc 100644 --- a/src/tools/bounding-box-tool/BoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/BoundingBoxTool.ts @@ -1,3 +1,7 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { GeoJSON, Feature, @@ -10,9 +14,13 @@ import { BaseTool } from '../BaseTool.js'; import { BoundingBoxSchema, BoundingBoxInput -} from './BoundingBoxTool.schema.js'; +} from './BoundingBoxTool.input.schema.js'; +import { BoundingBoxOutputSchema } from './BoundingBoxTool.output.schema.js'; -export class BoundingBoxTool extends BaseTool { +export class BoundingBoxTool extends BaseTool< + typeof BoundingBoxSchema, + typeof BoundingBoxOutputSchema +> { readonly name = 'bounding_box_tool'; readonly description = 'Calculates bounding box of given GeoJSON content, returns as [minX, minY, maxX, maxY]'; @@ -25,12 +33,13 @@ export class BoundingBoxTool extends BaseTool { }; constructor() { - super({ inputSchema: BoundingBoxSchema }); + super({ + inputSchema: BoundingBoxSchema, + outputSchema: BoundingBoxOutputSchema + }); } - protected async execute( - input: BoundingBoxInput - ): Promise<{ type: 'text'; text: string }> { + async run(input: BoundingBoxInput): Promise { const { geojson } = input; // Parse GeoJSON if it's a string @@ -43,8 +52,16 @@ export class BoundingBoxTool extends BaseTool { const bbox = this.calculateBoundingBox(geojsonObject); return { - type: 'text', - text: JSON.stringify(bbox, null, 2) + content: [ + { + type: 'text', + text: JSON.stringify(bbox, null, 2) + } + ], + structuredContent: { + bbox + }, + isError: false }; } @@ -131,13 +148,4 @@ export class BoundingBoxTool extends BaseTool { return [minX, minY, maxX, maxY]; } - - private isPosition(coords: unknown): coords is Position { - return ( - Array.isArray(coords) && - coords.length >= 2 && - typeof coords[0] === 'number' && - typeof coords[1] === 'number' - ); - } } diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.schema.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.input.schema.ts similarity index 82% rename from src/tools/bounding-box-tool/CountryBoundingBoxTool.schema.ts rename to src/tools/bounding-box-tool/CountryBoundingBoxTool.input.schema.ts index 4d13641..2e1b3ef 100644 --- a/src/tools/bounding-box-tool/CountryBoundingBoxTool.schema.ts +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const CountryBoundingBoxSchema = z.object({ diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.output.schema.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.output.schema.ts new file mode 100644 index 0000000..e503c00 --- /dev/null +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.output.schema.ts @@ -0,0 +1,20 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const CountryBoundingBoxOutputSchema = z.object({ + bbox: z + .tuple([ + z.number().describe('minX (west longitude)'), + z.number().describe('minY (south latitude)'), + z.number().describe('maxX (east longitude)'), + z.number().describe('maxY (north latitude)') + ]) + .describe('Bounding box as [minX, minY, maxX, maxY]'), + message: z.string().optional().describe('Status or error message') +}); + +export type CountryBoundingBoxOutput = z.infer< + typeof CountryBoundingBoxOutputSchema +>; diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts index e834169..65190c7 100644 --- a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts @@ -1,12 +1,18 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { BaseTool } from '../BaseTool.js'; import { CountryBoundingBoxSchema, CountryBoundingBoxInput -} from './CountryBoundingBoxTool.schema.js'; +} from './CountryBoundingBoxTool.input.schema.js'; +import { CountryBoundingBoxOutputSchema } from './CountryBoundingBoxTool.output.schema.js'; import boundariesData from './BoundariesData.js'; export class CountryBoundingBoxTool extends BaseTool< - typeof CountryBoundingBoxSchema + typeof CountryBoundingBoxSchema, + typeof CountryBoundingBoxOutputSchema > { readonly name = 'country_bounding_box_tool'; readonly description = @@ -26,12 +32,13 @@ export class CountryBoundingBoxTool extends BaseTool< >; constructor() { - super({ inputSchema: CountryBoundingBoxSchema }); + super({ + inputSchema: CountryBoundingBoxSchema, + outputSchema: CountryBoundingBoxOutputSchema + }); } - protected async execute( - input: CountryBoundingBoxInput - ): Promise<{ type: 'text'; text: string }> { + async run(input: CountryBoundingBoxInput): Promise { const { iso_3166_1 } = input; const upperCaseCode = iso_3166_1.toUpperCase(); const bbox = this.boundariesData[upperCaseCode]; @@ -43,8 +50,16 @@ export class CountryBoundingBoxTool extends BaseTool< } return { - type: 'text', - text: JSON.stringify(bbox, null, 2) + content: [ + { + type: 'text', + text: JSON.stringify(bbox, null, 2) + } + ], + structuredContent: { + bbox + }, + isError: false }; } diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.schema.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.input.schema.ts similarity index 90% rename from src/tools/coordinate-conversion-tool/CoordinateConversionTool.schema.ts rename to src/tools/coordinate-conversion-tool/CoordinateConversionTool.input.schema.ts index 7ab9b66..1e3255f 100644 --- a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.schema.ts +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const CoordinateConversionSchema = z.object({ diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.output.schema.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.output.schema.ts new file mode 100644 index 0000000..aa1d99d --- /dev/null +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.output.schema.ts @@ -0,0 +1,16 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const CoordinateConversionOutputSchema = z.object({ + input: z.array(z.number()).length(2).describe('Input coordinates'), + output: z.array(z.number()).length(2).describe('Converted coordinates'), + from: z.enum(['wgs84', 'epsg3857']).describe('Source coordinate system'), + to: z.enum(['wgs84', 'epsg3857']).describe('Target coordinate system'), + message: z.string().optional().describe('Conversion status message') +}); + +export type CoordinateConversionOutput = z.infer< + typeof CoordinateConversionOutputSchema +>; diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts index aab9c64..1dbf95b 100644 --- a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts @@ -1,11 +1,20 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { BaseTool } from '../BaseTool.js'; +import { + CoordinateConversionOutput, + CoordinateConversionOutputSchema +} from './CoordinateConversionTool.output.schema.js'; import { CoordinateConversionSchema, CoordinateConversionInput -} from './CoordinateConversionTool.schema.js'; +} from './CoordinateConversionTool.input.schema.js'; export class CoordinateConversionTool extends BaseTool< - typeof CoordinateConversionSchema + typeof CoordinateConversionSchema, + typeof CoordinateConversionOutputSchema > { readonly name = 'coordinate_conversion_tool'; readonly description = @@ -19,28 +28,33 @@ export class CoordinateConversionTool extends BaseTool< }; constructor() { - super({ inputSchema: CoordinateConversionSchema }); + super({ + inputSchema: CoordinateConversionSchema, + outputSchema: CoordinateConversionOutputSchema + }); } - protected async execute( - input: CoordinateConversionInput - ): Promise<{ type: 'text'; text: string }> { + async run(input: CoordinateConversionInput): Promise { const { coordinates, from, to } = input; if (from === to) { + const outputResult: CoordinateConversionOutput = { + input: coordinates, + output: coordinates, + from, + to, + message: 'No conversion needed - source and target are the same' + }; + return { - type: 'text', - text: JSON.stringify( + content: [ { - input: coordinates, - output: coordinates, - from, - to, - message: 'No conversion needed - source and target are the same' - }, - null, - 2 - ) + type: 'text', + text: JSON.stringify(outputResult, null, 2) + } + ], + isError: false, + structuredContent: outputResult }; } @@ -54,18 +68,23 @@ export class CoordinateConversionTool extends BaseTool< throw new Error(`Unsupported conversion: ${from} to ${to}`); } + const outputResult: CoordinateConversionOutput = { + input: coordinates, + output: result, + from, + to, + message: 'Conversion successful' + }; + return { - type: 'text', - text: JSON.stringify( + content: [ { - input: coordinates, - output: result, - from, - to - }, - null, - 2 - ) + type: 'text', + text: JSON.stringify(outputResult, null, 2) + } + ], + isError: false, + structuredContent: outputResult }; } diff --git a/src/tools/create-style-tool/CreateStyleTool.input.schema.ts b/src/tools/create-style-tool/CreateStyleTool.input.schema.ts new file mode 100644 index 0000000..a3d5bce --- /dev/null +++ b/src/tools/create-style-tool/CreateStyleTool.input.schema.ts @@ -0,0 +1,17 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// INPUT Schema - For creating/updating styles (PATCH/POST request body) +export const MapboxStyleInputSchema = BaseStylePropertiesSchema.extend({ + name: z + .string() + .describe('Human-readable name for the style (REQUIRED for updates)') + // These fields should NOT be included in input - they're read-only + // If present, they'll be ignored or cause API errors +}).passthrough(); + +// Type exports +export type MapboxStyleInput = z.infer; diff --git a/src/tools/create-style-tool/CreateStyleTool.output.schema.ts b/src/tools/create-style-tool/CreateStyleTool.output.schema.ts new file mode 100644 index 0000000..91a2602 --- /dev/null +++ b/src/tools/create-style-tool/CreateStyleTool.output.schema.ts @@ -0,0 +1,30 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// OUTPUT Schema - For API responses (GET/PATCH response) +// TODO: Refactor into shared schema +export const MapboxStyleOutputSchema = BaseStylePropertiesSchema.extend({ + name: z.string().describe('Human-readable name for the style'), + + // API-specific properties (only present in responses) + id: z.string().describe('Unique style identifier'), + owner: z.string().describe('Username of the style owner'), + created: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was created'), + modified: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was last modified'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), + draft: z.boolean().optional().describe('Whether this is a draft version') +}).passthrough(); + +// Type exports +export type MapboxStyleOutput = z.infer; diff --git a/src/tools/create-style-tool/CreateStyleTool.schema.ts b/src/tools/create-style-tool/CreateStyleTool.schema.ts deleted file mode 100644 index 2e5c606..0000000 --- a/src/tools/create-style-tool/CreateStyleTool.schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod'; - -export const CreateStyleSchema = z.object({ - name: z.string().describe('Name for the new style'), - style: z.record(z.any()).describe('Mapbox style specification object') -}); - -export type CreateStyleInput = z.infer; diff --git a/src/tools/create-style-tool/CreateStyleTool.ts b/src/tools/create-style-tool/CreateStyleTool.ts index f1dbdb7..192ae97 100644 --- a/src/tools/create-style-tool/CreateStyleTool.ts +++ b/src/tools/create-style-tool/CreateStyleTool.ts @@ -1,13 +1,23 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { filterExpandedMapboxStyles } from '../../utils/styleUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { - CreateStyleSchema, - CreateStyleInput -} from './CreateStyleTool.schema.js'; + MapboxStyleInputSchema, + MapboxStyleInput +} from './CreateStyleTool.input.schema.js'; +import { + MapboxStyleOutput, + MapboxStyleOutputSchema +} from './CreateStyleTool.output.schema.js'; export class CreateStyleTool extends MapboxApiBasedTool< - typeof CreateStyleSchema + typeof MapboxStyleInputSchema, + typeof MapboxStyleOutputSchema > { name = 'create_style_tool'; description = 'Create a new Mapbox style'; @@ -19,28 +29,27 @@ export class CreateStyleTool extends MapboxApiBasedTool< title: 'Create Mapbox Style Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: CreateStyleSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: MapboxStyleInputSchema, + outputSchema: MapboxStyleOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( - input: CreateStyleInput, + input: MapboxStyleInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}?access_token=${accessToken}`; - const payload = { - name: input.name, - ...input.style - }; - - const response = await this.fetch(url, { + const response = await this.httpRequest(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(input) }); if (!response.ok) { @@ -49,8 +58,31 @@ export class CreateStyleTool extends MapboxApiBasedTool< ); } - const data = await response.json(); - // Return full style but filter out expanded Mapbox styles - return filterExpandedMapboxStyles(data); + const rawData = await response.json(); + // Validate response against schema with graceful fallback + let data: MapboxStyleOutput; + try { + data = MapboxStyleOutputSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` + ); + // Graceful fallback to raw data + data = rawData as MapboxStyleOutput; + } + + this.log('info', `CreateStyleTool: Successfully created style ${data.id}`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + } + ], + structuredContent: filterExpandedMapboxStyles(data), + isError: false + }; } } diff --git a/src/tools/create-token-tool/CreateTokenTool.schema.ts b/src/tools/create-token-tool/CreateTokenTool.input.schema.ts similarity index 93% rename from src/tools/create-token-tool/CreateTokenTool.schema.ts rename to src/tools/create-token-tool/CreateTokenTool.input.schema.ts index 5e58d92..5ab815a 100644 --- a/src/tools/create-token-tool/CreateTokenTool.schema.ts +++ b/src/tools/create-token-tool/CreateTokenTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; // Valid scopes for public tokens diff --git a/src/tools/create-token-tool/CreateTokenTool.output.schema.ts b/src/tools/create-token-tool/CreateTokenTool.output.schema.ts new file mode 100644 index 0000000..9e48e9b --- /dev/null +++ b/src/tools/create-token-tool/CreateTokenTool.output.schema.ts @@ -0,0 +1,21 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const CreateTokenOutputSchema = z.object({ + id: z.string().describe('Token ID'), + name: z.string().describe('Token name'), + scopes: z.array(z.string()).describe('Array of scopes assigned to the token'), + token: z.string().describe('The actual token string'), + created: z.string().describe('ISO 8601 creation timestamp'), + modified: z.string().describe('ISO 8601 last modified timestamp'), + usage: z.string().describe('Token usage type, e.g. pk or sk'), + default: z.boolean().describe('Whether this is the default token'), + note: z.string().optional().describe('Optional note or description'), + allowedUrls: z.array(z.string()).optional().describe('Array of allowed URLs'), + expires: z.string().optional().describe('Expiration time in ISO 8601 format'), + message: z.string().optional().describe('Status or error message') +}); + +export type CreateTokenOutput = z.infer; diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index d242550..d91e101 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -1,12 +1,19 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { CreateTokenSchema, CreateTokenInput -} from './CreateTokenTool.schema.js'; +} from './CreateTokenTool.input.schema.js'; +import { CreateTokenOutputSchema } from './CreateTokenTool.output.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class CreateTokenTool extends MapboxApiBasedTool< - typeof CreateTokenSchema + typeof CreateTokenSchema, + typeof CreateTokenOutputSchema > { readonly name = 'create_token_tool'; readonly description = @@ -19,15 +26,19 @@ export class CreateTokenTool extends MapboxApiBasedTool< title: 'Create Mapbox Token Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: CreateTokenSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: CreateTokenSchema, + outputSchema: CreateTokenOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: CreateTokenInput, accessToken?: string - ): Promise<{ type: 'text'; text: string }> { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); this.log( 'info', @@ -57,38 +68,53 @@ export class CreateTokenTool extends MapboxApiBasedTool< body.expires = input.expires; } - try { - const response = await this.fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - const errorBody = await response.text(); - this.log( - 'error', - `CreateTokenTool: API Error - Status: ${response.status}, Body: ${errorBody}` - ); - throw new Error( - `Failed to create token: ${response.status} ${response.statusText}` - ); - } + const response = await this.httpRequest(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); - const data = await response.json(); - this.log('info', `CreateTokenTool: Successfully created token`); + if (!response.ok) { + const errorBody = await response.text(); + this.log( + 'error', + `CreateTokenTool: API Error - Status: ${response.status}, Body: ${errorBody}` + ); + throw new Error( + `Failed to create token: ${response.status} ${response.statusText}` + ); + } + const data = await response.json(); + const parseResult = CreateTokenOutputSchema.safeParse(data); + if (!parseResult.success) { + this.log( + 'error', + `CreateTokenTool: Output schema validation failed\n${parseResult.error}` + ); return { - type: 'text', - text: JSON.stringify(data, null, 2) + content: [ + { + type: 'text', + text: `CreateTokenTool: Response does not conform to output schema:\n${parseResult.error}` + } + ], + isError: true }; - } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error(`Failed to create token: ${String(error)}`); } + this.log('info', `CreateTokenTool: Successfully created token`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(parseResult.data, null, 2) + } + ], + structuredContent: parseResult.data, + isError: false + }; } } diff --git a/src/tools/delete-style-tool/DeleteStyleTool.schema.ts b/src/tools/delete-style-tool/DeleteStyleTool.input.schema.ts similarity index 100% rename from src/tools/delete-style-tool/DeleteStyleTool.schema.ts rename to src/tools/delete-style-tool/DeleteStyleTool.input.schema.ts diff --git a/src/tools/delete-style-tool/DeleteStyleTool.ts b/src/tools/delete-style-tool/DeleteStyleTool.ts index 1be3a4c..2225dda 100644 --- a/src/tools/delete-style-tool/DeleteStyleTool.ts +++ b/src/tools/delete-style-tool/DeleteStyleTool.ts @@ -1,9 +1,14 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { DeleteStyleSchema, DeleteStyleInput -} from './DeleteStyleTool.schema.js'; +} from './DeleteStyleTool.input.schema.js'; export class DeleteStyleTool extends MapboxApiBasedTool< typeof DeleteStyleSchema @@ -18,33 +23,35 @@ export class DeleteStyleTool extends MapboxApiBasedTool< title: 'Delete Mapbox Style Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: DeleteStyleSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ inputSchema: DeleteStyleSchema, httpRequest: params.httpRequest }); } protected async execute( input: DeleteStyleInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; - const response = await this.fetch(url, { + const response = await this.httpRequest(url, { method: 'DELETE' }); - if (!response.ok) { + if (response.status !== 204) { throw new Error( `Failed to delete style: ${response.status} ${response.statusText}` ); } - // Delete typically returns 204 No Content - if (response.status === 204) { - return { success: true, message: 'Style deleted successfully' }; - } - - const data = await response.json(); - return data; + return { + content: [ + { + type: 'text', + text: 'Style deleted successfully' + } + ], + isError: false + }; } } diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.schema.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.input.schema.ts similarity index 87% rename from src/tools/geojson-preview-tool/GeojsonPreviewTool.schema.ts rename to src/tools/geojson-preview-tool/GeojsonPreviewTool.input.schema.ts index 45a19ee..fb8d27e 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.schema.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; // Simplified GeoJSON schema for maximum MCP client compatibility diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index cc573cd..a233f26 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -1,9 +1,13 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { GeoJSON } from 'geojson'; import { BaseTool } from '../BaseTool.js'; import { GeojsonPreviewSchema, GeojsonPreviewInput -} from './GeojsonPreviewTool.schema.js'; +} from './GeojsonPreviewTool.input.schema.js'; export class GeojsonPreviewTool extends BaseTool { name = 'geojson_preview_tool'; @@ -45,16 +49,22 @@ export class GeojsonPreviewTool extends BaseTool { ); } - protected async execute( - input: GeojsonPreviewInput - ): Promise<{ type: 'text'; text: string }> { + async run(input: GeojsonPreviewInput): Promise { try { // Parse and validate JSON format const geojsonData = JSON.parse(input.geojson); // Validate GeoJSON structure if (!this.isValidGeoJSON(geojsonData)) { - throw new Error('Invalid GeoJSON structure'); + return { + isError: true, + content: [ + { + type: 'text', + text: 'GeoJSON processing failed: Invalid GeoJSON structure' + } + ] + }; } // Generate geojson.io URL @@ -63,13 +73,26 @@ export class GeojsonPreviewTool extends BaseTool { const geojsonIOUrl = `https://geojson.io/#data=data:application/json,${encodedGeoJSON}`; return { - type: 'text', - text: geojsonIOUrl + isError: false, + content: [ + { + type: 'text', + text: geojsonIOUrl + } + ] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - throw new Error(`GeoJSON processing failed: ${errorMessage}`); + return { + isError: true, + content: [ + { + type: 'text', + text: `GeoJSON processing failed: ${errorMessage}` + } + ] + }; } } } diff --git a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.schema.ts b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.input.schema.ts similarity index 70% rename from src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.schema.ts rename to src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.input.schema.ts index ccc29ef..7701ebd 100644 --- a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.schema.ts +++ b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const GetMapboxDocSourceSchema = z.object({}); diff --git a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts index e1df7f2..dc2fd50 100644 --- a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts +++ b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts @@ -1,9 +1,13 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { BaseTool } from '../BaseTool.js'; import { GetMapboxDocSourceSchema, GetMapboxDocSourceInput -} from './GetMapboxDocSourceTool.schema.js'; +} from './GetMapboxDocSourceTool.input.schema.js'; export class GetMapboxDocSourceTool extends BaseTool< typeof GetMapboxDocSourceSchema @@ -19,26 +23,46 @@ export class GetMapboxDocSourceTool extends BaseTool< title: 'Get Mapbox Documentation Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: GetMapboxDocSourceSchema }); + private httpRequest: HttpRequest; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: GetMapboxDocSourceSchema + }); + this.httpRequest = params.httpRequest; } - protected async execute( + async run( // eslint-disable-next-line @typescript-eslint/no-unused-vars _input: GetMapboxDocSourceInput - ): Promise<{ type: 'text'; text: string }> { + ): Promise { try { - const response = await this.fetch('https://docs.mapbox.com/llms.txt'); + const response = await this.httpRequest( + 'https://docs.mapbox.com/llms.txt' + ); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + return { + content: [ + { + type: 'text', + text: `Failed to fetch Mapbox documentation: ${response.statusText}` + } + ], + isError: true + }; } const content = await response.text(); return { - type: 'text', - text: content + content: [ + { + type: 'text', + text: content + } + ], + isError: false }; } catch (error) { const errorMessage = diff --git a/src/tools/list-styles-tool/ListStylesTool.schema.ts b/src/tools/list-styles-tool/ListStylesTool.input.schema.ts similarity index 87% rename from src/tools/list-styles-tool/ListStylesTool.schema.ts rename to src/tools/list-styles-tool/ListStylesTool.input.schema.ts index e2715ba..adf6e12 100644 --- a/src/tools/list-styles-tool/ListStylesTool.schema.ts +++ b/src/tools/list-styles-tool/ListStylesTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const ListStylesSchema = z.object({ diff --git a/src/tools/list-styles-tool/ListStylesTool.output.schema.ts b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts new file mode 100644 index 0000000..353165b --- /dev/null +++ b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts @@ -0,0 +1,10 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +export const ListStylesOutputSchema = z.array(BaseStylePropertiesSchema); + +export type ListStylesOutput = z.infer; diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index d0d1b52..e8c0d1b 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -1,9 +1,19 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { ListStylesSchema, ListStylesInput } from './ListStylesTool.schema.js'; +import { + ListStylesSchema, + ListStylesInput +} from './ListStylesTool.input.schema.js'; +import { ListStylesOutputSchema } from './ListStylesTool.output.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class ListStylesTool extends MapboxApiBasedTool< - typeof ListStylesSchema + typeof ListStylesSchema, + typeof ListStylesOutputSchema > { name = 'list_styles_tool'; description = @@ -16,15 +26,19 @@ export class ListStylesTool extends MapboxApiBasedTool< title: 'List Mapbox Styles Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: ListStylesSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: ListStylesSchema, + outputSchema: ListStylesOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: ListStylesInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); // Build query parameters const params = new URLSearchParams(); @@ -43,15 +57,38 @@ export class ListStylesTool extends MapboxApiBasedTool< const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}?${params.toString()}`; - const response = await this.fetch(url); + const response = await this.httpRequest(url); - if (!response.ok) { - throw new Error( - `Failed to list styles: ${response.status} ${response.statusText}` + const data = await response.json(); + const parseResult = ListStylesOutputSchema.safeParse(data); + if (!parseResult.success) { + this.log( + 'error', + `ListStylesTool: Output schema validation failed\n${parseResult.error}` ); + return { + content: [ + { + type: 'text' as const, + text: `ListStylesTool: Response does not conform to output schema:\n${parseResult.error}` + } + ], + isError: true + }; } + this.log('info', `ListStylesTool: Successfully listed styles`); - const data = await response.json(); - return data; + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(parseResult.data, null, 2) + } + ], + structuredContent: { + data: parseResult.data + }, + isError: false + }; } } diff --git a/src/tools/list-tokens-tool/ListTokensTool.schema.ts b/src/tools/list-tokens-tool/ListTokensTool.input.schema.ts similarity index 100% rename from src/tools/list-tokens-tool/ListTokensTool.schema.ts rename to src/tools/list-tokens-tool/ListTokensTool.input.schema.ts diff --git a/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts b/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts new file mode 100644 index 0000000..4be7737 --- /dev/null +++ b/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts @@ -0,0 +1,27 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const TokenObjectSchema = z.object({ + id: z.string().describe('Token ID'), + name: z.string().describe('Token name'), + scopes: z.array(z.string()).describe('Array of scopes assigned to the token'), + token: z.string().describe('The actual token string'), + created: z.string().describe('ISO 8601 creation timestamp'), + modified: z.string().describe('ISO 8601 last modified timestamp'), + usage: z.string().describe('Token usage type, e.g. pk or sk'), + default: z.boolean().describe('Whether this is the default token'), + note: z.string().optional().describe('Optional note or description'), + allowedUrls: z.array(z.string()).optional().describe('Array of allowed URLs'), + expires: z.string().optional().describe('Expiration time in ISO 8601 format') +}); + +export const ListTokensOutputSchema = z.object({ + tokens: z.array(TokenObjectSchema), + count: z.number().describe('Total number of tokens returned'), + next_start: z.string().optional().describe('Pagination token for next page') +}); + +export type ListTokensOutput = z.infer; +export type TokenObject = z.infer; diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index 5f8d6b2..2588b8d 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -1,9 +1,19 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { ListTokensSchema, ListTokensInput } from './ListTokensTool.schema.js'; +import { + ListTokensSchema, + ListTokensInput +} from './ListTokensTool.input.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import { ListTokensOutputSchema } from './ListTokensTool.output.schema.js'; export class ListTokensTool extends MapboxApiBasedTool< - typeof ListTokensSchema + typeof ListTokensSchema, + typeof ListTokensOutputSchema > { readonly name = 'list_tokens_tool'; readonly description = @@ -16,19 +26,23 @@ export class ListTokensTool extends MapboxApiBasedTool< title: 'List Mapbox Tokens Tool' }; - constructor(private fetchImpl: typeof fetch = fetchClient) { - super({ inputSchema: ListTokensSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: ListTokensSchema, + outputSchema: ListTokensOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: ListTokensInput, accessToken?: string - ): Promise<{ type: 'text'; text: string }> { + ): Promise { if (!accessToken) { throw new Error('MAPBOX_ACCESS_TOKEN is not set'); } - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + const username = getUserNameFromToken(accessToken); this.log( 'info', @@ -72,7 +86,7 @@ export class ListTokensTool extends MapboxApiBasedTool< this.log('info', `ListTokensTool: Fetching page ${pageCount}`); this.log('debug', `ListTokensTool: Fetching URL: ${url}`); - const response = await this.fetchImpl(url, { + const response = await this.httpRequest(url, { method: 'GET', headers: { 'Content-Type': 'application/json' @@ -85,9 +99,15 @@ export class ListTokensTool extends MapboxApiBasedTool< 'error', `ListTokensTool: API Error - Status: ${response.status}, Body: ${errorBody}` ); - throw new Error( - `Failed to list tokens: ${response.status} ${response.statusText}` - ); + return { + isError: true, + content: [ + { + type: 'text', + text: `Failed to list tokens: ${response.status} ${response.statusText}` + } + ] + }; } const data = await response.json(); @@ -97,7 +117,27 @@ export class ListTokensTool extends MapboxApiBasedTool< ? data : (data as { tokens?: unknown[] }).tokens || []; - allTokens.push(...tokens); + // Validate tokens array against TokenObjectSchema + const { TokenObjectSchema } = await import( + './ListTokensTool.output.schema.js' + ); + const parseResult = TokenObjectSchema.array().safeParse(tokens); + if (!parseResult.success) { + this.log( + 'error', + `ListTokensTool: Token array schema validation failed\n${parseResult.error}` + ); + return { + isError: true, + content: [ + { + type: 'text', + text: `ListTokensTool: Response does not conform to token array schema:\n${parseResult.error}` + } + ] + }; + } + allTokens.push(...parseResult.data); this.log( 'info', `ListTokensTool: Retrieved ${tokens.length} tokens on page ${pageCount}` @@ -153,14 +193,26 @@ export class ListTokensTool extends MapboxApiBasedTool< } return { - type: 'text', - text: JSON.stringify(result, null, 2) + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ], + structuredContent: result, + isError: false }; } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error(`Failed to list tokens: ${String(error)}`); + this.log('error', `ListTokensTool: Unexpected error: ${error}`); + return { + isError: true, + content: [ + { + type: 'text', + text: `ListTokensTool: Unexpected error: ${error}` + } + ] + }; } } diff --git a/src/tools/preview-style-tool/PreviewStyleTool.schema.ts b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts similarity index 100% rename from src/tools/preview-style-tool/PreviewStyleTool.schema.ts rename to src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index 852ed6c..a63624e 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -1,9 +1,11 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { BaseTool } from '../BaseTool.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { PreviewStyleSchema, PreviewStyleInput -} from './PreviewStyleTool.schema.js'; +} from './PreviewStyleTool.input.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class PreviewStyleTool extends BaseTool { readonly name = 'preview_style_tool'; @@ -21,10 +23,8 @@ export class PreviewStyleTool extends BaseTool { super({ inputSchema: PreviewStyleSchema }); } - protected async execute( - input: PreviewStyleInput - ): Promise<{ type: 'text'; text: string }> { - const username = MapboxApiBasedTool.getUserNameFromToken(input.accessToken); + async run(input: PreviewStyleInput): Promise { + const username = getUserNameFromToken(input.accessToken); // Use the user-provided public token const publicToken = input.accessToken; @@ -51,8 +51,13 @@ export class PreviewStyleTool extends BaseTool { const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}.html?${params.toString()}${hashFragment}`; return { - type: 'text', - text: url + content: [ + { + type: 'text', + text: url + } + ], + isError: false }; } } diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.schema.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.ts similarity index 100% rename from src/tools/retrieve-style-tool/RetrieveStyleTool.schema.ts rename to src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.ts diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts new file mode 100644 index 0000000..91a2602 --- /dev/null +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts @@ -0,0 +1,30 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// OUTPUT Schema - For API responses (GET/PATCH response) +// TODO: Refactor into shared schema +export const MapboxStyleOutputSchema = BaseStylePropertiesSchema.extend({ + name: z.string().describe('Human-readable name for the style'), + + // API-specific properties (only present in responses) + id: z.string().describe('Unique style identifier'), + owner: z.string().describe('Username of the style owner'), + created: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was created'), + modified: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was last modified'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), + draft: z.boolean().optional().describe('Whether this is a draft version') +}).passthrough(); + +// Type exports +export type MapboxStyleOutput = z.infer; diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts index a8167da..b420998 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts @@ -1,13 +1,23 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import { filterExpandedMapboxStyles } from '../../utils/styleUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { RetrieveStyleSchema, RetrieveStyleInput -} from './RetrieveStyleTool.schema.js'; +} from './RetrieveStyleTool.input.schema.js'; +import { HttpRequest } from '../../utils/types.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { + MapboxStyleOutput, + MapboxStyleOutputSchema +} from './RetrieveStyleTool.output.schema.js'; export class RetrieveStyleTool extends MapboxApiBasedTool< - typeof RetrieveStyleSchema + typeof RetrieveStyleSchema, + typeof MapboxStyleOutputSchema > { name = 'retrieve_style_tool'; description = 'Retrieve a specific Mapbox style by ID'; @@ -19,18 +29,22 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< title: 'Retrieve Mapbox Style Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: RetrieveStyleSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: RetrieveStyleSchema, + outputSchema: MapboxStyleOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: RetrieveStyleInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; - const response = await this.fetch(url); + const response = await this.httpRequest(url); if (!response.ok) { throw new Error( @@ -38,8 +52,31 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< ); } - const data = await response.json(); - // Always filter out expanded Mapbox styles to prevent token overflow - return filterExpandedMapboxStyles(data); + const rawData = await response.json(); + // Validate response against schema with graceful fallback + let data: MapboxStyleOutput; + try { + data = MapboxStyleOutputSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` + ); + // Graceful fallback to raw data + data = rawData as MapboxStyleOutput; + } + + this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + } + ], + structuredContent: filterExpandedMapboxStyles(data), + isError: false + }; } } diff --git a/src/tools/style-builder-tool/StyleBuilderTool.schema.ts b/src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts similarity index 99% rename from src/tools/style-builder-tool/StyleBuilderTool.schema.ts rename to src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts index 256f4f6..0d34073 100644 --- a/src/tools/style-builder-tool/StyleBuilderTool.schema.ts +++ b/src/tools/style-builder-tool/StyleBuilderTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; const LayerConfigSchema = z.object({ diff --git a/src/tools/style-builder-tool/StyleBuilderTool.ts b/src/tools/style-builder-tool/StyleBuilderTool.ts index 47d3585..1b68d5a 100644 --- a/src/tools/style-builder-tool/StyleBuilderTool.ts +++ b/src/tools/style-builder-tool/StyleBuilderTool.ts @@ -1,8 +1,12 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { StyleBuilderToolSchema, type StyleBuilderToolInput -} from './StyleBuilderTool.schema.js'; +} from './StyleBuilderTool.input.schema.js'; // Using STREETS_V8_FIELDS as single source of truth instead of MAPBOX_STYLE_LAYERS import { STREETS_V8_FIELDS } from '../../constants/mapboxStreetsV8Fields.js'; import type { Layer, Filter, MapboxStyle } from '../../types/mapbox-style.js'; @@ -52,7 +56,6 @@ const SOURCE_LAYER_GEOMETRY: Record< export class StyleBuilderTool extends BaseTool { name = 'style_builder_tool'; - private currentSourceLayer?: string; // Track current source layer for better error messages readonly annotations = { readOnlyHint: true, destructiveHint: false, @@ -126,7 +129,7 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho super({ inputSchema: StyleBuilderToolSchema }); } - protected async execute(input: StyleBuilderToolInput) { + async run(input: StyleBuilderToolInput): Promise { try { const result = this.buildStyle(input); const { style, corrections, layerHelp, availableProperties } = result; @@ -1436,9 +1439,6 @@ ${JSON.stringify(style, null, 2)} const filters: unknown[] = []; const corrections: string[] = []; - // Set current source layer for better error messages - this.currentSourceLayer = sourceLayer; - // Get field definitions for this source layer const layerFields = STREETS_V8_FIELDS[sourceLayer as keyof typeof STREETS_V8_FIELDS]; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index aa62aec..ec25e25 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const StyleComparisonSchema = z.object({ diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 2aad35f..1d6d321 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -1,9 +1,13 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { BaseTool } from '../BaseTool.js'; -import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { StyleComparisonSchema, StyleComparisonInput } from './StyleComparisonTool.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class StyleComparisonTool extends BaseTool< typeof StyleComparisonSchema @@ -39,7 +43,7 @@ export class StyleComparisonTool extends BaseTool< // If it's just a style ID, try to get username from the token try { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + const username = getUserNameFromToken(accessToken); return `${username}/${style}`; } catch (error) { throw new Error( @@ -52,9 +56,7 @@ export class StyleComparisonTool extends BaseTool< } } - protected async execute( - input: StyleComparisonInput - ): Promise<{ type: 'text'; text: string }> { + async run(input: StyleComparisonInput): Promise { // Process style IDs to get username/styleId format const beforeStyleId = this.processStyleId(input.before, input.accessToken); const afterStyleId = this.processStyleId(input.after, input.accessToken); @@ -79,8 +81,13 @@ export class StyleComparisonTool extends BaseTool< } return { - type: 'text', - text: url + content: [ + { + type: 'text', + text: url + } + ], + isError: false }; } } diff --git a/src/tools/tilequery-tool/TilequeryTool.schema.ts b/src/tools/tilequery-tool/TilequeryTool.input.schema.ts similarity index 95% rename from src/tools/tilequery-tool/TilequeryTool.schema.ts rename to src/tools/tilequery-tool/TilequeryTool.input.schema.ts index b50a264..1808987 100644 --- a/src/tools/tilequery-tool/TilequeryTool.schema.ts +++ b/src/tools/tilequery-tool/TilequeryTool.input.schema.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { z } from 'zod'; export const TilequerySchema = z.object({ diff --git a/src/tools/tilequery-tool/TilequeryTool.output.schema.ts b/src/tools/tilequery-tool/TilequeryTool.output.schema.ts new file mode 100644 index 0000000..22c948c --- /dev/null +++ b/src/tools/tilequery-tool/TilequeryTool.output.schema.ts @@ -0,0 +1,77 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +// Coordinate pair schema +const CoordinatesSchema = z.tuple([z.number(), z.number()]); + +// Vector Tileset Feature Schema +const VectorTilequeryFeatureSchema = z.object({ + type: z.literal('Feature'), + id: z.union([z.string(), z.number()]), + geometry: z.object({ + type: z.literal('Point'), + coordinates: CoordinatesSchema + }), + properties: z + .object({ + tilequery: z.object({ + distance: z + .number() + .describe( + 'Approximate surface distance from feature to queried point, in meters' + ), + geometry: z + .enum(['point', 'linestring', 'polygon']) + .describe('Original geometry type of the feature'), + layer: z + .string() + .describe('The vector tile layer of the feature result') + }) + }) + .passthrough() // Allow additional properties from the original feature +}); + +// Rasterarray Tileset Feature Schema +const RasterarrayTilequeryFeatureSchema = z.object({ + type: z.literal('Feature'), + id: z.null(), + geometry: z.object({ + type: z.literal('Point'), + coordinates: CoordinatesSchema + }), + properties: z.object({ + tilequery: z.object({ + layer: z.string().describe('The layer that the feature belongs to'), + band: z.string().describe('The band that the feature belongs to'), + zoom: z + .number() + .describe('The maxzoom level at which the point value was extracted'), + units: z.string().describe('The unit of measurement for the point value') + }), + val: z.number().describe('Point value at the requested location') + }) +}); + +// Union of both feature types +const TilequeryFeatureSchema = z.union([ + VectorTilequeryFeatureSchema, + RasterarrayTilequeryFeatureSchema +]); + +// Main Tilequery Response Schema +export const TilequeryResponseSchema = z.object({ + type: z.literal('FeatureCollection'), + features: z.array(TilequeryFeatureSchema) +}); + +// Type exports inferred from Zod schemas +export type VectorTilequeryFeature = z.infer< + typeof VectorTilequeryFeatureSchema +>; +export type RasterarrayTilequeryFeature = z.infer< + typeof RasterarrayTilequeryFeatureSchema +>; +export type TilequeryFeature = z.infer; +export type TilequeryResponse = z.infer; diff --git a/src/tools/tilequery-tool/TilequeryTool.ts b/src/tools/tilequery-tool/TilequeryTool.ts index f5e75ca..ab34045 100644 --- a/src/tools/tilequery-tool/TilequeryTool.ts +++ b/src/tools/tilequery-tool/TilequeryTool.ts @@ -1,8 +1,22 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import { TilequerySchema, TilequeryInput } from './TilequeryTool.schema.js'; +import { + TilequerySchema, + TilequeryInput +} from './TilequeryTool.input.schema.js'; +import { + TilequeryResponse, + TilequeryResponseSchema +} from './TilequeryTool.output.schema.js'; -export class TilequeryTool extends MapboxApiBasedTool { +export class TilequeryTool extends MapboxApiBasedTool< + typeof TilequerySchema, + typeof TilequeryResponseSchema +> { name = 'tilequery_tool'; description = 'Query vector and raster data from Mapbox tilesets at geographic coordinates'; @@ -14,14 +28,18 @@ export class TilequeryTool extends MapboxApiBasedTool { title: 'Mapbox Tilequery Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: TilequerySchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: TilequerySchema, + outputSchema: TilequeryResponseSchema, + httpRequest: params.httpRequest + }); } protected async execute( input: TilequeryInput, accessToken?: string - ): Promise { + ): Promise { const { tilesetId, longitude, latitude, ...queryParams } = input; const url = new URL( `${MapboxApiBasedTool.mapboxApiEndpoint}v4/${tilesetId}/tilequery/${longitude},${latitude}.json` @@ -53,7 +71,7 @@ export class TilequeryTool extends MapboxApiBasedTool { url.searchParams.set('access_token', accessToken || ''); - const response = await this.fetch(url.toString()); + const response = await this.httpRequest(url.toString()); if (!response.ok) { const errorText = await response.text(); @@ -62,7 +80,52 @@ export class TilequeryTool extends MapboxApiBasedTool { ); } - const data = await response.json(); - return data; + if (!response.ok) { + const errorBody = await response.text(); + this.log( + 'error', + `SearchAndGeocodeTool: API Error - Status: ${response.status}, Body: ${errorBody}` + ); + return { + content: [ + { + type: 'text', + text: `Failed to search: ${response.status} ${response.statusText}` + } + ], + isError: true + }; + } + + const rawData = await response.json(); + + // Validate response against schema with graceful fallback + let data: TilequeryResponse; + try { + data = TilequeryResponseSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` + ); + // Graceful fallback to raw data + data = rawData as TilequeryResponse; + } + + this.log( + 'info', + `TilequeryTool: Successfully completed query, found ${data.features?.length || 0} results` + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2) + } + ], + structuredContent: data, + isError: false + }; } } diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 68666ea..fdfd910 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { BoundingBoxTool } from './bounding-box-tool/BoundingBoxTool.js'; import { CountryBoundingBoxTool } from './bounding-box-tool/CountryBoundingBoxTool.js'; import { CoordinateConversionTool } from './coordinate-conversion-tool/CoordinateConversionTool.js'; @@ -14,25 +17,26 @@ import { StyleBuilderTool } from './style-builder-tool/StyleBuilderTool.js'; import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js'; import { TilequeryTool } from './tilequery-tool/TilequeryTool.js'; import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js'; +import { httpRequest } from '../utils/httpPipeline.js'; // Central registry of all tools export const ALL_TOOLS = [ - new ListStylesTool(), - new CreateStyleTool(), - new RetrieveStyleTool(), - new UpdateStyleTool(), - new DeleteStyleTool(), + new ListStylesTool({ httpRequest }), + new CreateStyleTool({ httpRequest }), + new RetrieveStyleTool({ httpRequest }), + new UpdateStyleTool({ httpRequest }), + new DeleteStyleTool({ httpRequest }), new PreviewStyleTool(), new StyleBuilderTool(), new GeojsonPreviewTool(), - new CreateTokenTool(), - new ListTokensTool(), + new CreateTokenTool({ httpRequest }), + new ListTokensTool({ httpRequest }), new BoundingBoxTool(), new CountryBoundingBoxTool(), new CoordinateConversionTool(), - new GetMapboxDocSourceTool(), + new GetMapboxDocSourceTool({ httpRequest }), new StyleComparisonTool(), - new TilequeryTool() + new TilequeryTool({ httpRequest }) ] as const; export type ToolInstance = (typeof ALL_TOOLS)[number]; diff --git a/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts new file mode 100644 index 0000000..a3d5bce --- /dev/null +++ b/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts @@ -0,0 +1,17 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// INPUT Schema - For creating/updating styles (PATCH/POST request body) +export const MapboxStyleInputSchema = BaseStylePropertiesSchema.extend({ + name: z + .string() + .describe('Human-readable name for the style (REQUIRED for updates)') + // These fields should NOT be included in input - they're read-only + // If present, they'll be ignored or cause API errors +}).passthrough(); + +// Type exports +export type MapboxStyleInput = z.infer; diff --git a/src/tools/update-style-tool/UpdateStyleTool.output.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.output.schema.ts new file mode 100644 index 0000000..9cab567 --- /dev/null +++ b/src/tools/update-style-tool/UpdateStyleTool.output.schema.ts @@ -0,0 +1,29 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseStylePropertiesSchema } from '../../schemas/style.js'; + +// OUTPUT Schema - For API responses (GET/PATCH response) +export const MapboxStyleOutputSchema = BaseStylePropertiesSchema.extend({ + name: z.string().describe('Human-readable name for the style'), + + // API-specific properties (only present in responses) + id: z.string().describe('Unique style identifier'), + owner: z.string().describe('Username of the style owner'), + created: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was created'), + modified: z + .string() + .datetime() + .describe('ISO 8601 timestamp when style was last modified'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), + draft: z.boolean().optional().describe('Whether this is a draft version') +}).passthrough(); + +// Type exports +export type MapboxStyleOutput = z.infer; diff --git a/src/tools/update-style-tool/UpdateStyleTool.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.schema.ts deleted file mode 100644 index 08d8229..0000000 --- a/src/tools/update-style-tool/UpdateStyleTool.schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from 'zod'; - -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') -}); - -export type UpdateStyleInput = z.infer; diff --git a/src/tools/update-style-tool/UpdateStyleTool.ts b/src/tools/update-style-tool/UpdateStyleTool.ts index b487322..992a67f 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.ts @@ -1,13 +1,23 @@ -import { fetchClient } from '../../utils/fetchRequest.js'; +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { HttpRequest } from '../../utils/types.js'; import { filterExpandedMapboxStyles } from '../../utils/styleUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { - UpdateStyleSchema, - UpdateStyleInput -} from './UpdateStyleTool.schema.js'; + MapboxStyleInputSchema, + MapboxStyleInput +} from './UpdateStyleTool.input.schema.js'; +import { getUserNameFromToken } from '../../utils/jwtUtils.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { + MapboxStyleOutputSchema, + MapboxStyleOutput +} from './UpdateStyleTool.output.schema.js'; export class UpdateStyleTool extends MapboxApiBasedTool< - typeof UpdateStyleSchema + typeof MapboxStyleInputSchema, + typeof MapboxStyleOutputSchema > { name = 'update_style_tool'; description = 'Update an existing Mapbox style'; @@ -19,22 +29,26 @@ export class UpdateStyleTool extends MapboxApiBasedTool< title: 'Update Mapbox Style Tool' }; - constructor(private fetch: typeof globalThis.fetch = fetchClient) { - super({ inputSchema: UpdateStyleSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: MapboxStyleInputSchema, + outputSchema: MapboxStyleOutputSchema, + httpRequest: params.httpRequest + }); } protected async execute( - input: UpdateStyleInput, + input: MapboxStyleInput, accessToken?: string - ): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + ): Promise { + const username = getUserNameFromToken(accessToken); const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; - const payload: any = {}; + const payload: Record = {}; if (input.name) payload.name = input.name; if (input.style) Object.assign(payload, input.style); - const response = await this.fetch(url, { + const response = await this.httpRequest(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' @@ -48,8 +62,31 @@ export class UpdateStyleTool extends MapboxApiBasedTool< ); } - const data = await response.json(); - // Return full style but filter out expanded Mapbox styles - return filterExpandedMapboxStyles(data); + const rawData = await response.json(); + // Validate response against schema with graceful fallback + let data: MapboxStyleOutput; + try { + data = MapboxStyleOutputSchema.parse(rawData); + } catch (validationError) { + this.log( + 'warning', + `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` + ); + // Graceful fallback to raw data + data = rawData as MapboxStyleOutput; + } + + this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + } + ], + structuredContent: filterExpandedMapboxStyles(data), + isError: false + }; } } diff --git a/src/utils/fetchRequest.ts b/src/utils/httpPipeline.ts similarity index 69% rename from src/utils/fetchRequest.ts rename to src/utils/httpPipeline.ts index 74981dc..22afc0e 100644 --- a/src/utils/fetchRequest.ts +++ b/src/utils/httpPipeline.ts @@ -1,27 +1,35 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { getVersionInfo } from './versionUtils.js'; +import { type HttpRequest } from './types.js'; + +function createRandomId(prefix: string): string { + return `${prefix}${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; +} -export interface FetchPolicy { +export interface HttpPolicy { id: string; handle( input: string | URL | Request, init: RequestInit, - next: typeof fetch + next: HttpRequest ): Promise; } -export class PolicyPipeline { - private policies: FetchPolicy[] = []; - private fetchImpl: typeof fetch; +export class HttpPipeline { + private policies: HttpPolicy[] = []; + private httpRequestImpl: HttpRequest; - constructor(fetchImpl?: typeof fetch) { - this.fetchImpl = fetchImpl ?? fetch; + constructor(httpRequestImpl?: HttpRequest) { + this.httpRequestImpl = httpRequestImpl ?? fetch; } - usePolicy(policy: FetchPolicy) { + usePolicy(policy: HttpPolicy) { this.policies.push(policy); } - removePolicy(policyOrId: FetchPolicy | string) { + removePolicy(policyOrId: HttpPolicy | string) { if (typeof policyOrId === 'string') { this.policies = this.policies.filter((p) => p.id !== policyOrId); } else { @@ -29,7 +37,7 @@ export class PolicyPipeline { } } - findPolicyById(id: string): FetchPolicy | undefined { + findPolicyById(id: string): HttpPolicy | undefined { return this.policies.find((p) => p.id === id); } @@ -37,7 +45,7 @@ export class PolicyPipeline { return this.policies; } - async fetch( + async execute( input: string | URL | Request, init: RequestInit = {} ): Promise { @@ -47,31 +55,32 @@ export class PolicyPipeline { options: RequestInit ): Promise => { if (i < this.policies.length) { - return this.policies[i].handle(req, options, (nextReq, nextOptions) => - dispatch(i + 1, nextReq, nextOptions!) + return this.policies[i].handle( + req, + options, + (nextReq: string | URL | Request, nextOptions?: RequestInit) => + dispatch(i + 1, nextReq, nextOptions || {}) ); } - return this.fetchImpl(req, options); // Use injected fetch + return this.httpRequestImpl(req, options); // Use injected httpRequest }; return dispatch(0, input, init); } } -export class UserAgentPolicy implements FetchPolicy { +export class UserAgentPolicy implements HttpPolicy { id: string; constructor( private userAgent: string, id?: string ) { - this.id = - id ?? - `user-agent-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + this.id = id ?? createRandomId('user-agent-'); } async handle( input: string | URL | Request, init: RequestInit, - next: typeof fetch + next: HttpRequest ): Promise { let headers: Headers | Record; @@ -106,7 +115,7 @@ export class UserAgentPolicy implements FetchPolicy { } } -export class RetryPolicy implements FetchPolicy { +export class RetryPolicy implements HttpPolicy { id: string; constructor( @@ -115,15 +124,13 @@ export class RetryPolicy implements FetchPolicy { private maxDelayMs: number = 2000, id?: string ) { - this.id = - id ?? - `retry-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + this.id = id ?? createRandomId('retry-'); } async handle( input: string | URL | Request, init: RequestInit, - next: typeof fetch + next: HttpRequest ): Promise { let attempt = 0; let lastError: Response | undefined; @@ -153,12 +160,12 @@ export class RetryPolicy implements FetchPolicy { } } -const pipeline = new PolicyPipeline(); +const pipeline = new HttpPipeline(); const versionInfo = getVersionInfo(); pipeline.usePolicy( UserAgentPolicy.fromVersionInfo(versionInfo, 'system-user-agent-policy') ); pipeline.usePolicy(new RetryPolicy(3, 200, 2000, 'system-retry-policy')); -export const fetchClient = pipeline.fetch.bind(pipeline); -export const systemFetchPipeline = pipeline; +export const httpRequest = pipeline.execute.bind(pipeline); +export const systemHttpPipeline = pipeline; diff --git a/src/utils/jwtUtils.ts b/src/utils/jwtUtils.ts new file mode 100644 index 0000000..3ead561 --- /dev/null +++ b/src/utils/jwtUtils.ts @@ -0,0 +1,51 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +export function mapboxAccessToken() { + return process.env.MAPBOX_ACCESS_TOKEN; +} + +export function mapboxApiEndpoint() { + return process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'; +} + +/** + * Extracts the username from the Mapbox access token. + * Mapbox tokens are JWT tokens where the payload contains the username. + * @throws Error if the token is not set, invalid, or doesn't contain username + */ +export function getUserNameFromToken(access_token?: string): string { + const token = access_token || mapboxAccessToken(); + if (!token) { + throw new Error( + 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' + ); + } + + try { + // JWT format: header.payload.signature + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); + } + + // Decode the payload (second part) + const payload = JSON.parse( + Buffer.from(parts[1], 'base64').toString('utf-8') + ); + + // The username is stored in the 'u' field + if (!payload.u) { + throw new Error( + 'MAPBOX_ACCESS_TOKEN does not contain username in payload' + ); + } + + return payload.u; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error('Failed to parse MAPBOX_ACCESS_TOKEN'); + } +} diff --git a/src/utils/styleUtils.ts b/src/utils/styleUtils.ts index 2a8cd40..a88de18 100644 --- a/src/utils/styleUtils.ts +++ b/src/utils/styleUtils.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + /** * Filters out expanded Mapbox styles from imports to reduce response size. * This preserves the reference to the style (e.g., mapbox://styles/mapbox/standard) diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..689240f --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,9 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * HttpRequest interface that includes tracing information + */ +export interface HttpRequest { + (input: string | URL | Request, init?: RequestInit): Promise; +} diff --git a/src/utils/versionUtils-cjs.cts b/src/utils/versionUtils-cjs.cts index e8c9d34..4e3bf55 100644 --- a/src/utils/versionUtils-cjs.cts +++ b/src/utils/versionUtils-cjs.cts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { readFileSync } from 'node:fs'; import path from 'node:path'; diff --git a/src/utils/versionUtils.ts b/src/utils/versionUtils.ts index 44779ea..c628473 100644 --- a/src/utils/versionUtils.ts +++ b/src/utils/versionUtils.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index bbcac06..4d1e841 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -17,51 +17,16 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "Gets bounding box for a country by its ISO 3166-1 country code, returns as [minX, minY, maxX, maxY].", "toolName": "country_bounding_box_tool", }, - { - "className": "CreateStyleTool", - "description": "Create a new Mapbox style", - "toolName": "create_style_tool", - }, - { - "className": "CreateTokenTool", - "description": "Create a new Mapbox public access token with specified scopes and optional URL restrictions.", - "toolName": "create_token_tool", - }, - { - "className": "DeleteStyleTool", - "description": "Delete a Mapbox style by ID", - "toolName": "delete_style_tool", - }, { "className": "GeojsonPreviewTool", "description": "Generate a geojson.io URL to visualize GeoJSON data. Returns only the URL link.", "toolName": "geojson_preview_tool", }, - { - "className": "GetMapboxDocSourceTool", - "description": "Get the latest official Mapbox documentation, APIs, SDKs, and developer resources directly from Mapbox. Always up-to-date, comprehensive coverage of all current Mapbox services including mapping, navigation, search, geocoding, and mobile SDKs. Use this for accurate, official Mapbox information instead of web search.", - "toolName": "get_latest_mapbox_docs_tool", - }, - { - "className": "ListStylesTool", - "description": "List styles for a Mapbox account. Use limit parameter to avoid large responses (recommended: limit=5-10). Use start parameter for pagination.", - "toolName": "list_styles_tool", - }, - { - "className": "ListTokensTool", - "description": "List Mapbox access tokens for the authenticated user with optional filtering and pagination. When using pagination, the "start" parameter must be obtained from the "next_start" field of the previous response (it is not a token ID)", - "toolName": "list_tokens_tool", - }, { "className": "PreviewStyleTool", "description": "Generate preview URL for a Mapbox style using an existing public token", "toolName": "preview_style_tool", }, - { - "className": "RetrieveStyleTool", - "description": "Retrieve a specific Mapbox style by ID", - "toolName": "retrieve_style_tool", - }, { "className": "StyleBuilderTool", "description": "Generate Mapbox style JSON for creating new styles or updating existing ones. @@ -132,15 +97,5 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho "description": "Generate a comparison URL for comparing two Mapbox styles side-by-side", "toolName": "style_comparison_tool", }, - { - "className": "TilequeryTool", - "description": "Query vector and raster data from Mapbox tilesets at geographic coordinates", - "toolName": "tilequery_tool", - }, - { - "className": "UpdateStyleTool", - "description": "Update an existing Mapbox style", - "toolName": "update_style_tool", - }, ] `; diff --git a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts index 85f130a..3ed7eb8 100644 --- a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { BoundingBoxTool } from '../../../src/tools/bounding-box-tool/BoundingBoxTool.js'; @@ -268,7 +271,7 @@ describe('BoundingBoxTool', () => { it('should have correct input schema', async () => { const { BoundingBoxSchema } = await import( - '../../../src/tools/bounding-box-tool/BoundingBoxTool.schema.js' + '../../../src/tools/bounding-box-tool/BoundingBoxTool.input.schema.js' ); expect(BoundingBoxSchema).toBeDefined(); expect(BoundingBoxSchema.shape.geojson).toBeDefined(); diff --git a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts index 8d8d197..89e59e8 100644 --- a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { CountryBoundingBoxTool } from '../../../src/tools/bounding-box-tool/CountryBoundingBoxTool.js'; @@ -138,7 +141,7 @@ describe('CountryBoundingBoxTool', () => { it('should have correct input schema', async () => { const { CountryBoundingBoxSchema } = await import( - '../../../src/tools/bounding-box-tool/CountryBoundingBoxTool.schema.js' + '../../../src/tools/bounding-box-tool/CountryBoundingBoxTool.input.schema.js' ); expect(CountryBoundingBoxSchema).toBeDefined(); }); diff --git a/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts b/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts index 8e64cfb..a45c60d 100644 --- a/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts +++ b/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { CoordinateConversionTool } from '../../../src/tools/coordinate-conversion-tool/CoordinateConversionTool.js'; @@ -18,7 +21,7 @@ describe('CoordinateConversionTool', () => { it('should have correct input schema', async () => { const { CoordinateConversionSchema } = await import( - '../../../src/tools/coordinate-conversion-tool/CoordinateConversionTool.schema.js' + '../../../src/tools/coordinate-conversion-tool/CoordinateConversionTool.input.schema.js' ); expect(CoordinateConversionSchema).toBeDefined(); }); diff --git a/test/tools/create-style-tool/CreateStyleTool.test.ts b/test/tools/create-style-tool/CreateStyleTool.test.ts index f5beb61..45800db 100644 --- a/test/tools/create-style-tool/CreateStyleTool.test.ts +++ b/test/tools/create-style-tool/CreateStyleTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { CreateStyleTool } from '../../../src/tools/create-style-tool/CreateStyleTool.js'; const mockToken = @@ -19,40 +22,41 @@ describe('CreateStyleTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new CreateStyleTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new CreateStyleTool({ httpRequest }); expect(tool.name).toBe('create_style_tool'); expect(tool.description).toBe('Create a new Mapbox style'); }); it('should have correct input schema', async () => { - const { CreateStyleSchema } = await import( - '../../../src/tools/create-style-tool/CreateStyleTool.schema.js' + const { MapboxStyleInputSchema } = await import( + '../../../src/tools/create-style-tool/CreateStyleTool.input.schema.js' ); - expect(CreateStyleSchema).toBeDefined(); + expect(MapboxStyleInputSchema).toBeDefined(); }); }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => ({ id: 'new-style-id', name: 'Test Style' }) }); - await new CreateStyleTool(fetch).run({ + await new CreateStyleTool({ httpRequest }).run({ name: 'Test Style', style: { version: 8, sources: {}, layers: [] } }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 400, statusText: 'Bad Request' }); - const result = await new CreateStyleTool(fetch).run({ + const result = await new CreateStyleTool({ httpRequest }).run({ name: 'Test Style', style: { version: 8, sources: {}, layers: [] } }); @@ -62,6 +66,6 @@ describe('CreateStyleTool', () => { type: 'text', text: 'Failed to create style: 400 Bad Request' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index cb21087..08e001f 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -1,10 +1,14 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { MapboxApiBasedTool } from '../../../src/tools/MapboxApiBasedTool.js'; import { CreateTokenTool } from '../../../src/tools/create-token-tool/CreateTokenTool.js'; +import { HttpRequest } from 'src/utils/types.js'; // Create a token with username in the payload const payload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( @@ -23,15 +27,16 @@ describe('CreateTokenTool', () => { vi.clearAllMocks(); }); - function createTokenTool(fetchImpl?: typeof fetch) { - const instance = new CreateTokenTool(fetchImpl); + function createTokenTool(httpRequest: HttpRequest) { + const instance = new CreateTokenTool({ httpRequest }); instance['log'] = vi.fn(); return instance; } describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = createTokenTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createTokenTool(httpRequest); expect(tool.name).toBe('create_token_tool'); expect(tool.description).toBe( 'Create a new Mapbox public access token with specified scopes and optional URL restrictions.' @@ -40,7 +45,7 @@ describe('CreateTokenTool', () => { it('should have correct input schema', async () => { const { CreateTokenSchema } = await import( - '../../../src/tools/create-token-tool/CreateTokenTool.schema.js' + '../../../src/tools/create-token-tool/CreateTokenTool.input.schema.js' ); expect(CreateTokenSchema).toBeDefined(); }); @@ -48,7 +53,8 @@ describe('CreateTokenTool', () => { describe('validation', () => { it('validates required input fields', async () => { - const tool = createTokenTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createTokenTool(httpRequest); const result = await tool.run({}); expect(result.isError).toBe(true); @@ -58,7 +64,8 @@ describe('CreateTokenTool', () => { }); it('validates allowedUrls array length', async () => { - const tool = createTokenTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createTokenTool(httpRequest); const urls = new Array(101).fill('https://example.com'); @@ -75,7 +82,8 @@ describe('CreateTokenTool', () => { }); it('validates invalid scopes', async () => { - const tool = createTokenTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Test token', @@ -101,8 +109,7 @@ describe('CreateTokenTool', () => { vi.stubEnv('MAPBOX_ACCESS_TOKEN', invalidToken); // Setup fetch mock to prevent actual API calls - const { fetch, mockFetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest } = setupHttpRequest({ ok: true, status: 200, statusText: 'OK', @@ -110,7 +117,7 @@ describe('CreateTokenTool', () => { json: async () => ({ token: 'test-token' }) } as Response); - const toolWithInvalidToken = new CreateTokenTool(fetch); + const toolWithInvalidToken = new CreateTokenTool({ httpRequest }); toolWithInvalidToken['log'] = vi.fn(); const result = await toolWithInvalidToken.run({ @@ -145,13 +152,12 @@ describe('CreateTokenTool', () => { modified: '2024-01-01T00:00:00.000Z' }; - const { fetch, mockFetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => mockResponse } as Response); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Test token', @@ -170,7 +176,7 @@ describe('CreateTokenTool', () => { }); // Verify the request - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( `https://api.mapbox.com/tokens/v2/testuser?access_token=eyJhbGciOiJIUzI1NiJ9.${payload}.signature`, { method: 'POST', @@ -185,7 +191,7 @@ describe('CreateTokenTool', () => { ); // Verify User-Agent header was sent - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('creates a token with allowed URLs', async () => { @@ -199,13 +205,12 @@ describe('CreateTokenTool', () => { allowedUrls: ['https://example.com', 'https://app.example.com'] }; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => mockResponse } as Response); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Restricted token', @@ -218,7 +223,7 @@ describe('CreateTokenTool', () => { expect(responseData.allowedUrls).toEqual(mockResponse.allowedUrls); // Verify the request body included allowedUrls - const lastCall = mockFetch.mock.calls[0]; + const lastCall = mockHttpRequest.mock.calls[0]; const requestBody = JSON.parse(lastCall[1].body as string); expect(requestBody.allowedUrls).toEqual([ 'https://example.com', @@ -238,13 +243,12 @@ describe('CreateTokenTool', () => { expires: expiresAt }; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { mockHttpRequest, httpRequest } = setupHttpRequest({ ok: true, json: async () => mockResponse } as Response); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Token with expiration', @@ -257,14 +261,13 @@ describe('CreateTokenTool', () => { expect(responseData.expires).toEqual(expiresAt); // Verify the request body included expires - const lastCall = mockFetch.mock.calls[0]; + const lastCall = mockHttpRequest.mock.calls[0]; const requestBody = JSON.parse(lastCall[1].body as string); expect(requestBody.expires).toEqual(expiresAt); }); it('handles API errors gracefully', async () => { - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest } = setupHttpRequest({ ok: false, status: 401, statusText: 'Unauthorized', @@ -272,7 +275,7 @@ describe('CreateTokenTool', () => { '{"message": "Token does not have required scopes", "code": "TokenScopesInvalid"}' } as Response); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Test token', @@ -286,10 +289,14 @@ describe('CreateTokenTool', () => { }); it('handles network errors', async () => { - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockRejectedValueOnce(new Error('Network error')); + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 0, + statusText: 'Network Error', + text: async () => 'Network error' + }); - const tool = createTokenTool(fetch); + const tool = createTokenTool(httpRequest); const result = await tool.run({ note: 'Test token', @@ -318,13 +325,12 @@ describe('CreateTokenTool', () => { modified: '2024-01-01T00:00:00.000Z' }; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { mockHttpRequest, httpRequest } = setupHttpRequest({ ok: true, json: async () => mockResponse } as Response); - const toolWithCustomEndpoint = new CreateTokenTool(fetch); + const toolWithCustomEndpoint = new CreateTokenTool({ httpRequest }); toolWithCustomEndpoint['log'] = vi.fn(); await toolWithCustomEndpoint.run({ @@ -332,7 +338,7 @@ describe('CreateTokenTool', () => { scopes: ['styles:read'] }); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('https://api.staging.mapbox.com/tokens/v2/'), expect.any(Object) ); diff --git a/test/tools/delete-style-tool/DeleteStyleTool.test.ts b/test/tools/delete-style-tool/DeleteStyleTool.test.ts index 382081d..3b639d7 100644 --- a/test/tools/delete-style-tool/DeleteStyleTool.test.ts +++ b/test/tools/delete-style-tool/DeleteStyleTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { DeleteStyleTool } from '../../../src/tools/delete-style-tool/DeleteStyleTool.js'; const mockToken = @@ -19,26 +22,27 @@ describe('DeleteStyleTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new DeleteStyleTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new DeleteStyleTool({ httpRequest }); expect(tool.name).toBe('delete_style_tool'); expect(tool.description).toBe('Delete a Mapbox style by ID'); }); it('should have correct input schema', async () => { const { DeleteStyleSchema } = await import( - '../../../src/tools/delete-style-tool/DeleteStyleTool.schema.js' + '../../../src/tools/delete-style-tool/DeleteStyleTool.input.schema.js' ); expect(DeleteStyleSchema).toBeDefined(); }); }); it('returns success for 204 No Content', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, status: 204 }); - const result = await new DeleteStyleTool(fetch).run({ + const result = await new DeleteStyleTool({ httpRequest }).run({ styleId: 'style-123' }); @@ -46,17 +50,17 @@ describe('DeleteStyleTool', () => { type: 'text', text: '{"success":true,"message":"Style deleted successfully"}' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('returns response body for non-204 success', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, status: 200, json: async () => ({ deleted: true }) }); - const result = await new DeleteStyleTool(fetch).run({ + const result = await new DeleteStyleTool({ httpRequest }).run({ styleId: 'style-123' }); @@ -65,11 +69,11 @@ describe('DeleteStyleTool', () => { type: 'text', text: `{"deleted":true}` }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' @@ -77,7 +81,7 @@ describe('DeleteStyleTool', () => { let result; try { - result = await new DeleteStyleTool(fetch).run({ + result = await new DeleteStyleTool({ httpRequest }).run({ styleId: 'style-123' }); } catch (e) { @@ -93,6 +97,6 @@ describe('DeleteStyleTool', () => { type: 'text', text: 'Failed to delete style: 404 Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts index e28159b..f62b670 100644 --- a/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts +++ b/test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach } from 'vitest'; import { GeojsonPreviewTool } from '../../../src/tools/geojson-preview-tool/GeojsonPreviewTool.js'; @@ -17,7 +20,7 @@ describe('GeojsonPreviewTool', () => { it('should have correct input schema', async () => { const { GeojsonPreviewSchema } = await import( - '../../../src/tools/geojson-preview-tool/GeojsonPreviewTool.schema.js' + '../../../src/tools/geojson-preview-tool/GeojsonPreviewTool.input.schema.js' ); expect(GeojsonPreviewSchema).toBeDefined(); }); diff --git a/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts b/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts index 7e93f52..94a4b7a 100644 --- a/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts +++ b/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts @@ -1,10 +1,14 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, expect, it } from 'vitest'; import { GetMapboxDocSourceTool } from '../../../src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.js'; -import { setupFetch } from 'test/utils/fetchRequestUtils.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('GetMapboxDocSourceTool', () => { it('should have correct name and description', () => { - const tool = new GetMapboxDocSourceTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new GetMapboxDocSourceTool({ httpRequest }); expect(tool.name).toBe('get_latest_mapbox_docs_tool'); expect(tool.description).toContain( @@ -26,21 +30,23 @@ This is the Mapbox developer documentation for LLMs. ## APIs - Geocoding API for address search - Directions API for routing`; - - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, status: 200, text: () => Promise.resolve(mockContent) }); - const tool = new GetMapboxDocSourceTool(fetch); + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); - expect(mockFetch).toHaveBeenCalledWith('https://docs.mapbox.com/llms.txt', { - headers: { - 'User-Agent': 'TestServer/1.0.0 (default, no-tag, abcdef)' + expect(mockHttpRequest).toHaveBeenCalledWith( + 'https://docs.mapbox.com/llms.txt', + { + headers: { + 'User-Agent': 'TestServer/1.0.0 (default, no-tag, abcdef)' + } } - }); + ); expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe('text'); @@ -51,12 +57,12 @@ This is the Mapbox developer documentation for LLMs. }); it('should handle HTTP errors', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: false, status: 404 }); - const tool = new GetMapboxDocSourceTool(fetch); + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); @@ -72,12 +78,11 @@ This is the Mapbox developer documentation for LLMs. }); it('should handle network errors', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ text: () => Promise.reject(new Error('Network error')) }); - const tool = new GetMapboxDocSourceTool(fetch); - + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); expect(result.isError).toBe(true); @@ -92,12 +97,11 @@ This is the Mapbox developer documentation for LLMs. }); it('should handle unknown errors', async () => { - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ text: () => Promise.reject(new Error('Unknown error occurred')) }); - const tool = new GetMapboxDocSourceTool(fetch); - + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); expect(result.isError).toBe(true); @@ -114,13 +118,13 @@ This is the Mapbox developer documentation for LLMs. it('should work with empty input object', async () => { const mockContent = 'Test documentation content'; - const { fetch } = setupFetch({ + const { httpRequest } = setupHttpRequest({ ok: true, status: 200, text: () => Promise.resolve(mockContent) }); - const tool = new GetMapboxDocSourceTool(fetch); + const tool = new GetMapboxDocSourceTool({ httpRequest }); const result = await tool.run({}); diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index 2db891e..edb10d7 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { ListStylesTool } from '../../../src/tools/list-styles-tool/ListStylesTool.js'; const mockToken = 'sk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; @@ -18,7 +21,8 @@ describe('ListStylesTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new ListStylesTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new ListStylesTool({ httpRequest }); expect(tool.name).toBe('list_styles_tool'); expect(tool.description).toBe( 'List styles for a Mapbox account. Use limit parameter to avoid large responses (recommended: limit=5-10). Use start parameter for pagination.' @@ -27,14 +31,14 @@ describe('ListStylesTool', () => { it('should have correct input schema', async () => { const { ListStylesSchema } = await import( - '../../../src/tools/list-styles-tool/ListStylesTool.schema.js' + '../../../src/tools/list-styles-tool/ListStylesTool.input.schema.js' ); expect(ListStylesSchema).toBeDefined(); }); }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [ { id: 'style1', name: 'Test Style 1' }, @@ -42,86 +46,89 @@ describe('ListStylesTool', () => { ] }); - await new ListStylesTool(fetch).run({}); - assertHeadersSent(mockFetch); + await new ListStylesTool({ httpRequest }).run({}); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' }); - const result = await new ListStylesTool(fetch).run({}); + const result = await new ListStylesTool({ httpRequest }).run({}); expect(result.isError).toBe(true); expect(result.content[0]).toMatchObject({ type: 'text', text: 'Failed to list styles: 404 Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('extracts username from token for API call', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [] }); - await new ListStylesTool(fetch).run({}); + await new ListStylesTool({ httpRequest }).run({}); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('/styles/v1/test-user?access_token='), expect.any(Object) ); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('includes limit parameter when provided', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [] }); - await new ListStylesTool(fetch).run({ limit: 10 }); + await new ListStylesTool({ httpRequest }).run({ limit: 10 }); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringMatching(/\/styles\/v1\/test-user\?.*limit=10/), expect.any(Object) ); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('includes start parameter when provided', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [] }); - await new ListStylesTool(fetch).run({ start: 'abc123' }); + await new ListStylesTool({ httpRequest }).run({ start: 'abc123' }); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringMatching(/\/styles\/v1\/test-user\?.*start=abc123/), expect.any(Object) ); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('includes both limit and start parameters when provided', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => [] }); - await new ListStylesTool(fetch).run({ limit: 5, start: 'xyz789' }); + await new ListStylesTool({ httpRequest }).run({ + limit: 5, + start: 'xyz789' + }); - const calledUrl = mockFetch.mock.calls[0][0]; + const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toMatch(/\/styles\/v1\/test-user\?/); expect(calledUrl).toMatch(/limit=5/); expect(calledUrl).toMatch(/start=xyz789/); expect(calledUrl).toMatch(/access_token=/); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('returns style list on success', async () => { @@ -130,12 +137,12 @@ describe('ListStylesTool', () => { { id: 'style2', name: 'Test Style 2', owner: 'testuser' } ]; - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => mockStyles }); - const result = await new ListStylesTool(fetch).run({}); + const result = await new ListStylesTool({ httpRequest }).run({}); expect(result.isError).toBe(false); expect(result.content).toHaveLength(1); @@ -147,6 +154,6 @@ describe('ListStylesTool', () => { expect(parsedResponse).toEqual(mockStyles); } - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index d5418d8..976eca7 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -1,10 +1,14 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, afterEach, vi, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { MapboxApiBasedTool } from '../../../src/tools/MapboxApiBasedTool.js'; import { ListTokensTool } from '../../../src/tools/list-tokens-tool/ListTokensTool.js'; +import { HttpRequest } from '../../../src//utils/types.js'; // Create a token with username in the payload const payload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( @@ -23,8 +27,8 @@ describe('ListTokensTool', () => { vi.clearAllMocks(); }); - function createListTokensTool(fetchImpl?: typeof fetch): ListTokensTool { - const tool = new ListTokensTool(fetchImpl); + function createListTokensTool(httpRequest: HttpRequest): ListTokensTool { + const tool = new ListTokensTool({ httpRequest }); // Mock the log method to prevent actual logging during tests tool['log'] = vi.fn(); return tool; @@ -32,7 +36,8 @@ describe('ListTokensTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = createListTokensTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createListTokensTool(httpRequest); expect(tool.name).toBe('list_tokens_tool'); expect(tool.description).toBe( @@ -42,7 +47,7 @@ describe('ListTokensTool', () => { it('should have correct input schema', async () => { const { ListTokensSchema } = await import( - '../../../src/tools/list-tokens-tool/ListTokensTool.schema.js' + '../../../src/tools/list-tokens-tool/ListTokensTool.input.schema.js' ); expect(ListTokensSchema).toBeDefined(); }); @@ -50,7 +55,8 @@ describe('ListTokensTool', () => { describe('validation', () => { it('validates limit range', async () => { - const tool = createListTokensTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ limit: 101 }); expect(result.isError).toBe(true); @@ -60,7 +66,8 @@ describe('ListTokensTool', () => { }); it('validates sortby enum values', async () => { - const tool = createListTokensTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ sortby: 'invalid' as unknown as 'created' | 'modified' }); @@ -70,7 +77,8 @@ describe('ListTokensTool', () => { }); it('validates usage enum values', async () => { - const tool = createListTokensTool(); + const { httpRequest } = setupHttpRequest(); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ usage: 'invalid' as unknown as 'pk' }); @@ -92,8 +100,7 @@ describe('ListTokensTool', () => { vi.stubEnv('MAPBOX_ACCESS_TOKEN', invalidToken); // Setup fetch mock to prevent actual API calls - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest } = setupHttpRequest({ ok: true, status: 200, statusText: 'OK', @@ -101,7 +108,7 @@ describe('ListTokensTool', () => { json: async () => [] } as Response); - const toolWithInvalidToken = createListTokensTool(fetch); + const toolWithInvalidToken = createListTokensTool(httpRequest); const result = await toolWithInvalidToken.run({}); @@ -144,14 +151,13 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); @@ -165,7 +171,7 @@ describe('ListTokensTool', () => { expect(responseData.tokens[1].id).toBe('cktest456'); // Verify the request - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining( 'https://api.mapbox.com/tokens/v2/testuser?access_token=' ), @@ -178,7 +184,7 @@ describe('ListTokensTool', () => { ); // Verify User-Agent header was sent - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('filters by default token', async () => { @@ -195,14 +201,14 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ default: true }); expect(result.isError).toBe(false); @@ -211,7 +217,7 @@ describe('ListTokensTool', () => { expect(responseData.tokens[0].default).toBe(true); // Verify the request included the default parameter - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('default=true'), expect.any(Object) ); @@ -230,20 +236,20 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); const headers = new Headers(); headers.set( 'Link', '; rel="next"' ); - mockFetch.mockResolvedValueOnce({ + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers, json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ limit: 10, start: 'cktest789', @@ -255,7 +261,7 @@ describe('ListTokensTool', () => { expect(responseData.tokens).toHaveLength(1); // Verify all parameters were included in the request - const callUrl = mockFetch.mock.calls[0][0] as string; + const callUrl = mockHttpRequest.mock.calls[0][0] as string; expect(callUrl).toContain('limit=10'); expect(callUrl).toContain('start=cktest789'); expect(callUrl).toContain('sortby=created'); @@ -274,20 +280,20 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); const headers = new Headers(); headers.set( 'Link', '; rel="next"' ); - mockFetch.mockResolvedValueOnce({ + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers, json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ limit: 10 }); @@ -310,20 +316,19 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); const headers = new Headers(); headers.set( 'Link', '; rel="next"' ); - mockFetch.mockResolvedValueOnce({ + const { httpRequest } = setupHttpRequest({ ok: true, headers, json: async () => mockTokens - } as Response); + }); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ start: 'cktest789' }); @@ -346,7 +351,7 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); // First page with Link header const headers1 = new Headers(); headers1.set( @@ -357,19 +362,19 @@ describe('ListTokensTool', () => { // Second page without Link header (end of results) const headers2 = new Headers(); - mockFetch.mockResolvedValueOnce({ + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: headers1, json: async () => mockTokens } as Response); - mockFetch.mockResolvedValueOnce({ + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: headers2, json: async () => [] // Empty array for second page } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); @@ -392,14 +397,14 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ limit: 10 }); @@ -422,14 +427,14 @@ describe('ListTokensTool', () => { } ]; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({ usage: 'pk' }); @@ -439,15 +444,15 @@ describe('ListTokensTool', () => { expect(responseData.tokens[0].usage).toBe('pk'); // Verify the usage parameter was included - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('usage=pk'), expect.any(Object) ); }); it('handles API errors gracefully', async () => { - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized', @@ -455,7 +460,7 @@ describe('ListTokensTool', () => { '{"message": "Invalid access token", "code": "TokenInvalid"}' } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); @@ -466,10 +471,10 @@ describe('ListTokensTool', () => { }); it('handles network errors', async () => { - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockRejectedValueOnce(new Error('Network error')); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockRejectedValueOnce(new Error('Network error')); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); expect(result.isError).toBe(true); @@ -487,18 +492,18 @@ describe('ListTokensTool', () => { const mockTokens: object[] = []; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockTokens } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); await tool.run({}); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockHttpRequest).toHaveBeenCalledWith( expect.stringContaining('https://api.staging.mapbox.com/tokens/v2/'), expect.any(Object) ); @@ -524,14 +529,14 @@ describe('ListTokensTool', () => { ] }; - const { mockFetch, fetch } = setupFetch(); - mockFetch.mockResolvedValueOnce({ + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: new Headers(), json: async () => mockResponse } as Response); - const tool = createListTokensTool(fetch); + const tool = createListTokensTool(httpRequest); const result = await tool.run({}); expect(result.isError).toBe(false); diff --git a/test/tools/preview-style-tool/PreviewStyleTool.test.ts b/test/tools/preview-style-tool/PreviewStyleTool.test.ts index d98b82c..ea70336 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + process.env.MAPBOX_ACCESS_TOKEN = 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; @@ -19,7 +22,7 @@ describe('PreviewStyleTool', () => { it('should have correct input schema', async () => { const { PreviewStyleSchema } = await import( - '../../../src/tools/preview-style-tool/PreviewStyleTool.schema.js' + '../../../src/tools/preview-style-tool/PreviewStyleTool.input.schema.js' ); expect(PreviewStyleSchema).toBeDefined(); }); @@ -28,7 +31,9 @@ describe('PreviewStyleTool', () => { it('uses user-provided public token and returns preview URL', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', - accessToken: TEST_ACCESS_TOKEN + accessToken: TEST_ACCESS_TOKEN, + title: false, + zoomwheel: false }); expect(result.isError).toBe(false); @@ -43,7 +48,9 @@ describe('PreviewStyleTool', () => { it('includes styleId in URL', async () => { const result = await new PreviewStyleTool().run({ styleId: 'my-custom-style', - accessToken: TEST_ACCESS_TOKEN + accessToken: TEST_ACCESS_TOKEN, + title: false, + zoomwheel: false }); expect(result.content[0]).toMatchObject({ @@ -56,7 +63,8 @@ describe('PreviewStyleTool', () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', accessToken: TEST_ACCESS_TOKEN, - title: true + title: true, + zoomwheel: false }); expect(result.content[0]).toMatchObject({ @@ -69,7 +77,8 @@ describe('PreviewStyleTool', () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', accessToken: TEST_ACCESS_TOKEN, - zoomwheel: false + zoomwheel: false, + title: false }); expect(result.content[0]).toMatchObject({ @@ -81,7 +90,9 @@ describe('PreviewStyleTool', () => { it('includes fresh parameter for secure access', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', - accessToken: TEST_ACCESS_TOKEN + accessToken: TEST_ACCESS_TOKEN, + title: false, + zoomwheel: false }); expect(result.content[0]).toMatchObject({ @@ -94,7 +105,9 @@ describe('PreviewStyleTool', () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', accessToken: - 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.secret_token' + 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.secret_token', + title: false, + zoomwheel: false }); expect(result.isError).toBe(true); @@ -109,7 +122,9 @@ describe('PreviewStyleTool', () => { it('rejects temporary tokens', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', - accessToken: 'tk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.temp_token' + accessToken: 'tk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.temp_token', + title: false, + zoomwheel: false }); expect(result.isError).toBe(true); @@ -124,7 +139,9 @@ describe('PreviewStyleTool', () => { it('returns URL on success', async () => { const result = await new PreviewStyleTool().run({ styleId: 'test-style', - accessToken: TEST_ACCESS_TOKEN + accessToken: TEST_ACCESS_TOKEN, + title: false, + zoomwheel: false }); expect(result.isError).toBe(false); diff --git a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts index 1f7115f..8804435 100644 --- a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts +++ b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { RetrieveStyleTool } from '../../../src/tools/retrieve-style-tool/RetrieveStyleTool.js'; const mockToken = @@ -19,14 +22,15 @@ describe('RetrieveStyleTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new RetrieveStyleTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new RetrieveStyleTool({ httpRequest }); expect(tool.name).toBe('retrieve_style_tool'); expect(tool.description).toBe('Retrieve a specific Mapbox style by ID'); }); it('should have correct input schema', async () => { const { RetrieveStyleSchema } = await import( - '../../../src/tools/retrieve-style-tool/RetrieveStyleTool.schema.js' + '../../../src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.js' ); expect(RetrieveStyleSchema).toBeDefined(); }); @@ -34,13 +38,13 @@ describe('RetrieveStyleTool', () => { it('returns style data for successful fetch', async () => { const styleData = { id: 'style-123', name: 'Test Style' }; - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, status: 200, json: async () => styleData }); - const result = await new RetrieveStyleTool(fetch).run({ + const result = await new RetrieveStyleTool({ httpRequest }).run({ styleId: 'style-123' }); @@ -48,11 +52,11 @@ describe('RetrieveStyleTool', () => { type: 'text', text: JSON.stringify(styleData) }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' @@ -60,7 +64,7 @@ describe('RetrieveStyleTool', () => { let result; try { - result = await new RetrieveStyleTool(fetch).run({ + result = await new RetrieveStyleTool({ httpRequest }).run({ styleId: 'style-456' }); } catch (e) { @@ -77,6 +81,6 @@ describe('RetrieveStyleTool', () => { type: 'text', text: 'Failed to retrieve style: 404 Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/style-builder-tool/StyleBuilderTool.test.ts b/test/tools/style-builder-tool/StyleBuilderTool.test.ts index b405f62..e3ce397 100644 --- a/test/tools/style-builder-tool/StyleBuilderTool.test.ts +++ b/test/tools/style-builder-tool/StyleBuilderTool.test.ts @@ -1,6 +1,9 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { StyleBuilderTool } from '../../../src/tools/style-builder-tool/StyleBuilderTool.js'; -import type { StyleBuilderToolInput } from '../../../src/tools/style-builder-tool/StyleBuilderTool.schema.js'; +import type { StyleBuilderToolInput } from '../../../src/tools/style-builder-tool/StyleBuilderTool.input.schema.js'; describe('StyleBuilderTool', () => { let tool: StyleBuilderTool; @@ -23,17 +26,18 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0066ff' + color: '#0066ff', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); expect(result.content[0].type).toBe('text'); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain('Style Built Successfully'); expect(text).toContain('Test Style'); expect(text).toContain('"#0066ff"'); @@ -42,7 +46,7 @@ describe('StyleBuilderTool', () => { it('should handle dark mode', async () => { const input: StyleBuilderToolInput = { style_name: 'Dark Mode Style', - base_style: 'streets', // Use classic style to test background color + base_style: 'streets' as any, // Use classic style to test background color layers: [], global_settings: { mode: 'dark', @@ -50,10 +54,10 @@ describe('StyleBuilderTool', () => { } }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain('Mode:** dark'); expect(text).toContain('#000000'); }); @@ -69,13 +73,14 @@ describe('StyleBuilderTool', () => { layer_type: 'road', action: 'color', color: '#ff0000', - filter_properties: { class: 'primary' } + filter_properties: { class: 'primary' }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); expect(text).toContain('#ff0000'); @@ -91,13 +96,14 @@ describe('StyleBuilderTool', () => { action: 'highlight', color: '#ffff00', width: 5, - filter_properties: { class: 'major_rail' } + filter_properties: { class: 'major_rail' }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); expect(text).toContain('Highlighted'); @@ -111,13 +117,14 @@ describe('StyleBuilderTool', () => { layers: [ { layer_type: 'place_label', - action: 'hide' + action: 'hide', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); expect(text).toContain('Hidden'); @@ -130,13 +137,14 @@ describe('StyleBuilderTool', () => { layers: [ { layer_type: 'building', - action: 'show' + action: 'show', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); expect(text).toContain('Shown'); @@ -154,13 +162,14 @@ describe('StyleBuilderTool', () => { action: 'color', color: '#ff0000', width: 3, - filter_properties: { admin_level: 0, maritime: 'false' } + filter_properties: { admin_level: 0, maritime: 'false' }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); @@ -195,13 +204,14 @@ describe('StyleBuilderTool', () => { action: 'color', color: '#0000ff', opacity: 0.5, - filter_properties: { admin_level: 1, maritime: 'false' } + filter_properties: { admin_level: 1, maritime: 'false' }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(result.isError).toBe(false); @@ -230,19 +240,21 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0099ff' + color: '#0099ff', + render_type: 'symbol' }, { layer_type: 'landuse', filter_properties: { class: 'park' }, action: 'color', - color: '#00ff00' + color: '#00ff00', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); expect(jsonMatch).toBeTruthy(); @@ -273,12 +285,12 @@ describe('StyleBuilderTool', () => { // Test with classic style const input: StyleBuilderToolInput = { style_name: 'Essential Layers Test', - base_style: 'streets', // Use classic style + base_style: 'streets' as any, // Use classic style layers: [] // No layers specified }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -300,16 +312,17 @@ describe('StyleBuilderTool', () => { { layer_type: 'unknown_layer' as any, action: 'color', - color: '#ff0000' + color: '#ff0000', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); // Should return help message, not error expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain('not found'); expect(text).toContain('Available source layers'); }); @@ -323,15 +336,16 @@ describe('StyleBuilderTool', () => { layer_type: 'road', action: 'color', color: '#ff0000', - filter: ['==', ['get', 'class'], 'motorway'] + filter: ['==', ['get', 'class'], 'motorway'], + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -358,15 +372,16 @@ describe('StyleBuilderTool', () => { width: 3, zoom_based: true, min_zoom: 10, - max_zoom: 18 + max_zoom: 18, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -397,15 +412,16 @@ describe('StyleBuilderTool', () => { motorway: '#ff0000', primary: '#ff8800', secondary: '#ffff00' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -437,15 +453,16 @@ describe('StyleBuilderTool', () => { ['>', ['get', 'height'], 50], '#ff8800', '#808080' - ] + ], + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -471,15 +488,16 @@ describe('StyleBuilderTool', () => { opacity: 0.8, zoom_based: true, min_zoom: 14, - max_zoom: 16 + max_zoom: 16, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -503,7 +521,7 @@ describe('StyleBuilderTool', () => { const tool = new StyleBuilderTool(); const input: StyleBuilderToolInput = { style_name: 'Transit Test', - base_style: 'streets', + base_style: 'streets' as any, layers: [ { layer_type: 'transit', @@ -511,17 +529,17 @@ describe('StyleBuilderTool', () => { color: '#ff0000', filter_properties: { maki: 'bus' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const styleJson = JSON.parse( - result.content[0].text.match(/```json\n([\s\S]*?)\n```/)![1] - ); + const text = result.content[0].text as string; + const styleJson = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); const transitLayer = styleJson.layers.find((l: any) => l.id.includes('transit') @@ -540,24 +558,24 @@ describe('StyleBuilderTool', () => { const tool = new StyleBuilderTool(); const input: StyleBuilderToolInput = { style_name: 'Multi Transit Test', - base_style: 'streets', + base_style: 'streets' as any, layers: [ { layer_type: 'transit', action: 'highlight', filter_properties: { maki: ['bus', 'entrance', 'rail-metro'] - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const styleJson = JSON.parse( - result.content[0].text.match(/```json\n([\s\S]*?)\n```/)![1] - ); + const text = result.content[0].text as string; + const styleJson = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); const transitLayer = styleJson.layers.find((l: any) => l.id.includes('transit') @@ -586,17 +604,17 @@ describe('StyleBuilderTool', () => { color: '#9370DB', filter_properties: { toll: true - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const styleJson = JSON.parse( - result.content[0].text.match(/```json\n([\s\S]*?)\n```/)![1] - ); + const text = result.content[0].text as string; + const styleJson = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/)![1]); const roadsLayer = styleJson.layers.find((l: any) => l.id.includes('road-toll-true') @@ -618,15 +636,16 @@ describe('StyleBuilderTool', () => { color: '#ff0000', filter_properties: { class: 'motorway' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -650,15 +669,16 @@ describe('StyleBuilderTool', () => { filter_properties: { class: ['motorway', 'trunk'], structure: 'bridge' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -685,15 +705,16 @@ describe('StyleBuilderTool', () => { admin_level: 0, disputed: 'false', maritime: 'false' - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -717,13 +738,14 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0099ff' + color: '#0099ff', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -755,7 +777,8 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0099ff' + color: '#0099ff', + render_type: 'symbol' } ], standard_config: { @@ -784,8 +807,8 @@ describe('StyleBuilderTool', () => { } }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; expect(text).toContain('Standard Config:** 15 properties set'); expect(text).toContain('Theme: faded'); @@ -816,18 +839,19 @@ describe('StyleBuilderTool', () => { it('should generate Classic style with sources', async () => { const input: StyleBuilderToolInput = { style_name: 'Classic Style Test', - base_style: 'streets', + base_style: 'streets' as any, layers: [ { layer_type: 'water', action: 'color', - color: '#0099ff' + color: '#0099ff', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -858,25 +882,28 @@ describe('StyleBuilderTool', () => { layer_type: 'water', action: 'color', color: '#0099ff', - slot: 'bottom' + slot: 'bottom', + render_type: 'symbol' }, { layer_type: 'landuse', filter_properties: { class: 'park' }, action: 'color', color: '#00ff00', - slot: 'middle' + slot: 'middle', + render_type: 'symbol' }, { layer_type: 'poi_label', action: 'show', - slot: 'top' + slot: 'top', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -910,13 +937,15 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0099ff' + color: '#0099ff', + render_type: 'symbol' } - ] + ], + base_style: 'standard' }; - const result = await tool.execute(input); - const text = result.content[0].text; + const result = await tool.run(input); + const text = result.content[0].text as string; const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); const style = JSON.parse(jsonMatch![1]); @@ -948,15 +977,16 @@ describe('StyleBuilderTool', () => { color: '#00ff00', filter_properties: { type: ['wetland', 'swamp'] - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; // No longer expecting auto-correction since we're using the correct layer expect(text).toContain('Style Built Successfully'); @@ -990,15 +1020,16 @@ describe('StyleBuilderTool', () => { color: '#ff0000', filter_properties: { maki: 'restaurant' // This field only exists in poi_label - } + }, + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain( 'Determined source layer "poi_label" from filter properties' ); @@ -1014,29 +1045,33 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0066ff' + color: '#0066ff', + render_type: 'symbol' }, { layer_type: 'landuse', filter_properties: { class: 'park' }, action: 'highlight', - color: '#00ff00' + color: '#00ff00', + render_type: 'symbol' }, { layer_type: 'place_label', - action: 'hide' + action: 'hide', + render_type: 'symbol' }, { layer_type: 'building', - action: 'show' + action: 'show', + render_type: 'symbol' } ] }; - const result = await tool.execute(input); + const result = await tool.run(input); expect(result.isError).toBe(false); - const text = result.content[0].text; + const text = result.content[0].text as string; expect(text).toContain('Layers Configured:** 4'); expect(text).toContain('Set to #0066ff'); diff --git a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts index df8189f..aa9cb3e 100644 --- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -1,7 +1,18 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { MapboxApiBasedTool } from '../../../src/tools/MapboxApiBasedTool.js'; import { StyleComparisonTool } from '../../../src/tools/style-comparison-tool/StyleComparisonTool.js'; +vi.mock('../../../src/utils/jwtUtils.js', (actual) => { + return { + ...actual, + getUserNameFromToken: vi.fn() + }; +}); + +import { getUserNameFromToken } from 'src/utils/jwtUtils.js'; + describe('StyleComparisonTool', () => { let tool: StyleComparisonTool; @@ -10,7 +21,7 @@ describe('StyleComparisonTool', () => { }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); describe('run', () => { @@ -37,7 +48,7 @@ describe('StyleComparisonTool', () => { before: 'mapbox/streets-v12', after: 'mapbox/satellite-v9' // Missing accessToken - }; + } as any; const result = await tool.run(input); @@ -64,9 +75,7 @@ describe('StyleComparisonTool', () => { it('should handle just style IDs with valid public token', async () => { // Mock MapboxApiBasedTool.getUserNameFromToken to return a username - vi.spyOn(MapboxApiBasedTool, 'getUserNameFromToken').mockReturnValue( - 'testuser' - ); + vi.mocked(getUserNameFromToken).mockReturnValue('testuser'); const input = { before: 'style-id-1', @@ -117,13 +126,11 @@ describe('StyleComparisonTool', () => { it('should return error for style ID without valid username in token', async () => { // Mock getUserNameFromToken to throw an error - vi.spyOn(MapboxApiBasedTool, 'getUserNameFromToken').mockImplementation( - () => { - throw new Error( - 'MAPBOX_ACCESS_TOKEN does not contain username in payload' - ); - } - ); + vi.mocked(getUserNameFromToken).mockImplementation(() => { + throw new Error( + 'MAPBOX_ACCESS_TOKEN does not contain username in payload' + ); + }); const input = { before: 'style-id-only', diff --git a/test/tools/tilequery-tool/TilequeryTool.test.ts b/test/tools/tilequery-tool/TilequeryTool.test.ts index 444d436..8acd906 100644 --- a/test/tools/tilequery-tool/TilequeryTool.test.ts +++ b/test/tools/tilequery-tool/TilequeryTool.test.ts @@ -1,12 +1,17 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, beforeEach } from 'vitest'; import { TilequeryTool } from '../../../src/tools/tilequery-tool/TilequeryTool.js'; -import { TilequeryInput } from '../../../src/tools/tilequery-tool/TilequeryTool.schema.js'; +import { TilequeryInput } from '../../../src/tools/tilequery-tool/TilequeryTool.input.schema.js'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; describe('TilequeryTool', () => { let tool: TilequeryTool; beforeEach(() => { - tool = new TilequeryTool(); + const { httpRequest } = setupHttpRequest(); + tool = new TilequeryTool({ httpRequest }); }); describe('constructor', () => { diff --git a/test/tools/update-style-tool/UpdateStyleTool.test.ts b/test/tools/update-style-tool/UpdateStyleTool.test.ts index 518ca12..987815e 100644 --- a/test/tools/update-style-tool/UpdateStyleTool.test.ts +++ b/test/tools/update-style-tool/UpdateStyleTool.test.ts @@ -1,8 +1,11 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { - setupFetch, + setupHttpRequest, assertHeadersSent -} from '../../utils/fetchRequestUtils.js'; +} from '../../utils/httpPipelineUtils.js'; import { UpdateStyleTool } from '../../../src/tools/update-style-tool/UpdateStyleTool.js'; const mockToken = @@ -19,35 +22,36 @@ describe('UpdateStyleTool', () => { describe('tool metadata', () => { it('should have correct name and description', () => { - const tool = new UpdateStyleTool(); + const { httpRequest } = setupHttpRequest(); + const tool = new UpdateStyleTool({ httpRequest }); expect(tool.name).toBe('update_style_tool'); expect(tool.description).toBe('Update an existing Mapbox style'); }); it('should have correct input schema', async () => { - const { UpdateStyleSchema } = await import( - '../../../src/tools/update-style-tool/UpdateStyleTool.schema.js' + const { MapboxStyleInputSchema } = await import( + '../../../src/tools/update-style-tool/UpdateStyleTool.input.schema.js' ); - expect(UpdateStyleSchema).toBeDefined(); + expect(MapboxStyleInputSchema).toBeDefined(); }); }); it('sends custom header', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, json: async () => ({ id: 'updated-style-id', name: 'Updated Style' }) }); - await new UpdateStyleTool(fetch).run({ + await new UpdateStyleTool({ httpRequest }).run({ styleId: 'style-123', name: 'Updated Style', style: { version: 8, sources: {}, layers: [] } }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); it('handles fetch errors gracefully', async () => { - const { fetch, mockFetch } = setupFetch({ + const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: false, status: 404, statusText: 'Not Found' @@ -55,7 +59,7 @@ describe('UpdateStyleTool', () => { let result; try { - result = await new UpdateStyleTool(fetch).run({ + result = await new UpdateStyleTool({ httpRequest }).run({ styleId: 'style-123', name: 'Updated Style', style: { version: 8, sources: {}, layers: [] } @@ -74,6 +78,6 @@ describe('UpdateStyleTool', () => { type: 'text', text: 'Failed to update style: 404 Not Found' }); - assertHeadersSent(mockFetch); + assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/utils/fetchRequest.test.ts b/test/utils/fetchRequest.test.ts deleted file mode 100644 index 754c629..0000000 --- a/test/utils/fetchRequest.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { - RetryPolicy, - PolicyPipeline, - UserAgentPolicy -} from '../../src/utils/fetchRequest.js'; -import type { Mock } from 'vitest'; - -function createMockFetch( - responses: Array<{ status: number; ok?: boolean }> -): typeof fetch { - let call = 0; - return vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => { - const res = responses[Math.min(call, responses.length - 1)]; - call++; - return { - ok: res.ok ?? res.status < 400, - status: res.status, - statusText: `Status ${res.status}`, - json: async () => ({ status: res.status }) - } as Response; - }) as typeof fetch; -} - -describe('PolicyPipeline', () => { - describe('usePolicy, removePolicy, and listPolicies', () => { - it('adds policies with usePolicy', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1'); - const policy2 = new RetryPolicy(); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(2); - expect(policies[0]).toBe(policy1); - expect(policies[1]).toBe(policy2); - }); - - it('removes policies with removePolicy', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1'); - const policy2 = new RetryPolicy(); - const policy3 = new UserAgentPolicy('Agent3'); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - pipeline.usePolicy(policy3); - - pipeline.removePolicy(policy2); - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(2); - expect(policies[0]).toBe(policy1); - expect(policies[1]).toBe(policy3); - }); - - it('removePolicy does nothing if policy not found', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1'); - const policy2 = new RetryPolicy(); - - pipeline.usePolicy(policy1); - - pipeline.removePolicy(policy2); // Not in the list - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(1); - expect(policies[0]).toBe(policy1); - }); - - it('listPolicies returns empty array initially', () => { - const pipeline = new PolicyPipeline(); - expect(pipeline.listPolicies()).toEqual([]); - }); - - it('listPolicies returns the policies array', () => { - const pipeline = new PolicyPipeline(); - const policy = new UserAgentPolicy('Agent1'); - - pipeline.usePolicy(policy); - const policies1 = pipeline.listPolicies(); - const policies2 = pipeline.listPolicies(); - - expect(policies1).toBe(policies2); // Same reference - expect(policies1).toEqual(policies2); // Same content - expect(policies1).toContain(policy); - }); - }); - - describe('RetryPolicy', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('retries on 500 and returns last response after max retries', async () => { - const mockFetch = createMockFetch([ - { status: 500 }, - { status: 500 }, - { status: 500 }, - { status: 500 } - ]); - const pipeline = new PolicyPipeline(mockFetch); - pipeline.usePolicy(new RetryPolicy(3, 1, 10)); // Use small delays for test speed - - const response = await pipeline.fetch('http://test', {}); - - expect(mockFetch).toHaveBeenCalledTimes(4); - expect(response.status).toBe(500); - }); - - it('retries on 429 and succeeds if later response is ok', async () => { - const mockFetch = createMockFetch([ - { status: 429 }, - { status: 429 }, - { status: 200, ok: true } - ]); - const pipeline = new PolicyPipeline(mockFetch); - pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - - const response = await pipeline.fetch('http://test', {}); - - expect(mockFetch).toHaveBeenCalledTimes(3); - expect(response.status).toBe(200); - expect(response.ok).toBe(true); - }); - - it('does not retry on 400 errors', async () => { - const mockFetch = createMockFetch([{ status: 400 }]); - const pipeline = new PolicyPipeline(mockFetch); - pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - - const response = await pipeline.fetch('http://test', {}); - - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(response.status).toBe(400); - }); - - it('returns immediately on first success', async () => { - const mockFetch = createMockFetch([{ status: 200, ok: true }]); - const pipeline = new PolicyPipeline(mockFetch); - pipeline.usePolicy(new RetryPolicy(3, 1, 10)); - - const response = await pipeline.fetch('http://test', {}); - - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(response.status).toBe(200); - expect(response.ok).toBe(true); - }); - }); - - describe('UserAgentPolicy', () => { - it('sets the User-Agent header if not present', async () => { - const mockFetch = vi.fn( - async (input: string | URL | Request, init?: RequestInit) => { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - headers: init?.headers - } as Response; - } - ) as Mock; - - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); - pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); - - await pipeline.fetch('http://test', {}); - - const headers = mockFetch.mock.calls[0][1]?.headers as Record< - string, - string - >; - expect(headers['User-Agent']).toBe('TestAgent/1.0'); - }); - - it('does not overwrite an existing User-Agent header', async () => { - const mockFetch = vi.fn( - async (input: string | URL | Request, init?: RequestInit) => { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - headers: init?.headers - } as Response; - } - ) as Mock; - - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); - pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); - - await pipeline.fetch('http://test', { - headers: { - 'User-Agent': 'CustomAgent/2.0' - } - }); - - const headers = mockFetch.mock.calls[0][1]?.headers as Record< - string, - string - >; - expect(headers['User-Agent']).toBe('CustomAgent/2.0'); - }); - - it('works with headers as Headers object', async () => { - const mockFetch = vi.fn( - async (_input: string | URL | Request, init?: RequestInit) => { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - headers: init?.headers - } as Response; - } - ) as Mock; - - const pipeline = new PolicyPipeline(mockFetch as unknown as typeof fetch); - pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); - - const headers = new Headers(); - await pipeline.fetch('http://test', { headers }); - - expect(headers.get('User-Agent')).toBe('TestAgent/1.0'); - }); - }); - - describe('Policy ID functionality', () => { - it('assigns unique IDs to policies when not provided', () => { - const policy1 = new UserAgentPolicy('Agent1'); - const policy2 = new UserAgentPolicy('Agent2'); - const policy3 = new RetryPolicy(); - - expect(policy1.id).toBeDefined(); - expect(policy2.id).toBeDefined(); - expect(policy3.id).toBeDefined(); - expect(policy1.id).not.toBe(policy2.id); - expect(policy2.id).not.toBe(policy3.id); - }); - - it('uses custom ID when provided', () => { - const customId = 'my-custom-policy'; - const policy = new UserAgentPolicy('Agent1', customId); - - expect(policy.id).toBe(customId); - }); - - it('removes policies by ID using removePolicy', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1', 'policy-1'); - const policy2 = new RetryPolicy(3, 200, 2000, 'policy-2'); - const policy3 = new UserAgentPolicy('Agent3', 'policy-3'); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - pipeline.usePolicy(policy3); - - pipeline.removePolicy('policy-2'); // Remove by ID string - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(2); - expect(policies[0]).toBe(policy1); - expect(policies[1]).toBe(policy3); - }); - - it('removePolicy supports both policy instance and ID string', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1', 'policy-1'); - const policy2 = new RetryPolicy(3, 200, 2000, 'policy-2'); - const policy3 = new UserAgentPolicy('Agent3', 'policy-3'); - const policy4 = new UserAgentPolicy('Agent4', 'policy-4'); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - pipeline.usePolicy(policy3); - pipeline.usePolicy(policy4); - - // Remove by policy instance - pipeline.removePolicy(policy2); - - // Remove by ID string - pipeline.removePolicy('policy-4'); - - const policies = pipeline.listPolicies(); - expect(policies).toHaveLength(2); - expect(policies[0]).toBe(policy1); - expect(policies[1]).toBe(policy3); - }); - - it('finds policies by ID', () => { - const pipeline = new PolicyPipeline(); - const policy1 = new UserAgentPolicy('Agent1', 'policy-1'); - const policy2 = new RetryPolicy(3, 200, 2000, 'policy-2'); - - pipeline.usePolicy(policy1); - pipeline.usePolicy(policy2); - - expect(pipeline.findPolicyById('policy-1')).toBe(policy1); - expect(pipeline.findPolicyById('policy-2')).toBe(policy2); - expect(pipeline.findPolicyById('non-existent')).toBeUndefined(); - }); - - it('fromVersionInfo accepts optional ID parameter', () => { - const versionInfo = { - name: 'test-app', - version: '1.0.0', - sha: 'abc123', - tag: 'v1.0.0', - branch: 'main' - }; - - const policyWithoutId = UserAgentPolicy.fromVersionInfo(versionInfo); - const policyWithId = UserAgentPolicy.fromVersionInfo( - versionInfo, - 'custom-id' - ); - - expect(policyWithoutId.id).toBeDefined(); - expect(policyWithId.id).toBe('custom-id'); - }); - }); -}); diff --git a/test/utils/httpPipeline.test.ts b/test/utils/httpPipeline.test.ts new file mode 100644 index 0000000..1d52b56 --- /dev/null +++ b/test/utils/httpPipeline.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + RetryPolicy, + HttpPipeline, + UserAgentPolicy +} from '../../src/utils/httpPipeline.js'; +import type { Mock } from 'vitest'; + +function createMockFetch( + responses: Array<{ status: number; ok?: boolean }> +): typeof fetch { + let call = 0; + return vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => { + const res = responses[Math.min(call, responses.length - 1)]; + call++; + return { + ok: res.ok ?? res.status < 400, + status: res.status, + statusText: `Status ${res.status}`, + json: async () => ({ status: res.status }) + } as Response; + }) as typeof fetch; +} + +describe('HttpPipeline', () => { + describe('RetryPolicy', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('retries on 500 and returns last response after max retries', async () => { + const mockFetch = createMockFetch([ + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 } + ]); + const pipeline = new HttpPipeline(mockFetch); + pipeline.usePolicy(new RetryPolicy(3, 1, 10)); // Use small delays for test speed + + const response = await pipeline.execute('http://test', {}); + + expect(mockFetch).toHaveBeenCalledTimes(4); + expect(response.status).toBe(500); + }); + + it('retries on 429 and succeeds if later response is ok', async () => { + const mockFetch = createMockFetch([ + { status: 429 }, + { status: 429 }, + { status: 200, ok: true } + ]); + const pipeline = new HttpPipeline(mockFetch); + pipeline.usePolicy(new RetryPolicy(3, 1, 10)); + + const response = await pipeline.execute('http://test', {}); + + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(response.status).toBe(200); + expect(response.ok).toBe(true); + }); + + it('does not retry on 400 errors', async () => { + const mockFetch = createMockFetch([{ status: 400 }]); + const pipeline = new HttpPipeline(mockFetch); + pipeline.usePolicy(new RetryPolicy(3, 1, 10)); + + const response = await pipeline.execute('http://test', {}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(response.status).toBe(400); + }); + + it('returns immediately on first success', async () => { + const mockFetch = createMockFetch([{ status: 200, ok: true }]); + const pipeline = new HttpPipeline(mockFetch); + pipeline.usePolicy(new RetryPolicy(3, 1, 10)); + + const response = await pipeline.execute('http://test', {}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + expect(response.ok).toBe(true); + }); + }); + + describe('UserAgentPolicy', () => { + it('sets the User-Agent header if not present', async () => { + const mockFetch = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + headers: init?.headers + } as Response; + } + ) as Mock; + + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); + + await pipeline.execute('http://test', {}); + + const headers = mockFetch.mock.calls[0][1]?.headers as Record< + string, + string + >; + expect(headers['User-Agent']).toBe('TestAgent/1.0'); + }); + + it('does not overwrite an existing User-Agent header', async () => { + const mockFetch = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + headers: init?.headers + } as Response; + } + ) as Mock; + + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); + + await pipeline.execute('http://test', { + headers: { + 'User-Agent': 'CustomAgent/2.0' + } + }); + + const headers = mockFetch.mock.calls[0][1]?.headers as Record< + string, + string + >; + expect(headers['User-Agent']).toBe('CustomAgent/2.0'); + }); + + it('works with headers as Headers object', async () => { + const mockFetch = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + headers: init?.headers + } as Response; + } + ) as Mock; + + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + pipeline.usePolicy(new UserAgentPolicy('TestAgent/1.0')); + + const headers = new Headers(); + await pipeline.execute('http://test', { headers }); + + expect(headers.get('User-Agent')).toBe('TestAgent/1.0'); + }); + }); + + describe('Policy Management', () => { + it('can add and list policies', () => { + const mockFetch = vi.fn(); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'user-agent-test' + ); + const retryPolicy = new RetryPolicy(3, 100, 1000, 'retry-test'); + + pipeline.usePolicy(userAgentPolicy); + pipeline.usePolicy(retryPolicy); + + const policies = pipeline.listPolicies(); + expect(policies).toHaveLength(2); + expect(policies[0].id).toBe('user-agent-test'); + expect(policies[1].id).toBe('retry-test'); + }); + + it('can find policy by ID', () => { + const mockFetch = vi.fn(); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'user-agent-test' + ); + pipeline.usePolicy(userAgentPolicy); + + const foundPolicy = pipeline.findPolicyById('user-agent-test'); + expect(foundPolicy).toBe(userAgentPolicy); + + const notFoundPolicy = pipeline.findPolicyById('non-existent'); + expect(notFoundPolicy).toBeUndefined(); + }); + + it('can remove policy by ID', () => { + const mockFetch = vi.fn(); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'user-agent-test' + ); + const retryPolicy = new RetryPolicy(3, 100, 1000, 'retry-test'); + + pipeline.usePolicy(userAgentPolicy); + pipeline.usePolicy(retryPolicy); + + expect(pipeline.listPolicies()).toHaveLength(2); + + pipeline.removePolicy('user-agent-test'); + + const policies = pipeline.listPolicies(); + expect(policies).toHaveLength(1); + expect(policies[0].id).toBe('retry-test'); + }); + + it('can remove policy by reference', () => { + const mockFetch = vi.fn(); + const pipeline = new HttpPipeline(mockFetch as unknown as typeof fetch); + + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'user-agent-test' + ); + const retryPolicy = new RetryPolicy(3, 100, 1000, 'retry-test'); + + pipeline.usePolicy(userAgentPolicy); + pipeline.usePolicy(retryPolicy); + + expect(pipeline.listPolicies()).toHaveLength(2); + + pipeline.removePolicy(userAgentPolicy); + + const policies = pipeline.listPolicies(); + expect(policies).toHaveLength(1); + expect(policies[0].id).toBe('retry-test'); + }); + + it('generates automatic IDs for policies without explicit ID', () => { + const userAgentPolicy = new UserAgentPolicy('TestAgent/1.0'); + const retryPolicy = new RetryPolicy(3, 100, 1000); + + expect(userAgentPolicy.id).toMatch(/^user-agent-\d+-[a-z0-9]+$/); + expect(retryPolicy.id).toMatch(/^retry-\d+-[a-z0-9]+$/); + }); + + it('uses provided IDs when specified', () => { + const userAgentPolicy = new UserAgentPolicy( + 'TestAgent/1.0', + 'custom-ua-id' + ); + const retryPolicy = new RetryPolicy(3, 100, 1000, 'custom-retry-id'); + + expect(userAgentPolicy.id).toBe('custom-ua-id'); + expect(retryPolicy.id).toBe('custom-retry-id'); + }); + }); +}); diff --git a/test/utils/fetchRequestUtils.ts b/test/utils/httpPipelineUtils.ts similarity index 62% rename from test/utils/fetchRequestUtils.ts rename to test/utils/httpPipelineUtils.ts index ec72a48..a8e7846 100644 --- a/test/utils/fetchRequestUtils.ts +++ b/test/utils/httpPipelineUtils.ts @@ -1,13 +1,13 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + import { expect, vi } from 'vitest'; import type { Mock } from 'vitest'; -import { - PolicyPipeline, - UserAgentPolicy -} from '../../src/utils/fetchRequest.js'; +import { HttpPipeline, UserAgentPolicy } from '../../src/utils/httpPipeline.js'; -export function setupFetch(overrides?: any) { - const mockFetch = vi.fn(); - mockFetch.mockResolvedValue({ +export function setupHttpRequest(overrides?: Partial) { + const mockHttpRequest = vi.fn(); + mockHttpRequest.mockResolvedValue({ ok: true, status: 200, statusText: 'OK', @@ -18,10 +18,10 @@ export function setupFetch(overrides?: any) { // Build a real pipeline with UserAgentPolicy const userAgent = 'TestServer/1.0.0 (default, no-tag, abcdef)'; - const pipeline = new PolicyPipeline(mockFetch); + const pipeline = new HttpPipeline(mockHttpRequest); pipeline.usePolicy(new UserAgentPolicy(userAgent)); - return { fetch: pipeline.fetch.bind(pipeline), mockFetch }; + return { httpRequest: pipeline.execute.bind(pipeline), mockHttpRequest }; } export function assertHeadersSent(mockFetch: Mock) { From e9cacae37ada5ae670ad2920c414a4da72f348a9 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 20 Oct 2025 07:44:48 -0400 Subject: [PATCH 2/9] [quality tests] ensure quality of structuredContent --- src/schemas/style.ts | 72 ++++++----- src/tools/BaseTool.ts | 19 ++- .../bounding-box-tool/BoundingBoxTool.ts | 32 +++-- .../CountryBoundingBoxTool.ts | 16 ++- .../CoordinateConversionTool.ts | 41 +++++-- .../create-style-tool/CreateStyleTool.ts | 12 +- .../CreateTokenTool.output.schema.ts | 1 - .../create-token-tool/CreateTokenTool.ts | 22 +++- .../GeojsonPreviewTool.ts | 2 +- .../GetMapboxDocSourceTool.ts | 12 +- src/tools/list-styles-tool/ListStylesTool.ts | 12 ++ .../ListTokensTool.output.schema.ts | 1 - src/tools/list-tokens-tool/ListTokensTool.ts | 37 ++++-- .../preview-style-tool/PreviewStyleTool.ts | 19 ++- .../retrieve-style-tool/RetrieveStyleTool.ts | 2 +- .../style-builder-tool/StyleBuilderTool.ts | 4 +- .../StyleComparisonTool.ts | 27 +++- .../UpdateStyleTool.input.schema.ts | 12 +- .../update-style-tool/UpdateStyleTool.ts | 10 +- src/utils/jwtUtils.ts | 6 +- test/tools/MapboxApiBasedTool.test.ts | 116 ++++-------------- .../tool-naming-convention.test.ts.snap | 45 +++++++ .../create-style-tool/CreateStyleTool.test.ts | 16 ++- .../create-token-tool/CreateTokenTool.test.ts | 14 ++- .../delete-style-tool/DeleteStyleTool.test.ts | 37 +----- .../GetMapboxDocSourceTool.test.ts | 5 +- .../list-styles-tool/ListStylesTool.test.ts | 18 ++- .../list-tokens-tool/ListTokensTool.test.ts | 40 +++--- .../StyleBuilderTool.test.ts | 31 ++--- .../StyleComparisonTool.test.ts | 21 +--- test/tools/tool-naming-convention.test.ts | 16 ++- test/utils/jwtUtils.test.ts | 79 ++++++++++++ 32 files changed, 520 insertions(+), 277 deletions(-) create mode 100644 test/utils/jwtUtils.test.ts diff --git a/src/schemas/style.ts b/src/schemas/style.ts index dcf9643..8e337d9 100644 --- a/src/schemas/style.ts +++ b/src/schemas/style.ts @@ -199,40 +199,44 @@ const StyleImportSchema = z .passthrough(); // Base Style properties (shared between input and output) -export const BaseStylePropertiesSchema = z.object({ - // Required Style Spec properties - version: z - .literal(8) - .describe('Style specification version number. Must be 8'), - sources: z.record(SourceSchema).describe('Data source specifications'), - layers: z.array(LayerSchema).describe('Layers in draw order'), - - // Optional Style Spec properties - metadata: z - .record(z.any()) - .optional() - .describe('Arbitrary properties for tracking'), - center: CoordinatesSchema.optional().describe( - 'Default map center [longitude, latitude]' - ), - zoom: z.number().optional().describe('Default zoom level'), - bearing: z.number().optional().describe('Default bearing in degrees'), - pitch: z.number().optional().describe('Default pitch in degrees'), - sprite: z - .string() - .optional() - .describe('Base URL for sprite image and metadata'), - glyphs: z.string().optional().describe('URL template for glyph sets'), - light: LightSchema.optional().describe( - 'Global light source (deprecated, use lights)' - ), - lights: LightsSchema.optional().describe('Array of 3D light sources'), - terrain: TerrainSchema.optional().describe('Global terrain elevation'), - fog: z.record(z.any()).optional().describe('Fog properties'), - projection: z.record(z.any()).optional().describe('Map projection'), - transition: TransitionSchema.optional().describe('Default transition timing'), - imports: z.array(StyleImportSchema).optional().describe('Imported styles') -}); +export const BaseStylePropertiesSchema = z + .object({ + // Required Style Spec properties + version: z + .literal(8) + .describe('Style specification version number. Must be 8'), + sources: z.record(SourceSchema).describe('Data source specifications'), + layers: z.array(LayerSchema).describe('Layers in draw order'), + + // Optional Style Spec properties + metadata: z + .record(z.any()) + .optional() + .describe('Arbitrary properties for tracking'), + center: CoordinatesSchema.optional().describe( + 'Default map center [longitude, latitude]' + ), + zoom: z.number().optional().describe('Default zoom level'), + bearing: z.number().optional().describe('Default bearing in degrees'), + pitch: z.number().optional().describe('Default pitch in degrees'), + sprite: z + .string() + .optional() + .describe('Base URL for sprite image and metadata'), + glyphs: z.string().optional().describe('URL template for glyph sets'), + light: LightSchema.optional().describe( + 'Global light source (deprecated, use lights)' + ), + lights: LightsSchema.optional().describe('Array of 3D light sources'), + terrain: TerrainSchema.optional().describe('Global terrain elevation'), + fog: z.record(z.any()).optional().describe('Fog properties'), + projection: z.record(z.any()).optional().describe('Map projection'), + transition: TransitionSchema.optional().describe( + 'Default transition timing' + ), + imports: z.array(StyleImportSchema).optional().describe('Imported styles') + }) + .passthrough(); export type MapboxSource = z.infer; export type MapboxLayer = z.infer; diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 3f48a48..67e6ab0 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -35,10 +35,27 @@ export abstract class BaseTool< /** * Tool logic to be implemented by subclasses. */ - abstract run( + async run( rawInput: unknown, // eslint-disable-next-line @typescript-eslint/no-explicit-any extra?: RequestHandlerExtra + ): Promise { + try { + const input = this.inputSchema.parse(rawInput); + const accessToken = + extra?.authInfo?.token || process.env.MAPBOX_ACCESS_TOKEN; + return this.execute(input, accessToken); + } catch (error) { + return { + isError: true, + content: [{ type: 'text', text: (error as Error).message }] + }; + } + } + + protected abstract execute( + inputSchema: z.infer, + accessToken?: string ): Promise; /** diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.ts b/src/tools/bounding-box-tool/BoundingBoxTool.ts index c094acc..8590393 100644 --- a/src/tools/bounding-box-tool/BoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/BoundingBoxTool.ts @@ -39,17 +39,33 @@ export class BoundingBoxTool extends BaseTool< }); } - async run(input: BoundingBoxInput): Promise { + protected async execute(input: BoundingBoxInput): Promise { const { geojson } = input; - // Parse GeoJSON if it's a string - const geojsonObject = - typeof geojson === 'string' - ? (JSON.parse(geojson) as GeoJSON) - : (geojson as GeoJSON); - // Calculate bounding box - const bbox = this.calculateBoundingBox(geojsonObject); + let bbox; + try { + // Parse GeoJSON if it's a string + const geojsonObject = + typeof geojson === 'string' + ? (JSON.parse(geojson) as GeoJSON) + : (geojson as GeoJSON); + + bbox = this.calculateBoundingBox(geojsonObject); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error calculating bounding box: ${(error as Error).message}` + } + ], + structuredContent: { + error: (error as Error).message + }, + isError: true + }; + } return { content: [ diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts index 65190c7..1861edc 100644 --- a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts @@ -38,15 +38,23 @@ export class CountryBoundingBoxTool extends BaseTool< }); } - async run(input: CountryBoundingBoxInput): Promise { + protected async execute( + input: CountryBoundingBoxInput + ): Promise { const { iso_3166_1 } = input; const upperCaseCode = iso_3166_1.toUpperCase(); const bbox = this.boundariesData[upperCaseCode]; if (!bbox) { - throw new Error( - `Country code "${iso_3166_1}" not found. Please use a valid ISO 3166-1 country code (e.g., "CN", "US", "AE").` - ); + return { + content: [ + { + type: 'text', + text: `Country code "${iso_3166_1}" not found. Please use a valid ISO 3166-1 country code (e.g., "CN", "US", "AE").` + } + ], + isError: true + }; } return { diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts index 1dbf95b..c14f5a0 100644 --- a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts @@ -34,7 +34,9 @@ export class CoordinateConversionTool extends BaseTool< }); } - async run(input: CoordinateConversionInput): Promise { + protected async execute( + input: CoordinateConversionInput + ): Promise { const { coordinates, from, to } = input; if (from === to) { @@ -60,12 +62,37 @@ export class CoordinateConversionTool extends BaseTool< let result: [number, number]; - if (from === 'wgs84' && to === 'epsg3857') { - result = this.wgs84ToEpsg3857(coordinates[0], coordinates[1]); - } else if (from === 'epsg3857' && to === 'wgs84') { - result = this.epsg3857ToWgs84(coordinates[0], coordinates[1]); - } else { - throw new Error(`Unsupported conversion: ${from} to ${to}`); + const method = + from === 'wgs84' && to === 'epsg3857' + ? this.wgs84ToEpsg3857.bind(this) + : from === 'epsg3857' && to === 'wgs84' + ? this.epsg3857ToWgs84.bind(this) + : undefined; + + if (!method) { + return { + content: [ + { + type: 'text', + text: `Unsupported conversion: ${from} to ${to}` + } + ], + isError: true + }; + } + + try { + result = method(coordinates[0], coordinates[1]); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error during conversion: ${(error as Error).message}` + } + ], + isError: true + }; } const outputResult: CoordinateConversionOutput = { diff --git a/src/tools/create-style-tool/CreateStyleTool.ts b/src/tools/create-style-tool/CreateStyleTool.ts index 192ae97..341bdc9 100644 --- a/src/tools/create-style-tool/CreateStyleTool.ts +++ b/src/tools/create-style-tool/CreateStyleTool.ts @@ -53,9 +53,15 @@ export class CreateStyleTool extends MapboxApiBasedTool< }); if (!response.ok) { - throw new Error( - `Failed to create style: ${response.status} ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Failed to create style: ${response.status} ${response.statusText}` + } + ], + isError: true + }; } const rawData = await response.json(); diff --git a/src/tools/create-token-tool/CreateTokenTool.output.schema.ts b/src/tools/create-token-tool/CreateTokenTool.output.schema.ts index 9e48e9b..45f718e 100644 --- a/src/tools/create-token-tool/CreateTokenTool.output.schema.ts +++ b/src/tools/create-token-tool/CreateTokenTool.output.schema.ts @@ -5,7 +5,6 @@ import { z } from 'zod'; export const CreateTokenOutputSchema = z.object({ id: z.string().describe('Token ID'), - name: z.string().describe('Token name'), scopes: z.array(z.string()).describe('Array of scopes assigned to the token'), token: z.string().describe('The actual token string'), created: z.string().describe('ISO 8601 creation timestamp'), diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index d91e101..55f2726 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -59,7 +59,15 @@ export class CreateTokenTool extends MapboxApiBasedTool< if (input.allowedUrls) { if (input.allowedUrls.length > 100) { - throw new Error('Maximum 100 allowed URLs per token'); + return { + content: [ + { + type: 'text', + text: 'Maximum 100 allowed URLs per token' + } + ], + isError: true + }; } body.allowedUrls = input.allowedUrls; } @@ -82,9 +90,15 @@ export class CreateTokenTool extends MapboxApiBasedTool< 'error', `CreateTokenTool: API Error - Status: ${response.status}, Body: ${errorBody}` ); - throw new Error( - `Failed to create token: ${response.status} ${response.statusText}` - ); + return { + content: [ + { + type: 'text', + text: `Failed to create token: ${response.status} ${response.statusText}` + } + ], + isError: true + }; } const data = await response.json(); diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index a233f26..6d1e640 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -49,7 +49,7 @@ export class GeojsonPreviewTool extends BaseTool { ); } - async run(input: GeojsonPreviewInput): Promise { + protected async execute(input: GeojsonPreviewInput): Promise { try { // Parse and validate JSON format const geojsonData = JSON.parse(input.geojson); diff --git a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts index dc2fd50..ad7cb00 100644 --- a/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts +++ b/src/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.ts @@ -32,7 +32,7 @@ export class GetMapboxDocSourceTool extends BaseTool< this.httpRequest = params.httpRequest; } - async run( + protected async execute( // eslint-disable-next-line @typescript-eslint/no-unused-vars _input: GetMapboxDocSourceInput ): Promise { @@ -67,7 +67,15 @@ export class GetMapboxDocSourceTool extends BaseTool< } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - throw new Error(`Failed to fetch Mapbox documentation: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Failed to fetch Mapbox documentation: ${errorMessage}` + } + ], + isError: true + }; } } } diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index e8c0d1b..a4b2c53 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -59,6 +59,18 @@ export class ListStylesTool extends MapboxApiBasedTool< const response = await this.httpRequest(url); + if (!response.ok) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to list styles: ${response.status} ${response.statusText}` + } + ], + isError: true + }; + } + const data = await response.json(); const parseResult = ListStylesOutputSchema.safeParse(data); if (!parseResult.success) { diff --git a/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts b/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts index 4be7737..3ee729c 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts @@ -5,7 +5,6 @@ import { z } from 'zod'; export const TokenObjectSchema = z.object({ id: z.string().describe('Token ID'), - name: z.string().describe('Token name'), scopes: z.array(z.string()).describe('Array of scopes assigned to the token'), token: z.string().describe('The actual token string'), created: z.string().describe('ISO 8601 creation timestamp'), diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index 2588b8d..0e61cac 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -9,7 +9,10 @@ import { ListTokensInput } from './ListTokensTool.input.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; -import { ListTokensOutputSchema } from './ListTokensTool.output.schema.js'; +import { + ListTokensOutputSchema, + TokenObjectSchema +} from './ListTokensTool.output.schema.js'; export class ListTokensTool extends MapboxApiBasedTool< typeof ListTokensSchema, @@ -39,10 +42,31 @@ export class ListTokensTool extends MapboxApiBasedTool< accessToken?: string ): Promise { if (!accessToken) { - throw new Error('MAPBOX_ACCESS_TOKEN is not set'); + return { + isError: true, + content: [ + { + type: 'text', + text: 'MAPBOX_ACCESS_TOKEN is not set' + } + ] + }; } - const username = getUserNameFromToken(accessToken); + let userName; + try { + userName = getUserNameFromToken(accessToken); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Invalid access token: ${(error as Error).message}` + } + ] + }; + } this.log( 'info', @@ -70,7 +94,7 @@ export class ListTokensTool extends MapboxApiBasedTool< } let url: string | null = - `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${username}?${params.toString()}`; + `${MapboxApiBasedTool.mapboxApiEndpoint}tokens/v2/${userName}?${params.toString()}`; const allTokens: unknown[] = []; let pageCount = 0; let nextPageUrl: string | null = null; @@ -118,9 +142,6 @@ export class ListTokensTool extends MapboxApiBasedTool< : (data as { tokens?: unknown[] }).tokens || []; // Validate tokens array against TokenObjectSchema - const { TokenObjectSchema } = await import( - './ListTokensTool.output.schema.js' - ); const parseResult = TokenObjectSchema.array().safeParse(tokens); if (!parseResult.success) { this.log( @@ -196,7 +217,7 @@ export class ListTokensTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify(result, null, 2) + text: JSON.stringify(result) } ], structuredContent: result, diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index a63624e..bb0adc8 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -23,8 +23,21 @@ export class PreviewStyleTool extends BaseTool { super({ inputSchema: PreviewStyleSchema }); } - async run(input: PreviewStyleInput): Promise { - const username = getUserNameFromToken(input.accessToken); + protected async execute(input: PreviewStyleInput): Promise { + let userName: string; + try { + userName = getUserNameFromToken(input.accessToken); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: error instanceof Error ? error.message : String(error) + } + ] + }; + } // Use the user-provided public token const publicToken = input.accessToken; @@ -48,7 +61,7 @@ export class PreviewStyleTool extends BaseTool { const hashFragment = hashParams.length > 0 ? `#${hashParams.join('/')}` : ''; - const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}.html?${params.toString()}${hashFragment}`; + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${userName}/${input.styleId}.html?${params.toString()}${hashFragment}`; return { content: [ diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts index b420998..d963736 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts @@ -72,7 +72,7 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + text: JSON.stringify(filterExpandedMapboxStyles(data)) } ], structuredContent: filterExpandedMapboxStyles(data), diff --git a/src/tools/style-builder-tool/StyleBuilderTool.ts b/src/tools/style-builder-tool/StyleBuilderTool.ts index 1b68d5a..0f093f5 100644 --- a/src/tools/style-builder-tool/StyleBuilderTool.ts +++ b/src/tools/style-builder-tool/StyleBuilderTool.ts @@ -129,7 +129,9 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho super({ inputSchema: StyleBuilderToolSchema }); } - async run(input: StyleBuilderToolInput): Promise { + protected async execute( + input: StyleBuilderToolInput + ): Promise { try { const result = this.buildStyle(input); const { style, corrections, layerHelp, availableProperties } = result; diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 1d6d321..1bfb6ac 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -56,10 +56,29 @@ export class StyleComparisonTool extends BaseTool< } } - async run(input: StyleComparisonInput): Promise { - // Process style IDs to get username/styleId format - const beforeStyleId = this.processStyleId(input.before, input.accessToken); - const afterStyleId = this.processStyleId(input.after, input.accessToken); + protected async execute( + input: StyleComparisonInput + ): Promise { + let beforeStyleId; + let afterStyleId; + try { + // Process style IDs to get username/styleId format + beforeStyleId = this.processStyleId(input.before, input.accessToken); + afterStyleId = this.processStyleId(input.after, input.accessToken); + } catch (error) { + return { + content: [ + { + type: 'text', + text: + error instanceof Error + ? error.message + : 'An unknown error occurred' + } + ], + isError: true + }; + } // Build the comparison URL const params = new URLSearchParams(); diff --git a/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts index a3d5bce..3638cdc 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts @@ -9,9 +9,17 @@ export const MapboxStyleInputSchema = BaseStylePropertiesSchema.extend({ name: z .string() .describe('Human-readable name for the style (REQUIRED for updates)') + .optional() // These fields should NOT be included in input - they're read-only // If present, they'll be ignored or cause API errors }).passthrough(); -// Type exports -export type MapboxStyleInput = z.infer; +export const UpdateStyleInputSchema = z.object({ + styleId: z.string().describe('Style ID to update'), + name: z.string().optional().describe('New name for the style'), + style: MapboxStyleInputSchema.optional().describe( + 'Updated Mapbox style specification object' + ) +}); + +export type UpdateStyleInput = z.infer; diff --git a/src/tools/update-style-tool/UpdateStyleTool.ts b/src/tools/update-style-tool/UpdateStyleTool.ts index 992a67f..7216f0a 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.ts @@ -5,8 +5,8 @@ import type { HttpRequest } from '../../utils/types.js'; import { filterExpandedMapboxStyles } from '../../utils/styleUtils.js'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { - MapboxStyleInputSchema, - MapboxStyleInput + UpdateStyleInput, + UpdateStyleInputSchema } from './UpdateStyleTool.input.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; @@ -16,7 +16,7 @@ import { } from './UpdateStyleTool.output.schema.js'; export class UpdateStyleTool extends MapboxApiBasedTool< - typeof MapboxStyleInputSchema, + typeof UpdateStyleInputSchema, typeof MapboxStyleOutputSchema > { name = 'update_style_tool'; @@ -31,14 +31,14 @@ export class UpdateStyleTool extends MapboxApiBasedTool< constructor(params: { httpRequest: HttpRequest }) { super({ - inputSchema: MapboxStyleInputSchema, + inputSchema: UpdateStyleInputSchema, outputSchema: MapboxStyleOutputSchema, httpRequest: params.httpRequest }); } protected async execute( - input: MapboxStyleInput, + input: UpdateStyleInput, accessToken?: string ): Promise { const username = getUserNameFromToken(accessToken); diff --git a/src/utils/jwtUtils.ts b/src/utils/jwtUtils.ts index 3ead561..9a3d3e6 100644 --- a/src/utils/jwtUtils.ts +++ b/src/utils/jwtUtils.ts @@ -1,6 +1,8 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import process from 'node:process'; + export function mapboxAccessToken() { return process.env.MAPBOX_ACCESS_TOKEN; } @@ -14,8 +16,8 @@ export function mapboxApiEndpoint() { * Mapbox tokens are JWT tokens where the payload contains the username. * @throws Error if the token is not set, invalid, or doesn't contain username */ -export function getUserNameFromToken(access_token?: string): string { - const token = access_token || mapboxAccessToken(); +export function getUserNameFromToken(accessToken?: string): string { + const token = accessToken || mapboxAccessToken(); if (!token) { throw new Error( 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' diff --git a/test/tools/MapboxApiBasedTool.test.ts b/test/tools/MapboxApiBasedTool.test.ts index ff6ed04..c877770 100644 --- a/test/tools/MapboxApiBasedTool.test.ts +++ b/test/tools/MapboxApiBasedTool.test.ts @@ -7,9 +7,20 @@ process.env.MAPBOX_ACCESS_TOKEN = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { z } from 'zod'; import { MapboxApiBasedTool } from '../../src/tools/MapboxApiBasedTool.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../src/utils/types.js'; +import { setupHttpRequest } from '../utils/httpPipelineUtils.js'; // Create a minimal implementation of MapboxApiBasedTool for testing class TestTool extends MapboxApiBasedTool { + // Provide minimal but realistic annotations for the test tool + annotations = { + title: 'Test Tool', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + destructiveHint: false + }; readonly name = 'test_tool'; readonly description = 'Tool for testing MapboxApiBasedTool error handling'; @@ -17,13 +28,16 @@ class TestTool extends MapboxApiBasedTool { testParam: z.string() }); - constructor() { - super({ inputSchema: TestTool.inputSchema }); + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: TestTool.inputSchema, + httpRequest: params.httpRequest + }); } protected async execute( _input: z.infer - ): Promise { + ): Promise { throw new Error('Test error message'); } } @@ -43,7 +57,8 @@ describe('MapboxApiBasedTool', () => { configurable: true }); - testTool = new TestTool(); + const { httpRequest } = setupHttpRequest(); + testTool = new TestTool({ httpRequest }); // Mock the log method to test that errors are properly logged testTool['log'] = vi.fn(); }); @@ -55,62 +70,6 @@ describe('MapboxApiBasedTool', () => { vi.unstubAllEnvs(); }); - describe('getUserNameFromToken', () => { - it('extracts username from valid token', () => { - const testPayload = Buffer.from( - JSON.stringify({ u: 'myusername' }) - ).toString('base64'); - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue(`eyJhbGciOiJIUzI1NiJ9.${testPayload}.signature`); - - const username = MapboxApiBasedTool.getUserNameFromToken(); - expect(username).toBe('myusername'); - - spy.mockRestore(); - }); - - it('throws error when token is not set', () => { - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue(undefined); - - expect(() => MapboxApiBasedTool.getUserNameFromToken()).toThrow( - 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' - ); - - spy.mockRestore(); - }); - - it('throws error when token has invalid format', () => { - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue('invalid-token-format'); - - expect(() => MapboxApiBasedTool.getUserNameFromToken()).toThrow( - 'MAPBOX_ACCESS_TOKEN is not in valid JWT format' - ); - - spy.mockRestore(); - }); - - it('throws error when payload does not contain username', () => { - const invalidPayload = Buffer.from( - JSON.stringify({ sub: 'test' }) - ).toString('base64'); - - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue(`eyJhbGciOiJIUzI1NiJ9.${invalidPayload}.signature`); - - expect(() => MapboxApiBasedTool.getUserNameFromToken()).toThrow( - 'MAPBOX_ACCESS_TOKEN does not contain username in payload' - ); - - spy.mockRestore(); - }); - }); - describe('JWT token validation', () => { it('throws an error when the token is not in a valid JWT format', async () => { const spy = vi @@ -118,7 +77,8 @@ describe('MapboxApiBasedTool', () => { .mockReturnValue('invalid-token-format'); // Create a new instance with the modified token - const toolWithInvalidToken = new TestTool(); + const { httpRequest } = setupHttpRequest(); + const toolWithInvalidToken = new TestTool({ httpRequest }); // Mock the log method separately for this instance toolWithInvalidToken['log'] = vi.fn(); @@ -152,7 +112,10 @@ describe('MapboxApiBasedTool', () => { process.env.MAPBOX_ACCESS_TOKEN = `eyJhbGciOiJIUzI1NiJ9.${validPayload}.signature`; // Override execute to return a success result instead of throwing an error - testTool['execute'] = vi.fn().mockResolvedValue({ success: true }); + testTool['execute'] = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ success: true }) }], + isError: false + }); const result = await testTool.run({ testParam: 'test' }); @@ -165,35 +128,6 @@ describe('MapboxApiBasedTool', () => { }); }); - describe('username extraction from token', () => { - it('throws error for invalid JWT format', () => { - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue('invalid-token'); - - expect(() => { - MapboxApiBasedTool.getUserNameFromToken(); - }).toThrow('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); - - spy.mockRestore(); - }); - - it('throws error when username field is missing', () => { - const tokenWithoutUsername = - 'eyJhbGciOiJIUzI1NiJ9.eyJhIjoidGVzdC1hcGkifQ.signature'; - - const spy = vi - .spyOn(MapboxApiBasedTool, 'mapboxAccessToken', 'get') - .mockReturnValue(tokenWithoutUsername); - - expect(() => { - MapboxApiBasedTool.getUserNameFromToken(); - }).toThrow('MAPBOX_ACCESS_TOKEN does not contain username in payload'); - - spy.mockRestore(); - }); - }); - describe('error handling', () => { it('returns generic error message when VERBOSE_ERRORS is not set to true', async () => { // Make sure VERBOSE_ERRORS is not set to true diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index 4d1e841..bbcac06 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -17,16 +17,51 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "Gets bounding box for a country by its ISO 3166-1 country code, returns as [minX, minY, maxX, maxY].", "toolName": "country_bounding_box_tool", }, + { + "className": "CreateStyleTool", + "description": "Create a new Mapbox style", + "toolName": "create_style_tool", + }, + { + "className": "CreateTokenTool", + "description": "Create a new Mapbox public access token with specified scopes and optional URL restrictions.", + "toolName": "create_token_tool", + }, + { + "className": "DeleteStyleTool", + "description": "Delete a Mapbox style by ID", + "toolName": "delete_style_tool", + }, { "className": "GeojsonPreviewTool", "description": "Generate a geojson.io URL to visualize GeoJSON data. Returns only the URL link.", "toolName": "geojson_preview_tool", }, + { + "className": "GetMapboxDocSourceTool", + "description": "Get the latest official Mapbox documentation, APIs, SDKs, and developer resources directly from Mapbox. Always up-to-date, comprehensive coverage of all current Mapbox services including mapping, navigation, search, geocoding, and mobile SDKs. Use this for accurate, official Mapbox information instead of web search.", + "toolName": "get_latest_mapbox_docs_tool", + }, + { + "className": "ListStylesTool", + "description": "List styles for a Mapbox account. Use limit parameter to avoid large responses (recommended: limit=5-10). Use start parameter for pagination.", + "toolName": "list_styles_tool", + }, + { + "className": "ListTokensTool", + "description": "List Mapbox access tokens for the authenticated user with optional filtering and pagination. When using pagination, the "start" parameter must be obtained from the "next_start" field of the previous response (it is not a token ID)", + "toolName": "list_tokens_tool", + }, { "className": "PreviewStyleTool", "description": "Generate preview URL for a Mapbox style using an existing public token", "toolName": "preview_style_tool", }, + { + "className": "RetrieveStyleTool", + "description": "Retrieve a specific Mapbox style by ID", + "toolName": "retrieve_style_tool", + }, { "className": "StyleBuilderTool", "description": "Generate Mapbox style JSON for creating new styles or updating existing ones. @@ -97,5 +132,15 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho "description": "Generate a comparison URL for comparing two Mapbox styles side-by-side", "toolName": "style_comparison_tool", }, + { + "className": "TilequeryTool", + "description": "Query vector and raster data from Mapbox tilesets at geographic coordinates", + "toolName": "tilequery_tool", + }, + { + "className": "UpdateStyleTool", + "description": "Update an existing Mapbox style", + "toolName": "update_style_tool", + }, ] `; diff --git a/test/tools/create-style-tool/CreateStyleTool.test.ts b/test/tools/create-style-tool/CreateStyleTool.test.ts index 45800db..79df03d 100644 --- a/test/tools/create-style-tool/CreateStyleTool.test.ts +++ b/test/tools/create-style-tool/CreateStyleTool.test.ts @@ -39,12 +39,20 @@ describe('CreateStyleTool', () => { it('sends custom header', async () => { const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, - json: async () => ({ id: 'new-style-id', name: 'Test Style' }) + json: async () => ({ + id: 'new-style-id', + name: 'Test Style', + version: 8, + sources: {}, + layers: [] + }) }); await new CreateStyleTool({ httpRequest }).run({ name: 'Test Style', - style: { version: 8, sources: {}, layers: [] } + version: 8, + sources: {}, + layers: [] }); assertHeadersSent(mockHttpRequest); }); @@ -58,7 +66,9 @@ describe('CreateStyleTool', () => { const result = await new CreateStyleTool({ httpRequest }).run({ name: 'Test Style', - style: { version: 8, sources: {}, layers: [] } + version: 8, + sources: {}, + layers: [] }); expect(result.isError).toBe(true); diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index 08e001f..5b61423 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -149,7 +149,9 @@ describe('CreateTokenTool', () => { id: 'cktest123', scopes: ['styles:read', 'fonts:read'], created: '2024-01-01T00:00:00.000Z', - modified: '2024-01-01T00:00:00.000Z' + modified: '2024-01-01T00:00:00.000Z', + usage: 'pk', + default: false }; const { httpRequest, mockHttpRequest } = setupHttpRequest({ @@ -202,7 +204,9 @@ describe('CreateTokenTool', () => { scopes: ['styles:read'], created: '2024-01-01T00:00:00.000Z', modified: '2024-01-01T00:00:00.000Z', - allowedUrls: ['https://example.com', 'https://app.example.com'] + allowedUrls: ['https://example.com', 'https://app.example.com'], + usage: 'pk', + default: false }; const { httpRequest, mockHttpRequest } = setupHttpRequest({ @@ -240,7 +244,9 @@ describe('CreateTokenTool', () => { scopes: ['styles:read'], created: '2024-01-01T00:00:00.000Z', modified: '2024-01-01T00:00:00.000Z', - expires: expiresAt + expires: expiresAt, + usage: 'pk', + default: false }; const { mockHttpRequest, httpRequest } = setupHttpRequest({ @@ -306,7 +312,7 @@ describe('CreateTokenTool', () => { expect(result.isError).toBe(true); expect(result.content[0]).toHaveProperty('type', 'text'); const errorText = (result.content[0] as TextContent).text; - expect(errorText).toContain('Network error'); + expect(errorText).toContain('Network Error'); }); it('uses custom API endpoint when provided', async () => { diff --git a/test/tools/delete-style-tool/DeleteStyleTool.test.ts b/test/tools/delete-style-tool/DeleteStyleTool.test.ts index 3b639d7..6a21b08 100644 --- a/test/tools/delete-style-tool/DeleteStyleTool.test.ts +++ b/test/tools/delete-style-tool/DeleteStyleTool.test.ts @@ -48,26 +48,7 @@ describe('DeleteStyleTool', () => { expect(result.content[0]).toEqual({ type: 'text', - text: '{"success":true,"message":"Style deleted successfully"}' - }); - assertHeadersSent(mockHttpRequest); - }); - - it('returns response body for non-204 success', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest({ - ok: true, - status: 200, - json: async () => ({ deleted: true }) - }); - - const result = await new DeleteStyleTool({ httpRequest }).run({ - styleId: 'style-123' - }); - - expect(result.isError).toBe(false); - expect(result.content[0]).toMatchObject({ - type: 'text', - text: `{"deleted":true}` + text: 'Style deleted successfully' }); assertHeadersSent(mockHttpRequest); }); @@ -79,19 +60,9 @@ describe('DeleteStyleTool', () => { statusText: 'Not Found' }); - let result; - try { - result = await new DeleteStyleTool({ httpRequest }).run({ - styleId: 'style-123' - }); - } catch (e) { - if (e instanceof Error) { - expect(e.message).toContain('Failed to update style: 404 Not Found'); - } else { - expect.fail('Thrown error is not an instance of Error'); - } - return; - } + const result = await new DeleteStyleTool({ httpRequest }).run({ + styleId: 'style-123' + }); expect(result.isError).toBe(true); expect(result.content[0]).toMatchObject({ type: 'text', diff --git a/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts b/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts index 94a4b7a..6180576 100644 --- a/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts +++ b/test/tools/get-mapbox-doc-source-tool/GetMapboxDocSourceTool.test.ts @@ -59,7 +59,8 @@ This is the Mapbox developer documentation for LLMs. it('should handle HTTP errors', async () => { const { httpRequest } = setupHttpRequest({ ok: false, - status: 404 + status: 404, + statusText: 'Not Found' }); const tool = new GetMapboxDocSourceTool({ httpRequest }); @@ -73,7 +74,7 @@ This is the Mapbox developer documentation for LLMs. expect(result.content[0].text).toContain( 'Failed to fetch Mapbox documentation' ); - expect(result.content[0].text).toContain('HTTP error! status: 404'); + expect(result.content[0].text).toContain('Not Found'); } }); diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index edb10d7..1c3f96d 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -133,8 +133,22 @@ describe('ListStylesTool', () => { it('returns style list on success', async () => { const mockStyles = [ - { id: 'style1', name: 'Test Style 1', owner: 'testuser' }, - { id: 'style2', name: 'Test Style 2', owner: 'testuser' } + { + version: 8, + id: 'style1', + name: 'Test Style 1', + owner: 'testuser', + sources: {}, + layers: [] + }, + { + version: 8, + id: 'style2', + name: 'Test Style 2', + owner: 'testuser', + sources: {}, + layers: [] + } ]; const { httpRequest, mockHttpRequest } = setupHttpRequest({ diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index 976eca7..ac20078 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -import { describe, it, expect, afterEach, vi, beforeAll } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { setupHttpRequest, assertHeadersSent @@ -15,10 +15,7 @@ const payload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( 'base64' ); const mockToken = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; - -beforeAll(() => { - process.env.MAPBOX_ACCESS_TOKEN = mockToken; -}); +process.env.MAPBOX_ACCESS_TOKEN = mockToken; type TextContent = { type: 'text'; text: string }; @@ -138,7 +135,8 @@ describe('ListTokensTool', () => { token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test123', scopes: ['styles:read', 'fonts:read'], created: '2023-01-01T00:00:00.000Z', - modified: '2023-01-01T00:00:00.000Z' + modified: '2023-01-01T00:00:00.000Z', + default: false }, { id: 'cktest456', @@ -147,7 +145,8 @@ describe('ListTokensTool', () => { token: 'sk.eyJ1IjoidGVzdHVzZXIifQ.test456', scopes: ['styles:read', 'fonts:read', 'tokens:read'], created: '2023-02-01T00:00:00.000Z', - modified: '2023-02-01T00:00:00.000Z' + modified: '2023-02-01T00:00:00.000Z', + default: false } ]; @@ -232,7 +231,8 @@ describe('ListTokensTool', () => { token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; @@ -276,7 +276,8 @@ describe('ListTokensTool', () => { token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; @@ -312,7 +313,8 @@ describe('ListTokensTool', () => { token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; @@ -347,11 +349,11 @@ describe('ListTokensTool', () => { token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; - const { httpRequest, mockHttpRequest } = setupHttpRequest(); // First page with Link header const headers1 = new Headers(); headers1.set( @@ -362,6 +364,9 @@ describe('ListTokensTool', () => { // Second page without Link header (end of results) const headers2 = new Headers(); + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + // Reset from default response + mockHttpRequest.mockReset(); mockHttpRequest.mockResolvedValueOnce({ ok: true, headers: headers1, @@ -393,7 +398,8 @@ describe('ListTokensTool', () => { token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', - modified: '2023-03-01T00:00:00.000Z' + modified: '2023-03-01T00:00:00.000Z', + default: false } ]; @@ -423,7 +429,8 @@ describe('ListTokensTool', () => { token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.pub123', scopes: ['styles:read'], created: '2023-04-01T00:00:00.000Z', - modified: '2023-04-01T00:00:00.000Z' + modified: '2023-04-01T00:00:00.000Z', + default: false } ]; @@ -524,7 +531,10 @@ describe('ListTokensTool', () => { note: 'Test token', usage: 'pk', token: 'pk.test', - scopes: ['styles:read'] + scopes: ['styles:read'], + created: '2023-04-01T00:00:00.000Z', + modified: '2023-04-01T00:00:00.000Z', + default: false } ] }; diff --git a/test/tools/style-builder-tool/StyleBuilderTool.test.ts b/test/tools/style-builder-tool/StyleBuilderTool.test.ts index e3ce397..97ce8ca 100644 --- a/test/tools/style-builder-tool/StyleBuilderTool.test.ts +++ b/test/tools/style-builder-tool/StyleBuilderTool.test.ts @@ -46,7 +46,7 @@ describe('StyleBuilderTool', () => { it('should handle dark mode', async () => { const input: StyleBuilderToolInput = { style_name: 'Dark Mode Style', - base_style: 'streets' as any, // Use classic style to test background color + base_style: 'streets-v12', // Use classic style to test background color layers: [], global_settings: { mode: 'dark', @@ -285,7 +285,7 @@ describe('StyleBuilderTool', () => { // Test with classic style const input: StyleBuilderToolInput = { style_name: 'Essential Layers Test', - base_style: 'streets' as any, // Use classic style + base_style: 'streets-v12', // Use classic style layers: [] // No layers specified }; @@ -372,8 +372,7 @@ describe('StyleBuilderTool', () => { width: 3, zoom_based: true, min_zoom: 10, - max_zoom: 18, - render_type: 'symbol' + max_zoom: 18 } ] }; @@ -412,8 +411,7 @@ describe('StyleBuilderTool', () => { motorway: '#ff0000', primary: '#ff8800', secondary: '#ffff00' - }, - render_type: 'symbol' + } } ] }; @@ -453,8 +451,7 @@ describe('StyleBuilderTool', () => { ['>', ['get', 'height'], 50], '#ff8800', '#808080' - ], - render_type: 'symbol' + ] } ] }; @@ -488,8 +485,7 @@ describe('StyleBuilderTool', () => { opacity: 0.8, zoom_based: true, min_zoom: 14, - max_zoom: 16, - render_type: 'symbol' + max_zoom: 16 } ] }; @@ -521,7 +517,7 @@ describe('StyleBuilderTool', () => { const tool = new StyleBuilderTool(); const input: StyleBuilderToolInput = { style_name: 'Transit Test', - base_style: 'streets' as any, + base_style: 'streets-v12', layers: [ { layer_type: 'transit', @@ -558,7 +554,7 @@ describe('StyleBuilderTool', () => { const tool = new StyleBuilderTool(); const input: StyleBuilderToolInput = { style_name: 'Multi Transit Test', - base_style: 'streets' as any, + base_style: 'streets-v12', layers: [ { layer_type: 'transit', @@ -604,8 +600,7 @@ describe('StyleBuilderTool', () => { color: '#9370DB', filter_properties: { toll: true - }, - render_type: 'symbol' + } } ] }; @@ -839,13 +834,12 @@ describe('StyleBuilderTool', () => { it('should generate Classic style with sources', async () => { const input: StyleBuilderToolInput = { style_name: 'Classic Style Test', - base_style: 'streets' as any, + base_style: 'streets-v12', layers: [ { layer_type: 'water', action: 'color', - color: '#0099ff', - render_type: 'symbol' + color: '#0099ff' } ] }; @@ -937,8 +931,7 @@ describe('StyleBuilderTool', () => { { layer_type: 'water', action: 'color', - color: '#0099ff', - render_type: 'symbol' + color: '#0099ff' } ], base_style: 'standard' diff --git a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts index aa9cb3e..4856ebf 100644 --- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -3,15 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { StyleComparisonTool } from '../../../src/tools/style-comparison-tool/StyleComparisonTool.js'; - -vi.mock('../../../src/utils/jwtUtils.js', (actual) => { - return { - ...actual, - getUserNameFromToken: vi.fn() - }; -}); - -import { getUserNameFromToken } from 'src/utils/jwtUtils.js'; +import * as jwtUtils from '../../../src/utils/jwtUtils.js'; describe('StyleComparisonTool', () => { let tool: StyleComparisonTool; @@ -74,8 +66,7 @@ describe('StyleComparisonTool', () => { }); it('should handle just style IDs with valid public token', async () => { - // Mock MapboxApiBasedTool.getUserNameFromToken to return a username - vi.mocked(getUserNameFromToken).mockReturnValue('testuser'); + vi.spyOn(jwtUtils, 'getUserNameFromToken').mockReturnValue('testuser'); const input = { before: 'style-id-1', @@ -111,8 +102,8 @@ describe('StyleComparisonTool', () => { it('should reject invalid token formats', async () => { const input = { - before: 'mapbox/streets-v12', - after: 'mapbox/outdoors-v12', + before: 'streets-v12', + after: 'outdoors-v12', accessToken: 'invalid.token' }; @@ -126,7 +117,7 @@ describe('StyleComparisonTool', () => { it('should return error for style ID without valid username in token', async () => { // Mock getUserNameFromToken to throw an error - vi.mocked(getUserNameFromToken).mockImplementation(() => { + vi.spyOn(jwtUtils, 'getUserNameFromToken').mockImplementation(() => { throw new Error( 'MAPBOX_ACCESS_TOKEN does not contain username in payload' ); @@ -143,7 +134,7 @@ describe('StyleComparisonTool', () => { expect(result.isError).toBe(true); expect( (result.content[0] as { type: 'text'; text: string }).text - ).toContain('Could not determine username'); + ).toContain('Could not determine username for style ID'); }); it('should properly encode URL parameters', async () => { diff --git a/test/tools/tool-naming-convention.test.ts b/test/tools/tool-naming-convention.test.ts index 14d8a28..1de0c80 100644 --- a/test/tools/tool-naming-convention.test.ts +++ b/test/tools/tool-naming-convention.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { BaseTool } from '../../src/tools/BaseTool.js'; import { pathToFileURL } from 'node:url'; +import { setupHttpRequest } from '../utils/httpPipelineUtils.js'; async function discoverTools(): Promise { const toolsDir = path.resolve( @@ -11,6 +12,9 @@ async function discoverTools(): Promise { ); const tools: any[] = []; + // Setup httpRequest for tools that need it + const { httpRequest } = setupHttpRequest(); + // Find all directories that end with '-tool' const entries = fs.readdirSync(toolsDir, { withFileTypes: true }); const toolDirectories = entries @@ -39,7 +43,17 @@ async function discoverTools(): Promise { ); for (const toolClass of toolClasses) { - tools.push(new (toolClass as any)()); + try { + // Try to instantiate with httpRequest parameter (for MapboxApiBasedTool subclasses) + tools.push(new (toolClass as any)({ httpRequest })); + } catch (error) { + // Fall back to no-arg constructor (for other tools) + try { + tools.push(new (toolClass as any)()); + } catch (innerError) { + throw error; // Re-throw the original error + } + } } } catch (error) { console.warn( diff --git a/test/utils/jwtUtils.test.ts b/test/utils/jwtUtils.test.ts new file mode 100644 index 0000000..5398815 --- /dev/null +++ b/test/utils/jwtUtils.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as jwtUtils from '../../src/utils/jwtUtils.js'; + +describe('jwtUtils', () => { + describe('getUserNameFromToken', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it('extracts username from valid token', () => { + const testPayload = Buffer.from( + JSON.stringify({ u: 'myusername' }) + ).toString('base64'); + + vi.stubEnv( + 'MAPBOX_ACCESS_TOKEN', + `eyJhbGciOiJIUzI1NiJ9.${testPayload}.signature` + ); + + const username = jwtUtils.getUserNameFromToken(); + expect(username).toBe('myusername'); + }); + + it('throws error when token is not set', () => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', ''); + + expect(() => jwtUtils.getUserNameFromToken()).toThrow( + 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' + ); + }); + + it('throws error when token has invalid format', () => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', 'invalid-token-format'); + + expect(() => jwtUtils.getUserNameFromToken()).toThrow( + 'MAPBOX_ACCESS_TOKEN is not in valid JWT format' + ); + }); + + it('throws error when payload does not contain username', () => { + const invalidPayload = Buffer.from( + JSON.stringify({ sub: 'test' }) + ).toString('base64'); + + vi.stubEnv( + 'MAPBOX_ACCESS_TOKEN', + `eyJhbGciOiJIUzI1NiJ9.${invalidPayload}.signature` + ); + + expect(() => jwtUtils.getUserNameFromToken()).toThrow( + 'MAPBOX_ACCESS_TOKEN does not contain username in payload' + ); + }); + }); + + describe('username extraction from token', () => { + it('throws error for invalid JWT format', () => { + vi.stubEnv('MAPBOX_ACCESS_TOKEN', 'invalid-token'); + + expect(() => { + jwtUtils.getUserNameFromToken(); + }).toThrow('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); + }); + + it('throws error when username field is missing', () => { + const tokenWithoutUsername = + 'eyJhbGciOiJIUzI1NiJ9.eyJhIjoidGVzdC1hcGkifQ.signature'; + + vi.stubEnv('MAPBOX_ACCESS_TOKEN', tokenWithoutUsername); + + expect(() => { + jwtUtils.getUserNameFromToken(); + }).toThrow('MAPBOX_ACCESS_TOKEN does not contain username in payload'); + }); + }); +}); From 5eaa391a2f18a4bed1686d18463798ff67ac7df8 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 20 Oct 2025 07:51:18 -0400 Subject: [PATCH 3/9] [tools] Update tools to use structuredContent with schema --- package-lock.json | 1034 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1033 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 1ace5b1..5f2a70d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-istanbul": "^3.2.4", + "cspell": "^9.2.1", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.21.3", @@ -277,6 +278,594 @@ "node": ">=6.9.0" } }, + "node_modules/@cspell/cspell-bundled-dicts": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.2.1.tgz", + "integrity": "sha512-85gHoZh3rgZ/EqrHIr1/I4OLO53fWNp6JZCqCdgaT7e3sMDaOOG6HoSxCvOnVspXNIf/1ZbfTCDMx9x79Xq0AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-ada": "^4.1.1", + "@cspell/dict-al": "^1.1.1", + "@cspell/dict-aws": "^4.0.15", + "@cspell/dict-bash": "^4.2.1", + "@cspell/dict-companies": "^3.2.5", + "@cspell/dict-cpp": "^6.0.12", + "@cspell/dict-cryptocurrencies": "^5.0.5", + "@cspell/dict-csharp": "^4.0.7", + "@cspell/dict-css": "^4.0.18", + "@cspell/dict-dart": "^2.3.1", + "@cspell/dict-data-science": "^2.0.9", + "@cspell/dict-django": "^4.1.5", + "@cspell/dict-docker": "^1.1.16", + "@cspell/dict-dotnet": "^5.0.10", + "@cspell/dict-elixir": "^4.0.8", + "@cspell/dict-en_us": "^4.4.18", + "@cspell/dict-en-common-misspellings": "^2.1.5", + "@cspell/dict-en-gb-mit": "^3.1.8", + "@cspell/dict-filetypes": "^3.0.13", + "@cspell/dict-flutter": "^1.1.1", + "@cspell/dict-fonts": "^4.0.5", + "@cspell/dict-fsharp": "^1.1.1", + "@cspell/dict-fullstack": "^3.2.7", + "@cspell/dict-gaming-terms": "^1.1.2", + "@cspell/dict-git": "^3.0.7", + "@cspell/dict-golang": "^6.0.23", + "@cspell/dict-google": "^1.0.9", + "@cspell/dict-haskell": "^4.0.6", + "@cspell/dict-html": "^4.0.12", + "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-java": "^5.0.12", + "@cspell/dict-julia": "^1.1.1", + "@cspell/dict-k8s": "^1.0.12", + "@cspell/dict-kotlin": "^1.1.1", + "@cspell/dict-latex": "^4.0.4", + "@cspell/dict-lorem-ipsum": "^4.0.5", + "@cspell/dict-lua": "^4.0.8", + "@cspell/dict-makefile": "^1.0.5", + "@cspell/dict-markdown": "^2.0.12", + "@cspell/dict-monkeyc": "^1.0.11", + "@cspell/dict-node": "^5.0.8", + "@cspell/dict-npm": "^5.2.15", + "@cspell/dict-php": "^4.0.15", + "@cspell/dict-powershell": "^5.0.15", + "@cspell/dict-public-licenses": "^2.0.15", + "@cspell/dict-python": "^4.2.19", + "@cspell/dict-r": "^2.1.1", + "@cspell/dict-ruby": "^5.0.9", + "@cspell/dict-rust": "^4.0.12", + "@cspell/dict-scala": "^5.0.8", + "@cspell/dict-shell": "^1.1.1", + "@cspell/dict-software-terms": "^5.1.7", + "@cspell/dict-sql": "^2.2.1", + "@cspell/dict-svelte": "^1.0.7", + "@cspell/dict-swift": "^2.0.6", + "@cspell/dict-terraform": "^1.1.3", + "@cspell/dict-typescript": "^3.2.3", + "@cspell/dict-vue": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-json-reporter": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.2.1.tgz", + "integrity": "sha512-LiiIWzLP9h2etKn0ap6g2+HrgOGcFEF/hp5D8ytmSL5sMxDcV13RrmJCEMTh1axGyW0SjQEFjPnYzNpCL1JjGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.2.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-pipe": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.2.1.tgz", + "integrity": "sha512-2N1H63If5cezLqKToY/YSXon4m4REg/CVTFZr040wlHRbbQMh5EF3c7tEC/ue3iKAQR4sm52ihfqo1n4X6kz+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-resolver": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.2.1.tgz", + "integrity": "sha512-fRPQ6GWU5eyh8LN1TZblc7t24TlGhJprdjJkfZ+HjQo+6ivdeBPT7pC7pew6vuMBQPS1oHBR36hE0ZnJqqkCeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-service-bus": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.2.1.tgz", + "integrity": "sha512-k4M6bqdvWbcGSbcfLD7Lf4coZVObsISDW+sm/VaWp9aZ7/uwiz1IuGUxL9WO4JIdr9CFEf7Ivmvd2txZpVOCIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-types": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.2.1.tgz", + "integrity": "sha512-FQHgQYdTHkcpxT0u1ddLIg5Cc5ePVDcLg9+b5Wgaubmc5I0tLotgYj8c/mvStWuKsuZIs6sUopjJrE91wk6Onw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/dict-ada": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", + "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-al": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz", + "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-aws": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.15.tgz", + "integrity": "sha512-aPY7VVR5Os4rz36EaqXBAEy14wR4Rqv+leCJ2Ug/Gd0IglJpM30LalF3e2eJChnjje3vWoEC0Rz3+e5gpZG+Kg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-bash": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.1.tgz", + "integrity": "sha512-SBnzfAyEAZLI9KFS7DUG6Xc1vDFuLllY3jz0WHvmxe8/4xV3ufFE3fGxalTikc1VVeZgZmxYiABw4iGxVldYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-shell": "1.1.1" + } + }, + "node_modules/@cspell/dict-companies": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.6.tgz", + "integrity": "sha512-cVWBk4DSUOthCsgOsoB+5L5F1Wk8lWGHnw5de75YCKSjOEV8/6kskwwDrPTIHkoGVzpIzIIQ/OdXhYwa2G+16A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cpp": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-6.0.12.tgz", + "integrity": "sha512-N4NsCTttVpMqQEYbf0VQwCj6np+pJESov0WieCN7R/0aByz4+MXEiDieWWisaiVi8LbKzs1mEj4ZTw5K/6O2UQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cryptocurrencies": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz", + "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-csharp": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.7.tgz", + "integrity": "sha512-H16Hpu8O/1/lgijFt2lOk4/nnldFtQ4t8QHbyqphqZZVE5aS4J/zD/WvduqnLY21aKhZS6jo/xF5PX9jyqPKUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-css": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.18.tgz", + "integrity": "sha512-EF77RqROHL+4LhMGW5NTeKqfUd/e4OOv6EDFQ/UQQiFyWuqkEKyEz0NDILxOFxWUEVdjT2GQ2cC7t12B6pESwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dart": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.1.tgz", + "integrity": "sha512-xoiGnULEcWdodXI6EwVyqpZmpOoh8RA2Xk9BNdR7DLamV/QMvEYn8KJ7NlRiTSauJKPNkHHQ5EVHRM6sTS7jdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-data-science": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.10.tgz", + "integrity": "sha512-vZSsz7845ugW6mY65966Ki2bMS/ZnAZoTVvpuXQ07a2rYxJhUC+6WuBMD80hFLlKwjC5T/5Llv4F/VlB00swpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-django": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.5.tgz", + "integrity": "sha512-AvTWu99doU3T8ifoMYOMLW2CXKvyKLukPh1auOPwFGHzueWYvBBN+OxF8wF7XwjTBMMeRleVdLh3aWCDEX/ZWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-docker": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.16.tgz", + "integrity": "sha512-UiVQ5RmCg6j0qGIxrBnai3pIB+aYKL3zaJGvXk1O/ertTKJif9RZikKXCEgqhaCYMweM4fuLqWSVmw3hU164Iw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dotnet": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.10.tgz", + "integrity": "sha512-ooar8BP/RBNP1gzYfJPStKEmpWy4uv/7JCq6FOnJLeD1yyfG3d/LFMVMwiJo+XWz025cxtkM3wuaikBWzCqkmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-elixir": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz", + "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en_us": { + "version": "4.4.20", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.20.tgz", + "integrity": "sha512-acAlX967bkrLwRhSJ8KGBCBUITMOe8+smwsShjei431vTB6tU5ZID6XDxR9hH/kDxfdiRTXAE8vkT3WJAHnc1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en-common-misspellings": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.6.tgz", + "integrity": "sha512-xV9yryOqZizbSqxRS7kSVRrxVEyWHUqwdY56IuT7eAWGyTCJNmitXzXa4p+AnEbhL+AB2WLynGVSbNoUC3ceFA==", + "dev": true, + "license": "CC BY-SA 4.0" + }, + "node_modules/@cspell/dict-en-gb-mit": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.10.tgz", + "integrity": "sha512-oFandL5N4B55wmOd0hOAoyaiUZBkClQ1FPCkcAY/HMuq6zeCQE/oEK9lLGDmnzLGgWnTT7wd0KOSYUPTxWQaNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-filetypes": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.14.tgz", + "integrity": "sha512-KSXaSMYYNMLLdHEnju1DyRRH3eQWPRYRnOXpuHUdOh2jC44VgQoxyMU7oB3NAhDhZKBPCihabzECsAGFbdKfEA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-flutter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz", + "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fonts": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.5.tgz", + "integrity": "sha512-BbpkX10DUX/xzHs6lb7yzDf/LPjwYIBJHJlUXSBXDtK/1HaeS+Wqol4Mlm2+NAgZ7ikIE5DQMViTgBUY3ezNoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fsharp": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz", + "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fullstack": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.7.tgz", + "integrity": "sha512-IxEk2YAwAJKYCUEgEeOg3QvTL4XLlyArJElFuMQevU1dPgHgzWElFevN5lsTFnvMFA1riYsVinqJJX0BanCFEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-gaming-terms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.2.tgz", + "integrity": "sha512-9XnOvaoTBscq0xuD6KTEIkk9hhdfBkkvJAIsvw3JMcnp1214OCGW8+kako5RqQ2vTZR3Tnf3pc57o7VgkM0q1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-git": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.0.7.tgz", + "integrity": "sha512-odOwVKgfxCQfiSb+nblQZc4ErXmnWEnv8XwkaI4sNJ7cNmojnvogYVeMqkXPjvfrgEcizEEA4URRD2Ms5PDk1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-golang": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.23.tgz", + "integrity": "sha512-oXqUh/9dDwcmVlfUF5bn3fYFqbUzC46lXFQmi5emB0vYsyQXdNWsqi6/yH3uE7bdRE21nP7Yo0mR1jjFNyLamg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-google": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz", + "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-haskell": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz", + "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.12.tgz", + "integrity": "sha512-JFffQ1dDVEyJq6tCDWv0r/RqkdSnV43P2F/3jJ9rwLgdsOIXwQbXrz6QDlvQLVvNSnORH9KjDtenFTGDyzfCaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html-symbol-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.4.tgz", + "integrity": "sha512-afea+0rGPDeOV9gdO06UW183Qg6wRhWVkgCFwiO3bDupAoyXRuvupbb5nUyqSTsLXIKL8u8uXQlJ9pkz07oVXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-java": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz", + "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-julia": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz", + "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-k8s": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.12.tgz", + "integrity": "sha512-2LcllTWgaTfYC7DmkMPOn9GsBWsA4DZdlun4po8s2ysTP7CPEnZc1ZfK6pZ2eI4TsZemlUQQ+NZxMe9/QutQxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-kotlin": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz", + "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-latex": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-4.0.4.tgz", + "integrity": "sha512-YdTQhnTINEEm/LZgTzr9Voz4mzdOXH7YX+bSFs3hnkUHCUUtX/mhKgf1CFvZ0YNM2afjhQcmLaR9bDQVyYBvpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lorem-ipsum": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz", + "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lua": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz", + "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-makefile": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz", + "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-markdown": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.12.tgz", + "integrity": "sha512-ufwoliPijAgWkD/ivAMC+A9QD895xKiJRF/fwwknQb7kt7NozTLKFAOBtXGPJAB4UjhGBpYEJVo2elQ0FCAH9A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cspell/dict-css": "^4.0.18", + "@cspell/dict-html": "^4.0.12", + "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-typescript": "^3.2.3" + } + }, + "node_modules/@cspell/dict-monkeyc": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.0.11.tgz", + "integrity": "sha512-7Q1Ncu0urALI6dPTrEbSTd//UK0qjRBeaxhnm8uY5fgYNFYAG+u4gtnTIo59S6Bw5P++4H3DiIDYoQdY/lha8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-node": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.8.tgz", + "integrity": "sha512-AirZcN2i84ynev3p2/1NCPEhnNsHKMz9zciTngGoqpdItUb2bDt1nJBjwlsrFI78GZRph/VaqTVFwYikmncpXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-npm": { + "version": "5.2.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.18.tgz", + "integrity": "sha512-uJV1T7y9ifFysO22XmxjU7y95c+02lfCZHNsTYHw2KOL6tLjc3XK/i0xt9iGLkPpcxwNJSCdu13UpjXZGqce/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-php": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.0.15.tgz", + "integrity": "sha512-iepGB2gtToMWSTvybesn4/lUp4LwXcEm0s8vasJLP76WWVkq1zYjmeS+WAIzNgsuURyZ/9mGqhS0CWMuo74ODw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-powershell": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz", + "integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-public-licenses": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.15.tgz", + "integrity": "sha512-cJEOs901H13Pfy0fl4dCD1U+xpWIMaEPq8MeYU83FfDZvellAuSo4GqWCripfIqlhns/L6+UZEIJSOZnjgy7Wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-python": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.20.tgz", + "integrity": "sha512-c1wbfb3MDMSY4UTNdGnA18NkrcX6cMlYER0HSpGYh2jLK43gS1QL3j2B49qgnRYfcLUp4xgeA05vzCQsjGbwuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-data-science": "^2.0.10" + } + }, + "node_modules/@cspell/dict-r": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz", + "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-ruby": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.0.9.tgz", + "integrity": "sha512-H2vMcERMcANvQshAdrVx0XoWaNX8zmmiQN11dZZTQAZaNJ0xatdJoSqY8C8uhEMW89bfgpN+NQgGuDXW2vmXEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-rust": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.0.12.tgz", + "integrity": "sha512-z2QiH+q9UlNhobBJArvILRxV8Jz0pKIK7gqu4TgmEYyjiu1TvnGZ1tbYHeu9w3I/wOP6UMDoCBTty5AlYfW0mw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-scala": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.8.tgz", + "integrity": "sha512-YdftVmumv8IZq9zu1gn2U7A4bfM2yj9Vaupydotyjuc+EEZZSqAafTpvW/jKLWji2TgybM1L2IhmV0s/Iv9BTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-shell": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.1.1.tgz", + "integrity": "sha512-T37oYxE7OV1x/1D4/13Y8JZGa1QgDCXV7AVt3HLXjn0Fe3TaNDvf5sU0fGnXKmBPqFFrHdpD3uutAQb1dlp15g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-software-terms": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.9.tgz", + "integrity": "sha512-lpiSpS1iTF2n8barqVkPmhe5qXs5291IqcDUPr5ttFRxPMZ7pgrMUdvcdNUdkajymjDOyWfUNhdYXW7JndThZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-sql": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz", + "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-svelte": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz", + "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-swift": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz", + "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-terraform": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.3.tgz", + "integrity": "sha512-gr6wxCydwSFyyBKhBA2xkENXtVFToheqYYGFvlMZXWjviynXmh+NK/JTvTCk/VHk3+lzbO9EEQKee6VjrAUSbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-typescript": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", + "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-vue": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz", + "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dynamic-import": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.2.1.tgz", + "integrity": "sha512-izYQbk7ck0ffNA1gf7Gi3PkUEjj+crbYeyNK1hxHx5A+GuR416ozs0aEyp995KI2v9HZlXscOj3SC3wrWzHZeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.2.1", + "import-meta-resolve": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/filetypes": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.2.1.tgz", + "integrity": "sha512-Dy1y1pQ+7hi2gPs+jERczVkACtYbUHcLodXDrzpipoxgOtVxMcyZuo+84WYHImfu0gtM0wU2uLObaVgMSTnytw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/strong-weak-map": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.2.1.tgz", + "integrity": "sha512-1HsQWZexvJSjDocVnbeAWjjgqWE/0op/txxzDPvDqI2sE6pY0oO4Cinj2I8z+IP+m6/E6yjPxdb23ydbQbPpJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/url": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.2.1.tgz", + "integrity": "sha512-9EHCoGKtisPNsEdBQ28tKxKeBmiVS3D4j+AN8Yjr+Dmtu+YACKGWiMOddNZG2VejQNIdFx7FwzU00BGX68ELhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -2154,6 +2743,13 @@ "node": ">=0.10.0" } }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2422,6 +3018,35 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.2.tgz", + "integrity": "sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/change-case": { "version": "4.1.2", "dev": true, @@ -2519,6 +3144,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clear-module": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz", + "integrity": "sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^2.0.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clear-module/node_modules/parent-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-2.0.0.tgz", + "integrity": "sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clear-module/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "dev": true, @@ -2650,6 +3315,21 @@ "node": ">=20" } }, + "node_modules/comment-json": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.4.1.tgz", + "integrity": "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -2701,6 +3381,13 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "license": "MIT", @@ -2724,6 +3411,225 @@ "node": ">= 8" } }, + "node_modules/cspell": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.2.1.tgz", + "integrity": "sha512-PoKGKE9Tl87Sn/jwO4jvH7nTqe5Xrsz2DeJT5CkulY7SoL2fmsAqfbImQOFS2S0s36qD98t6VO+Ig2elEEcHew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-json-reporter": "9.2.1", + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-types": "9.2.1", + "@cspell/dynamic-import": "9.2.1", + "@cspell/url": "9.2.1", + "chalk": "^5.6.0", + "chalk-template": "^1.1.0", + "commander": "^14.0.0", + "cspell-config-lib": "9.2.1", + "cspell-dictionary": "9.2.1", + "cspell-gitignore": "9.2.1", + "cspell-glob": "9.2.1", + "cspell-io": "9.2.1", + "cspell-lib": "9.2.1", + "fast-json-stable-stringify": "^2.1.0", + "flatted": "^3.3.3", + "semver": "^7.7.2", + "tinyglobby": "^0.2.14" + }, + "bin": { + "cspell": "bin.mjs", + "cspell-esm": "bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" + } + }, + "node_modules/cspell-config-lib": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.2.1.tgz", + "integrity": "sha512-qqhaWW+0Ilc7493lXAlXjziCyeEmQbmPMc1XSJw2EWZmzb+hDvLdFGHoX18QU67yzBtu5hgQsJDEDZKvVDTsRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.2.1", + "comment-json": "^4.2.5", + "smol-toml": "^1.4.2", + "yaml": "^2.8.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-dictionary": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.2.1.tgz", + "integrity": "sha512-0hQVFySPsoJ0fONmDPwCWGSG6SGj4ERolWdx4t42fzg5zMs+VYGXpQW4BJneQ5Tfxy98Wx8kPhmh/9E8uYzLTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-types": "9.2.1", + "cspell-trie-lib": "9.2.1", + "fast-equals": "^5.2.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-gitignore": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.2.1.tgz", + "integrity": "sha512-WPnDh03gXZoSqVyXq4L7t9ljx6lTDvkiSRUudb125egEK5e9s04csrQpLI3Yxcnc1wQA2nzDr5rX9XQVvCHf7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.2.1", + "cspell-glob": "9.2.1", + "cspell-io": "9.2.1" + }, + "bin": { + "cspell-gitignore": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-glob": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.2.1.tgz", + "integrity": "sha512-CrT/6ld3rXhB36yWFjrx1SrMQzwDrGOLr+wYEnrWI719/LTYWWCiMFW7H+qhsJDTsR+ku8+OAmfRNBDXvh9mnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.2.1", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-glob/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/cspell-grammar": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.2.1.tgz", + "integrity": "sha512-10RGFG7ZTQPdwyW2vJyfmC1t8813y8QYRlVZ8jRHWzer9NV8QWrGnL83F+gTPXiKR/lqiW8WHmFlXR4/YMV+JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-types": "9.2.1" + }, + "bin": { + "cspell-grammar": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-io": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.2.1.tgz", + "integrity": "sha512-v9uWXtRzB+RF/Mzg5qMzpb8/yt+1bwtTt2rZftkLDLrx5ybVvy6rhRQK05gFWHmWVtWEe0P/pIxaG2Vz92C8Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-service-bus": "9.2.1", + "@cspell/url": "9.2.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-lib": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.2.1.tgz", + "integrity": "sha512-KeB6NHcO0g1knWa7sIuDippC3gian0rC48cvO0B0B0QwhOxNxWVp8cSmkycXjk4ijBZNa++IwFjeK/iEqMdahQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-bundled-dicts": "9.2.1", + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-resolver": "9.2.1", + "@cspell/cspell-types": "9.2.1", + "@cspell/dynamic-import": "9.2.1", + "@cspell/filetypes": "9.2.1", + "@cspell/strong-weak-map": "9.2.1", + "@cspell/url": "9.2.1", + "clear-module": "^4.1.2", + "comment-json": "^4.2.5", + "cspell-config-lib": "9.2.1", + "cspell-dictionary": "9.2.1", + "cspell-glob": "9.2.1", + "cspell-grammar": "9.2.1", + "cspell-io": "9.2.1", + "cspell-trie-lib": "9.2.1", + "env-paths": "^3.0.0", + "fast-equals": "^5.2.2", + "gensequence": "^7.0.0", + "import-fresh": "^3.3.1", + "resolve-from": "^5.0.0", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-lib/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cspell-trie-lib": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.2.1.tgz", + "integrity": "sha512-qOtbL+/tUzGFHH0Uq2wi7sdB9iTy66QNx85P7DKeRdX9ZH53uQd7qC4nEk+/JPclx1EgXX26svxr0jTGISJhLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.2.1", + "@cspell/cspell-types": "9.2.1", + "gensequence": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/debug": { "version": "4.4.1", "license": "MIT", @@ -2886,6 +3792,19 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/environment": { "version": "1.1.0", "dev": true, @@ -3295,6 +4214,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "dev": true, @@ -3486,6 +4419,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-equals": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz", + "integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "dev": true, @@ -3722,6 +4665,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensequence": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-7.0.0.tgz", + "integrity": "sha512-47Frx13aZh01afHJTB3zTtKIlFI6vWY+MYCN9Qpew6i52rfKjnhCF/l1YlC8UmEMvvntZZ6z4PiCcmyuedR2aQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -3837,6 +4790,32 @@ "node": "*" } }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/global-modules": { "version": "1.0.0", "dev": true, @@ -4113,6 +5092,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "dev": true, @@ -6416,6 +7406,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smol-toml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/snake-case": { "version": "3.0.4", "dev": true, @@ -7412,6 +8415,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", @@ -7496,13 +8513,28 @@ "version": "1.0.2", "license": "ISC" }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.0", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", "bin": { From 7950c9254e02b75c7b7421f4e49c55be2a073eee Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 20 Oct 2025 10:39:20 -0400 Subject: [PATCH 4/9] [tools] Update tools to use structuredContent with schema --- src/schemas/style.ts | 34 ++++--- src/tools/BaseTool.ts | 6 +- src/tools/MapboxApiBasedTool.ts | 66 +++++++++++++ .../bounding-box-tool/BoundingBoxTool.ts | 4 +- .../CountryBoundingBoxTool.ts | 4 +- .../CoordinateConversionTool.ts | 12 ++- .../create-style-tool/CreateStyleTool.ts | 20 ++-- .../create-token-tool/CreateTokenTool.ts | 6 +- .../delete-style-tool/DeleteStyleTool.ts | 4 +- .../ListStylesTool.output.schema.ts | 39 +++++++- src/tools/list-styles-tool/ListStylesTool.ts | 12 +-- src/tools/list-tokens-tool/ListTokensTool.ts | 6 +- .../retrieve-style-tool/RetrieveStyleTool.ts | 14 ++- src/tools/tilequery-tool/TilequeryTool.ts | 6 +- .../update-style-tool/UpdateStyleTool.ts | 14 ++- .../bounding-box-tool/BoundingBoxTool.test.ts | 44 ++++----- .../CountryBoundingBoxTool.test.ts | 22 +++-- .../CoordinateConversionTool.test.ts | 16 ++-- .../create-token-tool/CreateTokenTool.test.ts | 12 ++- .../list-styles-tool/ListStylesTool.test.ts | 93 ++++++++++++++++++- .../list-tokens-tool/ListTokensTool.test.ts | 36 +++++-- .../RetrieveStyleTool.test.ts | 55 ++++++++++- 22 files changed, 411 insertions(+), 114 deletions(-) diff --git a/src/schemas/style.ts b/src/schemas/style.ts index 8e337d9..7387a4d 100644 --- a/src/schemas/style.ts +++ b/src/schemas/style.ts @@ -224,17 +224,29 @@ export const BaseStylePropertiesSchema = z .optional() .describe('Base URL for sprite image and metadata'), glyphs: z.string().optional().describe('URL template for glyph sets'), - light: LightSchema.optional().describe( - 'Global light source (deprecated, use lights)' - ), - lights: LightsSchema.optional().describe('Array of 3D light sources'), - terrain: TerrainSchema.optional().describe('Global terrain elevation'), - fog: z.record(z.any()).optional().describe('Fog properties'), - projection: z.record(z.any()).optional().describe('Map projection'), - transition: TransitionSchema.optional().describe( - 'Default transition timing' - ), - imports: z.array(StyleImportSchema).optional().describe('Imported styles') + light: LightSchema.optional() + .nullable() + .describe('Global light source (deprecated, use lights)'), + lights: LightsSchema.optional() + .nullable() + .describe('Array of 3D light sources'), + terrain: TerrainSchema.optional() + .nullable() + .describe('Global terrain elevation'), + fog: z.record(z.any()).optional().nullable().describe('Fog properties'), + projection: z + .record(z.any()) + .optional() + .nullable() + .describe('Map projection'), + transition: TransitionSchema.optional() + .nullable() + .describe('Default transition timing'), + imports: z + .array(StyleImportSchema) + .optional() + .nullable() + .describe('Imported styles') }) .passthrough(); diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 67e6ab0..30bad2f 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -82,9 +82,13 @@ export abstract class BaseTool< // Add outputSchema if provided if (this.outputSchema) { + // Wrap the output schema in a data property since all tools return { data: ... } + const wrappedSchema = z.object({ + data: this.outputSchema + }); config.outputSchema = // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this.outputSchema as unknown as z.ZodObject).shape; + (wrappedSchema as unknown as z.ZodObject).shape; } return server.registerTool(this.name, config, (args, extra) => diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index eb261b3..8bd19e1 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -10,6 +10,14 @@ import type { } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../utils/types.js'; +/** + * Standard error response format from Mapbox API + */ +interface MapboxApiError { + message?: string; + [key: string]: unknown; +} + export abstract class MapboxApiBasedTool< InputSchema extends ZodTypeAny, OutputSchema extends ZodTypeAny = ZodTypeAny @@ -111,6 +119,64 @@ export abstract class MapboxApiBasedTool< } } + /** + * Handles HTTP error responses from Mapbox API. + * Attempts to parse the error response body to extract helpful messages. + * + * @param response - The failed HTTP response + * @param operation - Description of the operation that failed (e.g., "list styles", "create token") + * @returns A CallToolResult with error details + */ + protected async handleApiError( + response: Response, + operation: string + ): Promise { + let errorMessage = `Failed to ${operation}: ${response.status} ${response.statusText}`; + + try { + // Try to parse the response as JSON to get more details + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const errorData = (await response.json()) as MapboxApiError; + + // Mapbox API typically returns { "message": "..." } for errors + if (errorData.message) { + errorMessage = `Failed to ${operation}: ${errorData.message}`; + + // Check if it's a scope/permission error + if ( + errorData.message.toLowerCase().includes('scope') || + errorData.message.toLowerCase().includes('permission') + ) { + errorMessage += + '\n\nThis operation requires a token with appropriate scopes. Please check your MAPBOX_ACCESS_TOKEN has the necessary permissions.'; + } + } + } else { + // If not JSON, try to get text + const errorText = await response.text(); + if (errorText) { + errorMessage += `\n${errorText}`; + } + } + } catch (parseError) { + // If we can't parse the error body, just use the basic message + this.log('warning', `Failed to parse error response: ${parseError}`); + } + + this.log('error', `${this.name}: ${errorMessage}`); + + return { + content: [ + { + type: 'text', + text: errorMessage + } + ], + isError: true + }; + } + /** * Tool logic to be implemented by subclasses. * Must return a complete OutputSchema with content and optional structured content. diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.ts b/src/tools/bounding-box-tool/BoundingBoxTool.ts index 8590393..d3fe868 100644 --- a/src/tools/bounding-box-tool/BoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/BoundingBoxTool.ts @@ -71,11 +71,11 @@ export class BoundingBoxTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify(bbox, null, 2) + text: JSON.stringify({ data: bbox }, null, 2) } ], structuredContent: { - bbox + data: bbox }, isError: false }; diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts index 1861edc..a6441ef 100644 --- a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts @@ -61,11 +61,11 @@ export class CountryBoundingBoxTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify(bbox, null, 2) + text: JSON.stringify({ data: bbox }, null, 2) } ], structuredContent: { - bbox + data: bbox }, isError: false }; diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts index c14f5a0..5fdaaba 100644 --- a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts @@ -52,11 +52,13 @@ export class CoordinateConversionTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify(outputResult, null, 2) + text: JSON.stringify({ data: outputResult }, null, 2) } ], isError: false, - structuredContent: outputResult + structuredContent: { + data: outputResult + } }; } @@ -107,11 +109,13 @@ export class CoordinateConversionTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify(outputResult, null, 2) + text: JSON.stringify({ data: outputResult }, null, 2) } ], isError: false, - structuredContent: outputResult + structuredContent: { + data: outputResult + } }; } diff --git a/src/tools/create-style-tool/CreateStyleTool.ts b/src/tools/create-style-tool/CreateStyleTool.ts index 341bdc9..8ffca9d 100644 --- a/src/tools/create-style-tool/CreateStyleTool.ts +++ b/src/tools/create-style-tool/CreateStyleTool.ts @@ -53,15 +53,7 @@ export class CreateStyleTool extends MapboxApiBasedTool< }); if (!response.ok) { - return { - content: [ - { - type: 'text', - text: `Failed to create style: ${response.status} ${response.statusText}` - } - ], - isError: true - }; + return this.handleApiError(response, 'create style'); } const rawData = await response.json(); @@ -84,10 +76,16 @@ export class CreateStyleTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + text: JSON.stringify( + { data: filterExpandedMapboxStyles(data) }, + null, + 2 + ) } ], - structuredContent: filterExpandedMapboxStyles(data), + structuredContent: { + data: filterExpandedMapboxStyles(data) + }, isError: false }; } diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index 55f2726..b90041d 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -124,10 +124,12 @@ export class CreateTokenTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify(parseResult.data, null, 2) + text: JSON.stringify({ data: parseResult.data }, null, 2) } ], - structuredContent: parseResult.data, + structuredContent: { + data: parseResult.data + }, isError: false }; } diff --git a/src/tools/delete-style-tool/DeleteStyleTool.ts b/src/tools/delete-style-tool/DeleteStyleTool.ts index 2225dda..1595373 100644 --- a/src/tools/delete-style-tool/DeleteStyleTool.ts +++ b/src/tools/delete-style-tool/DeleteStyleTool.ts @@ -39,9 +39,7 @@ export class DeleteStyleTool extends MapboxApiBasedTool< }); if (response.status !== 204) { - throw new Error( - `Failed to delete style: ${response.status} ${response.statusText}` - ); + return this.handleApiError(response, 'delete style'); } return { diff --git a/src/tools/list-styles-tool/ListStylesTool.output.schema.ts b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts index 353165b..1092acf 100644 --- a/src/tools/list-styles-tool/ListStylesTool.output.schema.ts +++ b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts @@ -3,8 +3,43 @@ import { z } from 'zod'; -import { BaseStylePropertiesSchema } from '../../schemas/style.js'; +/** + * Schema for style metadata returned by the list styles endpoint. + * Note: This is different from a full style specification - it contains + * metadata about the style but may not include all style properties like layers. + */ +const StyleMetadataSchema = z + .object({ + // Core metadata fields always present + id: z.string().describe('Unique style ID'), + name: z.string().describe('Style name'), + owner: z.string().describe('Username of the style owner'), + created: z.string().describe('ISO 8601 timestamp of creation'), + modified: z.string().describe('ISO 8601 timestamp of last modification'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), -export const ListStylesOutputSchema = z.array(BaseStylePropertiesSchema); + // Optional Style Spec fields that may be included + version: z.literal(8).optional().describe('Style specification version'), + center: z + .tuple([z.number(), z.number()]) + .optional() + .describe('Default center [longitude, latitude]'), + zoom: z.number().optional().describe('Default zoom level'), + bearing: z.number().optional().describe('Default bearing in degrees'), + pitch: z.number().optional().describe('Default pitch in degrees'), + + // Sources and layers may or may not be included in list responses + sources: z.record(z.any()).optional().describe('Style data sources'), + layers: z.array(z.any()).optional().describe('Style layers'), + + // Additional metadata fields + protected: z.boolean().optional().describe('Whether style is protected'), + draft: z.boolean().optional().describe('Whether style is a draft') + }) + .passthrough(); // Allow additional fields from API + +export const ListStylesOutputSchema = z.array(StyleMetadataSchema); export type ListStylesOutput = z.infer; diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index a4b2c53..7be9b56 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -60,15 +60,7 @@ export class ListStylesTool extends MapboxApiBasedTool< const response = await this.httpRequest(url); if (!response.ok) { - return { - content: [ - { - type: 'text' as const, - text: `Failed to list styles: ${response.status} ${response.statusText}` - } - ], - isError: true - }; + return this.handleApiError(response, 'list styles'); } const data = await response.json(); @@ -94,7 +86,7 @@ export class ListStylesTool extends MapboxApiBasedTool< content: [ { type: 'text' as const, - text: JSON.stringify(parseResult.data, null, 2) + text: JSON.stringify({ data: parseResult.data }, null, 2) } ], structuredContent: { diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index 0e61cac..c4f1446 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -217,10 +217,12 @@ export class ListTokensTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify(result) + text: JSON.stringify({ data: result }, null, 2) } ], - structuredContent: result, + structuredContent: { + data: result + }, isError: false }; } catch (error) { diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts index d963736..af5a24e 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts @@ -47,9 +47,7 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< const response = await this.httpRequest(url); if (!response.ok) { - throw new Error( - `Failed to retrieve style: ${response.status} ${response.statusText}` - ); + return this.handleApiError(response, 'retrieve style'); } const rawData = await response.json(); @@ -72,10 +70,16 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify(filterExpandedMapboxStyles(data)) + text: JSON.stringify( + { data: filterExpandedMapboxStyles(data) }, + null, + 2 + ) } ], - structuredContent: filterExpandedMapboxStyles(data), + structuredContent: { + data: filterExpandedMapboxStyles(data) + }, isError: false }; } diff --git a/src/tools/tilequery-tool/TilequeryTool.ts b/src/tools/tilequery-tool/TilequeryTool.ts index ab34045..029f2a6 100644 --- a/src/tools/tilequery-tool/TilequeryTool.ts +++ b/src/tools/tilequery-tool/TilequeryTool.ts @@ -121,10 +121,12 @@ export class TilequeryTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify(data, null, 2) + text: JSON.stringify({ data }, null, 2) } ], - structuredContent: data, + structuredContent: { + data + }, isError: false }; } diff --git a/src/tools/update-style-tool/UpdateStyleTool.ts b/src/tools/update-style-tool/UpdateStyleTool.ts index 7216f0a..e96c681 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.ts @@ -57,9 +57,7 @@ export class UpdateStyleTool extends MapboxApiBasedTool< }); if (!response.ok) { - throw new Error( - `Failed to update style: ${response.status} ${response.statusText}` - ); + return this.handleApiError(response, 'update style'); } const rawData = await response.json(); @@ -82,10 +80,16 @@ export class UpdateStyleTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) + text: JSON.stringify( + { data: filterExpandedMapboxStyles(data) }, + null, + 2 + ) } ], - structuredContent: filterExpandedMapboxStyles(data), + structuredContent: { + data: filterExpandedMapboxStyles(data) + }, isError: false }; } diff --git a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts index 3ed7eb8..aa29f3b 100644 --- a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts @@ -25,9 +25,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -73.9857, 40.7484, -73.9857, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + data: [-73.9857, 40.7484, -73.9857, 40.7484] + }); }); it('should calculate bounding box for a Point with string input', async () => { @@ -41,9 +41,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -73.9857, 40.7484, -73.9857, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + data: [-73.9857, 40.7484, -73.9857, 40.7484] + }); }); it('should calculate bounding box for a LineString', async () => { @@ -61,9 +61,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -73.9919, 40.7484, -73.9857, 40.7614 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + data: [-73.9919, 40.7484, -73.9857, 40.7614] + }); }); it('should calculate bounding box for a Polygon', async () => { @@ -85,9 +85,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -73.9919, 40.7484, -73.9857, 40.7614 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + data: [-73.9919, 40.7484, -73.9857, 40.7614] + }); }); it('should calculate bounding box for a FeatureCollection', async () => { @@ -118,9 +118,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -74.006, 40.7128, -73.9857, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + data: [-74.006, 40.7128, -73.9857, 40.7484] + }); }); it('should calculate bounding box for a MultiPoint', async () => { @@ -138,9 +138,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -74.006, 40.7128, -73.9352, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + data: [-74.006, 40.7128, -73.9352, 40.7484] + }); }); it('should calculate bounding box for a MultiPolygon', async () => { @@ -173,7 +173,7 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([0, 0, 3, 3]); + expect(JSON.parse(textContent.text)).toEqual({ data: [0, 0, 3, 3] }); }); it('should calculate bounding box for a GeometryCollection', async () => { @@ -199,9 +199,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual([ - -74.006, 40.7128, -73.9352, 40.7484 - ]); + expect(JSON.parse(textContent.text)).toEqual({ + data: [-74.006, 40.7128, -73.9352, 40.7484] + }); }); it('should handle Feature with null geometry', async () => { diff --git a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts index 89e59e8..8660296 100644 --- a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts @@ -21,7 +21,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([73.599819, 21.144707, 134.762115, 53.424591]); + expect(bbox).toEqual({ + data: [73.599819, 21.144707, 134.762115, 53.424591] + }); }); it('should return bounding box for valid country code - United States (US)', async () => { @@ -31,7 +33,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([-168.069693, 25.133463, -67.292669, 71.284212]); + expect(bbox).toEqual({ + data: [-168.069693, 25.133463, -67.292669, 71.284212] + }); }); it('should return bounding box for valid country code - UAE (AE)', async () => { @@ -41,7 +45,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([51.590737, 22.705773, 56.376954, 26.050548]); + expect(bbox).toEqual({ + data: [51.590737, 22.705773, 56.376954, 26.050548] + }); }); it('should handle lowercase country codes', async () => { @@ -51,7 +57,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([73.599819, 21.144707, 134.762115, 53.424591]); + expect(bbox).toEqual({ + data: [73.599819, 21.144707, 134.762115, 53.424591] + }); }); it('should handle mixed case country codes', async () => { @@ -61,7 +69,9 @@ describe('CountryBoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); - expect(bbox).toEqual([-168.069693, 25.133463, -67.292669, 71.284212]); + expect(bbox).toEqual({ + data: [-168.069693, 25.133463, -67.292669, 71.284212] + }); }); it('should return error for invalid country code', async () => { @@ -165,7 +175,7 @@ describe('CountryBoundingBoxTool', () => { if (!result.isError) { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - const bbox = JSON.parse(textContent.text); + const bbox = JSON.parse(textContent.text).data; expect(Array.isArray(bbox)).toBe(true); expect(bbox).toHaveLength(4); } diff --git a/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts b/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts index a45c60d..a5400cc 100644 --- a/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts +++ b/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts @@ -41,7 +41,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ); + ).data; expect(parsed.input).toEqual([-74.006, 40.7128]); expect(parsed.from).toBe('wgs84'); expect(parsed.to).toBe('epsg3857'); @@ -65,7 +65,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ); + ).data; const [x, y] = parsed.output; expect(x).toBeCloseTo(0, 6); expect(y).toBeCloseTo(0, 6); @@ -84,7 +84,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ); + ).data; const [x, y] = parsed.output; expect(x).toBeCloseTo(0, 6); expect(y).toBeGreaterThan(19000000); // Should be a very large Y value @@ -135,7 +135,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ); + ).data; const [lon, lat] = parsed.output; expect(lon).toBeCloseTo(-74.006, 3); expect(lat).toBeCloseTo(40.7128, 3); @@ -154,7 +154,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ); + ).data; const [lon, lat] = parsed.output; expect(lon).toBeCloseTo(0, 6); expect(lat).toBeCloseTo(0, 6); @@ -192,7 +192,7 @@ describe('CoordinateConversionTool', () => { const mercatorCoords = JSON.parse( (toMercator.content[0] as { type: 'text'; text: string }).text - ).output; + ).data.output; // Convert EPSG:3857 -> WGS84 const backToWgs84 = await tool.run({ @@ -203,7 +203,7 @@ describe('CoordinateConversionTool', () => { const finalCoords = JSON.parse( (backToWgs84.content[0] as { type: 'text'; text: string }).text - ).output; + ).data.output; expect(finalCoords[0]).toBeCloseTo(originalCoords[0], 6); expect(finalCoords[1]).toBeCloseTo(originalCoords[1], 6); @@ -224,7 +224,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ); + ).data; expect(parsed.input).toEqual([-74.006, 40.7128]); expect(parsed.output).toEqual([-74.006, 40.7128]); expect(parsed.message).toContain('No conversion needed'); diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index 5b61423..79734e3 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -169,7 +169,9 @@ describe('CreateTokenTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData).toMatchObject({ token: mockResponse.token, note: mockResponse.note, @@ -223,7 +225,9 @@ describe('CreateTokenTool', () => { }); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.allowedUrls).toEqual(mockResponse.allowedUrls); // Verify the request body included allowedUrls @@ -263,7 +267,9 @@ describe('CreateTokenTool', () => { }); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.expires).toEqual(expiresAt); // Verify the request body included expires diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index 1c3f96d..365fa9f 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -67,6 +67,34 @@ describe('ListStylesTool', () => { assertHeadersSent(mockHttpRequest); }); + it('handles scope/permission errors with helpful message', async () => { + const mockHeaders = new Map([['content-type', 'application/json']]); + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: { + get: (name: string) => mockHeaders.get(name.toLowerCase()) + } as Headers, + json: async () => ({ + message: 'This API requires a token with styles:list scope.' + }) + }); + + const result = await new ListStylesTool({ httpRequest }).run({}); + + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe('text'); + const errorText = (result.content[0] as { type: 'text'; text: string }) + .text; + expect(errorText).toContain( + 'This API requires a token with styles:list scope' + ); + expect(errorText).toContain('appropriate scopes'); + expect(errorText).toContain('MAPBOX_ACCESS_TOKEN'); + assertHeadersSent(mockHttpRequest); + }); + it('extracts username from token for API call', async () => { const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, @@ -134,18 +162,24 @@ describe('ListStylesTool', () => { it('returns style list on success', async () => { const mockStyles = [ { - version: 8, id: 'style1', name: 'Test Style 1', owner: 'testuser', + created: '2020-05-05T08:27:39.280Z', + modified: '2020-05-05T08:27:41.353Z', + visibility: 'private' as const, + version: 8, sources: {}, layers: [] }, { - version: 8, id: 'style2', name: 'Test Style 2', owner: 'testuser', + created: '2020-05-06T08:27:39.280Z', + modified: '2020-05-06T08:27:41.353Z', + visibility: 'public' as const, + version: 8, sources: {}, layers: [] } @@ -164,10 +198,63 @@ describe('ListStylesTool', () => { const content = result.content[0]; if (content.type === 'text') { - const parsedResponse = JSON.parse(content.text); + const parsedResponse = JSON.parse(content.text).data; expect(parsedResponse).toEqual(mockStyles); } assertHeadersSent(mockHttpRequest); }); + + it('handles styles without layers field (real API response)', async () => { + // This matches the actual production API response format + const mockStyles = [ + { + center: [139.7667, 35.681249], + created: '2020-05-05T08:27:39.280Z', + id: 'ck9tnguii0ipm1ipf54wqhhwm', + modified: '2020-05-05T08:27:41.353Z', + name: 'Yahoo! Japan Streets', + owner: 'svc-okta-mapbox-staff-access', + sources: { + composite: { + url: 'mapbox://mapbox.mapbox-streets-v8,mapbox.road-detail-v1-33,mapbox.transit-v2', + type: 'vector' + } + }, + version: 8, + visibility: 'private' as const, + zoom: 16, + protected: false + } + ]; + + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: true, + json: async () => mockStyles + }); + + const result = await new ListStylesTool({ httpRequest }).run({}); + + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + + const content = result.content[0]; + if (content.type === 'text') { + const parsedResponse = JSON.parse(content.text).data; + expect(parsedResponse).toHaveLength(1); + expect(parsedResponse[0]).toMatchObject({ + id: 'ck9tnguii0ipm1ipf54wqhhwm', + name: 'Yahoo! Japan Streets', + owner: 'svc-okta-mapbox-staff-access', + visibility: 'private', + version: 8, + protected: false + }); + // Verify layers field is not required + expect(parsedResponse[0].layers).toBeUndefined(); + } + + assertHeadersSent(mockHttpRequest); + }); }); diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index ac20078..5c12df7 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -163,7 +163,9 @@ describe('ListTokensTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(2); expect(responseData.count).toBe(2); expect(responseData.tokens[0].id).toBe('cktest123'); @@ -211,7 +213,9 @@ describe('ListTokensTool', () => { const result = await tool.run({ default: true }); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(1); expect(responseData.tokens[0].default).toBe(true); @@ -257,7 +261,9 @@ describe('ListTokensTool', () => { }); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(1); // Verify all parameters were included in the request @@ -299,7 +305,9 @@ describe('ListTokensTool', () => { const result = await tool.run({ limit: 10 }); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(1); expect(responseData.next_start).toBe('cktest999'); }); @@ -335,7 +343,9 @@ describe('ListTokensTool', () => { const result = await tool.run({ start: 'cktest789' }); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(1); expect(responseData.next_start).toBe('cktest999'); }); @@ -384,7 +394,9 @@ describe('ListTokensTool', () => { const result = await tool.run({}); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(1); expect(responseData.next_start).toBeUndefined(); }); @@ -415,7 +427,9 @@ describe('ListTokensTool', () => { const result = await tool.run({ limit: 10 }); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(1); expect(responseData.next_start).toBeUndefined(); }); @@ -446,7 +460,9 @@ describe('ListTokensTool', () => { const result = await tool.run({ usage: 'pk' }); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(1); expect(responseData.tokens[0].usage).toBe('pk'); @@ -550,7 +566,9 @@ describe('ListTokensTool', () => { const result = await tool.run({}); expect(result.isError).toBe(false); - const responseData = JSON.parse((result.content[0] as TextContent).text); + const responseData = JSON.parse( + (result.content[0] as TextContent).text + ).data; expect(responseData.tokens).toHaveLength(1); expect(responseData.count).toBe(1); }); diff --git a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts index 8804435..33d0d71 100644 --- a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts +++ b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts @@ -50,7 +50,7 @@ describe('RetrieveStyleTool', () => { expect(result.content[0]).toMatchObject({ type: 'text', - text: JSON.stringify(styleData) + text: JSON.stringify({ data: styleData }, null, 2) }); assertHeadersSent(mockHttpRequest); }); @@ -83,4 +83,57 @@ describe('RetrieveStyleTool', () => { }); assertHeadersSent(mockHttpRequest); }); + + it('handles styles with null terrain and other nullable fields', async () => { + // Real-world API response with null values for optional fields + const styleData = { + id: 'cjxyz123', + name: 'Production Style', + owner: 'test-user', + version: 8, + created: '2020-01-01T00:00:00.000Z', + modified: '2020-01-02T00:00:00.000Z', + visibility: 'private' as const, + sources: { + composite: { + type: 'vector' as const, + url: 'mapbox://mapbox.mapbox-streets-v8' + } + }, + layers: [ + { + id: 'background', + type: 'background' as const, + paint: { 'background-color': '#000000' } + } + ], + terrain: null, // API returns null instead of omitting the field + fog: null, + lights: null + }; + + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: true, + status: 200, + json: async () => styleData + }); + + const result = await new RetrieveStyleTool({ httpRequest }).run({ + styleId: 'cjxyz123' + }); + + expect(result.isError).toBe(false); + expect(result.content[0].type).toBe('text'); + + const content = result.content[0]; + if (content.type === 'text') { + const parsedResponse = JSON.parse(content.text).data; + expect(parsedResponse.terrain).toBeNull(); + expect(parsedResponse.fog).toBeNull(); + expect(parsedResponse.lights).toBeNull(); + expect(parsedResponse.id).toBe('cjxyz123'); + } + + assertHeadersSent(mockHttpRequest); + }); }); From 278656f26e6e219e0a660a3aadd121b91920cb7d Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 20 Oct 2025 11:27:23 -0400 Subject: [PATCH 5/9] [tools] Update tools to use structuredContent with schema --- src/tools/bounding-box-tool/BoundingBoxTool.ts | 4 ++-- .../CountryBoundingBoxTool.ts | 4 ++-- .../bounding-box-tool/BoundingBoxTool.test.ts | 18 ++++++++++-------- .../CountryBoundingBoxTool.test.ts | 12 ++++++------ 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.ts b/src/tools/bounding-box-tool/BoundingBoxTool.ts index d3fe868..5f4b319 100644 --- a/src/tools/bounding-box-tool/BoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/BoundingBoxTool.ts @@ -71,11 +71,11 @@ export class BoundingBoxTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify({ data: bbox }, null, 2) + text: JSON.stringify({ data: { bbox } }, null, 2) } ], structuredContent: { - data: bbox + data: { bbox } }, isError: false }; diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts index a6441ef..c2f981c 100644 --- a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts @@ -61,11 +61,11 @@ export class CountryBoundingBoxTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify({ data: bbox }, null, 2) + text: JSON.stringify({ data: { bbox } }, null, 2) } ], structuredContent: { - data: bbox + data: { bbox } }, isError: false }; diff --git a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts index aa29f3b..4d30855 100644 --- a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts @@ -26,7 +26,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: [-73.9857, 40.7484, -73.9857, 40.7484] + data: { bbox: [-73.9857, 40.7484, -73.9857, 40.7484] } }); }); @@ -42,7 +42,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: [-73.9857, 40.7484, -73.9857, 40.7484] + data: { bbox: [-73.9857, 40.7484, -73.9857, 40.7484] } }); }); @@ -62,7 +62,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: [-73.9919, 40.7484, -73.9857, 40.7614] + data: { bbox: [-73.9919, 40.7484, -73.9857, 40.7614] } }); }); @@ -86,7 +86,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: [-73.9919, 40.7484, -73.9857, 40.7614] + data: { bbox: [-73.9919, 40.7484, -73.9857, 40.7614] } }); }); @@ -119,7 +119,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: [-74.006, 40.7128, -73.9857, 40.7484] + data: { bbox: [-74.006, 40.7128, -73.9857, 40.7484] } }); }); @@ -139,7 +139,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: [-74.006, 40.7128, -73.9352, 40.7484] + data: { bbox: [-74.006, 40.7128, -73.9352, 40.7484] } }); }); @@ -173,7 +173,9 @@ describe('BoundingBoxTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual({ data: [0, 0, 3, 3] }); + expect(JSON.parse(textContent.text)).toEqual({ + data: { bbox: [0, 0, 3, 3] } + }); }); it('should calculate bounding box for a GeometryCollection', async () => { @@ -200,7 +202,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: [-74.006, 40.7128, -73.9352, 40.7484] + data: { bbox: [-74.006, 40.7128, -73.9352, 40.7484] } }); }); diff --git a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts index 8660296..2d2693e 100644 --- a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts @@ -22,7 +22,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: [73.599819, 21.144707, 134.762115, 53.424591] + data: { bbox: [73.599819, 21.144707, 134.762115, 53.424591] } }); }); @@ -34,7 +34,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: [-168.069693, 25.133463, -67.292669, 71.284212] + data: { bbox: [-168.069693, 25.133463, -67.292669, 71.284212] } }); }); @@ -46,7 +46,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: [51.590737, 22.705773, 56.376954, 26.050548] + data: { bbox: [51.590737, 22.705773, 56.376954, 26.050548] } }); }); @@ -58,7 +58,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: [73.599819, 21.144707, 134.762115, 53.424591] + data: { bbox: [73.599819, 21.144707, 134.762115, 53.424591] } }); }); @@ -70,7 +70,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: [-168.069693, 25.133463, -67.292669, 71.284212] + data: { bbox: [-168.069693, 25.133463, -67.292669, 71.284212] } }); }); @@ -175,7 +175,7 @@ describe('CountryBoundingBoxTool', () => { if (!result.isError) { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - const bbox = JSON.parse(textContent.text).data; + const bbox = JSON.parse(textContent.text).data.bbox; expect(Array.isArray(bbox)).toBe(true); expect(bbox).toHaveLength(4); } From 446bb2f2b48706f9716345ff1794d4c533693000 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 20 Oct 2025 13:17:44 -0400 Subject: [PATCH 6/9] [tools] Update tools to use structuredContent with schema --- src/tools/BaseTool.ts | 8 ++--- .../bounding-box-tool/BoundingBoxTool.ts | 6 ++-- .../CountryBoundingBoxTool.ts | 6 ++-- .../CoordinateConversionTool.ts | 12 +++---- .../create-style-tool/CreateStyleTool.ts | 10 ++---- .../create-token-tool/CreateTokenTool.ts | 6 ++-- .../ListStylesTool.output.schema.ts | 9 ++++- src/tools/list-styles-tool/ListStylesTool.ts | 15 ++++---- src/tools/list-tokens-tool/ListTokensTool.ts | 6 ++-- .../retrieve-style-tool/RetrieveStyleTool.ts | 10 ++---- src/tools/tilequery-tool/TilequeryTool.ts | 6 ++-- .../update-style-tool/UpdateStyleTool.ts | 10 ++---- .../bounding-box-tool/BoundingBoxTool.test.ts | 16 ++++----- .../CountryBoundingBoxTool.test.ts | 12 +++---- .../CoordinateConversionTool.test.ts | 16 ++++----- .../create-token-tool/CreateTokenTool.test.ts | 12 ++----- .../list-styles-tool/ListStylesTool.test.ts | 12 +++++-- .../list-tokens-tool/ListTokensTool.test.ts | 36 +++++-------------- .../RetrieveStyleTool.test.ts | 4 +-- 19 files changed, 86 insertions(+), 126 deletions(-) diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 30bad2f..585a3a7 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -82,13 +82,11 @@ export abstract class BaseTool< // Add outputSchema if provided if (this.outputSchema) { - // Wrap the output schema in a data property since all tools return { data: ... } - const wrappedSchema = z.object({ - data: this.outputSchema - }); + // Pass the schema shape directly - don't wrap + // The MCP SDK will validate structuredContent.data against this schema config.outputSchema = // eslint-disable-next-line @typescript-eslint/no-explicit-any - (wrappedSchema as unknown as z.ZodObject).shape; + (this.outputSchema as unknown as z.ZodObject).shape; } return server.registerTool(this.name, config, (args, extra) => diff --git a/src/tools/bounding-box-tool/BoundingBoxTool.ts b/src/tools/bounding-box-tool/BoundingBoxTool.ts index 5f4b319..b5d9900 100644 --- a/src/tools/bounding-box-tool/BoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/BoundingBoxTool.ts @@ -71,12 +71,10 @@ export class BoundingBoxTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify({ data: { bbox } }, null, 2) + text: JSON.stringify({ bbox }, null, 2) } ], - structuredContent: { - data: { bbox } - }, + structuredContent: { bbox }, isError: false }; } diff --git a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts index c2f981c..57c2dc6 100644 --- a/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts +++ b/src/tools/bounding-box-tool/CountryBoundingBoxTool.ts @@ -61,12 +61,10 @@ export class CountryBoundingBoxTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify({ data: { bbox } }, null, 2) + text: JSON.stringify({ bbox }, null, 2) } ], - structuredContent: { - data: { bbox } - }, + structuredContent: { bbox }, isError: false }; } diff --git a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts index 5fdaaba..c14f5a0 100644 --- a/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts +++ b/src/tools/coordinate-conversion-tool/CoordinateConversionTool.ts @@ -52,13 +52,11 @@ export class CoordinateConversionTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify({ data: outputResult }, null, 2) + text: JSON.stringify(outputResult, null, 2) } ], isError: false, - structuredContent: { - data: outputResult - } + structuredContent: outputResult }; } @@ -109,13 +107,11 @@ export class CoordinateConversionTool extends BaseTool< content: [ { type: 'text', - text: JSON.stringify({ data: outputResult }, null, 2) + text: JSON.stringify(outputResult, null, 2) } ], isError: false, - structuredContent: { - data: outputResult - } + structuredContent: outputResult }; } diff --git a/src/tools/create-style-tool/CreateStyleTool.ts b/src/tools/create-style-tool/CreateStyleTool.ts index 8ffca9d..edf0f90 100644 --- a/src/tools/create-style-tool/CreateStyleTool.ts +++ b/src/tools/create-style-tool/CreateStyleTool.ts @@ -76,16 +76,10 @@ export class CreateStyleTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify( - { data: filterExpandedMapboxStyles(data) }, - null, - 2 - ) + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) } ], - structuredContent: { - data: filterExpandedMapboxStyles(data) - }, + structuredContent: filterExpandedMapboxStyles(data), isError: false }; } diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index b90041d..55f2726 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -124,12 +124,10 @@ export class CreateTokenTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify({ data: parseResult.data }, null, 2) + text: JSON.stringify(parseResult.data, null, 2) } ], - structuredContent: { - data: parseResult.data - }, + structuredContent: parseResult.data, isError: false }; } diff --git a/src/tools/list-styles-tool/ListStylesTool.output.schema.ts b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts index 1092acf..0129109 100644 --- a/src/tools/list-styles-tool/ListStylesTool.output.schema.ts +++ b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts @@ -40,6 +40,13 @@ const StyleMetadataSchema = z }) .passthrough(); // Allow additional fields from API -export const ListStylesOutputSchema = z.array(StyleMetadataSchema); +// API returns an array of styles +const StylesArraySchema = z.array(StyleMetadataSchema); + +// But structuredContent wraps it in an object +export const ListStylesOutputSchema = z.object({ + styles: StylesArraySchema +}); export type ListStylesOutput = z.infer; +export { StylesArraySchema }; diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index 7be9b56..ca30823 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -8,7 +8,10 @@ import { ListStylesSchema, ListStylesInput } from './ListStylesTool.input.schema.js'; -import { ListStylesOutputSchema } from './ListStylesTool.output.schema.js'; +import { + ListStylesOutputSchema, + StylesArraySchema +} from './ListStylesTool.output.schema.js'; import { getUserNameFromToken } from '../../utils/jwtUtils.js'; export class ListStylesTool extends MapboxApiBasedTool< @@ -64,7 +67,8 @@ export class ListStylesTool extends MapboxApiBasedTool< } const data = await response.json(); - const parseResult = ListStylesOutputSchema.safeParse(data); + // Validate the API response (which is an array) + const parseResult = StylesArraySchema.safeParse(data); if (!parseResult.success) { this.log( 'error', @@ -86,12 +90,11 @@ export class ListStylesTool extends MapboxApiBasedTool< content: [ { type: 'text' as const, - text: JSON.stringify({ data: parseResult.data }, null, 2) + text: JSON.stringify(parseResult.data, null, 2) } ], - structuredContent: { - data: parseResult.data - }, + // Wrap the array in an object for structuredContent + structuredContent: { styles: parseResult.data }, isError: false }; } diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index c4f1446..75e8b9d 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -217,12 +217,10 @@ export class ListTokensTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify({ data: result }, null, 2) + text: JSON.stringify(result, null, 2) } ], - structuredContent: { - data: result - }, + structuredContent: result, isError: false }; } catch (error) { diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts index af5a24e..d8454aa 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts @@ -70,16 +70,10 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify( - { data: filterExpandedMapboxStyles(data) }, - null, - 2 - ) + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) } ], - structuredContent: { - data: filterExpandedMapboxStyles(data) - }, + structuredContent: filterExpandedMapboxStyles(data), isError: false }; } diff --git a/src/tools/tilequery-tool/TilequeryTool.ts b/src/tools/tilequery-tool/TilequeryTool.ts index 029f2a6..ab34045 100644 --- a/src/tools/tilequery-tool/TilequeryTool.ts +++ b/src/tools/tilequery-tool/TilequeryTool.ts @@ -121,12 +121,10 @@ export class TilequeryTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify({ data }, null, 2) + text: JSON.stringify(data, null, 2) } ], - structuredContent: { - data - }, + structuredContent: data, isError: false }; } diff --git a/src/tools/update-style-tool/UpdateStyleTool.ts b/src/tools/update-style-tool/UpdateStyleTool.ts index e96c681..7f96023 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.ts @@ -80,16 +80,10 @@ export class UpdateStyleTool extends MapboxApiBasedTool< content: [ { type: 'text', - text: JSON.stringify( - { data: filterExpandedMapboxStyles(data) }, - null, - 2 - ) + text: JSON.stringify(filterExpandedMapboxStyles(data), null, 2) } ], - structuredContent: { - data: filterExpandedMapboxStyles(data) - }, + structuredContent: filterExpandedMapboxStyles(data), isError: false }; } diff --git a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts index 4d30855..747e4f1 100644 --- a/test/tools/bounding-box-tool/BoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/BoundingBoxTool.test.ts @@ -26,7 +26,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: { bbox: [-73.9857, 40.7484, -73.9857, 40.7484] } + bbox: [-73.9857, 40.7484, -73.9857, 40.7484] }); }); @@ -42,7 +42,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: { bbox: [-73.9857, 40.7484, -73.9857, 40.7484] } + bbox: [-73.9857, 40.7484, -73.9857, 40.7484] }); }); @@ -62,7 +62,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: { bbox: [-73.9919, 40.7484, -73.9857, 40.7614] } + bbox: [-73.9919, 40.7484, -73.9857, 40.7614] }); }); @@ -86,7 +86,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: { bbox: [-73.9919, 40.7484, -73.9857, 40.7614] } + bbox: [-73.9919, 40.7484, -73.9857, 40.7614] }); }); @@ -119,7 +119,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: { bbox: [-74.006, 40.7128, -73.9857, 40.7484] } + bbox: [-74.006, 40.7128, -73.9857, 40.7484] }); }); @@ -139,7 +139,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: { bbox: [-74.006, 40.7128, -73.9352, 40.7484] } + bbox: [-74.006, 40.7128, -73.9352, 40.7484] }); }); @@ -174,7 +174,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: { bbox: [0, 0, 3, 3] } + bbox: [0, 0, 3, 3] }); }); @@ -202,7 +202,7 @@ describe('BoundingBoxTool', () => { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; expect(JSON.parse(textContent.text)).toEqual({ - data: { bbox: [-74.006, 40.7128, -73.9352, 40.7484] } + bbox: [-74.006, 40.7128, -73.9352, 40.7484] }); }); diff --git a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts index 2d2693e..1245d74 100644 --- a/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts +++ b/test/tools/bounding-box-tool/CountryBoundingBoxTool.test.ts @@ -22,7 +22,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: { bbox: [73.599819, 21.144707, 134.762115, 53.424591] } + bbox: [73.599819, 21.144707, 134.762115, 53.424591] }); }); @@ -34,7 +34,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: { bbox: [-168.069693, 25.133463, -67.292669, 71.284212] } + bbox: [-168.069693, 25.133463, -67.292669, 71.284212] }); }); @@ -46,7 +46,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: { bbox: [51.590737, 22.705773, 56.376954, 26.050548] } + bbox: [51.590737, 22.705773, 56.376954, 26.050548] }); }); @@ -58,7 +58,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: { bbox: [73.599819, 21.144707, 134.762115, 53.424591] } + bbox: [73.599819, 21.144707, 134.762115, 53.424591] }); }); @@ -70,7 +70,7 @@ describe('CountryBoundingBoxTool', () => { const textContent = result.content[0] as TextContent; const bbox = JSON.parse(textContent.text); expect(bbox).toEqual({ - data: { bbox: [-168.069693, 25.133463, -67.292669, 71.284212] } + bbox: [-168.069693, 25.133463, -67.292669, 71.284212] }); }); @@ -175,7 +175,7 @@ describe('CountryBoundingBoxTool', () => { if (!result.isError) { expect(result.content[0]).toHaveProperty('type', 'text'); const textContent = result.content[0] as TextContent; - const bbox = JSON.parse(textContent.text).data.bbox; + const bbox = JSON.parse(textContent.text).bbox; expect(Array.isArray(bbox)).toBe(true); expect(bbox).toHaveLength(4); } diff --git a/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts b/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts index a5400cc..a45c60d 100644 --- a/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts +++ b/test/tools/coordinate-conversion-tool/CoordinateConversionTool.test.ts @@ -41,7 +41,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ).data; + ); expect(parsed.input).toEqual([-74.006, 40.7128]); expect(parsed.from).toBe('wgs84'); expect(parsed.to).toBe('epsg3857'); @@ -65,7 +65,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ).data; + ); const [x, y] = parsed.output; expect(x).toBeCloseTo(0, 6); expect(y).toBeCloseTo(0, 6); @@ -84,7 +84,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ).data; + ); const [x, y] = parsed.output; expect(x).toBeCloseTo(0, 6); expect(y).toBeGreaterThan(19000000); // Should be a very large Y value @@ -135,7 +135,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ).data; + ); const [lon, lat] = parsed.output; expect(lon).toBeCloseTo(-74.006, 3); expect(lat).toBeCloseTo(40.7128, 3); @@ -154,7 +154,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ).data; + ); const [lon, lat] = parsed.output; expect(lon).toBeCloseTo(0, 6); expect(lat).toBeCloseTo(0, 6); @@ -192,7 +192,7 @@ describe('CoordinateConversionTool', () => { const mercatorCoords = JSON.parse( (toMercator.content[0] as { type: 'text'; text: string }).text - ).data.output; + ).output; // Convert EPSG:3857 -> WGS84 const backToWgs84 = await tool.run({ @@ -203,7 +203,7 @@ describe('CoordinateConversionTool', () => { const finalCoords = JSON.parse( (backToWgs84.content[0] as { type: 'text'; text: string }).text - ).data.output; + ).output; expect(finalCoords[0]).toBeCloseTo(originalCoords[0], 6); expect(finalCoords[1]).toBeCloseTo(originalCoords[1], 6); @@ -224,7 +224,7 @@ describe('CoordinateConversionTool', () => { const parsed = JSON.parse( (result.content[0] as { type: 'text'; text: string }).text - ).data; + ); expect(parsed.input).toEqual([-74.006, 40.7128]); expect(parsed.output).toEqual([-74.006, 40.7128]); expect(parsed.message).toContain('No conversion needed'); diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index 79734e3..5b61423 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -169,9 +169,7 @@ describe('CreateTokenTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData).toMatchObject({ token: mockResponse.token, note: mockResponse.note, @@ -225,9 +223,7 @@ describe('CreateTokenTool', () => { }); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.allowedUrls).toEqual(mockResponse.allowedUrls); // Verify the request body included allowedUrls @@ -267,9 +263,7 @@ describe('CreateTokenTool', () => { }); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.expires).toEqual(expiresAt); // Verify the request body included expires diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index 365fa9f..f0cd534 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -198,10 +198,18 @@ describe('ListStylesTool', () => { const content = result.content[0]; if (content.type === 'text') { - const parsedResponse = JSON.parse(content.text).data; + const parsedResponse = JSON.parse(content.text); expect(parsedResponse).toEqual(mockStyles); } + // Verify structuredContent has the expected shape + if (result.structuredContent) { + expect(result.structuredContent).toHaveProperty('styles'); + expect( + (result.structuredContent as { styles: unknown[] }).styles + ).toEqual(mockStyles); + } + assertHeadersSent(mockHttpRequest); }); @@ -241,7 +249,7 @@ describe('ListStylesTool', () => { const content = result.content[0]; if (content.type === 'text') { - const parsedResponse = JSON.parse(content.text).data; + const parsedResponse = JSON.parse(content.text); expect(parsedResponse).toHaveLength(1); expect(parsedResponse[0]).toMatchObject({ id: 'ck9tnguii0ipm1ipf54wqhhwm', diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index 5c12df7..ac20078 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -163,9 +163,7 @@ describe('ListTokensTool', () => { expect(result.isError).toBe(false); expect(result.content[0]).toHaveProperty('type', 'text'); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(2); expect(responseData.count).toBe(2); expect(responseData.tokens[0].id).toBe('cktest123'); @@ -213,9 +211,7 @@ describe('ListTokensTool', () => { const result = await tool.run({ default: true }); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(1); expect(responseData.tokens[0].default).toBe(true); @@ -261,9 +257,7 @@ describe('ListTokensTool', () => { }); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(1); // Verify all parameters were included in the request @@ -305,9 +299,7 @@ describe('ListTokensTool', () => { const result = await tool.run({ limit: 10 }); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(1); expect(responseData.next_start).toBe('cktest999'); }); @@ -343,9 +335,7 @@ describe('ListTokensTool', () => { const result = await tool.run({ start: 'cktest789' }); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(1); expect(responseData.next_start).toBe('cktest999'); }); @@ -394,9 +384,7 @@ describe('ListTokensTool', () => { const result = await tool.run({}); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(1); expect(responseData.next_start).toBeUndefined(); }); @@ -427,9 +415,7 @@ describe('ListTokensTool', () => { const result = await tool.run({ limit: 10 }); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(1); expect(responseData.next_start).toBeUndefined(); }); @@ -460,9 +446,7 @@ describe('ListTokensTool', () => { const result = await tool.run({ usage: 'pk' }); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(1); expect(responseData.tokens[0].usage).toBe('pk'); @@ -566,9 +550,7 @@ describe('ListTokensTool', () => { const result = await tool.run({}); expect(result.isError).toBe(false); - const responseData = JSON.parse( - (result.content[0] as TextContent).text - ).data; + const responseData = JSON.parse((result.content[0] as TextContent).text); expect(responseData.tokens).toHaveLength(1); expect(responseData.count).toBe(1); }); diff --git a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts index 33d0d71..4d0858b 100644 --- a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts +++ b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts @@ -50,7 +50,7 @@ describe('RetrieveStyleTool', () => { expect(result.content[0]).toMatchObject({ type: 'text', - text: JSON.stringify({ data: styleData }, null, 2) + text: JSON.stringify(styleData, null, 2) }); assertHeadersSent(mockHttpRequest); }); @@ -127,7 +127,7 @@ describe('RetrieveStyleTool', () => { const content = result.content[0]; if (content.type === 'text') { - const parsedResponse = JSON.parse(content.text).data; + const parsedResponse = JSON.parse(content.text); expect(parsedResponse.terrain).toBeNull(); expect(parsedResponse.fog).toBeNull(); expect(parsedResponse.lights).toBeNull(); From 7f423a3a686828f198444a57b570ff45dbb5f19c Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 20 Oct 2025 14:15:32 -0400 Subject: [PATCH 7/9] [tools] Update tools to use structuredContent with schema --- src/schemas/style.ts | 397 ++++++++---------- src/tools/BaseTool.ts | 2 +- .../CreateTokenTool.output.schema.ts | 29 +- .../ListStylesTool.output.schema.ts | 60 ++- src/tools/list-styles-tool/ListStylesTool.ts | 6 +- .../ListTokensTool.output.schema.ts | 33 +- .../RetrieveStyleTool.output.schema.ts | 6 +- .../TilequeryTool.output.schema.ts | 10 +- .../create-token-tool/CreateTokenTool.test.ts | 8 +- .../list-styles-tool/ListStylesTool.test.ts | 10 +- .../list-tokens-tool/ListTokensTool.test.ts | 10 + 11 files changed, 296 insertions(+), 275 deletions(-) diff --git a/src/schemas/style.ts b/src/schemas/style.ts index 7387a4d..88fcb0e 100644 --- a/src/schemas/style.ts +++ b/src/schemas/style.ts @@ -10,136 +10,111 @@ const ColorSchema = z const CoordinatesSchema = z.tuple([z.number(), z.number()]); // Transition schema -const TransitionSchema = z - .object({ - duration: z.number().optional(), - delay: z.number().optional() - }) - .passthrough(); +const TransitionSchema = z.object({ + duration: z.number().optional(), + delay: z.number().optional() +}); // Light schema -const LightSchema = z - .object({ - anchor: z.enum(['map', 'viewport']).optional(), - position: z.tuple([z.number(), z.number(), z.number()]).optional(), - color: ColorSchema.optional(), - intensity: z.number().optional() - }) - .passthrough(); +const LightSchema = z.object({ + anchor: z.enum(['map', 'viewport']).optional(), + position: z.tuple([z.number(), z.number(), z.number()]).optional(), + color: ColorSchema.optional(), + intensity: z.number().optional() +}); // Lights (3D) schema const LightsSchema = z.array( - z - .object({ - id: z.string(), - type: z.enum(['ambient', 'directional']) - }) - .passthrough() + z.object({ + id: z.string(), + type: z.enum(['ambient', 'directional']), + properties: z.record(z.any()).optional() + }) ); // Terrain schema -const TerrainSchema = z - .object({ - source: z.string(), - exaggeration: z.number().optional() - }) - .passthrough(); +const TerrainSchema = z.object({ + source: z.string(), + exaggeration: z.number().optional() +}); // Source schemas -const VectorSourceSchema = z - .object({ - type: z.literal('vector'), - url: z.string().optional(), - tiles: z.array(z.string()).optional(), - bounds: z - .tuple([z.number(), z.number(), z.number(), z.number()]) - .optional(), - scheme: z.enum(['xyz', 'tms']).optional(), - minzoom: z.number().min(0).max(22).optional(), - maxzoom: z.number().min(0).max(22).optional(), - attribution: z.string().optional(), - promoteId: z.union([z.string(), z.record(z.string())]).optional(), - volatile: z.boolean().optional() - }) - .passthrough(); - -const RasterSourceSchema = z - .object({ - type: z.literal('raster'), - url: z.string().optional(), - tiles: z.array(z.string()).optional(), - bounds: z - .tuple([z.number(), z.number(), z.number(), z.number()]) - .optional(), - minzoom: z.number().min(0).max(22).optional(), - maxzoom: z.number().min(0).max(22).optional(), - tileSize: z.number().optional(), - scheme: z.enum(['xyz', 'tms']).optional(), - attribution: z.string().optional(), - volatile: z.boolean().optional() - }) - .passthrough(); - -const RasterDemSourceSchema = z - .object({ - type: z.literal('raster-dem'), - url: z.string().optional(), - tiles: z.array(z.string()).optional(), - bounds: z - .tuple([z.number(), z.number(), z.number(), z.number()]) - .optional(), - minzoom: z.number().min(0).max(22).optional(), - maxzoom: z.number().min(0).max(22).optional(), - tileSize: z.number().optional(), - attribution: z.string().optional(), - encoding: z.enum(['terrarium', 'mapbox']).optional(), - volatile: z.boolean().optional() - }) - .passthrough(); - -const GeoJSONSourceSchema = z - .object({ - type: z.literal('geojson'), - data: z.union([z.string(), z.any()]), // URL or inline GeoJSON - maxzoom: z.number().min(0).max(24).optional(), - attribution: z.string().optional(), - buffer: z.number().min(0).max(512).optional(), - tolerance: z.number().optional(), - cluster: z.boolean().optional(), - clusterRadius: z.number().optional(), - clusterMaxZoom: z.number().optional(), - clusterProperties: z.record(z.any()).optional(), - lineMetrics: z.boolean().optional(), - generateId: z.boolean().optional(), - promoteId: z.union([z.string(), z.record(z.string())]).optional() - }) - .passthrough(); - -const ImageSourceSchema = z - .object({ - type: z.literal('image'), - url: z.string(), - coordinates: z.tuple([ - CoordinatesSchema, - CoordinatesSchema, - CoordinatesSchema, - CoordinatesSchema - ]) - }) - .passthrough(); - -const VideoSourceSchema = z - .object({ - type: z.literal('video'), - urls: z.array(z.string()), - coordinates: z.tuple([ - CoordinatesSchema, - CoordinatesSchema, - CoordinatesSchema, - CoordinatesSchema - ]) - }) - .passthrough(); +const VectorSourceSchema = z.object({ + type: z.literal('vector'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + scheme: z.enum(['xyz', 'tms']).optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + attribution: z.string().optional(), + promoteId: z.union([z.string(), z.record(z.string())]).optional(), + volatile: z.boolean().optional() +}); + +const RasterSourceSchema = z.object({ + type: z.literal('raster'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + tileSize: z.number().optional(), + scheme: z.enum(['xyz', 'tms']).optional(), + attribution: z.string().optional(), + volatile: z.boolean().optional() +}); + +const RasterDemSourceSchema = z.object({ + type: z.literal('raster-dem'), + url: z.string().optional(), + tiles: z.array(z.string()).optional(), + bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + minzoom: z.number().min(0).max(22).optional(), + maxzoom: z.number().min(0).max(22).optional(), + tileSize: z.number().optional(), + attribution: z.string().optional(), + encoding: z.enum(['terrarium', 'mapbox']).optional(), + volatile: z.boolean().optional() +}); + +const GeoJSONSourceSchema = z.object({ + type: z.literal('geojson'), + data: z.union([z.string(), z.any()]), // URL or inline GeoJSON + maxzoom: z.number().min(0).max(24).optional(), + attribution: z.string().optional(), + buffer: z.number().min(0).max(512).optional(), + tolerance: z.number().optional(), + cluster: z.boolean().optional(), + clusterRadius: z.number().optional(), + clusterMaxZoom: z.number().optional(), + clusterProperties: z.record(z.any()).optional(), + lineMetrics: z.boolean().optional(), + generateId: z.boolean().optional(), + promoteId: z.union([z.string(), z.record(z.string())]).optional() +}); + +const ImageSourceSchema = z.object({ + type: z.literal('image'), + url: z.string(), + coordinates: z.tuple([ + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema + ]) +}); + +const VideoSourceSchema = z.object({ + type: z.literal('video'), + urls: z.array(z.string()), + coordinates: z.tuple([ + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema, + CoordinatesSchema + ]) +}); const SourceSchema = z.union([ VectorSourceSchema, @@ -151,104 +126,98 @@ const SourceSchema = z.union([ ]); // Layer schema (simplified - full schema would be very extensive) -const LayerSchema = z - .object({ - id: z.string().describe('Unique layer name'), - type: z.enum([ - 'fill', - 'line', - 'symbol', - 'circle', - 'heatmap', - 'fill-extrusion', - 'raster', - 'hillshade', - 'background', - 'sky', - 'slot', - 'clip', - 'model', - 'raster-particle', - 'building' - ]), - source: z - .string() - .optional() - .describe('Source name (not required for background/sky/slot)'), - 'source-layer': z - .string() - .optional() - .describe('Layer from vector tile source'), - minzoom: z.number().min(0).max(24).optional(), - maxzoom: z.number().min(0).max(24).optional(), - filter: z.any().optional().describe('Expression for filtering features'), - layout: z.record(z.any()).optional(), - paint: z.record(z.any()).optional(), - metadata: z.record(z.any()).optional(), - slot: z.string().optional().describe('Slot this layer is assigned to') - }) - .passthrough(); +const LayerSchema = z.object({ + id: z.string().describe('Unique layer name'), + type: z.enum([ + 'fill', + 'line', + 'symbol', + 'circle', + 'heatmap', + 'fill-extrusion', + 'raster', + 'hillshade', + 'background', + 'sky', + 'slot', + 'clip', + 'model', + 'raster-particle', + 'building' + ]), + source: z + .string() + .optional() + .describe('Source name (not required for background/sky/slot)'), + 'source-layer': z + .string() + .optional() + .describe('Layer from vector tile source'), + minzoom: z.number().min(0).max(24).optional(), + maxzoom: z.number().min(0).max(24).optional(), + filter: z.any().optional().describe('Expression for filtering features'), + layout: z.record(z.any()).optional(), + paint: z.record(z.any()).optional(), + metadata: z.record(z.any()).optional(), + slot: z.string().optional().describe('Slot this layer is assigned to') +}); // Style import schema -const StyleImportSchema = z - .object({ - id: z.string(), - url: z.string(), - config: z.record(z.any()).optional() - }) - .passthrough(); +const StyleImportSchema = z.object({ + id: z.string(), + url: z.string(), + config: z.record(z.any()).optional() +}); // Base Style properties (shared between input and output) -export const BaseStylePropertiesSchema = z - .object({ - // Required Style Spec properties - version: z - .literal(8) - .describe('Style specification version number. Must be 8'), - sources: z.record(SourceSchema).describe('Data source specifications'), - layers: z.array(LayerSchema).describe('Layers in draw order'), - - // Optional Style Spec properties - metadata: z - .record(z.any()) - .optional() - .describe('Arbitrary properties for tracking'), - center: CoordinatesSchema.optional().describe( - 'Default map center [longitude, latitude]' - ), - zoom: z.number().optional().describe('Default zoom level'), - bearing: z.number().optional().describe('Default bearing in degrees'), - pitch: z.number().optional().describe('Default pitch in degrees'), - sprite: z - .string() - .optional() - .describe('Base URL for sprite image and metadata'), - glyphs: z.string().optional().describe('URL template for glyph sets'), - light: LightSchema.optional() - .nullable() - .describe('Global light source (deprecated, use lights)'), - lights: LightsSchema.optional() - .nullable() - .describe('Array of 3D light sources'), - terrain: TerrainSchema.optional() - .nullable() - .describe('Global terrain elevation'), - fog: z.record(z.any()).optional().nullable().describe('Fog properties'), - projection: z - .record(z.any()) - .optional() - .nullable() - .describe('Map projection'), - transition: TransitionSchema.optional() - .nullable() - .describe('Default transition timing'), - imports: z - .array(StyleImportSchema) - .optional() - .nullable() - .describe('Imported styles') - }) - .passthrough(); +export const BaseStylePropertiesSchema = z.object({ + // Required Style Spec properties + version: z + .literal(8) + .describe('Style specification version number. Must be 8'), + sources: z.record(SourceSchema).describe('Data source specifications'), + layers: z.array(LayerSchema).describe('Layers in draw order'), + + // Optional Style Spec properties + metadata: z + .record(z.any()) + .optional() + .describe('Arbitrary properties for tracking'), + center: CoordinatesSchema.optional().describe( + 'Default map center [longitude, latitude]' + ), + zoom: z.number().optional().describe('Default zoom level'), + bearing: z.number().optional().describe('Default bearing in degrees'), + pitch: z.number().optional().describe('Default pitch in degrees'), + sprite: z + .string() + .optional() + .describe('Base URL for sprite image and metadata'), + glyphs: z.string().optional().describe('URL template for glyph sets'), + light: LightSchema.optional() + .nullable() + .describe('Global light source (deprecated, use lights)'), + lights: LightsSchema.optional() + .nullable() + .describe('Array of 3D light sources'), + terrain: TerrainSchema.optional() + .nullable() + .describe('Global terrain elevation'), + fog: z.record(z.any()).optional().nullable().describe('Fog properties'), + projection: z + .record(z.any()) + .optional() + .nullable() + .describe('Map projection'), + transition: TransitionSchema.optional() + .nullable() + .describe('Default transition timing'), + imports: z + .array(StyleImportSchema) + .optional() + .nullable() + .describe('Imported styles') +}); export type MapboxSource = z.infer; export type MapboxLayer = z.infer; diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 585a3a7..d1d499b 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -83,7 +83,7 @@ export abstract class BaseTool< // Add outputSchema if provided if (this.outputSchema) { // Pass the schema shape directly - don't wrap - // The MCP SDK will validate structuredContent.data against this schema + // The MCP SDK will validate structuredContent against this schema config.outputSchema = // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.outputSchema as unknown as z.ZodObject).shape; diff --git a/src/tools/create-token-tool/CreateTokenTool.output.schema.ts b/src/tools/create-token-tool/CreateTokenTool.output.schema.ts index 45f718e..370d0f4 100644 --- a/src/tools/create-token-tool/CreateTokenTool.output.schema.ts +++ b/src/tools/create-token-tool/CreateTokenTool.output.schema.ts @@ -4,17 +4,28 @@ import { z } from 'zod'; export const CreateTokenOutputSchema = z.object({ - id: z.string().describe('Token ID'), - scopes: z.array(z.string()).describe('Array of scopes assigned to the token'), - token: z.string().describe('The actual token string'), + id: z.string().describe("The token's unique identifier"), + usage: z + .enum(['pk', 'sk', 'tk']) + .describe('Token usage type: pk (public), sk (secret), or tk (temporary)'), + client: z.string().describe('The client for the token'), + default: z.boolean().describe('Whether this is the default token'), + scopes: z.array(z.string()).describe('Array of scopes granted to the token'), + note: z + .string() + .nullable() + .describe('Human-readable description of the token'), created: z.string().describe('ISO 8601 creation timestamp'), modified: z.string().describe('ISO 8601 last modified timestamp'), - usage: z.string().describe('Token usage type, e.g. pk or sk'), - default: z.boolean().describe('Whether this is the default token'), - note: z.string().optional().describe('Optional note or description'), - allowedUrls: z.array(z.string()).optional().describe('Array of allowed URLs'), - expires: z.string().optional().describe('Expiration time in ISO 8601 format'), - message: z.string().optional().describe('Status or error message') + allowedUrls: z + .array(z.string()) + .optional() + .describe('URLs that the token is restricted to'), + token: z.string().describe('The actual access token string'), + expires: z + .string() + .optional() + .describe('Expiration time in ISO 8601 format (temporary tokens only)') }); export type CreateTokenOutput = z.infer; diff --git a/src/tools/list-styles-tool/ListStylesTool.output.schema.ts b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts index 0129109..3c73b6d 100644 --- a/src/tools/list-styles-tool/ListStylesTool.output.schema.ts +++ b/src/tools/list-styles-tool/ListStylesTool.output.schema.ts @@ -8,37 +8,35 @@ import { z } from 'zod'; * Note: This is different from a full style specification - it contains * metadata about the style but may not include all style properties like layers. */ -const StyleMetadataSchema = z - .object({ - // Core metadata fields always present - id: z.string().describe('Unique style ID'), - name: z.string().describe('Style name'), - owner: z.string().describe('Username of the style owner'), - created: z.string().describe('ISO 8601 timestamp of creation'), - modified: z.string().describe('ISO 8601 timestamp of last modification'), - visibility: z - .enum(['public', 'private']) - .describe('Style visibility setting'), - - // Optional Style Spec fields that may be included - version: z.literal(8).optional().describe('Style specification version'), - center: z - .tuple([z.number(), z.number()]) - .optional() - .describe('Default center [longitude, latitude]'), - zoom: z.number().optional().describe('Default zoom level'), - bearing: z.number().optional().describe('Default bearing in degrees'), - pitch: z.number().optional().describe('Default pitch in degrees'), - - // Sources and layers may or may not be included in list responses - sources: z.record(z.any()).optional().describe('Style data sources'), - layers: z.array(z.any()).optional().describe('Style layers'), - - // Additional metadata fields - protected: z.boolean().optional().describe('Whether style is protected'), - draft: z.boolean().optional().describe('Whether style is a draft') - }) - .passthrough(); // Allow additional fields from API +const StyleMetadataSchema = z.object({ + // Core metadata fields always present + id: z.string().describe('Unique style ID'), + name: z.string().describe('Style name'), + owner: z.string().describe('Username of the style owner'), + created: z.string().describe('ISO 8601 timestamp of creation'), + modified: z.string().describe('ISO 8601 timestamp of last modification'), + visibility: z + .enum(['public', 'private']) + .describe('Style visibility setting'), + + // Optional Style Spec fields that may be included + version: z.literal(8).optional().describe('Style specification version'), + center: z + .tuple([z.number(), z.number()]) + .optional() + .describe('Default center [longitude, latitude]'), + zoom: z.number().optional().describe('Default zoom level'), + bearing: z.number().optional().describe('Default bearing in degrees'), + pitch: z.number().optional().describe('Default pitch in degrees'), + + // Sources and layers may or may not be included in list responses + sources: z.record(z.any()).optional().describe('Style data sources'), + layers: z.array(z.any()).optional().describe('Style layers'), + + // Additional metadata fields + protected: z.boolean().optional().describe('Whether style is protected'), + draft: z.boolean().optional().describe('Whether style is a draft') +}); // API returns an array of styles const StylesArraySchema = z.array(StyleMetadataSchema); diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index ca30823..a32e6c2 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -86,15 +86,15 @@ export class ListStylesTool extends MapboxApiBasedTool< } this.log('info', `ListStylesTool: Successfully listed styles`); + const wrappedData = { styles: parseResult.data }; return { content: [ { type: 'text' as const, - text: JSON.stringify(parseResult.data, null, 2) + text: JSON.stringify(wrappedData, null, 2) } ], - // Wrap the array in an object for structuredContent - structuredContent: { styles: parseResult.data }, + structuredContent: wrappedData, isError: false }; } diff --git a/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts b/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts index 3ee729c..15c5568 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.output.schema.ts @@ -4,16 +4,33 @@ import { z } from 'zod'; export const TokenObjectSchema = z.object({ - id: z.string().describe('Token ID'), - scopes: z.array(z.string()).describe('Array of scopes assigned to the token'), - token: z.string().describe('The actual token string'), + id: z.string().describe("The token's unique identifier"), + usage: z + .enum(['pk', 'sk', 'tk']) + .describe('Token usage type: pk (public), sk (secret), or tk (temporary)'), + client: z.string().describe('The client for the token'), + default: z.boolean().describe('Whether this is the default token'), + scopes: z.array(z.string()).describe('Array of scopes granted to the token'), + note: z + .string() + .nullable() + .describe('Human-readable description of the token'), created: z.string().describe('ISO 8601 creation timestamp'), modified: z.string().describe('ISO 8601 last modified timestamp'), - usage: z.string().describe('Token usage type, e.g. pk or sk'), - default: z.boolean().describe('Whether this is the default token'), - note: z.string().optional().describe('Optional note or description'), - allowedUrls: z.array(z.string()).optional().describe('Array of allowed URLs'), - expires: z.string().optional().describe('Expiration time in ISO 8601 format') + allowedUrls: z + .array(z.string()) + .optional() + .describe('URLs that the token is restricted to'), + token: z + .string() + .optional() + .describe( + 'The actual access token string (omitted for secret tokens in list responses)' + ), + expires: z + .string() + .optional() + .describe('Expiration time in ISO 8601 format (temporary tokens only)') }); export const ListTokensOutputSchema = z.object({ diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts index 91a2602..dc5f0ce 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.output.schema.ts @@ -23,8 +23,12 @@ export const MapboxStyleOutputSchema = BaseStylePropertiesSchema.extend({ visibility: z .enum(['public', 'private']) .describe('Style visibility setting'), + protected: z + .boolean() + .optional() + .describe('Whether style is protected from modifications'), draft: z.boolean().optional().describe('Whether this is a draft version') -}).passthrough(); +}); // Type exports export type MapboxStyleOutput = z.infer; diff --git a/src/tools/tilequery-tool/TilequeryTool.output.schema.ts b/src/tools/tilequery-tool/TilequeryTool.output.schema.ts index 22c948c..d4b3c86 100644 --- a/src/tools/tilequery-tool/TilequeryTool.output.schema.ts +++ b/src/tools/tilequery-tool/TilequeryTool.output.schema.ts @@ -9,7 +9,7 @@ const CoordinatesSchema = z.tuple([z.number(), z.number()]); // Vector Tileset Feature Schema const VectorTilequeryFeatureSchema = z.object({ type: z.literal('Feature'), - id: z.union([z.string(), z.number()]), + id: z.string().describe('Feature identifier'), geometry: z.object({ type: z.literal('Point'), coordinates: CoordinatesSchema @@ -30,7 +30,7 @@ const VectorTilequeryFeatureSchema = z.object({ .describe('The vector tile layer of the feature result') }) }) - .passthrough() // Allow additional properties from the original feature + .and(z.record(z.any())) // Allow additional properties from the original feature }); // Rasterarray Tileset Feature Schema @@ -50,7 +50,11 @@ const RasterarrayTilequeryFeatureSchema = z.object({ .describe('The maxzoom level at which the point value was extracted'), units: z.string().describe('The unit of measurement for the point value') }), - val: z.number().describe('Point value at the requested location') + val: z + .union([z.number(), z.array(z.number())]) + .describe( + 'Point value at the requested location (number for single-band, array for multi-dimensional data)' + ) }) }); diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index 5b61423..bb4aa86 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -151,6 +151,7 @@ describe('CreateTokenTool', () => { created: '2024-01-01T00:00:00.000Z', modified: '2024-01-01T00:00:00.000Z', usage: 'pk', + client: 'api', default: false }; @@ -206,6 +207,7 @@ describe('CreateTokenTool', () => { modified: '2024-01-01T00:00:00.000Z', allowedUrls: ['https://example.com', 'https://app.example.com'], usage: 'pk', + client: 'api', default: false }; @@ -246,6 +248,7 @@ describe('CreateTokenTool', () => { modified: '2024-01-01T00:00:00.000Z', expires: expiresAt, usage: 'pk', + client: 'api', default: false }; @@ -328,7 +331,10 @@ describe('CreateTokenTool', () => { id: 'cktest', scopes: ['styles:read'], created: '2024-01-01T00:00:00.000Z', - modified: '2024-01-01T00:00:00.000Z' + modified: '2024-01-01T00:00:00.000Z', + usage: 'pk', + client: 'api', + default: false }; const { mockHttpRequest, httpRequest } = setupHttpRequest({ diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index f0cd534..0f85d1f 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -199,7 +199,8 @@ describe('ListStylesTool', () => { const content = result.content[0]; if (content.type === 'text') { const parsedResponse = JSON.parse(content.text); - expect(parsedResponse).toEqual(mockStyles); + expect(parsedResponse).toHaveProperty('styles'); + expect(parsedResponse.styles).toEqual(mockStyles); } // Verify structuredContent has the expected shape @@ -250,8 +251,9 @@ describe('ListStylesTool', () => { const content = result.content[0]; if (content.type === 'text') { const parsedResponse = JSON.parse(content.text); - expect(parsedResponse).toHaveLength(1); - expect(parsedResponse[0]).toMatchObject({ + expect(parsedResponse).toHaveProperty('styles'); + expect(parsedResponse.styles).toHaveLength(1); + expect(parsedResponse.styles[0]).toMatchObject({ id: 'ck9tnguii0ipm1ipf54wqhhwm', name: 'Yahoo! Japan Streets', owner: 'svc-okta-mapbox-staff-access', @@ -260,7 +262,7 @@ describe('ListStylesTool', () => { protected: false }); // Verify layers field is not required - expect(parsedResponse[0].layers).toBeUndefined(); + expect(parsedResponse.styles[0].layers).toBeUndefined(); } assertHeadersSent(mockHttpRequest); diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index ac20078..d4fc7a8 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -132,6 +132,7 @@ describe('ListTokensTool', () => { id: 'cktest123', note: 'Default public token', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test123', scopes: ['styles:read', 'fonts:read'], created: '2023-01-01T00:00:00.000Z', @@ -142,6 +143,7 @@ describe('ListTokensTool', () => { id: 'cktest456', note: 'Secret token', usage: 'sk', + client: 'api', token: 'sk.eyJ1IjoidGVzdHVzZXIifQ.test456', scopes: ['styles:read', 'fonts:read', 'tokens:read'], created: '2023-02-01T00:00:00.000Z', @@ -192,6 +194,7 @@ describe('ListTokensTool', () => { id: 'ckdefault', note: 'Default public token', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.default', default: true, scopes: ['styles:read', 'fonts:read'], @@ -228,6 +231,7 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', @@ -273,6 +277,7 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', @@ -310,6 +315,7 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', @@ -346,6 +352,7 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', @@ -395,6 +402,7 @@ describe('ListTokensTool', () => { id: 'cktest789', note: 'Token 3', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test789', scopes: ['styles:read'], created: '2023-03-01T00:00:00.000Z', @@ -426,6 +434,7 @@ describe('ListTokensTool', () => { id: 'pktest123', note: 'Public token', usage: 'pk', + client: 'api', token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.pub123', scopes: ['styles:read'], created: '2023-04-01T00:00:00.000Z', @@ -530,6 +539,7 @@ describe('ListTokensTool', () => { id: 'cktest123', note: 'Test token', usage: 'pk', + client: 'api', token: 'pk.test', scopes: ['styles:read'], created: '2023-04-01T00:00:00.000Z', From 3d7abbc43d1d8b0619ac43bf63583a917223ab5c Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 21 Oct 2025 15:26:23 -0400 Subject: [PATCH 8/9] [tools] Update tools to use structuredContent with schema --- .../create-token-tool/CreateTokenTool.ts | 17 ++------------ src/tools/list-tokens-tool/ListTokensTool.ts | 15 +------------ src/tools/tilequery-tool/TilequeryTool.ts | 22 +------------------ 3 files changed, 4 insertions(+), 50 deletions(-) diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index 55f2726..12e1a08 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -84,21 +84,8 @@ export class CreateTokenTool extends MapboxApiBasedTool< body: JSON.stringify(body) }); - if (!response.ok) { - const errorBody = await response.text(); - this.log( - 'error', - `CreateTokenTool: API Error - Status: ${response.status}, Body: ${errorBody}` - ); - return { - content: [ - { - type: 'text', - text: `Failed to create token: ${response.status} ${response.statusText}` - } - ], - isError: true - }; + if (response.status !== 204) { + return this.handleApiError(response, 'create token'); } const data = await response.json(); diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index 75e8b9d..7a84d5d 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -118,20 +118,7 @@ export class ListTokensTool extends MapboxApiBasedTool< }); if (!response.ok) { - const errorBody = await response.text(); - this.log( - 'error', - `ListTokensTool: API Error - Status: ${response.status}, Body: ${errorBody}` - ); - return { - isError: true, - content: [ - { - type: 'text', - text: `Failed to list tokens: ${response.status} ${response.statusText}` - } - ] - }; + return this.handleApiError(response, 'list tokens'); } const data = await response.json(); diff --git a/src/tools/tilequery-tool/TilequeryTool.ts b/src/tools/tilequery-tool/TilequeryTool.ts index ab34045..3737293 100644 --- a/src/tools/tilequery-tool/TilequeryTool.ts +++ b/src/tools/tilequery-tool/TilequeryTool.ts @@ -74,27 +74,7 @@ export class TilequeryTool extends MapboxApiBasedTool< const response = await this.httpRequest(url.toString()); if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Tilequery request failed: ${response.status} ${response.statusText}. ${errorText}` - ); - } - - if (!response.ok) { - const errorBody = await response.text(); - this.log( - 'error', - `SearchAndGeocodeTool: API Error - Status: ${response.status}, Body: ${errorBody}` - ); - return { - content: [ - { - type: 'text', - text: `Failed to search: ${response.status} ${response.statusText}` - } - ], - isError: true - }; + return this.handleApiError(response, 'query tile'); } const rawData = await response.json(); From 25f5881092793c29014414c22a9e7cf6836bc006 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 21 Oct 2025 15:49:18 -0400 Subject: [PATCH 9/9] [tools] Update tools to use structuredContent with schema --- src/tools/create-token-tool/CreateTokenTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index 12e1a08..a32b47b 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -84,7 +84,7 @@ export class CreateTokenTool extends MapboxApiBasedTool< body: JSON.stringify(body) }); - if (response.status !== 204) { + if (!response.ok) { return this.handleApiError(response, 'create token'); }