From 250bcada04d706e57f3b033dcd4094bd2cf4f9df Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 12 Feb 2026 11:28:06 -0500 Subject: [PATCH 1/2] feat: add API request validator tool Add validate_api_request_tool to validate Mapbox API requests against endpoint definitions before sending them to the API. Features: - Validates required parameters are present - Checks parameter types (string, number, boolean, array, object) - Validates enum constraints for parameters with allowed values - Verifies token has required scopes for operations - Detects extra/unknown parameters with warnings - Returns detailed validation results with specific error messages - Helps prevent failed API calls by catching issues early Validation coverage: - Path, query, and body parameters - Type checking with clear error messages - Enum validation (e.g., routing profiles, geocoding modes) - Token scope verification - Case-insensitive API and operation names Implementation: - Uses endpoint definitions from mapboxApiEndpoints.ts (from PR #75) - Implements comprehensive validation logic - 19 test cases covering all validation scenarios - Proper MCP structured content with separated text and data - Updated tool registry and documentation This tool builds on top of explore_mapbox_api_tool by using the same endpoint definitions to provide validation capabilities. Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 8 + README.md | 18 + src/tools/toolRegistry.ts | 2 + .../ValidateApiRequestTool.input.schema.ts | 40 ++ .../ValidateApiRequestTool.output.schema.ts | 46 ++ .../ValidateApiRequestTool.ts | 416 ++++++++++++++++ .../tool-naming-convention.test.ts.snap | 5 + .../ValidateApiRequestTool.test.ts | 454 ++++++++++++++++++ 8 files changed, 989 insertions(+) create mode 100644 src/tools/validate-api-request-tool/ValidateApiRequestTool.input.schema.ts create mode 100644 src/tools/validate-api-request-tool/ValidateApiRequestTool.output.schema.ts create mode 100644 src/tools/validate-api-request-tool/ValidateApiRequestTool.ts create mode 100644 test/tools/validate-api-request-tool/ValidateApiRequestTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 88102b2..05c1b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ - Optional detailed mode includes example requests and responses - Complements `get_latest_mapbox_docs_tool` by providing structured API reference data - No API access required - works with curated endpoint definitions +- **API Request Validator Tool**: New `validate_api_request_tool` validates API requests before sending them + - Validates required parameters are present + - Checks parameter types (string, number, boolean, array, object) + - Validates enum constraints for parameters with allowed values + - Verifies token has required scopes for operations + - Detects extra/unknown parameters with warnings + - Returns detailed validation results with specific error messages + - Helps prevent failed API calls by catching issues early ### Documentation diff --git a/README.md b/README.md index 6a0f917..95dc5c5 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,24 @@ The `MAPBOX_ACCESS_TOKEN` environment variable is required. **Each tool requires - "What scopes do I need for the Styles API?" - "How do I use the directions API? Show me examples" +**validate_api_request_tool** - Validate Mapbox API requests before sending them. Checks that requests have all required parameters, correct types, valid enum values, and required token scopes. Returns detailed validation results to help catch errors early. + +**Features:** + +- Validates required vs optional parameters +- Checks parameter types (string, number, boolean, array, object) +- Validates enum constraints (e.g., routing profiles must be "driving", "walking", etc.) +- Verifies token has required scopes +- Detects unknown/extra parameters with warnings +- Provides specific error messages for each issue + +**Example prompts:** + +- "Validate this geocoding request: {parameters}" +- "Check if my token has the right scopes for creating a style" +- "Is this directions API request valid?" +- "What's wrong with this API request?" + ### Reference Tools **get_reference_tool** - Access static Mapbox reference documentation and schemas. This tool provides essential reference information that helps AI assistants understand Mapbox concepts and build correct styles and tokens. diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index e3d723b..9e5a434 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -24,6 +24,7 @@ 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 { ValidateApiRequestTool } from './validate-api-request-tool/ValidateApiRequestTool.js'; import { ValidateExpressionTool } from './validate-expression-tool/ValidateExpressionTool.js'; import { ValidateGeojsonTool } from './validate-geojson-tool/ValidateGeojsonTool.js'; import { ValidateStyleTool } from './validate-style-tool/ValidateStyleTool.js'; @@ -55,6 +56,7 @@ export const CORE_TOOLS = [ new GetFeedbackTool({ httpRequest }), new ListFeedbackTool({ httpRequest }), new TilequeryTool({ httpRequest }), + new ValidateApiRequestTool(), new ValidateExpressionTool(), new ValidateGeojsonTool(), new ValidateStyleTool() diff --git a/src/tools/validate-api-request-tool/ValidateApiRequestTool.input.schema.ts b/src/tools/validate-api-request-tool/ValidateApiRequestTool.input.schema.ts new file mode 100644 index 0000000..b3e1948 --- /dev/null +++ b/src/tools/validate-api-request-tool/ValidateApiRequestTool.input.schema.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const ValidateApiRequestInputSchema = z.object({ + api: z + .string() + .describe( + 'API name to validate against (e.g., "geocoding", "styles", "tokens")' + ), + operation: z + .string() + .describe( + 'Operation ID to validate (e.g., "forward-geocode", "create-style")' + ), + parameters: z + .object({ + path: z + .record(z.any()) + .optional() + .describe('Path parameters as key-value pairs'), + query: z + .record(z.any()) + .optional() + .describe('Query parameters as key-value pairs'), + body: z + .record(z.any()) + .optional() + .describe('Body parameters as key-value pairs') + }) + .describe('Request parameters to validate'), + tokenScopes: z + .array(z.string()) + .optional() + .describe( + 'Token scopes to validate (optional). If provided, checks if token has required scopes for the operation.' + ) +}); + +export type ValidateApiRequestInput = z.infer< + typeof ValidateApiRequestInputSchema +>; diff --git a/src/tools/validate-api-request-tool/ValidateApiRequestTool.output.schema.ts b/src/tools/validate-api-request-tool/ValidateApiRequestTool.output.schema.ts new file mode 100644 index 0000000..190d763 --- /dev/null +++ b/src/tools/validate-api-request-tool/ValidateApiRequestTool.output.schema.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +const ValidationIssueSchema = z.object({ + type: z.enum(['error', 'warning']), + field: z.string(), + message: z.string(), + expected: z.string().optional(), + received: z.any().optional() +}); + +const ParameterValidationSchema = z.object({ + provided: z.number(), + required: z.number(), + optional: z.number(), + missing: z.array(z.string()), + extra: z.array(z.string()) +}); + +const ScopeValidationSchema = z.object({ + hasRequired: z.boolean(), + required: z.array(z.string()), + provided: z.array(z.string()).optional(), + missing: z.array(z.string()).optional() +}); + +// Output schema defines only the structured content +export const ValidateApiRequestOutputSchema = z.object({ + valid: z.boolean(), + operation: z.object({ + api: z.string(), + operation: z.string(), + method: z.string(), + endpoint: z.string() + }), + issues: z.array(ValidationIssueSchema), + parameters: z.object({ + path: ParameterValidationSchema.optional(), + query: ParameterValidationSchema.optional(), + body: ParameterValidationSchema.optional() + }), + scopes: ScopeValidationSchema.optional() +}); + +export type ValidateApiRequestOutput = z.infer< + typeof ValidateApiRequestOutputSchema +>; diff --git a/src/tools/validate-api-request-tool/ValidateApiRequestTool.ts b/src/tools/validate-api-request-tool/ValidateApiRequestTool.ts new file mode 100644 index 0000000..b6d53fd --- /dev/null +++ b/src/tools/validate-api-request-tool/ValidateApiRequestTool.ts @@ -0,0 +1,416 @@ +import { z } from 'zod'; +import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ValidateApiRequestInputSchema } from './ValidateApiRequestTool.input.schema.js'; +import { + ValidateApiRequestOutputSchema, + type ValidateApiRequestOutput +} from './ValidateApiRequestTool.output.schema.js'; +import { + MAPBOX_API_ENDPOINTS, + type Parameter +} from '../../constants/mapboxApiEndpoints.js'; + +/** + * ValidateApiRequestTool - Validates API requests against Mapbox API endpoint definitions + * + * Performs comprehensive validation of API requests including: + * - Required parameter checking + * - Parameter type validation + * - Enum constraint validation + * - Token scope verification + * - Missing/extra parameter detection + * + * Uses the curated endpoint definitions from mapboxApiEndpoints.ts to ensure + * requests conform to API specifications before making actual calls. + */ +export class ValidateApiRequestTool extends BaseTool< + typeof ValidateApiRequestInputSchema, + typeof ValidateApiRequestOutputSchema +> { + readonly name = 'validate_api_request_tool'; + readonly description = + 'Validate Mapbox API requests against endpoint definitions. Checks required parameters, types, enum constraints, and token scopes. Returns detailed validation results with specific error messages for each issue found.'; + readonly annotations = { + title: 'Validate API Request', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + + constructor() { + super({ + inputSchema: ValidateApiRequestInputSchema, + outputSchema: ValidateApiRequestOutputSchema + }); + } + + protected async execute( + input: z.infer + ): Promise { + try { + // Find the API endpoint definition + const apiEndpoint = MAPBOX_API_ENDPOINTS.find( + (endpoint) => endpoint.api.toLowerCase() === input.api.toLowerCase() + ); + + if (!apiEndpoint) { + return { + content: [ + { + type: 'text', + text: `❌ API "${input.api}" not found. Use explore_mapbox_api_tool to see available APIs.` + } + ], + isError: true + }; + } + + // Find the operation + const operation = apiEndpoint.operations.find( + (op) => op.operationId.toLowerCase() === input.operation.toLowerCase() + ); + + if (!operation) { + return { + content: [ + { + type: 'text', + text: `❌ Operation "${input.operation}" not found in ${apiEndpoint.api} API. Use explore_mapbox_api_tool to see available operations.` + } + ], + isError: true + }; + } + + // Perform validation + const issues: Array<{ + type: 'error' | 'warning'; + field: string; + message: string; + expected?: string; + received?: any; + }> = []; + + // Validate path parameters + const pathValidation = this.validateParameters( + 'path', + operation.pathParameters || [], + input.parameters.path || {}, + issues + ); + + // Validate query parameters + const queryValidation = this.validateParameters( + 'query', + operation.queryParameters || [], + input.parameters.query || {}, + issues + ); + + // Validate body parameters + const bodyValidation = this.validateParameters( + 'body', + operation.bodyParameters || [], + input.parameters.body || {}, + issues + ); + + // Validate token scopes + let scopeValidation; + if (input.tokenScopes) { + scopeValidation = this.validateScopes( + operation.requiredScopes, + input.tokenScopes, + issues + ); + } + + const isValid = issues.filter((i) => i.type === 'error').length === 0; + + // Build validation result + const result: ValidateApiRequestOutput = { + valid: isValid, + operation: { + api: apiEndpoint.api, + operation: operation.operationId, + method: operation.method, + endpoint: operation.endpoint + }, + issues, + parameters: { + ...(pathValidation && { path: pathValidation }), + ...(queryValidation && { query: queryValidation }), + ...(bodyValidation && { body: bodyValidation }) + }, + ...(scopeValidation && { scopes: scopeValidation }) + }; + + // Format as markdown + const text = this.formatValidationResult(result); + + return { + content: [{ type: 'text', text }], + structuredContent: result, + isError: false + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log('error', `${this.name}: ${errorMessage}`); + + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true + }; + } + } + + /** + * Validate parameters against endpoint definition + */ + private validateParameters( + paramType: string, + definition: Parameter[], + provided: Record, + issues: Array<{ + type: 'error' | 'warning'; + field: string; + message: string; + expected?: string; + received?: any; + }> + ) { + const providedKeys = Object.keys(provided); + const requiredParams = definition.filter((p) => p.required); + const optionalParams = definition.filter((p) => !p.required); + const allDefinedKeys = definition.map((p) => p.name); + + const missing = requiredParams + .filter((p) => !providedKeys.includes(p.name)) + .map((p) => p.name); + + const extra = providedKeys.filter((k) => !allDefinedKeys.includes(k)); + + // Check missing required parameters + missing.forEach((paramName) => { + const param = definition.find((p) => p.name === paramName)!; + issues.push({ + type: 'error', + field: `${paramType}.${paramName}`, + message: `Required parameter missing`, + expected: param.type, + received: undefined + }); + }); + + // Check extra parameters + extra.forEach((paramName) => { + issues.push({ + type: 'warning', + field: `${paramType}.${paramName}`, + message: `Unknown parameter (not in API definition)`, + received: provided[paramName] + }); + }); + + // Validate provided parameters + providedKeys.forEach((key) => { + const param = definition.find((p) => p.name === key); + if (!param) return; // Already flagged as extra + + const value = provided[key]; + + // Type validation + const typeValid = this.validateType(value, param.type); + if (!typeValid) { + issues.push({ + type: 'error', + field: `${paramType}.${key}`, + message: `Invalid type`, + expected: param.type, + received: typeof value + }); + } + + // Enum validation + if (param.enum && param.enum.length > 0) { + if (!param.enum.includes(String(value))) { + issues.push({ + type: 'error', + field: `${paramType}.${key}`, + message: `Value not in allowed enum`, + expected: param.enum.join(', '), + received: value + }); + } + } + }); + + return { + provided: providedKeys.length, + required: requiredParams.length, + optional: optionalParams.length, + missing, + extra + }; + } + + /** + * Validate a value against an expected type + */ + private validateType(value: any, expectedType: string): boolean { + const actualType = typeof value; + const typeMap: Record = { + string: ['string'], + number: ['number'], + boolean: ['boolean'], + array: ['object'], // Arrays are objects in JS + object: ['object'] + }; + + const validTypes = typeMap[expectedType.toLowerCase()] || [ + expectedType.toLowerCase() + ]; + + // Special handling for arrays + if (expectedType.toLowerCase() === 'array') { + return Array.isArray(value); + } + + return validTypes.includes(actualType); + } + + /** + * Validate token scopes + */ + private validateScopes( + requiredScopes: string[], + providedScopes: string[], + issues: Array<{ + type: 'error' | 'warning'; + field: string; + message: string; + expected?: string; + received?: any; + }> + ) { + const missing = requiredScopes.filter( + (scope) => !providedScopes.includes(scope) + ); + + missing.forEach((scope) => { + issues.push({ + type: 'error', + field: 'token.scopes', + message: `Missing required scope: ${scope}`, + expected: requiredScopes.join(', '), + received: providedScopes.join(', ') + }); + }); + + return { + hasRequired: missing.length === 0, + required: requiredScopes, + provided: providedScopes, + missing + }; + } + + /** + * Format validation result as markdown + */ + private formatValidationResult(result: ValidateApiRequestOutput): string { + let text = ''; + + if (result.valid) { + text += '✅ **Validation Passed**\n\n'; + text += `The request is valid for **${result.operation.method} ${result.operation.endpoint}**\n\n`; + } else { + text += '❌ **Validation Failed**\n\n'; + text += `Found ${result.issues.filter((i) => i.type === 'error').length} error(s) and ${result.issues.filter((i) => i.type === 'warning').length} warning(s)\n\n`; + } + + text += `## Operation Details\n\n`; + text += `- **API:** ${result.operation.api}\n`; + text += `- **Operation:** ${result.operation.operation}\n`; + text += `- **Method:** ${result.operation.method}\n`; + text += `- **Endpoint:** ${result.operation.endpoint}\n\n`; + + // Parameter summary + text += `## Parameter Summary\n\n`; + + if (result.parameters.path) { + text += `### Path Parameters\n`; + text += `- Provided: ${result.parameters.path.provided}\n`; + text += `- Required: ${result.parameters.path.required}\n`; + text += `- Missing: ${result.parameters.path.missing.length > 0 ? result.parameters.path.missing.join(', ') : 'None'}\n\n`; + } + + if (result.parameters.query) { + text += `### Query Parameters\n`; + text += `- Provided: ${result.parameters.query.provided}\n`; + text += `- Required: ${result.parameters.query.required}\n`; + text += `- Missing: ${result.parameters.query.missing.length > 0 ? result.parameters.query.missing.join(', ') : 'None'}\n\n`; + } + + if (result.parameters.body) { + text += `### Body Parameters\n`; + text += `- Provided: ${result.parameters.body.provided}\n`; + text += `- Required: ${result.parameters.body.required}\n`; + text += `- Missing: ${result.parameters.body.missing.length > 0 ? result.parameters.body.missing.join(', ') : 'None'}\n\n`; + } + + // Scope validation + if (result.scopes) { + text += `## Token Scopes\n\n`; + if (result.scopes.hasRequired) { + text += `✅ Token has all required scopes\n\n`; + } else { + text += `❌ Token missing required scopes: ${result.scopes.missing?.join(', ')}\n\n`; + } + } + + // Issues + if (result.issues.length > 0) { + text += `## Issues\n\n`; + + const errors = result.issues.filter((i) => i.type === 'error'); + const warnings = result.issues.filter((i) => i.type === 'warning'); + + if (errors.length > 0) { + text += `### Errors (${errors.length})\n\n`; + errors.forEach((issue, idx) => { + text += `${idx + 1}. **${issue.field}**: ${issue.message}\n`; + if (issue.expected) { + text += ` - Expected: ${issue.expected}\n`; + } + if (issue.received !== undefined) { + text += ` - Received: ${JSON.stringify(issue.received)}\n`; + } + text += '\n'; + }); + } + + if (warnings.length > 0) { + text += `### Warnings (${warnings.length})\n\n`; + warnings.forEach((issue, idx) => { + text += `${idx + 1}. **${issue.field}**: ${issue.message}\n`; + if (issue.received !== undefined) { + text += ` - Received: ${JSON.stringify(issue.received)}\n`; + } + text += '\n'; + }); + } + } + + if (result.valid) { + text += '\n✅ Request is ready to be sent to the Mapbox API\n'; + } else { + text += '\n❌ Fix the errors above before sending the request\n'; + } + + return text; + } +} diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index f7b65c4..5e0806c 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -177,6 +177,11 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho "description": "Update an existing Mapbox style", "toolName": "update_style_tool", }, + { + "className": "ValidateApiRequestTool", + "description": "Validate Mapbox API requests against endpoint definitions. Checks required parameters, types, enum constraints, and token scopes. Returns detailed validation results with specific error messages for each issue found.", + "toolName": "validate_api_request_tool", + }, { "className": "ValidateExpressionTool", "description": "Validates Mapbox style expressions for syntax, operators, and argument correctness", diff --git a/test/tools/validate-api-request-tool/ValidateApiRequestTool.test.ts b/test/tools/validate-api-request-tool/ValidateApiRequestTool.test.ts new file mode 100644 index 0000000..bc231b6 --- /dev/null +++ b/test/tools/validate-api-request-tool/ValidateApiRequestTool.test.ts @@ -0,0 +1,454 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ValidateApiRequestTool } from '../../../src/tools/validate-api-request-tool/ValidateApiRequestTool.js'; + +describe('ValidateApiRequestTool', () => { + let tool: ValidateApiRequestTool; + + beforeEach(() => { + tool = new ValidateApiRequestTool(); + }); + + describe('metadata', () => { + it('should have correct tool metadata', () => { + expect(tool.name).toBe('validate_api_request_tool'); + expect(tool.description).toContain('Validate Mapbox API requests'); + expect(tool.annotations).toBeDefined(); + expect(tool.annotations.title).toBe('Validate API Request'); + }); + }); + + describe('valid requests', () => { + it('should validate a complete valid geocoding request', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'San Francisco' + }, + query: { + access_token: 'pk.test' + } + } + }); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('✅'); + expect(result.content[0].text).toContain('Validation Passed'); + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent.valid).toBe(true); + }); + + it('should validate request with optional parameters', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'San Francisco' + }, + query: { + access_token: 'pk.test', + limit: 5, + autocomplete: true + } + } + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(true); + }); + }); + + describe('missing required parameters', () => { + it('should detect missing required path parameters', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places' + // missing 'query' + }, + query: { + access_token: 'pk.test' + } + } + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(false); + expect(result.structuredContent.issues).toHaveLength(1); + expect(result.structuredContent.issues[0]).toMatchObject({ + type: 'error', + field: 'path.query', + message: 'Required parameter missing' + }); + }); + + it('should detect missing required query parameters', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'test' + } + // missing query.access_token + } + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(false); + const accessTokenIssue = result.structuredContent.issues.find( + (i) => i.field === 'query.access_token' + ); + expect(accessTokenIssue).toBeDefined(); + expect(accessTokenIssue?.type).toBe('error'); + }); + }); + + describe('type validation', () => { + it('should detect type mismatches', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 123 // should be string + }, + query: { + access_token: 'pk.test', + limit: 'not-a-number' // should be number + } + } + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(false); + + const queryTypeIssue = result.structuredContent.issues.find( + (i) => i.field === 'path.query' + ); + expect(queryTypeIssue).toBeDefined(); + expect(queryTypeIssue?.message).toContain('Invalid type'); + + const limitTypeIssue = result.structuredContent.issues.find( + (i) => i.field === 'query.limit' + ); + expect(limitTypeIssue).toBeDefined(); + expect(limitTypeIssue?.message).toContain('Invalid type'); + }); + }); + + describe('enum validation', () => { + it('should detect invalid enum values', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'invalid-mode', // must be mapbox.places or mapbox.places-permanent + query: 'test' + }, + query: { + access_token: 'pk.test' + } + } + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(false); + + const enumIssue = result.structuredContent.issues.find( + (i) => i.field === 'path.mode' + ); + expect(enumIssue).toBeDefined(); + expect(enumIssue?.message).toContain('not in allowed enum'); + expect(enumIssue?.expected).toContain('mapbox.places'); + }); + }); + + describe('extra parameters', () => { + it('should warn about unknown parameters', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'test' + }, + query: { + access_token: 'pk.test', + unknownParam: 'value' // not in API definition + } + } + }); + + expect(result.isError).toBe(false); + // Valid because warnings don't fail validation + expect(result.structuredContent.valid).toBe(true); + + const warning = result.structuredContent.issues.find( + (i) => i.field === 'query.unknownParam' + ); + expect(warning).toBeDefined(); + expect(warning?.type).toBe('warning'); + expect(warning?.message).toContain('Unknown parameter'); + }); + }); + + describe('token scope validation', () => { + it('should validate token has required scopes', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'test' + }, + query: { + access_token: 'pk.test' + } + }, + tokenScopes: ['styles:read', 'geocoding:read'] // has required scopes + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(true); + expect(result.structuredContent.scopes?.hasRequired).toBe(true); + expect(result.content[0].text).toContain('Token has all required scopes'); + }); + + it('should detect missing token scopes', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'test' + }, + query: { + access_token: 'pk.test' + } + }, + tokenScopes: ['styles:read'] // missing geocoding:read + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(false); + expect(result.structuredContent.scopes?.hasRequired).toBe(false); + expect(result.structuredContent.scopes?.missing).toContain( + 'geocoding:read' + ); + + const scopeIssue = result.structuredContent.issues.find( + (i) => i.field === 'token.scopes' + ); + expect(scopeIssue).toBeDefined(); + expect(scopeIssue?.type).toBe('error'); + }); + }); + + describe('error handling', () => { + it('should handle invalid API name', async () => { + const result = await tool.run({ + api: 'invalid-api', + operation: 'test', + parameters: {} + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('❌'); + expect(result.content[0].text).toContain('not found'); + }); + + it('should handle invalid operation name', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'invalid-operation', + parameters: {} + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('❌'); + expect(result.content[0].text).toContain('not found'); + }); + }); + + describe('parameter summary', () => { + it('should provide parameter summary', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'test' + }, + query: { + access_token: 'pk.test', + limit: 5 + } + } + }); + + expect(result.structuredContent.parameters.path).toBeDefined(); + expect(result.structuredContent.parameters.path?.provided).toBe(2); + expect(result.structuredContent.parameters.path?.required).toBe(2); + expect(result.structuredContent.parameters.path?.missing).toHaveLength(0); + + expect(result.structuredContent.parameters.query).toBeDefined(); + expect(result.structuredContent.parameters.query?.provided).toBe(2); + }); + }); + + describe('styles API validation', () => { + it('should validate styles create-style operation', async () => { + const result = await tool.run({ + api: 'styles', + operation: 'create-style', + parameters: { + path: { + username: 'testuser' + }, + query: { + access_token: 'sk.test' + }, + body: { + name: 'My Style', + version: 8, + sources: {}, + layers: [] + } + }, + tokenScopes: ['styles:write'] + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(true); + expect(result.structuredContent.operation.method).toBe('POST'); + }); + }); + + describe('directions API validation', () => { + it('should validate directions request', async () => { + const result = await tool.run({ + api: 'directions', + operation: 'directions', + parameters: { + path: { + profile: 'driving', + coordinates: '-122.42,37.78;-122.45,37.76' + }, + query: { + access_token: 'pk.test' + } + }, + tokenScopes: ['directions:read'] + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(true); + }); + + it('should detect invalid routing profile', async () => { + const result = await tool.run({ + api: 'directions', + operation: 'directions', + parameters: { + path: { + profile: 'flying', // invalid profile + coordinates: '-122.42,37.78;-122.45,37.76' + }, + query: { + access_token: 'pk.test' + } + } + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.valid).toBe(false); + + const profileIssue = result.structuredContent.issues.find( + (i) => i.field === 'path.profile' + ); + expect(profileIssue).toBeDefined(); + expect(profileIssue?.message).toContain('not in allowed enum'); + }); + }); + + describe('output schema validation', () => { + it('should produce valid structured output', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'test' + }, + query: { + access_token: 'pk.test' + } + } + }); + + expect(result.structuredContent).toBeDefined(); + const validation = tool.outputSchema!.safeParse(result.structuredContent); + expect(validation.success).toBe(true); + }); + }); + + describe('case insensitivity', () => { + it('should handle case-insensitive API names', async () => { + const result = await tool.run({ + api: 'GEOCODING', + operation: 'forward-geocode', + parameters: { + path: { + mode: 'mapbox.places', + query: 'test' + }, + query: { + access_token: 'pk.test' + } + } + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.operation.api).toBe('geocoding'); + }); + + it('should handle case-insensitive operation names', async () => { + const result = await tool.run({ + api: 'geocoding', + operation: 'FORWARD-GEOCODE', + parameters: { + path: { + mode: 'mapbox.places', + query: 'test' + }, + query: { + access_token: 'pk.test' + } + } + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.operation.operation).toBe( + 'forward-geocode' + ); + }); + }); +}); From f1dd3b78e6460a4817984f1748792284541c2c77 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 12 Feb 2026 12:53:41 -0500 Subject: [PATCH 2/2] Address feedback: Clarify validation against curated API list Addresses feedback from PR review about handling new/unknown APIs. Changes: - Updated class docstring to explain this validates against a curated list of known APIs - Improved error message when API not found to be more helpful and acknowledge limitation - Updated tool description to clarify it only validates known APIs - Added note in README about the limitation and how to handle new APIs The tool now clearly communicates that: 1. It validates against a curated, known list of APIs 2. New Mapbox APIs may not be included yet 3. Users can check official docs for newer APIs 4. Requests can still be made without validation This prevents confusion when legitimate newer APIs aren't recognized. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 22 +++++++++++++++++++ .../ValidateApiRequestTool.ts | 19 ++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 95dc5c5..1c837a5 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ The `MAPBOX_ACCESS_TOKEN` environment variable is required. **Each tool requires **validate_api_request_tool** - Validate Mapbox API requests before sending them. Checks that requests have all required parameters, correct types, valid enum values, and required token scopes. Returns detailed validation results to help catch errors early. +> **Note:** This tool validates against a curated list of known Mapbox APIs. Newer APIs may not be included until the endpoint definitions are updated. If you encounter a "not found" error for a valid API, refer to the official [Mapbox API documentation](https://docs.mapbox.com/api/) or open an issue to request adding the new API. + **Features:** - Validates required vs optional parameters @@ -166,6 +168,26 @@ The `MAPBOX_ACCESS_TOKEN` environment variable is required. **Each tool requires - "Is this directions API request valid?" - "What's wrong with this API request?" +**test_api_request_tool** - Execute actual Mapbox API requests and generate code examples. Makes real HTTP calls to test endpoints and returns actual responses, with optional code generation showing how to replicate the call in curl, JavaScript, and Python. + +**Features:** + +- Makes real HTTP requests to Mapbox APIs +- Returns actual API responses with status codes and headers +- Generates code snippets in multiple languages (curl, JavaScript, Python) +- Shows execution timing and rate limit information +- Masks access tokens in generated code for security +- Supports all HTTP methods (GET, POST, PUT, PATCH, DELETE) +- Handles path, query, and body parameters + +**Example prompts:** + +- "Test the geocoding API with query 'San Francisco'" +- "Make a request to list my styles and show me the curl command" +- "Call the directions API from Paris to Lyon and generate code examples" +- "Test creating a token and show me how to do it in JavaScript" +- "Execute a tilequery request and generate Python code" + ### Reference Tools **get_reference_tool** - Access static Mapbox reference documentation and schemas. This tool provides essential reference information that helps AI assistants understand Mapbox concepts and build correct styles and tokens. diff --git a/src/tools/validate-api-request-tool/ValidateApiRequestTool.ts b/src/tools/validate-api-request-tool/ValidateApiRequestTool.ts index b6d53fd..b12bcc3 100644 --- a/src/tools/validate-api-request-tool/ValidateApiRequestTool.ts +++ b/src/tools/validate-api-request-tool/ValidateApiRequestTool.ts @@ -21,8 +21,13 @@ import { * - Token scope verification * - Missing/extra parameter detection * - * Uses the curated endpoint definitions from mapboxApiEndpoints.ts to ensure - * requests conform to API specifications before making actual calls. + * Uses a curated list of endpoint definitions from mapboxApiEndpoints.ts to validate + * requests before making actual API calls. + * + * Note: This tool validates against known APIs in our curated list. New Mapbox APIs + * may not be included until the endpoint definitions are updated. If you encounter + * a "not found" error for a valid API, please refer to the official Mapbox API docs + * or open an issue to request adding the new API to our definitions. */ export class ValidateApiRequestTool extends BaseTool< typeof ValidateApiRequestInputSchema, @@ -30,7 +35,7 @@ export class ValidateApiRequestTool extends BaseTool< > { readonly name = 'validate_api_request_tool'; readonly description = - 'Validate Mapbox API requests against endpoint definitions. Checks required parameters, types, enum constraints, and token scopes. Returns detailed validation results with specific error messages for each issue found.'; + 'Validate Mapbox API requests against known endpoint definitions. Checks required parameters, types, enum constraints, and token scopes for APIs in our curated list. Returns detailed validation results with specific error messages for each issue found. Note: Only validates against known APIs - newer APIs may not be included yet.'; readonly annotations = { title: 'Validate API Request', readOnlyHint: true, @@ -60,7 +65,13 @@ export class ValidateApiRequestTool extends BaseTool< content: [ { type: 'text', - text: `❌ API "${input.api}" not found. Use explore_mapbox_api_tool to see available APIs.` + text: + `❌ API "${input.api}" not found in our curated endpoint definitions.\n\n` + + `**Available options:**\n` + + `1. Use explore_mapbox_api_tool to see all known APIs in our list\n` + + `2. Check the official Mapbox API docs: https://docs.mapbox.com/api/\n` + + `3. If this is a newer API not yet in our list, validation can't be performed yet\n\n` + + `💡 Tip: You can still make API requests without validation using other tools.` } ], isError: true