├── .nvmrc ├── CODEOWNERS ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── general.yaml ├── workflows │ └── test.yml ├── pull_request_template.md └── copilot-instructions.md ├── .husky ├── pre-commit └── setup-hooks.js ├── assets └── mapbox_mcp_server.gif ├── .dockerignore ├── docs ├── images │ ├── claude-mcp-section.png │ ├── hosted-mcp │ │ ├── auth-1.png │ │ ├── auth-2.png │ │ ├── open-ai-1.png │ │ ├── open-ai-2.png │ │ ├── open-ai-3.png │ │ ├── open-ai-4.png │ │ ├── vs-code-1.png │ │ ├── vs-code-2.png │ │ ├── vs-code-3.png │ │ ├── Claude-code-1.jpg │ │ ├── Claude-code-2.jpg │ │ └── claude-desktop-1.png │ ├── vscode-tools-menu.png │ ├── claude-desktop-settings.png │ ├── claude-permission-prompt.png │ ├── mapbox-server-tools-menu.png │ ├── mapbox-tool-example-usage.png │ └── vscode-tool-example-usage.png ├── using-mcp-with-smolagents │ ├── example_agent_output.png │ ├── requirements.txt │ ├── smolagents_example.py │ └── README.md ├── cursor-setup.md ├── goose-setup.md ├── trace-context-propagation.md ├── vscode-setup.md ├── engineering_standards.md ├── tracing-verification.md ├── complete-observability.md └── claude-desktop-setup.md ├── .editorconfig ├── test ├── project │ ├── fixtures │ │ ├── package.json │ │ ├── manifest.json │ │ └── server.json │ └── version-consistency.test.ts ├── utils │ ├── httpPipelineUtils.ts │ └── dateUtils.ts └── tools │ ├── tool-naming-convention.test.ts │ ├── resource-reader-tool │ └── ResourceReaderTool.test.ts │ ├── directions-tool │ └── DirectionsTool.output.schema.test.ts │ ├── annotations.test.ts │ ├── input-schema-validation.test.ts │ ├── version-tool │ ├── VersionTool.test.ts │ └── VersionTool.output.schema.test.ts │ ├── BaseTool.test.ts │ ├── matrix-tool │ └── MatrixTool.output.schema.test.ts │ ├── structured-content.test.ts │ ├── isochrone-tool │ ├── IsochroneTool.test.ts │ └── IsochroneTool.output.schema.test.ts │ └── category-list-tool │ └── CategoryListTool.output.schema.test.ts ├── tsconfig.src.json ├── tsconfig.json ├── src ├── tools │ ├── version-tool │ │ ├── VersionTool.input.schema.ts │ │ ├── VersionTool.output.schema.ts │ │ └── VersionTool.ts │ ├── category-list-tool │ │ ├── CategoryListTool.output.schema.ts │ │ ├── CategoryListTool.input.schema.ts │ │ └── CategoryListTool.ts │ ├── resource-reader-tool │ │ ├── ResourceReaderTool.input.schema.ts │ │ └── ResourceReaderTool.output.schema.ts │ ├── matrix-tool │ │ ├── MatrixTool.output.schema.ts │ │ └── MatrixTool.input.schema.ts │ ├── toolRegistry.ts │ ├── category-search-tool │ │ ├── CategorySearchTool.input.schema.ts │ │ └── CategorySearchTool.output.schema.ts │ ├── reverse-geocode-tool │ │ └── ReverseGeocodeTool.input.schema.ts │ ├── search-and-geocode-tool │ │ ├── SearchAndGeocodeTool.input.schema.ts │ │ └── SearchAndGeocodeTool.output.schema.ts │ ├── isochrone-tool │ │ ├── IsochroneTool.output.schema.ts │ │ └── IsochroneTool.input.schema.ts │ ├── BaseTool.ts │ └── static-map-image-tool │ │ └── StaticMapImageTool.ts ├── utils │ ├── types.ts │ ├── dateUtils.ts │ ├── versionUtils-cjs.cts │ └── versionUtils.ts ├── resources │ ├── resourceRegistry.ts │ ├── BaseResource.ts │ ├── category-list │ │ └── CategoryListResource.ts │ └── MapboxApiBasedResource.ts ├── schemas │ ├── shared.ts │ └── geojson.ts └── config │ └── toolConfig.ts ├── tsconfig.test.json ├── .cz.json ├── cspell.config.json ├── plop-templates ├── tool.input.schema.hbs ├── tool.output.schema.hbs ├── tool.test.hbs └── tool.hbs ├── vitest.config.ts ├── tsconfig.base.json ├── Dockerfile ├── scripts ├── add-shebang.cjs ├── build-helpers.cjs └── sync-manifest-version.cjs ├── server.json ├── manifest.json ├── LICENSE.md ├── eslint.config.mjs ├── TOOL_CONFIGURATION.md ├── plopfile.cjs ├── .gitignore ├── CHANGELOG.md ├── CLAUDE.md ├── AGENTS.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @mapbox/locationai 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | npm run sync-manifest 3 | git add manifest.json 4 | npx lint-staged -------------------------------------------------------------------------------- /assets/mapbox_mcp_server.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/assets/mapbox_mcp_server.gif -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | dist 4 | coverage 5 | .git 6 | .github 7 | .husky 8 | .vscode 9 | -------------------------------------------------------------------------------- /docs/images/claude-mcp-section.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/claude-mcp-section.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/auth-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/auth-1.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/auth-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/auth-2.png -------------------------------------------------------------------------------- /docs/images/vscode-tools-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/vscode-tools-menu.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/open-ai-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/open-ai-1.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/open-ai-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/open-ai-2.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/open-ai-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/open-ai-3.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/open-ai-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/open-ai-4.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/vs-code-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/vs-code-1.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/vs-code-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/vs-code-2.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/vs-code-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/vs-code-3.png -------------------------------------------------------------------------------- /docs/images/claude-desktop-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/claude-desktop-settings.png -------------------------------------------------------------------------------- /docs/images/claude-permission-prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/claude-permission-prompt.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/Claude-code-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/Claude-code-1.jpg -------------------------------------------------------------------------------- /docs/images/hosted-mcp/Claude-code-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/Claude-code-2.jpg -------------------------------------------------------------------------------- /docs/images/mapbox-server-tools-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/mapbox-server-tools-menu.png -------------------------------------------------------------------------------- /docs/images/mapbox-tool-example-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/mapbox-tool-example-usage.png -------------------------------------------------------------------------------- /docs/images/vscode-tool-example-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/vscode-tool-example-usage.png -------------------------------------------------------------------------------- /docs/images/hosted-mcp/claude-desktop-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/images/hosted-mcp/claude-desktop-1.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 -------------------------------------------------------------------------------- /test/project/fixtures/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-package", 3 | "version": "2.0.0", 4 | "description": "Test package for sync script" 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.src.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "composite": true 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /docs/using-mcp-with-smolagents/example_agent_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mcp-server/HEAD/docs/using-mcp-with-smolagents/example_agent_output.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { 4 | "path": "./tsconfig.src.json" 5 | }, 6 | { 7 | "path": "./tsconfig.test.json" 8 | } 9 | ], 10 | "files": [] 11 | } -------------------------------------------------------------------------------- /src/tools/version-tool/VersionTool.input.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | export const VersionSchema = z.object({}); 7 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "skipLibCheck": true, 5 | "lib": ["ESNext"], 6 | "resolveJsonModule": true 7 | }, 8 | "include": ["test", "src"] 9 | } -------------------------------------------------------------------------------- /test/project/fixtures/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "dxt_version": "0.1", 3 | "name": "test-manifest", 4 | "version": "1.0.0", 5 | "description": "Test manifest", 6 | "author": { 7 | "name": "Test Author" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /docs/using-mcp-with-smolagents/requirements.txt: -------------------------------------------------------------------------------- 1 | # Dependencies for using Mapbox MCP server with smolagents 2 | # Install with: pip install -r requirements.txt 3 | 4 | # smolagents with MCP support (includes mcp SDK) 5 | smolagents[mcp] 6 | -------------------------------------------------------------------------------- /.cz.json: -------------------------------------------------------------------------------- 1 | { 2 | "commitizen": { 3 | "name": "cz_conventional_commits", 4 | "tag_format": "v$version", 5 | "version_scheme": "semver", 6 | "version_provider": "npm", 7 | "update_changelog_on_bump": true, 8 | "major_version_zero": true 9 | } 10 | } -------------------------------------------------------------------------------- /test/project/fixtures/server.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "test-schema", 3 | "name": "test-server", 4 | "version": "1.5.0", 5 | "packages": [ 6 | { 7 | "identifier": "test-package", 8 | "version": "1.2.0", 9 | "registryType": "npm" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /cspell.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "words": [ 5 | "bbox", 6 | "denoise", 7 | "isochrone", 8 | "mapbox", 9 | "mmss", 10 | "tilequery" 11 | ], 12 | "ignorePaths": [ 13 | "node_modules", 14 | "dist" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/tools/category-list-tool/CategoryListTool.output.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | // Schema for the simplified output that the tool actually returns 7 | // Just an array of category ID strings 8 | export const CategoryListResponseSchema = z.object({ 9 | listItems: z.array(z.string()) 10 | }); 11 | 12 | export type CategoryListResponse = z.infer; 13 | -------------------------------------------------------------------------------- /src/tools/version-tool/VersionTool.output.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | // Schema for version tool output - matches the VersionInfo interface 7 | export const VersionResponseSchema = z.object({ 8 | name: z.string(), 9 | version: z.string(), 10 | sha: z.string(), 11 | tag: z.string(), 12 | branch: z.string() 13 | }); 14 | 15 | export type VersionResponse = z.infer; 16 | -------------------------------------------------------------------------------- /plop-templates/tool.input.schema.hbs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import z from 'zod'; 5 | 6 | /** 7 | * Input schema for {{pascalCase name}}Tool 8 | */ 9 | export const {{pascalCase name}}InputSchema = z.object({ 10 | query: z.string().describe('Natural language query for {{lowerCase name}}') 11 | }); 12 | 13 | /** 14 | * Type inference for {{pascalCase name}}Input 15 | */ 16 | export type {{pascalCase name}}Input = z.infer; 17 | -------------------------------------------------------------------------------- /src/tools/resource-reader-tool/ResourceReaderTool.input.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | export const ResourceReaderToolInputSchema = z.object({ 7 | uri: z 8 | .string() 9 | .min(1, 'URI must not be empty') 10 | .describe( 11 | 'The resource URI to read (e.g., "mapbox://categories" or "mapbox://categories/ja")' 12 | ) 13 | }); 14 | 15 | export type ResourceReaderToolInput = z.infer< 16 | typeof ResourceReaderToolInputSchema 17 | >; 18 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import type { Span } from '@opentelemetry/api'; 5 | 6 | /** 7 | * Enhanced RequestInit with tracing context 8 | */ 9 | export interface TracedRequestInit extends RequestInit { 10 | tracingContext?: { 11 | parentSpan?: Span; 12 | sessionId?: string; 13 | userId?: string; 14 | }; 15 | } 16 | 17 | /** 18 | * HttpRequest interface that includes tracing information 19 | */ 20 | export interface HttpRequest { 21 | (input: string | URL | Request, init?: TracedRequestInit): Promise; 22 | } 23 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | testTimeout: 60000, 6 | hookTimeout: 60000, 7 | reporters: ['default', 'junit'], 8 | outputFile: { 9 | junit: 'test-results.xml' 10 | }, 11 | watch: false, 12 | include: ['test/**/*.test.ts'], 13 | coverage: { 14 | include: ['src/**/*.ts'], 15 | exclude: ['src/**/*-cjs.cts', 'vitest*.config.ts'], 16 | provider: 'istanbul', 17 | reporter: ['text', 'json', 'html'], 18 | reportsDirectory: 'coverage' 19 | } 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/tools/resource-reader-tool/ResourceReaderTool.output.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | export const ResourceReaderToolOutputSchema = z.object({ 7 | uri: z.string().describe('The URI that was read'), 8 | mimeType: z.string().optional().describe('MIME type of the content'), 9 | text: z.string().optional().describe('Text content of the resource'), 10 | blob: z.string().optional().describe('Base64-encoded blob content') 11 | }); 12 | 13 | export type ResourceReaderToolOutput = z.infer< 14 | typeof ResourceReaderToolOutputSchema 15 | >; 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: "23" 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Compile typescript 27 | run: npm run build 28 | 29 | - name: Run tests 30 | run: npm test -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": [ 5 | "ES2022", 6 | "ESNext" 7 | ], 8 | "declaration": true, 9 | "declarationMap": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "nodenext", 15 | "moduleResolution": "nodenext", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "skipLibCheck": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "pkce-challenge": [ 22 | "node_modules/pkce-challenge/dist/index.node" 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-slim 2 | 3 | # Create app directory 4 | WORKDIR /app 5 | 6 | # Copy package.json and package-lock.json 7 | COPY package*.json ./ 8 | 9 | # Install dependencies - completely skip prepare scripts during Docker build 10 | RUN npm install --ignore-scripts 11 | 12 | # Copy the rest of the application 13 | COPY . . 14 | 15 | # Create an empty version.json before the build to prevent errors 16 | RUN mkdir -p dist && echo '{"sha":"unknown","tag":"unknown","branch":"docker","version":"0.0.1"}' > dist/version.json 17 | 18 | # Build the application, overriding the git commands to avoid errors 19 | RUN npx tshy 20 | 21 | # Command to run the server 22 | CMD ["node", "dist/esm/index.js"] 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | - Closes #[issue-number] (if applicable) 6 | 7 | --- 8 | 9 | ## Testing 10 | 11 | 12 | 13 | --- 14 | 15 | ## Checklist 16 | 17 | - [ ] Code has been tested locally 18 | - [ ] Unit tests have been added or updated 19 | - [ ] Documentation has been updated if needed 20 | 21 | --- 22 | 23 | ## Additional Notes 24 | 25 | 26 | -------------------------------------------------------------------------------- /plop-templates/tool.output.schema.hbs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | /** 7 | * Output schema for {{pascalCase name}}Tool 8 | * 9 | * TODO: Define the appropriate output schema for your tool 10 | * This is a placeholder schema that should be replaced with your actual output structure 11 | */ 12 | export const {{pascalCase name}}OutputSchema = z.object({ 13 | // Add your output schema fields here 14 | result: z.unknown().describe('Tool execution result') 15 | }); 16 | 17 | /** 18 | * Type inference for {{pascalCase name}}Output 19 | */ 20 | export type {{pascalCase name}}Output = z.infer; 21 | -------------------------------------------------------------------------------- /scripts/add-shebang.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const filePathCJS = path.resolve(__dirname, '../dist/commonjs/index.js'); 5 | const filePathESM = path.resolve(__dirname, '../dist/esm/index.js'); 6 | addShebang(filePathCJS); 7 | addShebang(filePathESM); 8 | 9 | function addShebang(filePath) { 10 | const shebang = '#!/usr/bin/env node\n'; 11 | 12 | let content = fs.readFileSync(filePath, 'utf-8'); 13 | 14 | if (!content.startsWith(shebang)) { 15 | content = shebang + content; 16 | fs.writeFileSync(filePath, content); 17 | console.log(`Shebang added to ${filePath}`); 18 | } else { 19 | console.log(`Shebang already exists in ${filePath}`); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/dateUtils.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | /** 5 | * Helper function to format ISO datetime strings according to Mapbox API requirements. 6 | * It converts the format YYYY-MM-DDThh:mm:ss (with seconds but no timezone) to 7 | * YYYY-MM-DDThh:mm (no seconds, no timezone) by removing the seconds part. 8 | * Other valid formats are left unchanged. 9 | */ 10 | export const formatIsoDateTime = (dateTime: string): string => { 11 | // Regex for matching YYYY-MM-DDThh:mm:ss format (with seconds but no timezone) 12 | const dateWithSecondsNoTz = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; 13 | 14 | if (dateWithSecondsNoTz.test(dateTime)) { 15 | // Extract up to the minutes part only, dropping the seconds 16 | return dateTime.substring(0, dateTime.lastIndexOf(':')); 17 | } 18 | 19 | // Return unchanged if it's already in a valid format 20 | return dateTime; 21 | }; 22 | -------------------------------------------------------------------------------- /src/tools/matrix-tool/MatrixTool.output.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | // Waypoint object schema (used for both sources and destinations) 7 | const MatrixWaypointSchema = z.object({ 8 | name: z.string(), 9 | location: z.tuple([z.number(), z.number()]), 10 | distance: z.number() 11 | }); 12 | 13 | // Main Matrix API response schema 14 | export const MatrixResponseSchema = z.object({ 15 | code: z.string(), 16 | durations: z.array(z.array(z.number().nullable())).optional(), 17 | distances: z.array(z.array(z.number().nullable())).optional(), 18 | sources: z.array(MatrixWaypointSchema), 19 | destinations: z.array(MatrixWaypointSchema), 20 | message: z.string().optional() // Present in error responses 21 | }); 22 | 23 | export type MatrixResponse = z.infer; 24 | export type MatrixWaypoint = z.infer; 25 | -------------------------------------------------------------------------------- /src/tools/category-list-tool/CategoryListTool.input.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | import { languageSchema } from '../../schemas/shared.js'; 6 | 7 | export const CategoryListInputSchema = z.object({ 8 | language: languageSchema.optional(), 9 | limit: z 10 | .number() 11 | .min(1) 12 | .max(100) 13 | .optional() 14 | .describe( 15 | 'Number of categories to return (1-100). WARNING: Only use this parameter if you need to optimize token usage. If using pagination, please make multiple calls to retrieve all categories before proceeding with other tasks. If not specified, returns all categories.' 16 | ), 17 | offset: z 18 | .number() 19 | .min(0) 20 | .optional() 21 | .default(0) 22 | .describe('Number of categories to skip for pagination. Default is 0.') 23 | }); 24 | 25 | export type CategoryListInput = z.infer; 26 | -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", 3 | "name": "io.github.mapbox/mcp-server", 4 | "description": "Geospatial intelligence with Mapbox APIs like geocoding, POI search, directions, isochrones, etc.", 5 | "repository": { 6 | "url": "https://github.com/mapbox/mcp-server", 7 | "source": "github" 8 | }, 9 | "version": "0.7.0", 10 | "packages": [ 11 | { 12 | "registryType": "npm", 13 | "registryBaseUrl": "https://registry.npmjs.org", 14 | "runtimeHint": "npx", 15 | "version": "0.7.0", 16 | "identifier": "@mapbox/mcp-server", 17 | "transport": { 18 | "type": "stdio" 19 | }, 20 | "environmentVariables": [ 21 | { 22 | "description": "Your Mapbox access token", 23 | "format": "string", 24 | "isRequired": true, 25 | "isSecret": true, 26 | "name": "MAPBOX_ACCESS_TOKEN" 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /plop-templates/tool.test.hbs: -------------------------------------------------------------------------------- 1 | import { {{pascalCase name}}Tool } from '../{{kebabCase name}}-tool/{{pascalCase name}}Tool.js'; 2 | import { setupFetch, assertHeadersSent } from '../../utils/requestUtils.test-helpers.js'; 3 | 4 | describe('{{pascalCase name}}Tool', () => { 5 | afterEach(() => { 6 | jest.restoreAllMocks(); 7 | }); 8 | 9 | it('sends custom header', async () => { 10 | const mockFetch = setupFetch(); 11 | await new {{pascalCase name}}Tool().run({...}); 12 | assertHeadersSent(mockFetch); 13 | }); 14 | 15 | it('handles fetch errors gracefully', async () => { 16 | const mockFetch = setupFetch({ 17 | ok: false, 18 | status: 404, 19 | statusText: 'Not Found' 20 | }); 21 | 22 | const result = await new {{pascalCase name}}Tool().run({...}); 23 | 24 | expect(result.isError).toBe(true); 25 | expect(result.content[0]).toMatchObject({ 26 | type: 'text', 27 | text: 'Request failed with status 404: Not Found' 28 | }); 29 | assertHeadersSent(mockFetch); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "dxt_version": "0.1", 3 | "display_name": "Mapbox MCP Server", 4 | "name": "@mapbox/mcp-server", 5 | "version": "0.7.0", 6 | "description": "Mapbox MCP server.", 7 | "author": { 8 | "name": "Mapbox, Inc." 9 | }, 10 | "server": { 11 | "type": "node", 12 | "entry_point": "dist/esm/index.js", 13 | "mcp_config": { 14 | "command": "node", 15 | "args": [ 16 | "${__dirname}/dist/esm/index.js" 17 | ], 18 | "env": { 19 | "MAPBOX_ACCESS_TOKEN": "${user_config.MAPBOX_ACCESS_TOKEN}" 20 | } 21 | } 22 | }, 23 | "user_config": { 24 | "MAPBOX_ACCESS_TOKEN": { 25 | "type": "string", 26 | "title": "Mapbox access_token", 27 | "description": "Enter your Mapbox secret access token to get started, if you don't have one, please register from https://account.mapbox.com/access-tokens/", 28 | "required": true, 29 | "sensitive": true 30 | } 31 | }, 32 | "license": "BSD-3-Clause", 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/mapbox/mcp-server.git" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mapbox, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.husky/setup-hooks.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync, mkdirSync, chmodSync, constants } from 'node:fs'; 2 | import { execSync } from 'node:child_process'; 3 | import { platform } from 'node:os'; 4 | 5 | mkdirSync('.husky', { recursive: true }); 6 | 7 | execSync('git config core.hooksPath .husky'); 8 | 9 | writeFileSync( 10 | '.husky/pre-commit', 11 | `#!/usr/bin/env sh 12 | npm run sync-manifest 13 | git add manifest.json 14 | npx lint-staged` 15 | ); 16 | 17 | // Cross-platform way to make the file executable 18 | if (platform() === 'win32') { 19 | // On Windows, executable permissions don't matter as much 20 | console.log('pre-commit script created.'); 21 | } else { 22 | // On Unix systems, use chmod 23 | try { 24 | execSync('chmod +x .husky/pre-commit'); 25 | console.log('pre-commit script created.'); 26 | } catch { 27 | // Fallback to Node.js fs.chmodSync if available 28 | try { 29 | chmodSync( 30 | '.husky/pre-commit', 31 | constants.S_IRWXU | constants.S_IRGRP | constants.S_IXGRP 32 | ); // 0o750 33 | console.log('pre-commit script created.'); 34 | } catch { 35 | console.warn( 36 | 'Warning: Could not set executable permissions on the hook file' 37 | ); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/utils/httpPipelineUtils.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { expect, vi } from 'vitest'; 5 | import type { Mock } from 'vitest'; 6 | import { HttpPipeline, UserAgentPolicy } from '../../src/utils/httpPipeline.js'; 7 | 8 | export function setupHttpRequest(overrides?: Partial) { 9 | const mockHttpRequest = vi.fn(); 10 | mockHttpRequest.mockResolvedValue({ 11 | ok: true, 12 | status: 200, 13 | statusText: 'OK', 14 | json: async () => ({ success: true }), 15 | arrayBuffer: async () => new ArrayBuffer(0), 16 | ...overrides 17 | }); 18 | 19 | // Build a real pipeline with UserAgentPolicy 20 | const userAgent = 'TestServer/1.0.0 (default, no-tag, abcdef)'; 21 | const pipeline = new HttpPipeline(mockHttpRequest); 22 | pipeline.usePolicy(new UserAgentPolicy(userAgent)); 23 | 24 | return { httpRequest: pipeline.execute.bind(pipeline), mockHttpRequest }; 25 | } 26 | 27 | export function assertHeadersSent(mockFetch: Mock) { 28 | expect(mockFetch).toHaveBeenCalledTimes(1); 29 | const callArgs = mockFetch.mock.calls[0]; 30 | const requestInit = callArgs[1]; 31 | expect(requestInit?.headers).toMatchObject({ 32 | 'User-Agent': expect.any(String) 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/resources/resourceRegistry.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | // INSERT NEW RESOURCE IMPORT HERE 5 | import { CategoryListResource } from './category-list/CategoryListResource.js'; 6 | import { httpRequest } from '../utils/httpPipeline.js'; 7 | 8 | // Central registry of all resources 9 | export const ALL_RESOURCES = [ 10 | // INSERT NEW RESOURCE INSTANCE HERE 11 | new CategoryListResource({ httpRequest }) 12 | ] as const; 13 | 14 | export type ResourceInstance = (typeof ALL_RESOURCES)[number]; 15 | 16 | export function getAllResources(): readonly ResourceInstance[] { 17 | return ALL_RESOURCES; 18 | } 19 | 20 | export function getResourceByUri(uri: string): ResourceInstance | undefined { 21 | // Find exact match first 22 | const exactMatch = ALL_RESOURCES.find((resource) => resource.uri === uri); 23 | if (exactMatch) return exactMatch; 24 | 25 | // Find pattern match (e.g., mapbox://categories/ja matches mapbox://categories) 26 | return ALL_RESOURCES.find((resource) => { 27 | // Check if the URI starts with the resource's base URI 28 | const basePattern = resource.uri.replace(/\*/g, '.*'); 29 | const regex = new RegExp(`^${basePattern}`); 30 | return regex.test(uri); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /test/utils/dateUtils.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { describe, it, expect } from 'vitest'; 5 | import { formatIsoDateTime } from '../../src/utils/dateUtils.js'; 6 | 7 | describe('formatIsoDateTime', () => { 8 | it('converts YYYY-MM-DDThh:mm:ss to YYYY-MM-DDThh:mm by removing seconds', () => { 9 | const input = '2025-06-05T10:30:45'; 10 | const expected = '2025-06-05T10:30'; 11 | 12 | expect(formatIsoDateTime(input)).toBe(expected); 13 | }); 14 | 15 | it('leaves YYYY-MM-DDThh:mm:ssZ format unchanged', () => { 16 | const input = '2025-06-05T10:30:45Z'; 17 | 18 | expect(formatIsoDateTime(input)).toBe(input); 19 | }); 20 | 21 | it('leaves YYYY-MM-DDThh:mm:ss±hh:mm format unchanged', () => { 22 | const input = '2025-06-05T10:30:45+02:00'; 23 | 24 | expect(formatIsoDateTime(input)).toBe(input); 25 | }); 26 | 27 | it('leaves YYYY-MM-DDThh:mm format unchanged', () => { 28 | const input = '2025-06-05T10:30'; 29 | 30 | expect(formatIsoDateTime(input)).toBe(input); 31 | }); 32 | 33 | it('handles edge cases with zeros in seconds', () => { 34 | const input = '2025-06-05T10:30:00'; 35 | const expected = '2025-06-05T10:30'; 36 | 37 | expect(formatIsoDateTime(input)).toBe(expected); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/project/version-consistency.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { readFileSync } from 'node:fs'; 3 | import { join, dirname } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)); 7 | 8 | describe('Version Consistency', () => { 9 | it('should have matching versions in package.json, server.json, and manifest.json', () => { 10 | const packageJsonPath = join(__dirname, '..', '..', 'package.json'); 11 | const serverJsonPath = join(__dirname, '..', '..', 'server.json'); 12 | const manifestJsonPath = join(__dirname, '..', '..', 'manifest.json'); 13 | 14 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); 15 | const serverJson = JSON.parse(readFileSync(serverJsonPath, 'utf-8')); 16 | const manifestJson = JSON.parse(readFileSync(manifestJsonPath, 'utf-8')); 17 | 18 | const packageVersion = packageJson.version; 19 | const serverVersion = serverJson.version; 20 | const serverPackageVersion = serverJson.packages?.[0]?.version; 21 | const manifestVersion = manifestJson.version; 22 | 23 | expect(serverVersion).toBe(packageVersion); 24 | expect(serverPackageVersion).toBe(packageVersion); 25 | expect(manifestVersion).toBe(packageVersion); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/schemas/shared.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | export const longitudeSchema = z.number().min(-180).max(180); 7 | 8 | export const latitudeSchema = z.number().min(-90).max(90); 9 | 10 | /** 11 | * Zod schema for a coordinate object with longitude and latitude properties. 12 | * Longitude must be between -180 and 180. Latitude must be between -90 and 90. 13 | */ 14 | export const coordinateSchema = z.object({ 15 | longitude: z 16 | .number() 17 | .min(-180, 'Longitude must be between -180 and 180 degrees') 18 | .max(180, 'Longitude must be between -180 and 180 degrees'), 19 | latitude: z 20 | .number() 21 | .min(-90, 'Latitude must be between -90 and 90 degrees') 22 | .max(90, 'Latitude must be between -90 and 90 degrees') 23 | }); 24 | 25 | export const countrySchema = z 26 | .array(z.string().length(2)) 27 | .describe('Array of ISO 3166 alpha 2 country codes to limit results'); 28 | 29 | export const languageSchema = z 30 | .string() 31 | .describe( 32 | 'IETF language tag for the response (e.g., "en", "es", "fr", "de", "ja")' 33 | ); 34 | 35 | export const bboxSchema = z.object({ 36 | minLongitude: z.number().min(-180).max(180), 37 | minLatitude: z.number().min(-90).max(90), 38 | maxLongitude: z.number().min(-180).max(180), 39 | maxLatitude: z.number().min(-90).max(90) 40 | }); 41 | -------------------------------------------------------------------------------- /scripts/build-helpers.cjs: -------------------------------------------------------------------------------- 1 | // Cross-platform build helper script 2 | const fs = require('node:fs'); 3 | const path = require('node:path'); 4 | const process = require('node:process'); 5 | const { execSync } = require('child_process'); 6 | 7 | // Create directory recursively (cross-platform equivalent of mkdir -p) 8 | function mkdirp(dirPath) { 9 | const absolutePath = path.resolve(dirPath); 10 | if (!fs.existsSync(absolutePath)) { 11 | fs.mkdirSync(absolutePath, { recursive: true }); 12 | } 13 | } 14 | 15 | // Generate version info 16 | function generateVersion() { 17 | mkdirp('dist'); 18 | 19 | const sha = execSync('git rev-parse HEAD').toString().trim(); 20 | const tag = execSync('git describe --tags --always').toString().trim(); 21 | const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); 22 | const version = process.env.npm_package_version; 23 | 24 | const versionInfo = { 25 | sha, 26 | tag, 27 | branch, 28 | version 29 | }; 30 | 31 | fs.writeFileSync('dist/esm/version.json', JSON.stringify(versionInfo, null, 2)); 32 | fs.writeFileSync('dist/commonjs/version.json', JSON.stringify(versionInfo, null, 2)); 33 | 34 | console.log('Generated version.json:', versionInfo); 35 | } 36 | 37 | // Process command line arguments 38 | const command = process.argv[2]; 39 | 40 | switch (command) { 41 | case 'generate-version': 42 | generateVersion(); 43 | break; 44 | default: 45 | console.error('Unknown command:', command); 46 | process.exit(1); 47 | } 48 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import { defineConfig } from 'eslint/config'; 3 | import tseslint from 'typescript-eslint'; 4 | import globals from 'globals'; 5 | import nPlugin from 'eslint-plugin-n'; 6 | import prettierPluginRecommended from 'eslint-plugin-prettier/recommended'; 7 | import unusedImports from 'eslint-plugin-unused-imports'; 8 | 9 | export default defineConfig( 10 | eslint.configs.recommended, 11 | tseslint.configs.recommended, 12 | prettierPluginRecommended, 13 | { 14 | plugins: { 15 | n: nPlugin, 16 | 'unused-imports': unusedImports 17 | }, 18 | languageOptions: { 19 | ecmaVersion: 'latest', 20 | sourceType: 'module', 21 | globals: { 22 | ...globals.node 23 | } 24 | }, 25 | rules: { 26 | '@typescript-eslint/no-explicit-any': 'warn', 27 | '@typescript-eslint/consistent-type-imports': [ 28 | 'error', 29 | { 30 | prefer: 'type-imports', 31 | disallowTypeAnnotations: false 32 | } 33 | ], 34 | 'n/prefer-node-protocol': 'warn', 35 | 'unused-imports/no-unused-imports': 'error', 36 | 'unused-imports/no-unused-vars': [ 37 | 'warn', 38 | { 39 | vars: 'all', 40 | varsIgnorePattern: '^_', 41 | args: 'after-used', 42 | argsIgnorePattern: '^_' 43 | } 44 | ] 45 | } 46 | }, 47 | { 48 | files: ['test/**/*.ts'], 49 | rules: { 50 | '@typescript-eslint/no-unused-vars': 'off' 51 | } 52 | } 53 | ); 54 | -------------------------------------------------------------------------------- /plop-templates/tool.hbs: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; 3 | import type { HttpRequest } from '../../utils/types.js'; 4 | import { {{pascalCase name}}InputSchema } from './{{pascalCase name}}Tool.input.schema.js'; 5 | import { 6 | {{pascalCase name}}OutputSchema, 7 | type {{pascalCase name}}Output 8 | } from './{{pascalCase name}}Tool.output.schema.js'; 9 | 10 | /** 11 | * {{pascalCase name}}Tool - TODO: Add description 12 | */ 13 | export class {{pascalCase name}}Tool extends MapboxApiBasedTool< 14 | typeof {{pascalCase name}}InputSchema, 15 | typeof {{pascalCase name}}OutputSchema 16 | > { 17 | name = '{{toolName}}'; 18 | description = 'TODO: Add tool description here.'; 19 | 20 | constructor({ httpRequest }: { httpRequest: HttpRequest }) { 21 | super({ 22 | inputSchema: {{pascalCase name}}InputSchema, 23 | outputSchema: {{pascalCase name}}OutputSchema, 24 | httpRequest 25 | }); 26 | } 27 | 28 | /** 29 | * Execute the tool logic 30 | * @param input - Validated input from {{pascalCase name}}InputSchema 31 | * @returns CallToolResult with structured output 32 | */ 33 | protected async execute( 34 | input: z.infer 35 | ): Promise { 36 | ... write your logic here ... 37 | // e.g.: 38 | // const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}...?access_token=${MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN}`; 39 | // const response = await fetch(url); 40 | // const data = await response.json(); 41 | // return data; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/versionUtils-cjs.cts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { readFileSync } from 'node:fs'; 5 | import path from 'node:path'; 6 | 7 | export interface VersionInfo { 8 | name: string; 9 | version: string; 10 | sha: string; 11 | tag: string; 12 | branch: string; 13 | } 14 | 15 | export function getVersionInfo(): VersionInfo { 16 | const name = 'Mapbox MCP server'; 17 | try { 18 | const dirname = __dirname; 19 | 20 | // Try to read from version.json first (for build artifacts) 21 | const versionJsonPath = path.resolve(dirname, '..', 'version.json'); 22 | try { 23 | const versionData = readFileSync(versionJsonPath, 'utf-8'); 24 | const info = JSON.parse(versionData) as VersionInfo; 25 | info['name'] = name; 26 | return info; 27 | } catch { 28 | // Fall back to package.json 29 | const packageJsonPath = path.resolve( 30 | dirname, 31 | '..', 32 | '..', 33 | '..', 34 | 'package.json' 35 | ); 36 | const packageData = readFileSync(packageJsonPath, 'utf-8'); 37 | const packageInfo = JSON.parse(packageData); 38 | 39 | return { 40 | name: name, 41 | version: packageInfo.version || '0.0.0', 42 | sha: 'unknown', 43 | tag: 'unknown', 44 | branch: 'unknown' 45 | }; 46 | } 47 | } catch (error) { 48 | console.warn(`Failed to read version info: ${error}`); 49 | return { 50 | name: name, 51 | version: '0.0.0', 52 | sha: 'unknown', 53 | tag: 'unknown', 54 | branch: 'unknown' 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general.yaml: -------------------------------------------------------------------------------- 1 | name: General Issue 2 | description: Report a bug, request a feature, or propose a task 3 | title: "[Issue] " 4 | labels: [needs-triage] 5 | body: 6 | - type: dropdown 7 | id: type 8 | attributes: 9 | label: Type of Issue 10 | description: What kind of issue is being reported? 11 | options: 12 | - Bug Report 13 | - Feature Request 14 | - Refactor / Cleanup 15 | - Documentation 16 | - Question 17 | validations: 18 | required: true 19 | 20 | - type: input 21 | id: context 22 | attributes: 23 | label: Context or Problem 24 | description: What was observed or what problem is this issue addressing? 25 | placeholder: e.g., "The tool crashes when..." 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: steps 31 | attributes: 32 | label: Steps to Reproduce or Task Description 33 | description: Provide a clear sequence of actions to replicate the issue or complete the task. 34 | placeholder: | 35 | 1. Open the app 36 | 2. Click "Run" 37 | 3. Observe error message 38 | validations: 39 | required: false 40 | 41 | - type: textarea 42 | id: expected 43 | attributes: 44 | label: Expected Outcome 45 | description: What was expected to happen? 46 | placeholder: "The tool should complete without errors..." 47 | validations: 48 | required: false 49 | 50 | - type: textarea 51 | id: notes 52 | attributes: 53 | label: Additional Notes or References 54 | description: Include logs, screenshots, or links that support this issue. 55 | placeholder: "Logs, screenshots, related issues, design notes..." 56 | validations: 57 | required: false -------------------------------------------------------------------------------- /src/tools/toolRegistry.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | // INSERT NEW TOOL IMPORT HERE 5 | import { CategoryListTool } from './category-list-tool/CategoryListTool.js'; 6 | import { CategorySearchTool } from './category-search-tool/CategorySearchTool.js'; 7 | import { DirectionsTool } from './directions-tool/DirectionsTool.js'; 8 | import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; 9 | import { MatrixTool } from './matrix-tool/MatrixTool.js'; 10 | import { ResourceReaderTool } from './resource-reader-tool/ResourceReaderTool.js'; 11 | import { ReverseGeocodeTool } from './reverse-geocode-tool/ReverseGeocodeTool.js'; 12 | import { StaticMapImageTool } from './static-map-image-tool/StaticMapImageTool.js'; 13 | import { SearchAndGeocodeTool } from './search-and-geocode-tool/SearchAndGeocodeTool.js'; 14 | import { VersionTool } from './version-tool/VersionTool.js'; 15 | import { httpRequest } from '../utils/httpPipeline.js'; 16 | 17 | // Central registry of all tools 18 | export const ALL_TOOLS = [ 19 | // INSERT NEW TOOL INSTANCE HERE 20 | new VersionTool(), 21 | new ResourceReaderTool(), 22 | new CategoryListTool({ httpRequest }), 23 | new CategorySearchTool({ httpRequest }), 24 | new DirectionsTool({ httpRequest }), 25 | new IsochroneTool({ httpRequest }), 26 | new MatrixTool({ httpRequest }), 27 | new ReverseGeocodeTool({ httpRequest }), 28 | new StaticMapImageTool({ httpRequest }), 29 | new SearchAndGeocodeTool({ httpRequest }) 30 | ] as const; 31 | 32 | export type ToolInstance = (typeof ALL_TOOLS)[number]; 33 | 34 | export function getAllTools(): readonly ToolInstance[] { 35 | return ALL_TOOLS; 36 | } 37 | 38 | export function getToolByName(name: string): ToolInstance | undefined { 39 | return ALL_TOOLS.find((tool) => tool.name === name); 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/versionUtils.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { readFileSync } from 'node:fs'; 5 | import path from 'node:path'; 6 | import { fileURLToPath } from 'node:url'; 7 | 8 | export interface VersionInfo { 9 | name: string; 10 | version: string; 11 | sha: string; 12 | tag: string; 13 | branch: string; 14 | } 15 | 16 | export function getVersionInfo(): VersionInfo { 17 | const name = 'Mapbox MCP server'; 18 | try { 19 | /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ 20 | // @ts-ignore CJS build will fail with this line, but ESM needs it 21 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 22 | 23 | // Try to read from version.json first (for build artifacts) 24 | const versionJsonPath = path.resolve(dirname, '..', 'version.json'); 25 | try { 26 | const versionData = readFileSync(versionJsonPath, 'utf-8'); 27 | const info = JSON.parse(versionData) as VersionInfo; 28 | info.name = name; 29 | return info; 30 | } catch { 31 | // Fall back to package.json 32 | const packageJsonPath = path.resolve( 33 | dirname, 34 | '..', 35 | '..', 36 | '..', 37 | 'package.json' 38 | ); 39 | const packageData = readFileSync(packageJsonPath, 'utf-8'); 40 | const packageInfo = JSON.parse(packageData); 41 | 42 | return { 43 | name: name, 44 | version: packageInfo.version || '0.0.0', 45 | sha: 'unknown', 46 | tag: 'unknown', 47 | branch: 'unknown' 48 | }; 49 | } 50 | } catch (error) { 51 | console.warn(`Failed to read version info: ${error}`); 52 | return { 53 | name: name, 54 | version: '0.0.0', 55 | sha: 'unknown', 56 | tag: 'unknown', 57 | branch: 'unknown' 58 | }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/tools/tool-naming-convention.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { describe, it, expect, vi } from 'vitest'; 5 | import { getAllTools } from '../../src/tools/toolRegistry.js'; 6 | 7 | // Mock getVersionInfo to avoid import.meta.url issues in vitest 8 | vi.mock('../../src/utils/versionUtils.js', () => ({ 9 | getVersionInfo: vi.fn(() => ({ 10 | name: 'Mapbox MCP server', 11 | version: '1.0.0', 12 | sha: 'mock-sha', 13 | tag: 'mock-tag', 14 | branch: 'mock-branch' 15 | })) 16 | })); 17 | 18 | describe('Tool Naming Convention', () => { 19 | // Dynamically get all tools from the central registry 20 | const tools = [...getAllTools()]; 21 | 22 | function isSnakeCase(str: string): boolean { 23 | return /^[a-z0-9_]+$/.test(str) && !str.includes('__'); 24 | } 25 | 26 | function endsWithTool(str: string): boolean { 27 | return str.endsWith('_tool'); 28 | } 29 | 30 | const toolData = tools.map((tool) => ({ 31 | className: tool.constructor.name, 32 | name: tool.name 33 | })); 34 | 35 | it.each(toolData)('$className should have snake_case name', ({ name }) => { 36 | expect(isSnakeCase(name)).toBe(true); 37 | }); 38 | 39 | it.each(toolData)( 40 | '$className should have name ending with "_tool"', 41 | ({ name }) => { 42 | expect(endsWithTool(name)).toBe(true); 43 | } 44 | ); 45 | 46 | it('all tools should follow both naming conventions', () => { 47 | const violations = tools 48 | .filter((tool) => !isSnakeCase(tool.name) || !endsWithTool(tool.name)) 49 | .map((tool) => ({ 50 | className: tool.constructor.name, 51 | name: tool.name, 52 | isSnakeCase: isSnakeCase(tool.name), 53 | endsWithTool: endsWithTool(tool.name) 54 | })); 55 | 56 | expect(violations).toEqual([]); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/resources/BaseResource.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 5 | import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; 6 | import type { 7 | ServerRequest, 8 | ServerNotification, 9 | ReadResourceResult 10 | } from '@modelcontextprotocol/sdk/types.js'; 11 | 12 | /** 13 | * Base class for all MCP resources 14 | */ 15 | export abstract class BaseResource { 16 | abstract readonly uri: string; 17 | abstract readonly name: string; 18 | abstract readonly description?: string; 19 | abstract readonly mimeType?: string; 20 | 21 | protected server: McpServer | null = null; 22 | 23 | /** 24 | * Installs the resource to the given MCP server. 25 | */ 26 | installTo(server: McpServer): void { 27 | this.server = server; 28 | 29 | server.registerResource( 30 | this.name, 31 | this.uri, 32 | { 33 | title: this.name, 34 | description: this.description, 35 | mimeType: this.mimeType 36 | }, 37 | 38 | ( 39 | uri: URL, 40 | extra: RequestHandlerExtra 41 | ) => this.read(uri.toString(), extra) 42 | ); 43 | } 44 | 45 | /** 46 | * Resource read logic to be implemented by subclasses. 47 | * @param uri The resource URI as a string 48 | * @param extra Additional request context 49 | */ 50 | abstract read( 51 | uri: string, 52 | extra?: RequestHandlerExtra 53 | ): Promise; 54 | 55 | /** 56 | * Helper method to send logging messages 57 | */ 58 | protected log( 59 | level: 'debug' | 'info' | 'warning' | 'error', 60 | data: unknown 61 | ): void { 62 | if (this.server?.server) { 63 | void this.server.server.sendLoggingMessage({ level, data }); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/tools/category-search-tool/CategorySearchTool.input.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | import { languageSchema, bboxSchema } from '../../schemas/shared.js'; 6 | 7 | export const CategorySearchInputSchema = z.object({ 8 | category: z 9 | .string() 10 | .describe( 11 | 'The canonical place category name to search for (e.g., "restaurant", "hotel", "cafe"). To get the full list of supported categories, use the category_list_tool.' 12 | ), 13 | language: languageSchema.optional(), 14 | limit: z 15 | .number() 16 | .min(1) 17 | .max(25) 18 | .optional() 19 | .default(10) 20 | .describe('Maximum number of results to return (1-25)'), 21 | proximity: z 22 | .object({ 23 | longitude: z.number().min(-180).max(180), 24 | latitude: z.number().min(-90).max(90) 25 | }) 26 | .optional() 27 | .describe( 28 | 'Location to bias results towards as {longitude, latitude}. If not provided, defaults to IP-based location.' 29 | ), 30 | bbox: bboxSchema 31 | .describe('Bounding box to limit results within specified bounds') 32 | .optional(), 33 | country: z 34 | .array(z.string().length(2)) 35 | .optional() 36 | .describe('Array of ISO 3166 alpha 2 country codes to limit results'), 37 | poi_category_exclusions: z 38 | .array(z.string()) 39 | .optional() 40 | .describe('Array of POI categories to exclude from results'), 41 | format: z 42 | .enum(['json_string', 'formatted_text']) 43 | .optional() 44 | .default('formatted_text') 45 | .describe( 46 | 'Output format: "json_string" returns raw GeoJSON data as a JSON string that can be parsed; "formatted_text" returns human-readable text with place names, addresses, and coordinates. Both return as text content but json_string contains parseable JSON data while formatted_text is for display.' 47 | ) 48 | }); 49 | -------------------------------------------------------------------------------- /test/tools/resource-reader-tool/ResourceReaderTool.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | process.env.MAPBOX_ACCESS_TOKEN = 'pk.eyJzdWIiOiJ0ZXN0In0.signature'; 5 | 6 | import { describe, it, expect } from 'vitest'; 7 | import { ResourceReaderTool } from '../../../src/tools/resource-reader-tool/ResourceReaderTool.js'; 8 | 9 | describe('ResourceReaderTool', () => { 10 | it('returns error for invalid resource URI', async () => { 11 | const tool = new ResourceReaderTool(); 12 | const result = await tool.run({ 13 | uri: 'mapbox://invalid-resource' 14 | }); 15 | 16 | expect(result.isError).toBe(true); 17 | expect(result.content[0].type).toBe('text'); 18 | const text = (result.content[0] as { type: 'text'; text: string }).text; 19 | expect(text).toContain('Resource not found'); 20 | }); 21 | 22 | it('validates input parameters correctly', () => { 23 | const tool = new ResourceReaderTool(); 24 | 25 | expect(() => 26 | tool.inputSchema.parse({ uri: 'mapbox://categories' }) 27 | ).not.toThrow(); 28 | expect(() => 29 | tool.inputSchema.parse({ uri: 'mapbox://categories/ja' }) 30 | ).not.toThrow(); 31 | 32 | // Invalid: missing URI 33 | expect(() => tool.inputSchema.parse({})).toThrow(); 34 | 35 | // Invalid: empty URI 36 | expect(() => tool.inputSchema.parse({ uri: '' })).toThrow(); 37 | }); 38 | 39 | it('should have output schema defined', () => { 40 | const tool = new ResourceReaderTool(); 41 | expect(tool.outputSchema).toBeDefined(); 42 | expect(tool.outputSchema).toBeTruthy(); 43 | }); 44 | 45 | it('has correct tool metadata', () => { 46 | const tool = new ResourceReaderTool(); 47 | 48 | expect(tool.name).toBe('resource_reader_tool'); 49 | expect(tool.description).toContain('MCP resource'); 50 | expect(tool.annotations).toMatchObject({ 51 | readOnlyHint: true, 52 | destructiveHint: false, 53 | idempotentHint: true, 54 | openWorldHint: true 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/tools/reverse-geocode-tool/ReverseGeocodeTool.input.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | import { 6 | longitudeSchema, 7 | latitudeSchema, 8 | countrySchema, 9 | languageSchema 10 | } from '../../schemas/shared.js'; 11 | 12 | export const ReverseGeocodeInputSchema = z.object({ 13 | longitude: longitudeSchema.describe( 14 | 'Longitude coordinate to reverse geocode' 15 | ), 16 | latitude: latitudeSchema.describe('Latitude coordinate to reverse geocode'), 17 | permanent: z 18 | .boolean() 19 | .optional() 20 | .default(false) 21 | .describe('Whether results can be stored permanently'), 22 | country: countrySchema.optional(), 23 | language: languageSchema.optional(), 24 | limit: z 25 | .number() 26 | .min(1) 27 | .max(5) 28 | .optional() 29 | .default(1) 30 | .describe( 31 | 'Maximum number of results (1-5). Use 1 for best results. If you need more than 1 result, you must specify exactly one type in the types parameter.' 32 | ), 33 | types: z 34 | .array( 35 | z.enum([ 36 | 'country', 37 | 'region', 38 | 'postcode', 39 | 'district', 40 | 'place', 41 | 'locality', 42 | 'neighborhood', 43 | 'address' 44 | ]) 45 | ) 46 | .optional() 47 | .describe('Array of feature types to filter results'), 48 | worldview: z 49 | .enum(['us', 'cn', 'jp', 'in']) 50 | .optional() 51 | .default('us') 52 | .describe('Returns features from a specific regional perspective'), 53 | format: z 54 | .enum(['json_string', 'formatted_text']) 55 | .optional() 56 | .default('formatted_text') 57 | .describe( 58 | 'Output format: "json_string" returns raw GeoJSON data as a JSON string that can be parsed; "formatted_text" returns human-readable text with place names, addresses, and coordinates. Both return as text content but json_string contains parseable JSON data while formatted_text is for display.' 59 | ) 60 | }); 61 | -------------------------------------------------------------------------------- /src/tools/search-and-geocode-tool/SearchAndGeocodeTool.input.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | import { bboxSchema, countrySchema } from '../../schemas/shared.js'; 6 | 7 | export const SearchAndGeocodeInputSchema = z.object({ 8 | q: z 9 | .string() 10 | .max(256) 11 | .describe('Search query text. Limited to 256 characters.'), 12 | language: z 13 | .string() 14 | .optional() 15 | .describe( 16 | 'ISO language code for the response (e.g., "en", "es", "fr", "de", "ja")' 17 | ), 18 | proximity: z 19 | .object({ 20 | longitude: z.number().min(-180).max(180), 21 | latitude: z.number().min(-90).max(90) 22 | }) 23 | .optional() 24 | .describe( 25 | 'Location to bias results towards as {longitude, latitude}. If not provided, defaults to IP-based location. STRONGLY ENCOURAGED for relevant results.' 26 | ), 27 | bbox: bboxSchema 28 | .optional() 29 | .describe( 30 | 'Bounding box to limit results within [minLon, minLat, maxLon, maxLat]' 31 | ), 32 | country: countrySchema 33 | .optional() 34 | .describe('Array of ISO 3166 alpha 2 country codes to limit results'), 35 | types: z 36 | .array(z.string()) 37 | .optional() 38 | .describe( 39 | 'Array of feature types to filter results (e.g., ["poi", "address", "place"])' 40 | ), 41 | poi_category: z 42 | .array(z.string()) 43 | .optional() 44 | .describe( 45 | 'Array of POI categories to include (e.g., ["restaurant", "cafe"])' 46 | ), 47 | auto_complete: z 48 | .boolean() 49 | .optional() 50 | .describe('Enable partial and fuzzy matching'), 51 | eta_type: z 52 | .enum(['navigation']) 53 | .optional() 54 | .describe('Request estimated time of arrival (ETA) to results'), 55 | navigation_profile: z 56 | .enum(['driving', 'walking', 'cycling', 'driving-traffic']) 57 | .optional() 58 | .describe('Routing profile for ETA calculations'), 59 | origin: z 60 | .object({ 61 | longitude: z.number().min(-180).max(180), 62 | latitude: z.number().min(-90).max(90) 63 | }) 64 | .optional() 65 | }); 66 | -------------------------------------------------------------------------------- /TOOL_CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # Tool Configuration Guide 2 | 3 | The Mapbox MCP Server supports command-line configuration to enable or disable specific tools at startup. 4 | 5 | ## Command-Line Options 6 | 7 | ### --enable-tools 8 | 9 | Enable only specific tools (exclusive mode). When this option is used, only the listed tools will be available. 10 | 11 | ```bash 12 | --enable-tools version_tool,directions_tool 13 | ``` 14 | 15 | ### --disable-tools 16 | 17 | Disable specific tools. All other tools will remain enabled. 18 | 19 | ```bash 20 | --disable-tools static_map_image_tool,matrix_tool 21 | ``` 22 | 23 | ## Available Tools 24 | 25 | The following tools are available in the Mapbox MCP Server: 26 | 27 | - `version_tool` - Get version information 28 | - `category_search_tool` - Search for POIs by category 29 | - `directions_tool` - Get directions between locations 30 | - `forward_geocode_tool` - Convert addresses to coordinates 31 | - `search_and_geocode_tool` - Search for POIs, brands, chains, geocode cities, towns, addresses 32 | - `isochrone_tool` - Calculate reachable areas from a point 33 | - `matrix_tool` - Calculate travel times between multiple points 34 | - `poi_search_tool` - Search for points of interest 35 | - `reverse_geocode_tool` - Convert coordinates to addresses 36 | - `static_map_image_tool` - Generate static map images 37 | 38 | ## Usage Examples 39 | 40 | ### Node.js 41 | 42 | ```bash 43 | node dist/esm/index.js --enable-tools forward_geocode_tool,reverse_geocode_tool 44 | ``` 45 | 46 | ### NPX 47 | 48 | ```bash 49 | npx @mapbox/mcp-server --disable-tools static_map_image_tool 50 | ``` 51 | 52 | ### Docker 53 | 54 | ```bash 55 | docker run mapbox/mcp-server --enable-tools directions_tool,isochrone_tool,matrix_tool 56 | ``` 57 | 58 | ### Claude Desktop App Configuration 59 | 60 | In your Claude Desktop configuration file: 61 | 62 | ```json 63 | { 64 | "mcpServers": { 65 | "mapbox": { 66 | "command": "node", 67 | "args": [ 68 | "/path/to/index.js", 69 | "--enable-tools", 70 | "version_tool,directions_tool" 71 | ] 72 | } 73 | } 74 | } 75 | ``` 76 | 77 | ## Notes 78 | 79 | - If both `--enable-tools` and `--disable-tools` are provided, `--enable-tools` takes precedence 80 | - Tool names must match exactly (case-sensitive) 81 | - Multiple tools can be specified using comma separation 82 | - Invalid tool names are silently ignored 83 | - Arguments are passed after the main command, regardless of how the server is invoked 84 | -------------------------------------------------------------------------------- /plopfile.cjs: -------------------------------------------------------------------------------- 1 | module.exports = function (plop) { 2 | plop.setGenerator('create-tool', { 3 | description: 'Generate a TypeScript class and its test', 4 | prompts: [ 5 | { 6 | type: 'input', 7 | name: 'name', 8 | message: 'Tool class name without suffix using PascalCase e.g. Search:', 9 | }, 10 | { 11 | type: 'input', 12 | name: 'toolName', 13 | message: 'Tool name property in snake_case. Must end with _tool e.g. search_tool:', 14 | }, 15 | ], 16 | actions: [ 17 | { 18 | type: 'add', 19 | path: 'src/tools/{{kebabCase name}}-tool/{{pascalCase name}}Tool.ts', 20 | templateFile: 'plop-templates/tool.hbs', 21 | }, 22 | { 23 | type: 'add', 24 | path: 'test/tools/{{kebabCase name}}-tool/{{pascalCase name}}Tool.test.ts', 25 | templateFile: 'plop-templates/tool.test.hbs', 26 | }, 27 | { 28 | type: 'add', 29 | path: 'src/tools/{{kebabCase name}}-tool/{{pascalCase name}}Tool.input.schema.ts', 30 | templateFile: 'plop-templates/tool.input.schema.hbs', 31 | }, 32 | { 33 | type: 'add', 34 | path: 'src/tools/{{kebabCase name}}-tool/{{pascalCase name}}Tool.output.schema.ts', 35 | templateFile: 'plop-templates/tool.output.schema.hbs', 36 | }, 37 | { 38 | type: 'append', 39 | path: 'src/tools/toolRegistry.ts', 40 | pattern: /(\/\/ INSERT NEW TOOL IMPORT HERE)/, 41 | template: "import { {{pascalCase name}}Tool } from './{{kebabCase name}}-tool/{{pascalCase name}}Tool.js';", 42 | }, 43 | { 44 | type: 'append', 45 | path: 'src/tools/toolRegistry.ts', 46 | pattern: /(\/\/ INSERT NEW TOOL INSTANCE HERE)/, 47 | template: ' new {{pascalCase name}}Tool(),', 48 | }, 49 | { 50 | type: 'append', 51 | path: 'README.md', 52 | pattern: /(### Mapbox API tools)/, 53 | template: '\n\n#### {{titleCase name}} tool\n\nDescription goes here...\nUses the *Link to Mapbox API documentation here*', 54 | }, 55 | ], 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Guidelines for Mapbox MCP Server 2 | 3 | This document defines responsible use of GitHub Copilot in this repository. Copilot is a productivity tool that must work within our engineering standards. 4 | 5 | ## Core Principles 6 | 7 | ### 1. Review, Don't Auto-Accept 8 | 9 | - **Always review** Copilot suggestions before accepting 10 | - **Understand first** - only use code you fully comprehend 11 | - **No blind copy-paste** - adapt suggestions to project context 12 | - **Iterate** - use suggestions as starting points, not final solutions 13 | 14 | ### 2. Security First 15 | 16 | - **Never accept** suggestions with hardcoded secrets, tokens, or credentials 17 | - **Review carefully** any code handling sensitive data 18 | - **Environment variables** - ensure secrets use `.env` files, never committed 19 | 20 | ### 3. Quality Standards 21 | 22 | - All Copilot code must comply with standards in **CLAUDE.md** and **docs/engineering_standards.md** 23 | - Pre-commit hooks will enforce linting, formatting, and TypeScript strictness 24 | - If Copilot suggests patterns inconsistent with the codebase, reject and write manually 25 | 26 | ### 4. Testing & Documentation 27 | 28 | - Include unit tests for all Copilot-generated features or fixes 29 | - Add or update JSDoc comments as you would for hand-written code 30 | - Update README.md or CHANGELOG.md if user-facing changes 31 | 32 | ### 5. Collaboration 33 | 34 | - All Copilot code goes through standard PR review process 35 | - Mention in PR description if significant portion is Copilot-generated 36 | - Provide feedback if Copilot produces poor/unsafe suggestions 37 | 38 | ## When to Avoid Copilot 39 | 40 | - **Critical business logic** - prefer hand-written code with thorough review 41 | - **Security-sensitive code** - authentication, authorization, data validation 42 | - **Legal/compliance code** - licensing, terms of service, privacy policies 43 | - **Architecture decisions** - let humans make strategic choices 44 | 45 | ## Learning from the Codebase 46 | 47 | Copilot learns patterns from existing code. This codebase has strong conventions: 48 | 49 | - Dependency injection for testability (see src/tools/) 50 | - HttpPipeline for all HTTP requests (see src/utils/httpPipeline.ts) 51 | - Consistent tool structure extending MapboxApiBasedTool 52 | 53 | Let Copilot learn these patterns rather than explicitly re-teaching them. 54 | 55 | --- 56 | 57 | **Remember**: Copilot is an assistant, not an authority. You own the code you commit. 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log\* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | 12 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 13 | 14 | # Runtime data 15 | 16 | pids 17 | _.pid 18 | _.seed 19 | \*.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | 27 | coverage 28 | \*.lcov 29 | 30 | # nyc test coverage 31 | 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | 40 | bower_components 41 | 42 | # node-waf configuration 43 | 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | 48 | build/Release 49 | 50 | # Dependency directories 51 | 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # TypeScript v1 declaration files 56 | 57 | typings/ 58 | 59 | # TypeScript cache 60 | 61 | \*.tsbuildinfo 62 | 63 | # Optional npm cache directory 64 | 65 | .npm 66 | 67 | # Optional eslint cache 68 | 69 | .eslintcache 70 | 71 | # Microbundle cache 72 | 73 | .rpt2_cache/ 74 | .rts2_cache_cjs/ 75 | .rts2_cache_es/ 76 | .rts2_cache_umd/ 77 | 78 | # Optional REPL history 79 | 80 | .node_repl_history 81 | 82 | # Output of 'npm pack' 83 | 84 | \*.tgz 85 | 86 | # Yarn Integrity file 87 | 88 | .yarn-integrity 89 | 90 | # dotenv environment variables file 91 | 92 | .env 93 | .env.test 94 | 95 | # parcel-bundler cache (https://parceljs.org/) 96 | 97 | .cache 98 | 99 | # Next.js build output 100 | 101 | .next 102 | 103 | # Nuxt.js build / generate output 104 | 105 | .nuxt 106 | dist 107 | 108 | # Gatsby files 109 | 110 | .cache/ 111 | 112 | # Comment in the public line in if your project uses Gatsby and _not_ Next.js 113 | 114 | # https://nextjs.org/blog/next-9-1#public-directory-support 115 | 116 | # public 117 | 118 | # vuepress build output 119 | 120 | .vuepress/dist 121 | 122 | # Serverless directories 123 | 124 | .serverless/ 125 | 126 | # FuseBox cache 127 | 128 | .fusebox/ 129 | 130 | # DynamoDB Local files 131 | 132 | .dynamodb/ 133 | 134 | # TernJS port file 135 | 136 | .tern-port 137 | 138 | # Typehead 139 | 140 | dist 141 | 142 | # IDE 143 | 144 | .vscode 145 | .venv 146 | .DS_Store 147 | 148 | # tshy 149 | .tshy/ 150 | .tshy-build/ 151 | 152 | # Test results 153 | test-results.xml 154 | -------------------------------------------------------------------------------- /docs/cursor-setup.md: -------------------------------------------------------------------------------- 1 | # Cursor Setup 2 | 3 | This guide explains how to configure Cursor IDE for use with the Mapbox MCP Server. 4 | 5 | ## Requirements 6 | 7 | - [Cursor](https://www.cursor.com/) installed 8 | - Mapbox MCP Server [built locally](../README.md#Inspecting-server) 9 | 10 | ```sh 11 | # from repository root: 12 | # using node 13 | npm run build 14 | 15 | # note your absolute path to node, you will need it for MCP config 16 | # For Mac/Linux 17 | which node 18 | # For Windows 19 | where node 20 | 21 | # or alternatively, using docker 22 | docker build -t mapbox-mcp-server . 23 | ``` 24 | 25 | ## Setup Instructions 26 | 27 | ### Configure Cursor to use Mapbox MCP Server 28 | 29 | 1. Go to Cursor Settings/ MCP Tools and click on "Add Custom MCP". 30 | 2. Add either of the following MCP config: 31 | 32 | - NPM version 33 | ```json 34 | { 35 | "mcpServers": { 36 | "MapboxServer": { 37 | "type": "stdio", 38 | "command": "", 39 | "args": ["-y", "@mapbox/mcp-server"], 40 | "env": { 41 | "MAPBOX_ACCESS_TOKEN": "" 42 | } 43 | } 44 | } 45 | } 46 | ``` 47 | - Docker version 48 | ```json 49 | { 50 | "mcpServers": { 51 | "MapboxServer": { 52 | "type": "stdio", 53 | "command": "docker", 54 | "args": ["run", "-i", "--rm", "mapbox-mcp-server"], 55 | "env": { 56 | "MAPBOX_ACCESS_TOKEN": "" 57 | } 58 | } 59 | } 60 | } 61 | ``` 62 | - Node version 63 | ```json 64 | { 65 | "mcpServers": { 66 | "MapboxServer": { 67 | "type": "stdio", 68 | "command": "", 69 | "args": ["/YOUR_PATH_TO_GIT_REPOSITORY/dist/esm/index.js"], 70 | "env": { 71 | "MAPBOX_ACCESS_TOKEN": "" 72 | } 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | 3. Click "Save" to apply the configuration. 79 | 80 | ## MCP-UI Support 81 | 82 | Cursor IDE does not currently support the MCP-UI specification for embedded interactive elements. When you use tools like `static_map_image_tool`, you'll receive: 83 | 84 | - ✅ **Base64-encoded map images** that Cursor can display 85 | - ❌ **Interactive iframe embeds** (not supported by Cursor IDE) 86 | 87 | The server is fully backwards compatible - all tools work normally, you just won't see interactive map embeds. For more information about MCP-UI support in this server, see the [MCP-UI documentation](mcp-ui.md). 88 | -------------------------------------------------------------------------------- /src/tools/isochrone-tool/IsochroneTool.output.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | /** 7 | * Isochrone feature properties based on Mapbox Isochrone API documentation 8 | * https://docs.mapbox.com/api/navigation/isochrone/ 9 | */ 10 | export const IsochroneFeaturePropertiesSchema = z.object({ 11 | /** The value of the metric used in this contour (time in minutes or distance in meters) */ 12 | contour: z.number().int(), 13 | /** The color of the isochrone line if the geometry is LineString */ 14 | color: z.string().optional(), 15 | /** The opacity of the isochrone line if the geometry is LineString */ 16 | opacity: z.number().optional(), 17 | /** The fill color of the isochrone polygon if the geometry is Polygon (geojson.io format) */ 18 | fill: z.string().optional(), 19 | /** The fill opacity of the isochrone polygon if the geometry is Polygon (geojson.io format) */ 20 | 'fill-opacity': z.number().optional(), 21 | /** The fill color of the isochrone polygon if the geometry is Polygon (Leaflet format) */ 22 | fillColor: z.string().optional(), 23 | /** The fill opacity of the isochrone polygon if the geometry is Polygon (Leaflet format) */ 24 | fillOpacity: z.number().optional(), 25 | /** The metric that the contour represents - either "distance" or "time" */ 26 | metric: z.enum(['distance', 'time']).optional() 27 | }); 28 | 29 | /** 30 | * Isochrone geometry - can be either LineString or Polygon 31 | */ 32 | export const IsochroneGeometrySchema = z.union([ 33 | z.object({ 34 | type: z.literal('LineString'), 35 | coordinates: z.array(z.tuple([z.number(), z.number()])) // [longitude, latitude] pairs 36 | }), 37 | z.object({ 38 | type: z.literal('Polygon'), 39 | coordinates: z.array(z.array(z.tuple([z.number(), z.number()]))) // Array of linear rings 40 | }) 41 | ]); 42 | 43 | /** 44 | * Individual isochrone feature 45 | */ 46 | export const IsochroneFeatureSchema = z.object({ 47 | type: z.literal('Feature'), 48 | properties: IsochroneFeaturePropertiesSchema, 49 | geometry: IsochroneGeometrySchema 50 | }); 51 | 52 | /** 53 | * Complete Isochrone API response 54 | * Returns a GeoJSON FeatureCollection containing isochrone contours 55 | */ 56 | export const IsochroneResponseSchema = z.object({ 57 | type: z.literal('FeatureCollection'), 58 | features: z.array(IsochroneFeatureSchema) 59 | }); 60 | 61 | export type IsochroneResponse = z.infer; 62 | export type IsochroneFeature = z.infer; 63 | export type IsochroneGeometry = z.infer; 64 | export type IsochroneFeatureProperties = z.infer< 65 | typeof IsochroneFeaturePropertiesSchema 66 | >; 67 | -------------------------------------------------------------------------------- /test/tools/directions-tool/DirectionsTool.output.schema.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | process.env.MAPBOX_ACCESS_TOKEN = 5 | 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; 6 | 7 | import { describe, it, expect, vi } from 'vitest'; 8 | import { DirectionsTool } from '../../../src/tools/directions-tool/DirectionsTool.js'; 9 | import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; 10 | 11 | describe('DirectionsTool output schema registration', () => { 12 | it('should have an output schema defined', () => { 13 | const { httpRequest } = setupHttpRequest(); 14 | const tool = new DirectionsTool({ httpRequest }); 15 | expect(tool.outputSchema).toBeDefined(); 16 | expect(tool.outputSchema).toBeTruthy(); 17 | }); 18 | 19 | it('should register output schema with MCP server', () => { 20 | const { httpRequest } = setupHttpRequest(); 21 | const tool = new DirectionsTool({ httpRequest }); 22 | 23 | // Mock the installTo method to verify it gets called with output schema 24 | const mockInstallTo = vi.fn().mockImplementation(() => { 25 | // Verify that the tool has an output schema when being installed 26 | expect(tool.outputSchema).toBeDefined(); 27 | return tool; 28 | }); 29 | 30 | Object.defineProperty(tool, 'installTo', { 31 | value: mockInstallTo 32 | }); 33 | 34 | // Simulate server registration 35 | tool.installTo({} as never); 36 | expect(mockInstallTo).toHaveBeenCalled(); 37 | }); 38 | 39 | it('should validate response structure matches schema', () => { 40 | const { httpRequest } = setupHttpRequest(); 41 | const tool = new DirectionsTool({ httpRequest }); 42 | const mockResponse = { 43 | routes: [ 44 | { 45 | duration: 100, 46 | distance: 1000, 47 | leg_summaries: ['Main St', 'Oak Ave'], 48 | intersecting_admins: ['USA'], 49 | num_legs: 1, 50 | congestion_information: { 51 | length_low: 500, 52 | length_moderate: 300, 53 | length_heavy: 150, 54 | length_severe: 50 55 | }, 56 | average_speed_kph: 45 57 | } 58 | ], 59 | waypoints: [ 60 | { 61 | name: 'Start Location', 62 | snap_location: [-122.4194, 37.7749], 63 | snap_distance: 10 64 | }, 65 | { 66 | name: 'End Location', 67 | snap_location: [-122.4094, 37.7849], 68 | snap_distance: 5 69 | } 70 | ] 71 | }; 72 | 73 | // This should not throw if the schema is correct 74 | expect(() => { 75 | if (tool.outputSchema) { 76 | tool.outputSchema.parse(mockResponse); 77 | } 78 | }).not.toThrow(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /docs/goose-setup.md: -------------------------------------------------------------------------------- 1 | # Goose Setup 2 | 3 | This guide explains how to configure Goose AI agent framework with the Mapbox MCP Server. 4 | 5 | ## Requirements 6 | 7 | - [Goose](https://github.com/block/goose) installed on your system 8 | - A Mapbox access token ([get one here](https://account.mapbox.com/)) 9 | 10 | ## Setup Instructions 11 | 12 | ### 1. Install Goose 13 | 14 | Follow the [official Goose installation instructions](https://github.com/block/goose#installation). 15 | 16 | ### 2. Configure Mapbox MCP Server 17 | 18 | Add the Mapbox MCP Server to Goose's configuration file (typically `~/.config/goose/profiles.yaml`): 19 | 20 | ```yaml 21 | default: 22 | provider: openai 23 | processor: gpt-4 24 | accelerator: gpt-4o-mini 25 | moderator: passive 26 | toolkits: 27 | - name: developer 28 | - name: mcp 29 | requires: 30 | servers: 31 | - name: mapbox 32 | command: npx 33 | args: 34 | - '-y' 35 | - '@mapbox/mcp-server' 36 | env: 37 | MAPBOX_ACCESS_TOKEN: 'your_token_here' 38 | ``` 39 | 40 | Alternatively, if you've built the server locally: 41 | 42 | ```yaml 43 | default: 44 | provider: openai 45 | processor: gpt-4 46 | accelerator: gpt-4o-mini 47 | moderator: passive 48 | toolkits: 49 | - name: developer 50 | - name: mcp 51 | requires: 52 | servers: 53 | - name: mapbox 54 | command: node 55 | args: 56 | - '/path/to/mcp-server/dist/esm/index.js' 57 | env: 58 | MAPBOX_ACCESS_TOKEN: 'your_token_here' 59 | ``` 60 | 61 | ### 3. Start Goose 62 | 63 | ```bash 64 | goose session start 65 | ``` 66 | 67 | ### 4. Try It Out 68 | 69 | Ask Goose to create a map: 70 | 71 | ``` 72 | Show me a map of the Golden Gate Bridge 73 | ``` 74 | 75 | ## MCP-UI Support ✨ 76 | 77 | **Goose supports MCP-UI**, which means you'll see **inline interactive maps** rendered directly in the chat when you use the `static_map_image_tool`. This provides a richer visual experience compared to clients that only display static images. 78 | 79 | For more information about MCP-UI features, see the [MCP-UI documentation](mcp-ui.md). 80 | 81 | ## Troubleshooting 82 | 83 | ### Maps not appearing? 84 | 85 | Verify that: 86 | 87 | 1. Your Mapbox access token is correct 88 | 2. Goose is properly configured (check `~/.config/goose/profiles.yaml`) 89 | 3. The MCP server is accessible (check Goose logs) 90 | 91 | ### Want to disable MCP-UI? 92 | 93 | Add the `--disable-mcp-ui` flag to args: 94 | 95 | ```yaml 96 | args: 97 | - '-y' 98 | - '@mapbox/mcp-server' 99 | - '--disable-mcp-ui' 100 | ``` 101 | 102 | --- 103 | 104 | For more information about Goose, visit the [official Goose repository](https://github.com/block/goose). 105 | -------------------------------------------------------------------------------- /src/tools/isochrone-tool/IsochroneTool.input.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | export const IsochroneInputSchema = z.object({ 7 | profile: z 8 | .enum([ 9 | 'mapbox/driving-traffic', 10 | 'mapbox/driving', 11 | 'mapbox/cycling', 12 | 'mapbox/walking' 13 | ]) 14 | .default('mapbox/driving-traffic') 15 | .describe('Mode of travel.'), 16 | coordinates: z 17 | .object({ 18 | longitude: z.number().min(-180).max(180), 19 | latitude: z.number().min(-90).max(90) 20 | }) 21 | .describe( 22 | 'A coordinate object with longitude and latitude properties around which to center the isochrone lines. Longitude: -180 to 180, Latitude: -85.0511 to 85.0511' 23 | ), 24 | 25 | contours_minutes: z 26 | .array(z.number().int().min(1).max(60)) 27 | .max(4) 28 | .optional() 29 | .describe( 30 | 'Contour times in minutes. Times must be in increasing order. Must be specified either contours_minutes or contours_meters.' 31 | ), 32 | 33 | contours_meters: z 34 | .array(z.number().int().min(1).max(100000)) 35 | .max(4) 36 | .optional() 37 | .describe( 38 | 'Distances in meters. Distances must be in increasing order. Must be specified either contours_minutes or contours_meters.' 39 | ), 40 | 41 | contours_colors: z 42 | .array(z.string().regex(/^[0-9a-fA-F]{6}$/)) 43 | .max(4) 44 | .optional() 45 | .describe( 46 | 'Contour colors as hex strings without starting # (for example ff0000 for red. must match contours_minutes or contours_meters length if provided).' 47 | ), 48 | 49 | polygons: z 50 | .boolean() 51 | .default(false) 52 | .optional() 53 | .describe('Whether to return Polygons (true) or LineStrings (false).'), 54 | 55 | denoise: z 56 | .number() 57 | .min(0) 58 | .max(1) 59 | .default(1) 60 | .optional() 61 | .describe( 62 | 'A floating point value that can be used to remove smaller contours. A value of 1.0 will only return the largest contour for a given value.' 63 | ), 64 | 65 | generalize: z 66 | .number() 67 | .min(0) 68 | .describe( 69 | `Positive number in meters that is used to simplify geometries. 70 | - Walking: use 0-500. Prefer 50-200 for short contours (minutes < 10 or meters < 5000), 300-500 as they grow. 71 | - Driving: use 1000-5000. Start at 2000, use 3000 if minutes > 10 or meters > 20000. Use 4000-5000 if near 60 minutes or 100000 meters. 72 | `.trim() 73 | ), 74 | 75 | exclude: z 76 | .array(z.enum(['motorway', 'toll', 'ferry', 'unpaved', 'cash_only_tolls'])) 77 | .optional() 78 | .describe('Exclude certain road types and custom locations from routing.'), 79 | 80 | depart_at: z 81 | .string() 82 | .optional() 83 | .describe( 84 | 'An ISO 8601 date-time string representing the time to depart (format string: YYYY-MM-DDThh:mmss±hh:mm).' 85 | ) 86 | }); 87 | -------------------------------------------------------------------------------- /test/tools/annotations.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { describe, it, expect } from 'vitest'; 5 | import { getAllTools } from '../../src/tools/toolRegistry.js'; 6 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | 8 | describe('Tool Annotations', () => { 9 | it('should have annotations for all tools', () => { 10 | const tools = getAllTools(); 11 | 12 | tools.forEach((tool) => { 13 | expect(tool.annotations).toBeDefined(); 14 | expect(typeof tool.annotations.title).toBe('string'); 15 | expect(tool.annotations.title).toBeTruthy(); 16 | expect(typeof tool.annotations.readOnlyHint).toBe('boolean'); 17 | expect(typeof tool.annotations.destructiveHint).toBe('boolean'); 18 | expect(typeof tool.annotations.idempotentHint).toBe('boolean'); 19 | expect(typeof tool.annotations.openWorldHint).toBe('boolean'); 20 | }); 21 | }); 22 | 23 | it('should properly install tools with annotations to server', () => { 24 | const server = new McpServer( 25 | { name: 'test-server', version: '1.0.0' }, 26 | { capabilities: { tools: {} } } 27 | ); 28 | 29 | const tools = getAllTools(); 30 | const registeredTools = tools.map((tool) => tool.installTo(server as any)); 31 | 32 | // All tools should be registered successfully 33 | expect(registeredTools).toHaveLength(tools.length); 34 | registeredTools.forEach((registeredTool) => { 35 | expect(registeredTool).toBeDefined(); 36 | }); 37 | }); 38 | 39 | it('should have appropriate read-only hints for search tools', () => { 40 | const tools = getAllTools(); 41 | 42 | // Search tools should be read-only 43 | const searchTools = tools.filter( 44 | (tool) => 45 | tool.name.includes('search') || 46 | tool.name.includes('geocode') || 47 | tool.name.includes('category') || 48 | tool.name.includes('version') || 49 | tool.name.includes('matrix') || 50 | tool.name.includes('directions') || 51 | tool.name.includes('isochrone') || 52 | tool.name.includes('static_map') 53 | ); 54 | 55 | searchTools.forEach((tool) => { 56 | expect(tool.annotations.readOnlyHint).toBe(true); 57 | expect(tool.annotations.destructiveHint).toBe(false); 58 | }); 59 | }); 60 | 61 | it('should have open world hints for external API tools', () => { 62 | const tools = getAllTools(); 63 | 64 | // Most Mapbox API tools interact with external services (open world) 65 | const apiTools = tools.filter((tool) => tool.name !== 'version_tool'); 66 | 67 | apiTools.forEach((tool) => { 68 | expect(tool.annotations.openWorldHint).toBe(true); 69 | }); 70 | }); 71 | 72 | it('should have closed world hint for version tool', () => { 73 | const tools = getAllTools(); 74 | const versionTool = tools.find((tool) => tool.name === 'version_tool'); 75 | 76 | expect(versionTool).toBeDefined(); 77 | expect(versionTool!.annotations.openWorldHint).toBe(false); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/config/toolConfig.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import type { ToolInstance } from '../tools/toolRegistry.js'; 5 | 6 | export interface ToolConfig { 7 | enabledTools?: string[]; 8 | disabledTools?: string[]; 9 | enableMcpUi?: boolean; 10 | } 11 | 12 | export function parseToolConfigFromArgs(): ToolConfig { 13 | const args = process.argv.slice(2); 14 | const config: ToolConfig = {}; 15 | 16 | // Check environment variable first (takes precedence) 17 | if (process.env.ENABLE_MCP_UI !== undefined) { 18 | config.enableMcpUi = process.env.ENABLE_MCP_UI === 'true'; 19 | } 20 | 21 | for (let i = 0; i < args.length; i++) { 22 | const arg = args[i]; 23 | 24 | if (arg === '--enable-tools') { 25 | const value = args[++i]; 26 | if (value) { 27 | config.enabledTools = value.split(',').map((t) => t.trim()); 28 | } 29 | } else if (arg === '--disable-tools') { 30 | const value = args[++i]; 31 | if (value) { 32 | config.disabledTools = value.split(',').map((t) => t.trim()); 33 | } 34 | } else if (arg === '--disable-mcp-ui') { 35 | // Command-line flag can disable it if env var not set 36 | if (config.enableMcpUi === undefined) { 37 | config.enableMcpUi = false; 38 | } 39 | } 40 | } 41 | 42 | // Default to true if not set (enabled by default) 43 | if (config.enableMcpUi === undefined) { 44 | config.enableMcpUi = true; 45 | } 46 | 47 | return config; 48 | } 49 | 50 | export function filterTools( 51 | tools: readonly ToolInstance[], 52 | config: ToolConfig 53 | ): ToolInstance[] { 54 | let filteredTools = [...tools]; 55 | 56 | // If enabledTools is specified, only those tools should be enabled 57 | // This takes precedence over disabledTools 58 | if (config.enabledTools !== undefined) { 59 | filteredTools = filteredTools.filter((tool) => 60 | config.enabledTools!.includes(tool.name) 61 | ); 62 | // Return early since enabledTools takes precedence 63 | return filteredTools; 64 | } 65 | 66 | // Apply disabledTools filter only if enabledTools is not specified 67 | if (config.disabledTools && config.disabledTools.length > 0) { 68 | filteredTools = filteredTools.filter( 69 | (tool) => !config.disabledTools!.includes(tool.name) 70 | ); 71 | } 72 | 73 | return filteredTools; 74 | } 75 | 76 | /** 77 | * Check if MCP-UI support is enabled. 78 | * MCP-UI is enabled by default and can be explicitly disabled via: 79 | * - Environment variable: ENABLE_MCP_UI=false 80 | * - Command-line flag: --disable-mcp-ui 81 | * 82 | * @returns true if MCP-UI is enabled (default), false if explicitly disabled 83 | */ 84 | export function isMcpUiEnabled(): boolean { 85 | // Check environment variable first (takes precedence) 86 | if (process.env.ENABLE_MCP_UI === 'false') { 87 | return false; 88 | } 89 | 90 | // Check command-line arguments 91 | const args = process.argv.slice(2); 92 | if (args.includes('--disable-mcp-ui')) { 93 | return false; 94 | } 95 | 96 | // Default to enabled 97 | return true; 98 | } 99 | -------------------------------------------------------------------------------- /src/tools/version-tool/VersionTool.ts: -------------------------------------------------------------------------------- 1 | import { context, SpanStatusCode, trace } from '@opentelemetry/api'; 2 | import { createLocalToolExecutionContext } from '../../utils/tracing.js'; 3 | // Copyright (c) Mapbox, Inc. 4 | // Licensed under the MIT License. 5 | 6 | import { BaseTool } from '../BaseTool.js'; 7 | import { getVersionInfo } from '../../utils/versionUtils.js'; 8 | import { VersionSchema } from './VersionTool.input.schema.js'; 9 | import { VersionResponseSchema } from './VersionTool.output.schema.js'; 10 | import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 11 | 12 | export class VersionTool extends BaseTool< 13 | typeof VersionSchema, 14 | typeof VersionResponseSchema 15 | > { 16 | readonly name = 'version_tool'; 17 | readonly description = 18 | 'Get the current version information of the MCP server'; 19 | readonly annotations = { 20 | title: 'Version Information Tool', 21 | readOnlyHint: true, 22 | destructiveHint: false, 23 | idempotentHint: true, 24 | openWorldHint: false 25 | }; 26 | 27 | constructor() { 28 | super({ 29 | inputSchema: VersionSchema, 30 | outputSchema: VersionResponseSchema 31 | }); 32 | } 33 | 34 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 35 | async run(_rawInput: unknown): Promise { 36 | // Create tracing context for this tool 37 | const toolContext = createLocalToolExecutionContext(this.name, 0); 38 | return await context.with( 39 | trace.setSpan(context.active(), toolContext.span), 40 | async () => { 41 | try { 42 | const versionInfo = getVersionInfo(); 43 | const versionText = `MCP Server Version Information:\n- Name: ${versionInfo.name}\n- Version: ${versionInfo.version}\n- SHA: ${versionInfo.sha}\n- Tag: ${versionInfo.tag}\n- Branch: ${versionInfo.branch}`; 44 | 45 | // Validate with graceful fallback 46 | const validatedVersionInfo = this.validateOutput( 47 | versionInfo 48 | ) as Record; 49 | 50 | toolContext.span.setStatus({ code: SpanStatusCode.OK }); 51 | toolContext.span.end(); 52 | return { 53 | content: [{ type: 'text' as const, text: versionText }], 54 | structuredContent: validatedVersionInfo, 55 | isError: false 56 | }; 57 | } catch (error) { 58 | const errorMessage = 59 | error instanceof Error ? error.message : String(error); 60 | toolContext.span.setStatus({ 61 | code: SpanStatusCode.ERROR, 62 | message: errorMessage 63 | }); 64 | toolContext.span.end(); 65 | this.log( 66 | 'error', 67 | `${this.name}: Error during execution: ${errorMessage}` 68 | ); 69 | return { 70 | content: [ 71 | { 72 | type: 'text' as const, 73 | text: `VersionTool: Error during execution: ${errorMessage}` 74 | } 75 | ], 76 | isError: true 77 | }; 78 | } 79 | } 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /test/tools/input-schema-validation.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { describe, it, expect, vi } from 'vitest'; 5 | import { z } from 'zod'; 6 | import { getAllTools } from '../../src/tools/toolRegistry.js'; 7 | 8 | // Mock getVersionInfo to avoid import.meta.url issues in vitest 9 | vi.mock('../utils/versionUtils.js', () => ({ 10 | getVersionInfo: vi.fn(() => ({ 11 | name: 'Mapbox MCP server', 12 | version: '1.0.0', 13 | sha: 'mock-sha', 14 | tag: 'mock-tag', 15 | branch: 'mock-branch' 16 | })) 17 | })); 18 | 19 | function detectTupleUsage(schema: z.ZodType): string[] { 20 | const issues: string[] = []; 21 | 22 | function traverse(node: z.ZodType, path: string = ''): void { 23 | // Check if this is specifically a ZodTuple 24 | if (node instanceof z.ZodTuple) { 25 | issues.push( 26 | `${path}: z.tuple() detected - this causes JSON schema generation issues` 27 | ); 28 | } 29 | 30 | // Traverse nested schemas 31 | if (node instanceof z.ZodArray) { 32 | if (node._def.type) { 33 | traverse(node._def.type, `${path}[item]`); 34 | } 35 | } else if (node instanceof z.ZodObject) { 36 | const shape = node._def.shape(); 37 | for (const [key, value] of Object.entries(shape)) { 38 | traverse(value as z.ZodType, path ? `${path}.${key}` : key); 39 | } 40 | } else if (node instanceof z.ZodUnion) { 41 | node._def.options.forEach((option: z.ZodType, index: number) => { 42 | traverse(option, `${path}[union_option_${index}]`); 43 | }); 44 | } else if (node instanceof z.ZodOptional) { 45 | traverse(node._def.innerType, path); 46 | } else if (node instanceof z.ZodDefault) { 47 | traverse(node._def.innerType, path); 48 | } else if (node instanceof z.ZodNullable) { 49 | traverse(node._def.innerType, path); 50 | } 51 | } 52 | 53 | traverse(schema); 54 | return issues; 55 | } 56 | 57 | describe('Input Schema Validation - No Tuples', () => { 58 | // Dynamically get all tools from the central registry 59 | const tools = [...getAllTools()]; 60 | 61 | const schemas = tools.map((tool) => ({ 62 | name: tool.constructor.name, 63 | schema: (tool as { inputSchema: z.ZodType }).inputSchema 64 | })); 65 | 66 | it.each(schemas)( 67 | '$name should not contain z.tuple() usage', 68 | ({ name: _name, schema }) => { 69 | const tupleIssues = detectTupleUsage(schema); 70 | expect(tupleIssues).toEqual([]); 71 | } 72 | ); 73 | 74 | // Negative test to ensure detection works 75 | it('should detect z.tuple() usage in test schemas', () => { 76 | const schemaWithTuple = z.object({ 77 | coordinates: z.tuple([z.number(), z.number()]), 78 | data: z.object({ 79 | nestedTuple: z.tuple([z.string(), z.boolean()]) 80 | }) 81 | }); 82 | 83 | const tupleIssues = detectTupleUsage(schemaWithTuple); 84 | expect(tupleIssues).toHaveLength(2); 85 | expect(tupleIssues[0]).toContain('coordinates: z.tuple() detected'); 86 | expect(tupleIssues[1]).toContain('data.nestedTuple: z.tuple() detected'); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /docs/trace-context-propagation.md: -------------------------------------------------------------------------------- 1 | # Trace Context Propagation in MCP Server 2 | 3 | This document explains how traces are connected throughout the system from tool execution to HTTP requests. 4 | 5 | ## 🔗 **Trace Context Flow** 6 | 7 | ``` 8 | MCP Tool Execution Span (Root) 9 | ├── Tool Business Logic 10 | ├── HTTP Request Span (Child) 11 | ├── Network Request 12 | └── Response Processing 13 | ``` 14 | 15 | ## 🎯 **Context Propagation Mechanism** 16 | 17 | ### 1. **HTTP Pipeline Context** 18 | 19 | ```typescript 20 | // In TracingPolicy.handle() 21 | return context.with(trace.setSpan(context.active(), span), async () => { 22 | const response = await next(input, init); 23 | // ... process response 24 | return response; 25 | }); 26 | ``` 27 | 28 | ## 🔧 **Key Implementation Details** 29 | 30 | ### Context Propagation APIs Used 31 | 32 | - `context.with()`: Executes code within a specific context 33 | - `trace.setSpan()`: Sets the active span in the context 34 | - `context.active()`: Gets the current active context 35 | 36 | ### Automatic Instrumentation 37 | 38 | - **HTTP calls**: Automatically instrumented by `@opentelemetry/instrumentation-http` 39 | - **Custom spans**: Created for business logic and enhanced with metadata 40 | 41 | ## 📊 **Resulting Trace Structure** 42 | 43 | ### Example Trace Hierarchy: 44 | 45 | ``` 46 | 🔄 reverse_geocode_tool (16.4s) 47 | ├── 🌐 HTTP GET api.mapbox.com/search (125ms) 48 | │ ├── http.method: GET 49 | │ ├── http.status_code: 200 50 | │ └── http.response.content_length: 2048 51 | └── 📄 Result formatting (50ms) 52 | ``` 53 | 54 | ### Span Attributes Connected: 55 | 56 | - **Tool context**: `tool.name`, `tool.input.size` 57 | - **HTTP context**: `http.method`, `http.url`, `http.status_code` 58 | - **Session context**: `session.id`, `user.id` (when available) 59 | 60 | ## 🚀 **Benefits of Connected Traces** 61 | 62 | ### 1. **End-to-End Visibility** 63 | 64 | - See complete request flow from tool invocation to API responses 65 | - Identify bottlenecks in the request chain 66 | - Track errors and their propagation 67 | 68 | ### 2. **Cost Attribution** 69 | 70 | - Track which tools are most expensive 71 | - Monitor usage patterns by user/session 72 | 73 | ### 3. **Performance Analysis** 74 | 75 | - Identify slow HTTP requests 76 | - Optimize tool execution paths 77 | 78 | ### 4. **Error Correlation** 79 | 80 | - See which HTTP failures cause tool failures 81 | - Debug complex multi-service interactions 82 | 83 | ## 🔍 **Querying Connected Traces** 84 | 85 | ### Find slow HTTP requests in tool context: 86 | 87 | ``` 88 | span.name CONTAINS "HTTP" AND duration > 1000ms 89 | ``` 90 | 91 | ## 🛠 **Configuration** 92 | 93 | ### Required Environment Variables: 94 | 95 | ```bash 96 | # Enable tracing with OTLP endpoint 97 | OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 98 | ``` 99 | 100 | ### Trace Sampling: 101 | 102 | The system uses default sampling (100% in dev, configurable in prod) to ensure all traces are connected properly. 103 | 104 | This architecture ensures that every HTTP request can be traced back to the originating tool execution, providing complete observability! 🎉 105 | -------------------------------------------------------------------------------- /docs/using-mcp-with-smolagents/smolagents_example.py: -------------------------------------------------------------------------------- 1 | # Code based on smolagents example from: 2 | # https://github.com/huggingface/smolagents/blob/main/examples/agent_from_any_llm.py 3 | import os 4 | from mcp import StdioServerParameters 5 | from smolagents import ( 6 | CodeAgent, 7 | InferenceClientModel, 8 | LiteLLMModel, 9 | OpenAIServerModel, 10 | TransformersModel, 11 | ToolCollection 12 | ) 13 | 14 | # Choose which inference type to use! 15 | # Options: "inference_client", "transformers", "ollama", "litellm", "openai" 16 | # chosen_inference = "inference_client" 17 | chosen_inference = "openai" 18 | 19 | print(f"Chose model: '{chosen_inference}'") 20 | 21 | if chosen_inference == "inference_client": 22 | model = InferenceClientModel( 23 | model_id="meta-llama/Llama-3.3-70B-Instruct", 24 | provider="nebius" 25 | ) 26 | 27 | elif chosen_inference == "transformers": 28 | model = TransformersModel( 29 | model_id="HuggingFaceTB/SmolLM2-1.7B-Instruct", 30 | device_map="auto", 31 | max_new_tokens=1000 32 | ) 33 | 34 | elif chosen_inference == "ollama": 35 | model = LiteLLMModel( 36 | model_id="ollama_chat/llama3.2", 37 | # Replace with remote open-ai compatible server if necessary 38 | api_base="http://localhost:11434", 39 | # Replace with API key if necessary 40 | api_key="your-api-key", 41 | # ollama default is 2048 which often fails. 8192 works for easy tasks, 42 | # more is better. Check https://huggingface.co/spaces/NyxKrage/ 43 | # LLM-Model-VRAM-Calculator to calculate VRAM needs for your model. 44 | num_ctx=8192, 45 | ) 46 | 47 | elif chosen_inference == "litellm": 48 | # For anthropic: use 'anthropic/claude-3-7-sonnet-latest' 49 | model = LiteLLMModel(model_id="gpt-4o") 50 | 51 | elif chosen_inference == "openai": 52 | # For anthropic: use 'anthropic/claude-3-7-sonnet-latest' 53 | model = OpenAIServerModel(model_id="gpt-4.1-2025-04-14") 54 | 55 | 56 | # Make sure there is an access token available 57 | if os.environ.get("MAPBOX_ACCESS_TOKEN", None) is None: 58 | raise EnvironmentError( 59 | "To use Mapbox MCP you need to export " 60 | "`MAPBOX_ACCESS_TOKEN` environmental variable." 61 | ) 62 | 63 | # Run server with node 64 | # alternatively you can use command="docker" and 65 | # args=["run", "-i", "--rm", "mapbox-mcp-server"] 66 | server_parameters = StdioServerParameters( 67 | command="/Users/username/.nvm/versions/node/v22.3.0/bin/node", 68 | args=["/YOUR_PATH_TO_REPOSITORY/dist/esm/index.js"], 69 | env={ 70 | "MAPBOX_ACCESS_TOKEN": os.environ["MAPBOX_ACCESS_TOKEN"] 71 | } 72 | ) 73 | 74 | 75 | # Connect to MCP, create agent with MCP's tool and run it 76 | with ToolCollection.from_mcp( 77 | server_parameters, 78 | trust_remote_code=True, 79 | structured_output=True 80 | ) as tool_collection: 81 | agent = CodeAgent( 82 | tools=tool_collection.tools, 83 | model=model, 84 | verbosity_level=2, 85 | stream_outputs=True 86 | ) 87 | result = agent.run( 88 | "How long does it take to drive from Big Ben to Eiffel Tower?" 89 | ) 90 | print("CodeAgent:", result) 91 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.0 2 | 3 | ### Features Added 4 | 5 | - **MCP Resources Support**: Added native MCP resource API support 6 | 7 | - Introduced `CategoryListResource` exposing category lists as `mapbox://categories` resource 8 | - Supports localized category lists via URI pattern `mapbox://categories/{language}` (e.g., `mapbox://categories/ja` for Japanese) 9 | - Created base resource infrastructure (`BaseResource`, `MapboxApiBasedResource`) for future resource implementations 10 | - Added `ResourceReaderTool` as fallback for clients without native resource support 11 | - Enables more efficient access to static reference data without tool calls 12 | 13 | - **MCP-UI Support**: Added rich UI embedding for compatible MCP clients 14 | - `StaticMapImageTool` now returns both image data and an embeddable iframe URL 15 | - Enables inline map visualization in compatible clients (e.g., Goose) 16 | - Fully backwards compatible - clients without MCP-UI support continue working unchanged 17 | - Enabled by default, can be disabled via `ENABLE_MCP_UI=false` env var or `--disable-mcp-ui` flag 18 | - Added `@mcp-ui/server@^5.13.1` dependency 19 | - Configuration helper functions in `toolConfig.ts` 20 | 21 | ### Deprecations 22 | 23 | - **CategoryListTool**: Marked as deprecated in favor of the new `mapbox://categories` resource 24 | - Tool remains functional for backward compatibility 25 | - Users are encouraged to migrate to either the native resource API or `resource_reader_tool` 26 | 27 | ## 0.6.1 28 | 29 | ### Other 30 | 31 | - Update to MCP registry schema version 2025-10-17 32 | 33 | ## 0.6.0 (Unreleased) 34 | 35 | ### Features Added 36 | 37 | - Support for `structuredContent` for all applicable tools 38 | - Registers output schemas with the MCP server and validates schemas 39 | - Adds OpenTelemetry Instrumentation for all HTTP calls 40 | 41 | ### Bug Fixes 42 | 43 | - Fixed the version tool to properly emit the git version and branch 44 | 45 | ### Other Features 46 | 47 | - Refactored `fetchClient` to be generic `httpRequest`. 48 | 49 | ## 0.5.5 50 | 51 | - Add server.json for MCP registry 52 | 53 | ## 0.5.0 54 | 55 | - Introduce new tool: SearchAndGeocodeTool 56 | - Remove former tools: ForwardGeocodeTool, PoiSearchTool; their 57 | capabilities are combined in the new tool 58 | 59 | ## 0.4.1 60 | 61 | - Minor changes to tool descriptions for clarity 62 | 63 | ## 0.4.0 (Unreleased) 64 | 65 | ### Features Added 66 | 67 | - New fetch pipeline with automatic retry behavior 68 | 69 | ### Bug Fixes 70 | 71 | - Dual emits ESM and CommonJS bundles with types per target 72 | 73 | ### Other Features 74 | 75 | - Migrated from Jest to vitest 76 | 77 | ## v0.2.0 (2025-06-25) 78 | 79 | - **Format Options**: Add `format` parameter to all geocoding and search tools 80 | - CategorySearchTool, ForwardGeocodeTool, PoiSearchTool, and ReverseGeocodeTool now support both `json_string` and `formatted_text` output formats 81 | - `json_string` returns raw GeoJSON data as parseable JSON string 82 | - `formatted_text` returns human-readable text with place names, addresses, and coordinates 83 | - Default to `formatted_text` for backward compatibility 84 | - Comprehensive test coverage for both output formats 85 | 86 | ## v0.1.0 (2025-06-12) 87 | 88 | - **Support-NPM-Package**: Introduce the NPM package of this mcp-server 89 | 90 | ## v0.0.1 (2025-06-11) 91 | 92 | - First tag release 93 | -------------------------------------------------------------------------------- /src/tools/matrix-tool/MatrixTool.input.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | import { coordinateSchema } from '../../schemas/shared.js'; 6 | 7 | export const MatrixInputSchema = z.object({ 8 | coordinates: z 9 | .array(coordinateSchema) 10 | .min(2, 'At least two coordinate pairs are required.') 11 | .max( 12 | 25, 13 | 'Up to 25 coordinate pairs are supported for most profiles (10 for driving-traffic).' 14 | ) 15 | .describe( 16 | 'Array of coordinate objects with longitude and latitude properties. ' + 17 | 'Must include at least 2 coordinate pairs. ' + 18 | 'Up to 25 coordinates total are supported for most profiles (10 for driving-traffic).' 19 | ), 20 | profile: z 21 | .enum(['driving-traffic', 'driving', 'walking', 'cycling']) 22 | .describe( 23 | 'Routing profile for different modes of transport. Options: \n' + 24 | '- driving-traffic: automotive with current traffic conditions (limited to 10 coordinates)\n' + 25 | '- driving: automotive based on typical traffic\n' + 26 | '- walking: pedestrian/hiking\n' + 27 | '- cycling: bicycle' 28 | ), 29 | annotations: z 30 | .enum(['duration', 'distance', 'duration,distance', 'distance,duration']) 31 | .optional() 32 | .describe( 33 | 'Specifies the resulting matrices. Possible values are: duration (default), distance, or both values separated by a comma.' 34 | ), 35 | approaches: z 36 | .string() 37 | .optional() 38 | .describe( 39 | 'A semicolon-separated list indicating the side of the road from which to approach waypoints. ' + 40 | 'Accepts "unrestricted" (default, route can arrive at the waypoint from either side of the road) ' + 41 | 'or "curb" (route will arrive at the waypoint on the driving_side of the region). ' + 42 | 'If provided, the number of approaches must be the same as the number of waypoints. ' + 43 | 'You can skip a coordinate and show its position with the ; separator.' 44 | ), 45 | bearings: z 46 | .string() 47 | .optional() 48 | .describe( 49 | 'A semicolon-separated list of headings and allowed deviation indicating the direction of movement. ' + 50 | 'Input as two comma-separated values per location: a heading course measured clockwise from true north ' + 51 | 'between 0 and 360, and the range of degrees by which the angle can deviate (recommended value is 45° or 90°), ' + 52 | 'formatted as {angle,degrees}. If provided, the number of bearings must equal the number of coordinates. ' + 53 | 'You can skip a coordinate and show its position in the list with the ; separator.' 54 | ), 55 | destinations: z 56 | .string() 57 | .optional() 58 | .describe( 59 | 'Use the coordinates at given indices as destinations. ' + 60 | 'Possible values are: a semicolon-separated list of 0-based indices, or "all" (default). ' + 61 | 'The option "all" allows using all coordinates as destinations.' 62 | ), 63 | sources: z 64 | .string() 65 | .optional() 66 | .describe( 67 | 'Use the coordinates at given indices as sources. ' + 68 | 'Possible values are: a semicolon-separated list of 0-based indices, or "all" (default). ' + 69 | 'The option "all" allows using all coordinates as sources.' 70 | ) 71 | }); 72 | -------------------------------------------------------------------------------- /test/tools/version-tool/VersionTool.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 5 | import type { MockedFunction } from 'vitest'; 6 | import { getVersionInfo } from '../../../src/utils/versionUtils.js'; 7 | import { VersionTool } from '../../../src/tools/version-tool/VersionTool.js'; 8 | 9 | vi.mock('../../../src/utils/versionUtils.js', () => ({ 10 | getVersionInfo: vi.fn(() => ({ 11 | name: 'Test MCP Server', 12 | version: '1.0.0', 13 | sha: 'abc123', 14 | tag: 'v1.0.0', 15 | branch: 'main' 16 | })) 17 | })); 18 | 19 | const mockGetVersionInfo = getVersionInfo as MockedFunction< 20 | typeof getVersionInfo 21 | >; 22 | 23 | describe('VersionTool', () => { 24 | let tool: VersionTool; 25 | 26 | beforeEach(() => { 27 | tool = new VersionTool(); 28 | }); 29 | 30 | describe('run', () => { 31 | it('should return version information', async () => { 32 | const result = await tool.run({}); 33 | 34 | expect(result.isError).toBe(false); 35 | expect(result.content).toHaveLength(1); 36 | 37 | // Best approach: exact match with template literal for readability and precision 38 | const expectedText = `MCP Server Version Information: 39 | - Name: Test MCP Server 40 | - Version: 1.0.0 41 | - SHA: abc123 42 | - Tag: v1.0.0 43 | - Branch: main`; 44 | expect(result.content[0]).toEqual({ 45 | type: 'text', 46 | text: expectedText 47 | }); 48 | 49 | // Verify structured content is included 50 | expect(result.structuredContent).toBeDefined(); 51 | expect(result.structuredContent).toEqual({ 52 | name: 'Test MCP Server', 53 | version: '1.0.0', 54 | sha: 'abc123', 55 | tag: 'v1.0.0', 56 | branch: 'main' 57 | }); 58 | }); 59 | 60 | it('should handle fallback version info correctly', async () => { 61 | // Mock getVersionInfo to return fallback values (which is realistic behavior) 62 | mockGetVersionInfo.mockImplementationOnce(() => ({ 63 | name: 'Mapbox MCP server', 64 | version: '0.0.0', 65 | sha: 'unknown', 66 | tag: 'unknown', 67 | branch: 'unknown' 68 | })); 69 | 70 | const result = await tool.run({}); 71 | 72 | expect(result.isError).toBe(false); 73 | expect(result.content).toHaveLength(1); 74 | expect(result.content[0].type).toBe('text'); 75 | expect( 76 | (result.content[0] as { type: 'text'; text: string }).text 77 | ).toContain('Version: 0.0.0'); 78 | expect( 79 | (result.content[0] as { type: 'text'; text: string }).text 80 | ).toContain('SHA: unknown'); 81 | expect(result.structuredContent).toEqual({ 82 | name: 'Mapbox MCP server', 83 | version: '0.0.0', 84 | sha: 'unknown', 85 | tag: 'unknown', 86 | branch: 'unknown' 87 | }); 88 | }); 89 | }); 90 | 91 | describe('properties', () => { 92 | it('should have correct name', () => { 93 | expect(tool.name).toBe('version_tool'); 94 | }); 95 | 96 | it('should have correct description', () => { 97 | expect(tool.description).toBe( 98 | 'Get the current version information of the MCP server' 99 | ); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /docs/vscode-setup.md: -------------------------------------------------------------------------------- 1 | # VS Code Setup 2 | 3 | This guide explains how to configure VS Code for use with the Mapbox MCP Server. 4 | 5 | ## Requirements 6 | 7 | - VS Code installed and configured with copilot 8 | - Mapbox MCP Server built locally 9 | 10 | ```sh 11 | # from repository root: 12 | # using node 13 | npm install 14 | npm run build 15 | 16 | # note your absolute path to node, you will need it for MCP config 17 | # For Mac/Linux 18 | which node 19 | # For Windows 20 | where node 21 | 22 | # or alternatively, using docker 23 | docker build -t mapbox-mcp-server . 24 | ``` 25 | 26 | ## Setup Instructions 27 | 28 | ### Configure VS Code to use Mapbox MCP Server 29 | 30 | 1. Go to your `settings.json` 31 | 1. At the top level add MCP config, for example: 32 | - NPM version 33 | ```json 34 | "mcp": { 35 | "servers": { 36 | "MapboxServer": { 37 | "type": "stdio", 38 | "command": , 39 | "args": ["-y", "@mapbox/mcp-server"], 40 | "env": { 41 | "MAPBOX_ACCESS_TOKEN": 42 | } 43 | } 44 | }, 45 | }, 46 | ``` 47 | - Docker version 48 | ```json 49 | "mcp": { 50 | "servers": { 51 | "MapboxServer": { 52 | "type": "stdio", 53 | "command": "docker", 54 | "args": [ 55 | "run", 56 | "-i", 57 | "--rm", 58 | "mapbox-mcp-server" 59 | ], 60 | "env": { 61 | "MAPBOX_ACCESS_TOKEN": "YOUR_TOKEN" 62 | } 63 | } 64 | }, 65 | }, 66 | ``` 67 | - Node version 68 | ```json 69 | "mcp": { 70 | "servers": { 71 | "MapboxServer": { 72 | "type": "stdio", 73 | "command": , 74 | "args": [ 75 | "/YOUR_PATH_TO_GIT_REPOSITORY/dist/esm/index.js" 76 | ], 77 | "env": { 78 | "MAPBOX_ACCESS_TOKEN": "YOUR_TOKEN" 79 | } 80 | } 81 | }, 82 | }, 83 | ``` 84 | 85 | You might need to restart VS Code. You should see Mapbox Server appear in tools menu. 86 | 87 | ![Mapbox Server appears in tools menu](images/vscode-tools-menu.png) 88 | 89 | #### Example of working tools 90 | 91 | ![Example prompt](images/vscode-tool-example-usage.png) 92 | 93 | Note, the results can vary based on current traffic conditions and exact values of parameters used. 94 | 95 | ## MCP-UI Support 96 | 97 | VS Code does not currently support the MCP-UI specification for embedded interactive elements. When you use tools like `static_map_image_tool`, you'll receive: 98 | 99 | - ✅ **Base64-encoded map images** that VS Code can display 100 | - ❌ **Interactive iframe embeds** (not supported by VS Code) 101 | 102 | The server is fully backwards compatible - all tools work normally, you just won't see interactive map embeds. For more information about MCP-UI support in this server, see the [MCP-UI documentation](mcp-ui.md). 103 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Mapbox MCP Server 2 | 3 | An MCP (Model Context Protocol) server that provides AI agents with geospatial intelligence capabilities through Mapbox APIs. This enables AI applications to understand locations, navigate the physical world, and access rich spatial data including geocoding, search, routing, travel time analysis, and map visualization. 4 | 5 | ## Tech Stack 6 | 7 | - **Runtime**: Node.js 22+ LTS 8 | - **Language**: TypeScript (strict mode) 9 | - **Testing**: Vitest 10 | - **Package Manager**: npm 11 | 12 | ## Project Structure 13 | 14 | ``` 15 | src/ 16 | ├── index.ts # Main MCP server entry point 17 | ├── config/toolConfig.ts # Tool configuration parser 18 | ├── tools/ # MCP tool implementations 19 | │ ├── MapboxApiBasedTool.ts # Base class for Mapbox API tools 20 | │ ├── toolRegistry.ts # Tool registration system 21 | │ └── */Tool.ts # Individual tool implementations 22 | ├── resources/ # MCP resources (static data) 23 | │ ├── MapboxApiBasedResource.ts 24 | │ └── resourceRegistry.ts 25 | └── utils/ 26 | ├── httpPipeline.ts # HTTP policy pipeline system 27 | ├── tracing.ts # OpenTelemetry instrumentation 28 | └── versionUtils.ts # Version info utilities 29 | 30 | test/ # Mirrors src/ structure 31 | ``` 32 | 33 | ## Key Patterns 34 | 35 | ### Tool Architecture 36 | 37 | - All Mapbox API tools extend `MapboxApiBasedTool` (src/tools/MapboxApiBasedTool.ts:16) 38 | - Tools receive `httpRequest` via dependency injection (src/tools/toolRegistry.ts:22-29) 39 | - Register new tools in `ALL_TOOLS` array (src/tools/toolRegistry.ts:18) 40 | 41 | ### HTTP Pipeline System 42 | 43 | - **Never patch global.fetch** - use `HttpPipeline` with dependency injection instead 44 | - Pipeline applies policies (User-Agent, Retry, etc.) to all HTTP requests (src/utils/httpPipeline.ts:21) 45 | - Default pipeline exported as `httpRequest` (src/utils/httpPipeline.ts) 46 | 47 | ### Resource System 48 | 49 | - Static reference data exposed as MCP resources (src/resources/) 50 | - Resources use URI pattern: `mapbox://resource-name` 51 | - Example: category list at `mapbox://categories` or `mapbox://categories/{language}` 52 | 53 | ## Essential Workflows 54 | 55 | ### Development 56 | 57 | ```bash 58 | npm install # Install dependencies 59 | npm test # Run tests with Vitest 60 | npm run build # Compile TypeScript 61 | npm run inspect:build # Test with MCP inspector 62 | ``` 63 | 64 | ### Creating a New Tool 65 | 66 | ```bash 67 | npx plop create-tool # Interactive tool scaffolding 68 | # Provide tool name without suffix (e.g., "Search" creates "SearchTool") 69 | ``` 70 | 71 | ### Pre-commit 72 | 73 | - Husky hooks auto-run linting and formatting 74 | - All checks must pass before commit 75 | 76 | ## Important Constraints 77 | 78 | - **Dependency Injection**: Tools must accept `httpRequest` parameter for testability 79 | - **No Global Patching**: Use explicit pipelines instead of modifying globals 80 | - **Strict Types**: Avoid `any` - add comment if absolutely necessary 81 | - **Test Mocking**: Never make real network calls in tests 82 | 83 | ## Documentation 84 | 85 | - **Detailed Standards**: See docs/engineering_standards.md for complete guidelines 86 | - **Tracing Setup**: See docs/tracing.md for OpenTelemetry configuration 87 | - **Integration Guides**: See docs/ for Claude Desktop, VS Code, Cursor, and Goose setup 88 | -------------------------------------------------------------------------------- /src/resources/category-list/CategoryListResource.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { MapboxApiBasedResource } from '../MapboxApiBasedResource.js'; 5 | import type { HttpRequest } from '../../utils/types.js'; 6 | import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; 7 | 8 | // Interface for the full API response from Mapbox 9 | interface MapboxApiResponse { 10 | listItems: Array<{ 11 | canonical_id: string; 12 | icon: string; 13 | name: string; 14 | version?: string; 15 | uuid?: string; 16 | }>; 17 | attribution: string; 18 | version: string; 19 | } 20 | 21 | // API Documentation: https://docs.mapbox.com/api/search/search-box/#list-categories 22 | 23 | /** 24 | * Resource for retrieving the list of supported categories from Mapbox Search API 25 | * 26 | * Available URIs: 27 | * - mapbox://categories - Default (English) 28 | * - mapbox://categories/{language} - Localized (e.g., mapbox://categories/ja) 29 | */ 30 | export class CategoryListResource extends MapboxApiBasedResource { 31 | readonly uri = 'mapbox://categories'; 32 | readonly name = 'Mapbox Categories'; 33 | readonly description = 34 | 'List of all available Mapbox Search API categories. Categories can be used for filtering search results. Supports localization via language parameter in URI (e.g., mapbox://categories/ja for Japanese).'; 35 | readonly mimeType = 'application/json'; 36 | 37 | constructor(params: { httpRequest: HttpRequest }) { 38 | super(params); 39 | } 40 | 41 | protected async execute( 42 | uri: string, 43 | accessToken: string 44 | ): Promise { 45 | // Parse language from URI if present 46 | // Format: mapbox://categories or mapbox://categories/ja 47 | const language = this.extractLanguageFromUri(uri); 48 | 49 | const apiUrl = new URL( 50 | 'https://api.mapbox.com/search/searchbox/v1/list/category' 51 | ); 52 | 53 | apiUrl.searchParams.set('access_token', accessToken); 54 | 55 | if (language) { 56 | apiUrl.searchParams.set('language', language); 57 | } 58 | 59 | const response = await this.httpRequest(apiUrl.toString(), { 60 | method: 'GET', 61 | headers: { 62 | 'User-Agent': `@mapbox/mcp-server/${process.env.npm_package_version || 'dev'}` 63 | } 64 | }); 65 | 66 | if (!response.ok) { 67 | throw new Error( 68 | `Mapbox API request failed: ${response.status} ${response.statusText}` 69 | ); 70 | } 71 | 72 | const rawData = await response.json(); 73 | const data = rawData as MapboxApiResponse; 74 | 75 | // Extract just the category IDs for a simplified response 76 | const categoryIds = data.listItems.map((item) => item.canonical_id); 77 | 78 | const result = { 79 | listItems: categoryIds, 80 | version: data.version, 81 | attribution: data.attribution 82 | }; 83 | 84 | return { 85 | contents: [ 86 | { 87 | uri, 88 | mimeType: 'application/json', 89 | text: JSON.stringify(result, null, 2) 90 | } 91 | ] 92 | }; 93 | } 94 | 95 | /** 96 | * Extract language code from URI 97 | * @param uri Resource URI (e.g., mapbox://categories/ja) 98 | * @returns Language code or undefined if not present 99 | */ 100 | private extractLanguageFromUri(uri: string): string | undefined { 101 | const match = uri.match(/^mapbox:\/\/categories\/([a-z]{2})$/i); 102 | return match?.[1]; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AI Agent Instructions for Mapbox MCP Server 2 | 3 | > **Note**: If you're using Claude Code specifically, see CLAUDE.md instead. This file is for general AI coding assistants. 4 | 5 | ## What This Project Does 6 | 7 | This is an MCP (Model Context Protocol) server that provides AI applications with geospatial intelligence capabilities through Mapbox APIs. It enables AI agents to understand locations, navigate the physical world, and access spatial data including geocoding, search, routing, travel time analysis, and map visualization. 8 | 9 | ## Tech Stack 10 | 11 | - **Runtime**: Node.js 22+ LTS 12 | - **Language**: TypeScript (strict mode) 13 | - **Testing**: Vitest 14 | - **Package Manager**: npm 15 | 16 | ## Project Structure 17 | 18 | ``` 19 | src/ 20 | ├── index.ts # Main MCP server entry point 21 | ├── config/toolConfig.ts # Tool configuration parser 22 | ├── tools/ # MCP tool implementations 23 | │ ├── MapboxApiBasedTool.ts # Base class for Mapbox API tools 24 | │ ├── toolRegistry.ts # Tool registration system 25 | │ └── */Tool.ts # Individual tool implementations 26 | ├── resources/ # MCP resources (static data) 27 | │ └── resourceRegistry.ts 28 | └── utils/ 29 | ├── httpPipeline.ts # HTTP policy pipeline system 30 | └── tracing.ts # OpenTelemetry instrumentation 31 | 32 | test/ # Mirrors src/ structure 33 | ``` 34 | 35 | ## Critical Patterns 36 | 37 | ### HTTP Request Architecture 38 | 39 | - **Never patch global.fetch** - use `HttpPipeline` with dependency injection 40 | - All HTTP requests flow through the pipeline system (src/utils/httpPipeline.ts:21) 41 | - Tools receive `httpRequest` via constructor (src/tools/toolRegistry.ts:22-29) 42 | 43 | ### Tool Development 44 | 45 | - Extend `MapboxApiBasedTool` base class (src/tools/MapboxApiBasedTool.ts:16) 46 | - Accept `httpRequest` parameter in constructor for testability 47 | - Register in `ALL_TOOLS` array (src/tools/toolRegistry.ts:18) 48 | - Use `npx plop create-tool` for scaffolding new tools 49 | 50 | ### Testing 51 | 52 | - Use Vitest exclusively 53 | - Mock external APIs - no real network calls in tests 54 | - Use dependency injection to inject mock fetch functions 55 | - Tests mirror `src/` directory structure 56 | 57 | ## Essential Commands 58 | 59 | ```bash 60 | npm install # Install dependencies 61 | npm test # Run tests 62 | npm run build # Compile TypeScript 63 | npm run lint # Check code quality (auto-fixed by pre-commit hooks) 64 | npm run inspect:build # Test with MCP inspector 65 | npx plop create-tool # Scaffold a new tool 66 | ``` 67 | 68 | ## Common Pitfalls to Avoid 69 | 70 | 1. **Don't patch globals** - especially `global.fetch` 71 | 2. **Don't make real network calls in tests** - use mocks 72 | 3. **Don't commit without tests** - new features need test coverage 73 | 4. **Don't hardcode secrets** - use environment variables 74 | 5. **Don't ignore type errors** - strict TypeScript is enforced 75 | 76 | ## Documentation 77 | 78 | - **CLAUDE.md** - Detailed standards and patterns for Claude Code users 79 | - **docs/engineering_standards.md** - Complete engineering guidelines 80 | - **docs/tracing.md** - OpenTelemetry setup 81 | - **README.md** - User-facing documentation and integration guides 82 | 83 | ## Factual Errors to Watch For 84 | 85 | When analyzing or modifying this codebase: 86 | 87 | - The HTTP pipeline class is `HttpPipeline`, not `PolicyPipeline` 88 | - The HTTP pipeline file is `src/utils/httpPipeline.ts`, not `fetchRequest.ts` 89 | - Base tool class uses `httpRequest` parameter, not `fetch` 90 | 91 | --- 92 | 93 | For detailed code quality standards, testing requirements, and collaboration guidelines, see docs/engineering_standards.md. 94 | -------------------------------------------------------------------------------- /src/tools/BaseTool.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import type { 5 | McpServer, 6 | RegisteredTool 7 | } from '@modelcontextprotocol/sdk/server/mcp.js'; 8 | import type { 9 | ToolAnnotations, 10 | CallToolResult 11 | } from '@modelcontextprotocol/sdk/types.js'; 12 | import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; 13 | import type { ZodTypeAny } from 'zod'; 14 | import type { z } from 'zod'; 15 | 16 | export abstract class BaseTool< 17 | InputSchema extends ZodTypeAny, 18 | OutputSchema extends ZodTypeAny = ZodTypeAny 19 | > { 20 | abstract readonly name: string; 21 | abstract readonly description: string; 22 | abstract readonly annotations: ToolAnnotations; 23 | 24 | readonly inputSchema: InputSchema; 25 | readonly outputSchema?: OutputSchema; 26 | protected server: McpServer | null = null; 27 | 28 | constructor(params: { 29 | inputSchema: InputSchema; 30 | outputSchema?: OutputSchema; 31 | }) { 32 | this.inputSchema = params.inputSchema; 33 | this.outputSchema = params.outputSchema; 34 | } 35 | 36 | /** 37 | * Installs the tool to the given MCP server. 38 | */ 39 | installTo(server: McpServer): RegisteredTool { 40 | this.server = server; 41 | 42 | const config: { 43 | title?: string; 44 | description?: string; 45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 46 | inputSchema?: any; 47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 48 | outputSchema?: any; 49 | annotations?: ToolAnnotations; 50 | } = { 51 | title: this.annotations.title, 52 | description: this.description, 53 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 54 | inputSchema: (this.inputSchema as unknown as z.ZodObject).shape, 55 | annotations: this.annotations 56 | }; 57 | 58 | // Add outputSchema if provided 59 | if (this.outputSchema) { 60 | config.outputSchema = 61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 62 | (this.outputSchema as unknown as z.ZodObject).shape; 63 | } 64 | 65 | return server.registerTool(this.name, config, (args, extra) => 66 | this.run(args, extra) 67 | ); 68 | } 69 | 70 | /** 71 | * Tool logic to be implemented by subclasses. 72 | */ 73 | abstract run( 74 | rawInput: unknown, 75 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 76 | extra?: RequestHandlerExtra 77 | ): Promise; 78 | 79 | /** 80 | * Helper method to send logging messages 81 | */ 82 | protected log( 83 | level: 'debug' | 'info' | 'warning' | 'error', 84 | data: unknown 85 | ): void { 86 | if (this.server?.server) { 87 | void this.server.server.sendLoggingMessage({ level, data }); 88 | } 89 | } 90 | 91 | /** 92 | * Validates output data against the output schema with graceful fallback. 93 | * If validation fails, logs a warning and returns the raw data. 94 | * @param rawData The raw data to validate 95 | * @returns The validated data, or raw data if validation fails 96 | */ 97 | protected validateOutput(rawData: unknown): T { 98 | if (!this.outputSchema) { 99 | return rawData as T; 100 | } 101 | 102 | try { 103 | return this.outputSchema.parse(rawData) as T; 104 | } catch (validationError) { 105 | this.log( 106 | 'warning', 107 | `${this.name}: Output schema validation failed: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` 108 | ); 109 | // Graceful fallback to raw data 110 | return rawData as T; 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/schemas/geojson.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | /** 5 | * GeoJSON interfaces based on RFC 7946 6 | * https://tools.ietf.org/html/rfc7946 7 | */ 8 | 9 | export type GeoJSONGeometryType = 10 | | 'Point' 11 | | 'LineString' 12 | | 'Polygon' 13 | | 'MultiPoint' 14 | | 'MultiLineString' 15 | | 'MultiPolygon' 16 | | 'GeometryCollection'; 17 | 18 | export type GeoJSONFeatureType = 'Feature'; 19 | 20 | export type GeoJSONFeatureCollectionType = 'FeatureCollection'; 21 | 22 | export type GeoJSONType = 23 | | GeoJSONGeometryType 24 | | GeoJSONFeatureType 25 | | GeoJSONFeatureCollectionType; 26 | 27 | /** 28 | * Position array [longitude, latitude] or [longitude, latitude, elevation] 29 | */ 30 | export type Position = [number, number] | [number, number, number]; 31 | 32 | /** 33 | * Base interface for all GeoJSON objects 34 | */ 35 | export interface GeoJSONBase { 36 | type: GeoJSONType; 37 | bbox?: 38 | | [number, number, number, number] 39 | | [number, number, number, number, number, number]; 40 | } 41 | 42 | /** 43 | * Point geometry 44 | */ 45 | export interface Point extends GeoJSONBase { 46 | type: 'Point'; 47 | coordinates: Position; 48 | } 49 | 50 | /** 51 | * LineString geometry 52 | */ 53 | export interface LineString extends GeoJSONBase { 54 | type: 'LineString'; 55 | coordinates: Position[]; 56 | } 57 | 58 | /** 59 | * Polygon geometry 60 | */ 61 | export interface Polygon extends GeoJSONBase { 62 | type: 'Polygon'; 63 | coordinates: Position[][]; 64 | } 65 | 66 | /** 67 | * MultiPoint geometry 68 | */ 69 | export interface MultiPoint extends GeoJSONBase { 70 | type: 'MultiPoint'; 71 | coordinates: Position[]; 72 | } 73 | 74 | /** 75 | * MultiLineString geometry 76 | */ 77 | export interface MultiLineString extends GeoJSONBase { 78 | type: 'MultiLineString'; 79 | coordinates: Position[][]; 80 | } 81 | 82 | /** 83 | * MultiPolygon geometry 84 | */ 85 | export interface MultiPolygon extends GeoJSONBase { 86 | type: 'MultiPolygon'; 87 | coordinates: Position[][][]; 88 | } 89 | 90 | /** 91 | * GeometryCollection 92 | */ 93 | export interface GeometryCollection extends GeoJSONBase { 94 | type: 'GeometryCollection'; 95 | geometries: Geometry[]; 96 | } 97 | 98 | /** 99 | * Union of all geometry types 100 | */ 101 | export type Geometry = 102 | | Point 103 | | LineString 104 | | Polygon 105 | | MultiPoint 106 | | MultiLineString 107 | | MultiPolygon 108 | | GeometryCollection; 109 | 110 | /** 111 | * GeoJSON Feature with properties 112 | */ 113 | export interface Feature< 114 | P = Record, 115 | G extends Geometry = Geometry 116 | > extends GeoJSONBase { 117 | type: 'Feature'; 118 | geometry: G | null; 119 | properties: P | null; 120 | id?: string | number; 121 | } 122 | 123 | /** 124 | * GeoJSON FeatureCollection 125 | */ 126 | export interface FeatureCollection< 127 | P = Record, 128 | G extends Geometry = Geometry 129 | > extends GeoJSONBase { 130 | type: 'FeatureCollection'; 131 | features: Feature[]; 132 | } 133 | 134 | /** 135 | * Union of all GeoJSON objects 136 | */ 137 | export type GeoJSON = Geometry | Feature | FeatureCollection; 138 | 139 | /** 140 | * Mapbox-specific properties commonly found in Mapbox API responses 141 | */ 142 | export interface MapboxFeatureProperties extends Record { 143 | name?: string; 144 | name_preferred?: string; 145 | full_address?: string; 146 | place_formatted?: string; 147 | feature_type?: string; 148 | poi_category?: string | string[]; 149 | category?: string; 150 | mapbox_id?: string; 151 | address?: string; 152 | } 153 | 154 | /** 155 | * Mapbox Feature with common properties 156 | */ 157 | export type MapboxFeature = Feature; 158 | 159 | /** 160 | * Mapbox FeatureCollection with common properties 161 | */ 162 | export type MapboxFeatureCollection = 163 | FeatureCollection; 164 | -------------------------------------------------------------------------------- /scripts/sync-manifest-version.cjs: -------------------------------------------------------------------------------- 1 | // Sync manifest.json and server.json versions with package.json 2 | const fs = require('node:fs'); 3 | const path = require('node:path'); 4 | 5 | /** 6 | * Pure function that syncs versions across config objects 7 | * @param {Object} packageJson - The package.json object 8 | * @param {Object} manifestJson - The manifest.json object 9 | * @param {Object} serverJson - The server.json object 10 | * @returns {Object} Result containing updated configs and change information 11 | */ 12 | function syncVersionsCore(packageJson, manifestJson, serverJson) { 13 | const packageVersion = packageJson.version; 14 | const result = { 15 | packageVersion, 16 | updatedManifest: null, 17 | updatedServer: null, 18 | changes: { 19 | manifest: false, 20 | server: false, 21 | serverPackage: false 22 | }, 23 | oldVersions: { 24 | manifest: manifestJson.version, 25 | server: serverJson.version, 26 | serverPackage: serverJson.packages?.[0]?.version 27 | } 28 | }; 29 | 30 | // Check and update manifest.json 31 | if (manifestJson.version !== packageVersion) { 32 | result.updatedManifest = { ...manifestJson, version: packageVersion }; 33 | result.changes.manifest = true; 34 | } 35 | 36 | // Check and update server.json 37 | const serverNeedsUpdate = serverJson.version !== packageVersion; 38 | const packageNeedsUpdate = serverJson.packages?.[0] && 39 | serverJson.packages[0].version !== packageVersion; 40 | 41 | if (serverNeedsUpdate || packageNeedsUpdate) { 42 | result.updatedServer = JSON.parse(JSON.stringify(serverJson)); // Deep clone 43 | 44 | if (serverNeedsUpdate) { 45 | result.updatedServer.version = packageVersion; 46 | result.changes.server = true; 47 | } 48 | 49 | if (packageNeedsUpdate) { 50 | result.updatedServer.packages[0].version = packageVersion; 51 | result.changes.serverPackage = true; 52 | } 53 | } 54 | 55 | return result; 56 | } 57 | 58 | function syncVersions() { 59 | const packageJsonPath = path.join(process.cwd(), 'package.json'); 60 | const manifestJsonPath = path.join(process.cwd(), 'manifest.json'); 61 | const serverJsonPath = path.join(process.cwd(), 'server.json'); 62 | 63 | // Read files 64 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); 65 | const manifestJson = JSON.parse(fs.readFileSync(manifestJsonPath, 'utf-8')); 66 | const serverJson = JSON.parse(fs.readFileSync(serverJsonPath, 'utf-8')); 67 | 68 | // Sync versions using pure function 69 | const result = syncVersionsCore(packageJson, manifestJson, serverJson); 70 | 71 | let updatedFiles = []; 72 | 73 | // Write updated manifest if needed 74 | if (result.updatedManifest) { 75 | fs.writeFileSync( 76 | manifestJsonPath, 77 | JSON.stringify(result.updatedManifest, null, 2) + '\n', 78 | 'utf-8' 79 | ); 80 | console.log( 81 | `✓ Updated manifest.json version: ${result.oldVersions.manifest} → ${result.packageVersion}` 82 | ); 83 | updatedFiles.push('manifest.json'); 84 | } 85 | 86 | // Write updated server if needed 87 | if (result.updatedServer) { 88 | fs.writeFileSync( 89 | serverJsonPath, 90 | JSON.stringify(result.updatedServer, null, 2) + '\n', 91 | 'utf-8' 92 | ); 93 | console.log( 94 | `✓ Updated server.json versions: ${result.oldVersions.server} → ${result.packageVersion}` 95 | ); 96 | updatedFiles.push('server.json'); 97 | } 98 | 99 | if (updatedFiles.length === 0) { 100 | console.log(`✓ All versions already in sync: ${result.packageVersion}`); 101 | } else { 102 | console.log(`✓ Synced ${updatedFiles.join(', ')} with package.json version: ${result.packageVersion}`); 103 | } 104 | } 105 | 106 | // Export for testing 107 | if (typeof module !== 'undefined' && module.exports) { 108 | module.exports = { syncVersionsCore, syncVersions }; 109 | } 110 | 111 | // Run if called directly 112 | if (require.main === module) { 113 | syncVersions(); 114 | } -------------------------------------------------------------------------------- /docs/engineering_standards.md: -------------------------------------------------------------------------------- 1 | # Engineering Standards 2 | 3 | This document defines the detailed standards and best practices for all contributors to the Mapbox MCP server. 4 | 5 | ## 1. Code Quality 6 | 7 | - **TypeScript Only:** All code must be written in TypeScript. No JavaScript files in `src/` or `test/`. 8 | - **Linting:** All code must pass ESLint and Prettier checks before merging. Run `npm run lint` and `npm run format`. 9 | - **Strict Typing:** Use strict types. Avoid `any` unless absolutely necessary and justified with a comment. 10 | - **No Global Pollution:** Do not patch or override global objects (e.g., `global.fetch`). Use dependency injection and explicit pipelines. 11 | 12 | ## 2. Testing 13 | 14 | - **Test Coverage:** All new features and bug fixes must include unit tests. Aim for 100% coverage on critical logic. 15 | - **Testing Framework:** Use Vitest for all tests. Place tests in the `test/` directory, mirroring the `src/` structure. 16 | - **Mocking:** Use dependency injection for testability. Mock external services and APIs; do not make real network calls in tests. 17 | - **CI Passing:** All tests must pass in CI before merging. 18 | 19 | ## 3. Documentation 20 | 21 | - **JSDoc:** All public classes, methods, and exported functions must have JSDoc comments. 22 | - **README:** Update the main `README.md` with any new features, breaking changes, or setup instructions. 23 | - **Changelog:** All user-facing changes must be documented in `CHANGELOG.md` following semantic versioning. 24 | 25 | ## 4. API & Tooling 26 | 27 | - **Explicit Pipelines:** Use the `HttpPipeline` for all HTTP requests. Add policies (e.g., User-Agent, Retry) via the pipeline, not by patching globals. 28 | - **Tool Registration:** All tools must be registered via the standard interface and support dependency injection for fetch/pipeline. 29 | - **Error Handling:** Handle and log errors gracefully. Do not swallow exceptions. 30 | 31 | ### Code Examples 32 | 33 | ```typescript 34 | // Correct: Use HttpPipeline with dependency injection 35 | const pipeline = new HttpPipeline(); 36 | pipeline.usePolicy(new UserAgentPolicy(userAgent)); 37 | pipeline.usePolicy(new RetryPolicy(3, 200, 2000)); 38 | 39 | class MyTool extends MapboxApiBasedTool { 40 | constructor(httpRequest: HttpRequest) { 41 | super({ inputSchema: MySchema, httpRequest }); 42 | } 43 | } 44 | 45 | // Incorrect: Global fetch patching 46 | global.fetch = myCustomFetch; // ❌ Don't do this 47 | ``` 48 | 49 | ## 5. Collaboration 50 | 51 | - **Pull Requests:** All changes must be submitted via pull request. PRs should be small, focused, and reference relevant issues. 52 | - **Reviews:** At least one approval from a core maintainer is required before merging. 53 | - **Issue Tracking:** Use GitHub Issues for bugs, features, and technical debt. Link PRs to issues. 54 | 55 | ## 6. Security & Secrets 56 | 57 | - **No Secrets in Code:** Never commit API keys, tokens, or secrets. Use environment variables and `.env` files (excluded from git). 58 | - **Dependency Updates:** Keep dependencies up to date and monitor for vulnerabilities. 59 | 60 | ## 7. Automation 61 | 62 | - **Pre-commit Hooks:** Use pre-commit hooks to enforce linting and formatting. 63 | - **CI/CD:** All merges to `main` must pass CI checks. 64 | 65 | ## 8. Accessibility & Inclusion 66 | 67 | - **Naming:** Use clear, descriptive names for files, variables, and tools. 68 | - **Comments:** Write comments for complex logic. Assume the next reader is not the original author. 69 | 70 | ## Environment Variables 71 | 72 | ### OpenTelemetry Configuration 73 | 74 | - `OTEL_EXPORTER_OTLP_ENDPOINT` — OTLP endpoint URL (e.g., `http://localhost:4318`) 75 | - `OTEL_SERVICE_NAME` — Override service name (default: `mapbox-mcp-server`) 76 | - `OTEL_EXPORTER_OTLP_HEADERS` — JSON string of additional headers for OTLP exporter 77 | - `OTEL_LOG_LEVEL` — OTEL diagnostic log level: `NONE` (default), `ERROR`, `WARN`, `INFO`, `DEBUG`, `VERBOSE`. Set to `NONE` to prevent OTEL logs from polluting stdio transport. 78 | 79 | ## Getting Started 80 | 81 | 1. Install dependencies: `npm install` 82 | 2. Run tests: `npm test` 83 | 3. Check linting: `npm run lint` 84 | 4. Format code: `npm run format` 85 | 5. Build project: `npm run build` 86 | -------------------------------------------------------------------------------- /test/tools/BaseTool.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { describe, it, expect, vi } from 'vitest'; 5 | import { BaseTool } from '../../src/tools/BaseTool.js'; 6 | import { z } from 'zod'; 7 | import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 8 | 9 | // Create a concrete test implementation of BaseTool 10 | class TestTool extends BaseTool< 11 | typeof TestInputSchema, 12 | typeof TestOutputSchema 13 | > { 14 | name = 'test_tool'; 15 | description = 'A test tool'; 16 | annotations = { 17 | title: 'Test Tool', 18 | readOnlyHint: true, 19 | destructiveHint: false, 20 | idempotentHint: true, 21 | openWorldHint: false 22 | }; 23 | 24 | async run(): Promise { 25 | return { 26 | content: [{ type: 'text', text: 'test' }], 27 | isError: false 28 | }; 29 | } 30 | 31 | // Expose validateOutput for testing 32 | public testValidateOutput(rawData: unknown): T { 33 | return this.validateOutput(rawData); 34 | } 35 | 36 | // Expose log method for testing 37 | public testLog( 38 | level: 'debug' | 'info' | 'warning' | 'error', 39 | data: unknown 40 | ): void { 41 | this.log(level, data); 42 | } 43 | } 44 | 45 | const TestInputSchema = z.object({ 46 | input: z.string() 47 | }); 48 | 49 | const TestOutputSchema = z.object({ 50 | output: z.string(), 51 | count: z.number() 52 | }); 53 | 54 | describe('BaseTool', () => { 55 | describe('validateOutput', () => { 56 | it('should return validated data when schema validation succeeds', () => { 57 | const tool = new TestTool({ 58 | inputSchema: TestInputSchema, 59 | outputSchema: TestOutputSchema 60 | }); 61 | 62 | const rawData = { 63 | output: 'test result', 64 | count: 42 65 | }; 66 | 67 | const result = tool.testValidateOutput(rawData); 68 | 69 | expect(result).toEqual(rawData); 70 | }); 71 | 72 | it('should return raw data and log warning when schema validation fails', () => { 73 | const tool = new TestTool({ 74 | inputSchema: TestInputSchema, 75 | outputSchema: TestOutputSchema 76 | }); 77 | 78 | // Spy on the log method 79 | const logSpy = vi.spyOn(tool as any, 'log'); 80 | 81 | const rawData = { 82 | output: 'test result', 83 | count: 'not a number' // Invalid: should be a number 84 | }; 85 | 86 | const result = tool.testValidateOutput(rawData); 87 | 88 | // Should return raw data despite validation failure 89 | expect(result).toEqual(rawData); 90 | 91 | // Should have logged a warning 92 | expect(logSpy).toHaveBeenCalledWith( 93 | 'warning', 94 | expect.stringContaining('Output schema validation failed') 95 | ); 96 | }); 97 | 98 | it('should return raw data when no output schema is provided', () => { 99 | const tool = new TestTool({ 100 | inputSchema: TestInputSchema 101 | // No outputSchema 102 | }); 103 | 104 | const rawData = { 105 | anything: 'goes', 106 | here: 123 107 | }; 108 | 109 | const result = tool.testValidateOutput(rawData); 110 | 111 | expect(result).toEqual(rawData); 112 | }); 113 | 114 | it('should handle array data with validation failure', () => { 115 | const ArrayOutputSchema = z.object({ 116 | items: z.array(z.string()) 117 | }); 118 | 119 | const tool = new TestTool({ 120 | inputSchema: TestInputSchema, 121 | outputSchema: ArrayOutputSchema as any 122 | }); 123 | 124 | const logSpy = vi.spyOn(tool as any, 'log'); 125 | 126 | const rawData = { 127 | items: ['string', 123, 'another string'] // Invalid: 123 is not a string 128 | }; 129 | 130 | const result = tool.testValidateOutput(rawData); 131 | 132 | // Should return raw data despite validation failure 133 | expect(result).toEqual(rawData); 134 | 135 | // Should have logged a warning 136 | expect(logSpy).toHaveBeenCalledWith( 137 | 'warning', 138 | expect.stringContaining('Output schema validation failed') 139 | ); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | // Search Box API feature properties schema 7 | const SearchBoxFeaturePropertiesSchema = z.object({ 8 | // Basic identification 9 | mapbox_id: z.string().optional(), 10 | feature_type: z.string().optional(), 11 | name: z.string().optional(), 12 | name_preferred: z.string().optional(), 13 | 14 | // Address components 15 | full_address: z.string().optional(), 16 | place_formatted: z.string().optional(), 17 | address_number: z.string().optional(), 18 | street_name: z.string().optional(), 19 | 20 | // Administrative areas 21 | context: z 22 | .object({ 23 | country: z 24 | .object({ 25 | name: z.string().optional(), 26 | country_code: z.string().optional(), 27 | country_code_alpha_3: z.string().optional() 28 | }) 29 | .optional(), 30 | region: z 31 | .object({ 32 | name: z.string().optional(), 33 | region_code: z.string().optional(), 34 | region_code_full: z.string().optional() 35 | }) 36 | .optional(), 37 | postcode: z 38 | .object({ 39 | name: z.string().optional() 40 | }) 41 | .optional(), 42 | district: z 43 | .object({ 44 | name: z.string().optional() 45 | }) 46 | .optional(), 47 | place: z 48 | .object({ 49 | name: z.string().optional() 50 | }) 51 | .optional(), 52 | locality: z 53 | .object({ 54 | name: z.string().optional() 55 | }) 56 | .optional(), 57 | neighborhood: z 58 | .object({ 59 | name: z.string().optional() 60 | }) 61 | .optional(), 62 | street: z 63 | .object({ 64 | name: z.string().optional() 65 | }) 66 | .optional(), 67 | address: z 68 | .object({ 69 | address_number: z.string().optional(), 70 | street_name: z.string().optional() 71 | }) 72 | .optional() 73 | }) 74 | .optional(), 75 | 76 | // Coordinates and bounds 77 | coordinates: z 78 | .object({ 79 | longitude: z.number(), 80 | latitude: z.number(), 81 | accuracy: z.string().optional(), 82 | routable_points: z 83 | .array( 84 | z.object({ 85 | name: z.string(), 86 | latitude: z.number(), 87 | longitude: z.number() 88 | }) 89 | ) 90 | .optional() 91 | }) 92 | .optional(), 93 | bbox: z.array(z.number()).length(4).optional(), 94 | 95 | // POI specific fields 96 | poi_category: z.array(z.string()).optional(), 97 | poi_category_ids: z.array(z.string()).optional(), 98 | brand: z.array(z.string()).optional(), 99 | brand_id: z.union([z.string(), z.array(z.string())]).optional(), 100 | external_ids: z.record(z.string()).optional(), 101 | 102 | // Additional metadata 103 | maki: z.string().optional(), 104 | operational_status: z.string().optional(), 105 | 106 | // ETA information (when requested) 107 | eta: z 108 | .object({ 109 | duration: z.number().optional(), 110 | distance: z.number().optional() 111 | }) 112 | .optional() 113 | }); 114 | 115 | // GeoJSON geometry schema 116 | const GeometrySchema = z.object({ 117 | type: z.literal('Point'), 118 | coordinates: z.array(z.number()).length(2) 119 | }); 120 | 121 | // Search Box API feature schema 122 | const SearchBoxFeatureSchema = z.object({ 123 | type: z.literal('Feature'), 124 | geometry: GeometrySchema, 125 | properties: SearchBoxFeaturePropertiesSchema 126 | }); 127 | 128 | // Main Search Box API response schema 129 | export const SearchBoxResponseSchema = z.object({ 130 | type: z.literal('FeatureCollection'), 131 | features: z.array(SearchBoxFeatureSchema), 132 | attribution: z.string().optional() 133 | }); 134 | 135 | export type SearchBoxResponse = z.infer; 136 | export type SearchBoxFeature = z.infer; 137 | export type SearchBoxFeatureProperties = z.infer< 138 | typeof SearchBoxFeaturePropertiesSchema 139 | >; 140 | -------------------------------------------------------------------------------- /docs/using-mcp-with-smolagents/README.md: -------------------------------------------------------------------------------- 1 | # Using Smolagents with Mapbox MCP 2 | 3 | This example demonstrates how to integrate Mapbox's Model Context Protocol (MCP) server with Smolagents, allowing AI agents to access Mapbox's location-based tools. 4 | 5 | ## Overview 6 | 7 | The `smolagents_example.py` script shows a simple but powerful implementation of connecting an AI agent to Mapbox's MCP server. It enables the agent to perform location-based tasks such as: 8 | 9 | - Getting directions between landmarks 10 | - Searching for points of interest 11 | - Geocoding locations 12 | - Calculating travel times and distances 13 | - Generating static map images 14 | - And more... 15 | 16 | ## Prerequisites 17 | 18 | - Python with `smolagents` and `mcp` packages installed: 19 | - Option 1: `pip install 'smolagents[mcp]'` 20 | - Option 2: `pip install -r requirements.txt` (from this directory) 21 | - A Mapbox access token (set as an environment variable) 22 | - Node.js or docker (to run the MCP server) 23 | 24 | With NPM package, you don't need to clone this repository, and build it. But If you want to use the local codes, you need to clone and build it: 25 | 26 | ```sh 27 | # Build node (from repository root) 28 | npm run build 29 | 30 | # note your absolute path to node, you will need it for MCP config 31 | # For Mac/Linux 32 | which node 33 | # For Windows 34 | where node 35 | 36 | # Alternatively, build docker 37 | docker build -t mapbox-mcp-server . 38 | ``` 39 | 40 | ## How It Works 41 | 42 | The script demonstrates different ways to configure language models: 43 | 44 | 1. **InferenceClient**: For hosted models 45 | 2. **Transformers**: For local models through HuggingFace 46 | 3. **Ollama**: For local models through Ollama 47 | 4. **LiteLLM**: For accessing various API-based models 48 | 5. **OpenAI**: For OpenAI's models 49 | 50 | It connects to the Mapbox MCP server, which exposes Mapbox's functionality as tools that the AI agent can use to answer location-based questions. 51 | 52 | **Important:** The example uses `structured_output=True` when connecting to MCP, which enables smolagents to properly handle the structured data returned by Mapbox tools. This allows the agent to work with complex data structures like directions, geocoding results, and map features more effectively. 53 | 54 | ## Getting Started 55 | 56 | 1. Set your Mapbox access token: 57 | 58 | ``` 59 | export MAPBOX_ACCESS_TOKEN=your_token_here 60 | ``` 61 | 62 | 2. Update the path to your node and the MCP server in the script: 63 | 64 | - If you want to use NPM version: 65 | 66 | ```python 67 | server_parameters = StdioServerParameters( 68 | command=, 69 | args=["-y", "@mapbox/mcp-server"], 70 | env={ 71 | "MAPBOX_ACCESS_TOKEN": os.environ["MAPBOX_ACCESS_TOKEN"] 72 | } 73 | ) 74 | ``` 75 | 76 | - If you want to use the local code version from this repository: 77 | ```python 78 | server_parameters = StdioServerParameters( 79 | command=, 80 | args=["/YOUR_PATH_TO_REPOSITORY/dist/esm/index.js"], 81 | env={ 82 | "MAPBOX_ACCESS_TOKEN": os.environ["MAPBOX_ACCESS_TOKEN"] 83 | } 84 | ) 85 | ``` 86 | 87 | 3. Choose your preferred model by setting the `chosen_inference` variable 88 | 89 | 4. Run the example: 90 | ``` 91 | python smolagents_example.py 92 | ``` 93 | 94 | The example asks the agent how long it takes to drive from Big Ben to the Eiffel Tower, demonstrating how the agent can use Mapbox's tools to provide a meaningful answer. 95 | 96 | If everything works well you can expect output that looks like this: 97 | ![Example of agent response](example_agent_output.png) 98 | 99 | ## Customization 100 | 101 | You can modify the question at the end of the script to test different location-based queries: 102 | 103 | ```python 104 | agent.run("Your location-based question here") 105 | ``` 106 | 107 | ## Learn More 108 | 109 | For more information about: 110 | 111 | - Smolagents: [Smolagents Documentation](https://github.com/smol-ai/smolagents) 112 | - Mapbox MCP: See the [main repository documentation](../../README.md) 113 | - Additional setup guides: 114 | - [Claude Desktop Setup](../claude-desktop-setup.md) 115 | - [VS Code Setup](../vscode-setup.md) 116 | -------------------------------------------------------------------------------- /src/resources/MapboxApiBasedResource.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; 5 | import type { 6 | ServerRequest, 7 | ServerNotification, 8 | ReadResourceResult 9 | } from '@modelcontextprotocol/sdk/types.js'; 10 | import { BaseResource } from './BaseResource.js'; 11 | import type { HttpRequest } from '../utils/types.js'; 12 | import { context, trace, SpanStatusCode } from '@opentelemetry/api'; 13 | import { getTracer } from '../utils/tracing.js'; 14 | 15 | /** 16 | * Base class for Mapbox API-based resources 17 | */ 18 | export abstract class MapboxApiBasedResource extends BaseResource { 19 | abstract readonly uri: string; 20 | abstract readonly name: string; 21 | abstract readonly description?: string; 22 | abstract readonly mimeType?: string; 23 | 24 | static get mapboxAccessToken() { 25 | return process.env.MAPBOX_ACCESS_TOKEN; 26 | } 27 | 28 | static get mapboxApiEndpoint() { 29 | return process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'; 30 | } 31 | 32 | protected httpRequest: HttpRequest; 33 | 34 | constructor(params: { httpRequest: HttpRequest }) { 35 | super(); 36 | this.httpRequest = params.httpRequest; 37 | } 38 | 39 | /** 40 | * Validates if a string has the format of a JWT token (header.payload.signature) 41 | * Docs: https://docs.mapbox.com/api/accounts/tokens/#token-format 42 | * @param token The token string to validate 43 | * @returns boolean indicating if the token has valid JWT format 44 | */ 45 | private isValidJwtFormat(token: string): boolean { 46 | // JWT consists of three parts separated by dots: header.payload.signature 47 | const parts = token.split('.'); 48 | if (parts.length !== 3) return false; 49 | 50 | // Check that all parts are non-empty 51 | return parts.every((part) => part.length > 0); 52 | } 53 | 54 | /** 55 | * Validates and reads the resource. 56 | */ 57 | async read( 58 | uri: string, 59 | extra?: RequestHandlerExtra 60 | ): Promise { 61 | // First check if token is provided via authentication context 62 | const authToken = extra?.authInfo?.token; 63 | const accessToken = authToken || MapboxApiBasedResource.mapboxAccessToken; 64 | 65 | if (!accessToken) { 66 | const errorMessage = 67 | 'No access token available. Please provide via Bearer auth or MAPBOX_ACCESS_TOKEN env var'; 68 | this.log('error', `${this.name}: ${errorMessage}`); 69 | throw new Error(errorMessage); 70 | } 71 | 72 | // Validate that the token has the correct JWT format 73 | if (!this.isValidJwtFormat(accessToken)) { 74 | const errorMessage = 'Access token is not in valid JWT format'; 75 | this.log('error', `${this.name}: ${errorMessage}`); 76 | throw new Error(errorMessage); 77 | } 78 | 79 | const tracer = getTracer(); 80 | const span = tracer.startSpan(`resource.read.${this.name}`, { 81 | attributes: { 82 | 'resource.uri': uri, 83 | 'resource.name': this.name, 84 | 'operation.type': 'resource_read' 85 | } 86 | }); 87 | 88 | try { 89 | // Execute within the span context 90 | const result = await context.with( 91 | trace.setSpan(context.active(), span), 92 | async () => { 93 | return await this.execute(uri, accessToken); 94 | } 95 | ); 96 | 97 | // Mark span as successful and end it 98 | span.setStatus({ code: SpanStatusCode.OK }); 99 | span.end(); 100 | return result; 101 | } catch (error) { 102 | const errorMessage = 103 | error instanceof Error ? error.message : String(error); 104 | this.log( 105 | 'error', 106 | `${this.name}: Error during execution: ${errorMessage}` 107 | ); 108 | 109 | // Mark span as failed and end it 110 | span.setStatus({ 111 | code: SpanStatusCode.ERROR, 112 | message: errorMessage 113 | }); 114 | span.end(); 115 | 116 | throw error; 117 | } 118 | } 119 | 120 | /** 121 | * Resource-specific logic to be implemented by subclasses. 122 | */ 123 | protected abstract execute( 124 | uri: string, 125 | accessToken: string 126 | ): Promise; 127 | } 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mapbox/mcp-server", 3 | "version": "0.7.0", 4 | "description": "Mapbox MCP server.", 5 | "mcpName": "io.github.mapbox/mcp-server", 6 | "main": "./dist/commonjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "type": "module", 9 | "bin": { 10 | "mcp-server": "dist/esm/index.js" 11 | }, 12 | "scripts": { 13 | "build": "npm run prepare && tshy && npm run generate-version && node scripts/add-shebang.cjs", 14 | "format": "prettier --check \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\"", 15 | "format:fix": "prettier --write \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\"", 16 | "generate-version": "node scripts/build-helpers.cjs generate-version", 17 | "inspect:build": "npm run build && npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" node dist/esm/index.js", 18 | "inspect:dev": "npx @modelcontextprotocol/inspector -e MAPBOX_ACCESS_TOKEN=\"$MAPBOX_ACCESS_TOKEN\" npx -y tsx src/index.ts", 19 | "lint": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\"", 20 | "lint:fix": "eslint \"./src/**/*.{ts,tsx}\" \"./test/**/*.{ts,tsx}\" --fix", 21 | "prepare": "husky && node .husky/setup-hooks.js", 22 | "spellcheck": "cspell \"*.md\" \"src/**/*.ts\" \"test/**/*.ts\"", 23 | "sync-manifest": "node scripts/sync-manifest-version.cjs", 24 | "test": "vitest", 25 | "tracing:jaeger:start": "docker run --rm -d --name jaeger -p 16686:16686 -p 14250:14250 -p 4317:4317 -p 4318:4318 jaegertracing/all-in-one:latest", 26 | "tracing:jaeger:stop": "docker stop jaeger", 27 | "tracing:verify": "node -e \"console.log('🔍 Verifying tracing setup with Jaeger...\\n1. Copy .env.example to .env and add your MAPBOX_ACCESS_TOKEN\\n2. Start Jaeger: npm run tracing:jaeger:start\\n3. Run server: npm run inspect:build\\n4. Check traces at: http://localhost:16686')\"" 28 | }, 29 | "lint-staged": { 30 | "*.{js,jsx,ts,tsx}": "eslint --fix", 31 | "*.{js,jsx,ts,tsx,md,html,css}": "prettier --write" 32 | }, 33 | "license": "MIT", 34 | "homepage": "https://github.com/mapbox/mcp-server#readme", 35 | "devDependencies": { 36 | "@eslint/js": "^9.27.0", 37 | "@types/node": "^22.0.0", 38 | "@typescript-eslint/eslint-plugin": "^8.0.0", 39 | "@typescript-eslint/parser": "^8.0.0", 40 | "@vitest/coverage-istanbul": "^3.2.4", 41 | "cspell": "^9.2.1", 42 | "eslint": "^9.0.0", 43 | "eslint-config-prettier": "^10.1.8", 44 | "eslint-plugin-n": "^17.21.3", 45 | "eslint-plugin-prettier": "^5.5.4", 46 | "eslint-plugin-unused-imports": "^4.2.0", 47 | "globals": "^16.3.0", 48 | "husky": "^9.0.0", 49 | "lint-staged": "^16.1.0", 50 | "plop": "^4.0.1", 51 | "prettier": "^3.0.0", 52 | "tshy": "^3.0.2", 53 | "typescript": "^5.8.3", 54 | "typescript-eslint": "^8.42.0", 55 | "vitest": "^3.2.4" 56 | }, 57 | "prettier": { 58 | "singleQuote": true, 59 | "trailingComma": "none" 60 | }, 61 | "engines": { 62 | "node": ">=22" 63 | }, 64 | "files": [ 65 | "dist" 66 | ], 67 | "repository": { 68 | "type": "git", 69 | "url": "https://github.com/mapbox/mcp-server.git" 70 | }, 71 | "keywords": [ 72 | "mapbox", 73 | "mcp" 74 | ], 75 | "dependencies": { 76 | "@mcp-ui/server": "^5.13.1", 77 | "@modelcontextprotocol/sdk": "^1.21.1", 78 | "@opentelemetry/api": "^1.9.0", 79 | "@opentelemetry/auto-instrumentations-node": "^0.56.0", 80 | "@opentelemetry/exporter-trace-otlp-http": "^0.56.0", 81 | "@opentelemetry/instrumentation": "^0.56.0", 82 | "@opentelemetry/resources": "^1.30.1", 83 | "@opentelemetry/sdk-node": "^0.56.0", 84 | "@opentelemetry/sdk-trace-base": "^1.30.1", 85 | "@opentelemetry/semantic-conventions": "^1.30.1", 86 | "zod": "^3.25.42" 87 | }, 88 | "tshy": { 89 | "project": "./tsconfig.src.json", 90 | "exports": { 91 | ".": "./src/index.ts" 92 | }, 93 | "dialects": [ 94 | "esm", 95 | "commonjs" 96 | ], 97 | "selfLink": false 98 | }, 99 | "exports": { 100 | ".": { 101 | "import": { 102 | "types": "./dist/esm/index.d.ts", 103 | "default": "./dist/esm/index.js" 104 | }, 105 | "require": { 106 | "types": "./dist/commonjs/index.d.ts", 107 | "default": "./dist/commonjs/index.js" 108 | } 109 | } 110 | }, 111 | "types": "./dist/commonjs/index.d.ts" 112 | } 113 | -------------------------------------------------------------------------------- /src/tools/category-list-tool/CategoryListTool.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; 5 | import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 6 | import type { HttpRequest } from '../../utils/types.js'; 7 | import type { CategoryListInput } from './CategoryListTool.input.schema.js'; 8 | import { CategoryListInputSchema } from './CategoryListTool.input.schema.js'; 9 | import { CategoryListResponseSchema } from './CategoryListTool.output.schema.js'; 10 | 11 | // Interface for the full API response from Mapbox 12 | interface MapboxApiResponse { 13 | listItems: Array<{ 14 | canonical_id: string; 15 | icon: string; 16 | name: string; 17 | version?: string; 18 | uuid?: string; 19 | }>; 20 | attribution: string; 21 | version: string; 22 | } 23 | 24 | // API Documentation: https://docs.mapbox.com/api/search/search-box/#list-categories 25 | 26 | /** 27 | * Tool for retrieving the list of supported categories from Mapbox Search API 28 | */ 29 | export class CategoryListTool extends MapboxApiBasedTool< 30 | typeof CategoryListInputSchema, 31 | typeof CategoryListResponseSchema 32 | > { 33 | name = 'category_list_tool'; 34 | description = 35 | '[DEPRECATED: Use resource_reader_tool with "mapbox://categories" URI instead] Tool for retrieving the list of supported categories from Mapbox Search API. This tool is kept for backward compatibility with clients that do not support MCP resources. Use this when another function requires a list of categories. Returns all available category IDs by default. Only use pagination (limit/offset) if token usage optimization is required. If using pagination, make multiple calls to retrieve ALL categories before proceeding with other tasks to ensure complete data.'; 36 | annotations = { 37 | title: 'Category List Tool (Deprecated)', 38 | readOnlyHint: true, 39 | destructiveHint: false, 40 | idempotentHint: true, 41 | openWorldHint: true 42 | }; 43 | 44 | constructor(params: { httpRequest: HttpRequest }) { 45 | super({ 46 | inputSchema: CategoryListInputSchema, 47 | outputSchema: CategoryListResponseSchema, 48 | httpRequest: params.httpRequest 49 | }); 50 | } 51 | 52 | protected async execute( 53 | input: CategoryListInput, 54 | accessToken: string 55 | ): Promise { 56 | const url = new URL( 57 | 'https://api.mapbox.com/search/searchbox/v1/list/category' 58 | ); 59 | 60 | url.searchParams.set('access_token', accessToken); 61 | 62 | if (input.language) { 63 | url.searchParams.set('language', input.language); 64 | } 65 | 66 | const response = await this.httpRequest(url.toString(), { 67 | method: 'GET', 68 | headers: { 69 | 'User-Agent': `@mapbox/mcp-server/${process.env.npm_package_version || 'dev'}` 70 | } 71 | }); 72 | 73 | if (!response.ok) { 74 | return { 75 | content: [ 76 | { 77 | type: 'text', 78 | text: `Mapbox API request failed: ${response.status} ${response.statusText}` 79 | } 80 | ], 81 | isError: true 82 | }; 83 | } 84 | 85 | const rawData = await response.json(); 86 | 87 | // Parse the API response (which has the full structure) 88 | const data = rawData as MapboxApiResponse; 89 | 90 | // Apply pagination - if no limit specified, return all 91 | const startIndex = input.offset || 0; 92 | let endIndex = data.listItems.length; 93 | 94 | if (input.limit) { 95 | endIndex = Math.min(startIndex + input.limit, data.listItems.length); 96 | } 97 | 98 | // Extract just the category IDs for our simplified response 99 | const categoryIds = data.listItems 100 | .slice(startIndex, endIndex) 101 | .map((item) => item.canonical_id); 102 | 103 | const result = { listItems: categoryIds }; 104 | 105 | // Validate our simplified output against the schema 106 | try { 107 | CategoryListResponseSchema.parse(result); 108 | } catch (validationError) { 109 | this.log( 110 | 'warning', 111 | `Output schema validation failed: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` 112 | ); 113 | } 114 | 115 | return { 116 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 117 | structuredContent: result, 118 | isError: false 119 | }; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /docs/tracing-verification.md: -------------------------------------------------------------------------------- 1 | # Tracing Verification Guide 2 | 3 | This guide shows how to verify that OpenTelemetry tracing is working correctly with the MCP server. 4 | 5 | ## Quick Start with Jaeger 6 | 7 | ### 1. Start Jaeger (One-time setup) 8 | 9 | ```bash 10 | # Start Jaeger in Docker (requires Docker to be installed) 11 | npm run tracing:jaeger:start 12 | ``` 13 | 14 | This starts Jaeger with: 15 | 16 | - **UI**: http://localhost:16686 (view traces here) 17 | - **OTLP HTTP endpoint**: http://localhost:4318 (where our traces go) 18 | 19 | ### 2. Run MCP Server with Tracing 20 | 21 | ```bash 22 | # Run the MCP inspector with tracing enabled 23 | npm run inspect:build:tracing 24 | ``` 25 | 26 | This will: 27 | 28 | - Build the server 29 | - Start it with SSE transport (so we get console logs) 30 | - Enable tracing with OTLP endpoint pointing to Jaeger 31 | - Set service name to `mapbox-mcp-server-inspector` 32 | 33 | ### 3. Generate Some Traces 34 | 35 | In the MCP inspector: 36 | 37 | 1. Execute any tool (e.g., search for "San Francisco") 38 | 2. Try multiple tools to generate various traces 39 | 3. Each tool execution creates traces 40 | 41 | ### 4. View Traces in Jaeger 42 | 43 | 1. Open http://localhost:16686 in your browser 44 | 2. Select service: `mapbox-mcp-server-inspector` 45 | 3. Click "Find Traces" 46 | 4. You should see traces for: 47 | - Tool executions (e.g., `tool.search_tool`) 48 | - HTTP requests (e.g., `http.get`) 49 | - Any errors or performance issues 50 | 51 | ### 5. Stop Jaeger (When done) 52 | 53 | ```bash 54 | npm run tracing:jaeger:stop 55 | ``` 56 | 57 | ## What to Look For 58 | 59 | ### Successful Tracing Setup 60 | 61 | ✅ **Console output shows**: `OpenTelemetry tracing: enabled` 62 | 63 | ✅ **Jaeger UI shows traces** for your service 64 | 65 | ✅ **Trace details include**: 66 | 67 | - Tool name and execution time 68 | - HTTP requests to Mapbox APIs 69 | - Input/output sizes 70 | - Success/error status 71 | - Session context (if using JWT) 72 | 73 | ### Troubleshooting 74 | 75 | ❌ **"OpenTelemetry tracing: disabled"** 76 | 77 | - Check that `OTEL_EXPORTER_OTLP_ENDPOINT` is set 78 | - Verify Jaeger is running: `docker ps | grep jaeger` 79 | 80 | ❌ **No traces in Jaeger** 81 | 82 | - Wait a few seconds after tool execution 83 | - Check Jaeger is receiving data: http://localhost:16686 84 | - Verify the service name matches: `mapbox-mcp-server-inspector` 85 | 86 | ❌ **Docker not available** 87 | 88 | - Use alternative OTLP collector 89 | - Note: Console tracing is not supported with stdio transport 90 | 91 | ## Alternative OTLP Endpoints 92 | 93 | ### Local OTEL Collector 94 | 95 | ```bash 96 | OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 97 | ``` 98 | 99 | ### AWS X-Ray (via ADOT) 100 | 101 | ```bash 102 | OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:2000 103 | AWS_REGION=us-east-1 104 | ``` 105 | 106 | ### Google Cloud Trace 107 | 108 | ```bash 109 | OTEL_EXPORTER_OTLP_ENDPOINT=https://cloudtrace.googleapis.com/v1/projects/PROJECT_ID/traces:batchWrite 110 | ``` 111 | 112 | ### Honeycomb 113 | 114 | ```bash 115 | OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io/v1/traces 116 | OTEL_EXPORTER_OTLP_HEADERS='{"x-honeycomb-team":"YOUR_API_KEY"}' 117 | ``` 118 | 119 | ## Verifying Different Transports 120 | 121 | ### SSE Transport (HTTP) - Full Logging 122 | 123 | ```bash 124 | SERVER_TRANSPORT=sse OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 npm run inspect:build 125 | ``` 126 | 127 | ### stdio Transport - Silent Operation 128 | 129 | ```bash 130 | OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 npm run inspect:build 131 | ``` 132 | 133 | ## Production Considerations 134 | 135 | - **Performance**: Tracing adds <1% CPU overhead 136 | - **Network**: Each trace is ~1-5KB sent to OTLP endpoint 137 | - **Sampling**: Use `OTEL_TRACES_SAMPLER=traceidratio` and `OTEL_TRACES_SAMPLER_ARG=0.1` for high-volume environments 138 | - **Security**: Traces don't include sensitive input data, only metadata 139 | 140 | ## Example Trace Data 141 | 142 | A successful tool execution trace includes: 143 | 144 | ```json 145 | { 146 | "traceId": "1234567890abcdef", 147 | "spanId": "abcdef1234567890", 148 | "operationName": "tool.search_tool", 149 | "startTime": "2025-10-07T12:00:00Z", 150 | "duration": "245ms", 151 | "tags": { 152 | "tool.name": "search_tool", 153 | "tool.input.size": 156, 154 | "tool.output.size": 2048, 155 | "session.id": "session-uuid", 156 | "http.method": "GET", 157 | "http.url": "https://api.mapbox.com/search/...", 158 | "http.status_code": 200 159 | } 160 | } 161 | ``` 162 | 163 | This gives you complete visibility into tool performance, API calls, and any issues. 164 | -------------------------------------------------------------------------------- /docs/complete-observability.md: -------------------------------------------------------------------------------- 1 | # Complete MCP Server Observability 2 | 3 | This MCP server now has **comprehensive end-to-end tracing** for all major operations. Here's the complete observability stack: 4 | 5 | ## 🏗️ **Complete Tracing Architecture** 6 | 7 | ### **1. Configuration Loading Tracing** 8 | 9 | - .env file loading and parsing (using Node.js built-in parseEnv) 10 | - Number of environment variables loaded 11 | - Configuration errors and warnings 12 | - Startup configuration validation 13 | 14 | ### **2. Tool Execution Tracing** 15 | 16 | - Tool lifecycle (start, execute, complete) 17 | - Input validation and processing 18 | - Error handling and propagation 19 | - Business logic performance 20 | 21 | ### **3. HTTP Request Tracing** 22 | 23 | - All outbound HTTP calls (Mapbox APIs, external services) 24 | - Request/response metadata (headers, status codes, timing) 25 | - CloudFront correlation IDs for Mapbox API requests 26 | - Cache hit/miss tracking via CloudFront headers 27 | - Retry logic and failure handling 28 | - Network-level performance metrics 29 | 30 | ## 🔗 **Connected Trace Hierarchy** 31 | 32 | ``` 33 | ⚙️ Configuration Loading (Startup) 34 | └── config.load_env 35 | ├── File existence check 36 | ├── Environment variable parsing 37 | └── Configuration validation 38 | 39 | 🔄 MCP Tool Execution (Root) 40 | ├── 🌐 HTTP API Calls 41 | │ ├── Request Preparation 42 | │ ├── Network Transfer (with CloudFront correlation) 43 | │ └── Response Processing 44 | └── 📊 Business Logic 45 | ├── Data Transformation 46 | ├── Validation 47 | └── Result Formatting 48 | ``` 49 | 50 | ## 🎯 **Instrumentation Coverage** 51 | 52 | | Component | Automatic | Custom Spans | Attributes | Context Propagation | 53 | | --------- | --------- | ------------ | ---------- | ------------------- | 54 | | **Tools** | ❌ | ✅ | ✅ | ✅ | 55 | | **HTTP** | ✅ | ✅ | ✅ | ✅ | 56 | 57 | ## 📊 **Trace Attributes Captured** 58 | 59 | ### Configuration Context 60 | 61 | ```json 62 | { 63 | "config.file.path": "/app/.env", 64 | "config.file.exists": true, 65 | "config.vars.loaded": 5, 66 | "operation.type": "config_load", 67 | "config.load.success": true 68 | } 69 | ``` 70 | 71 | ### Tool Context 72 | 73 | ```json 74 | { 75 | "tool.name": "boundaries_search", 76 | "tool.input.size": 156, 77 | "tool.success": true, 78 | "tool.duration_ms": 2340 79 | } 80 | ``` 81 | 82 | ### HTTP Context (with CloudFront Correlation) 83 | 84 | ```json 85 | { 86 | "http.method": "POST", 87 | "http.url": "https://api.mapbox.com/search/v1", 88 | "http.status_code": 200, 89 | "http.response.content_length": 2048, 90 | "http.duration_ms": 125, 91 | "http.response.header.x_amz_cf_id": "HsL_E2ZgW72g4tg...", 92 | "http.response.header.x_amz_cf_pop": "IAD55-P3", 93 | "http.response.header.x_cache": "Miss from cloudfront", 94 | "http.response.header.etag": "W/\"21fe5-88gH...\"" 95 | } 96 | ``` 97 | 98 | ## 🚀 **Getting Started** 99 | 100 | ### Environment Configuration 101 | 102 | ```bash 103 | # Enable OTEL tracing 104 | OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 105 | 106 | # API Keys 107 | MAPBOX_ACCESS_TOKEN=pk.xxx 108 | ``` 109 | 110 | ### Trace Collection 111 | 112 | The server automatically sends traces to your OTLP endpoint (Jaeger, Zipkin, New Relic, DataDog, etc.) 113 | 114 | ## 🔍 **Query Examples** 115 | 116 | ### Find slow operations across all systems: 117 | 118 | ``` 119 | duration > 1000ms 120 | ``` 121 | 122 | ### Find HTTP failures: 123 | 124 | ``` 125 | http.status_code >= 400 126 | ``` 127 | 128 | ## 📈 **Observability Benefits** 129 | 130 | ### **Performance Optimization** 131 | 132 | - Identify bottlenecks across the entire request flow 133 | - Compare performance between different AI models 134 | - Monitor HTTP API response times 135 | 136 | ### **Cost Management** 137 | 138 | - Identify expensive operations 139 | - Optimize resource allocation 140 | 141 | ### **Error Tracking** 142 | 143 | - Complete error propagation visibility 144 | - Root cause analysis across systems 145 | - Performance impact of failures 146 | - User experience correlation 147 | 148 | ### **Capacity Planning** 149 | 150 | - API rate limit monitoring 151 | - Resource usage patterns 152 | - Scaling decision support 153 | 154 | ## 🎉 **Complete Stack Coverage** 155 | 156 | The MCP server now provides **360° observability**: 157 | 158 | ✅ **Application Level**: Tool execution, business logic, error handling 159 | ✅ **API Level**: HTTP requests, responses, external service calls 160 | ✅ **Infrastructure Level**: Network timing, resource utilization, system health 161 | 162 | Every operation is traceable from user request to final response! 🚀 163 | -------------------------------------------------------------------------------- /test/tools/matrix-tool/MatrixTool.output.schema.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { describe, it, expect, vi } from 'vitest'; 5 | import { MatrixTool } from '../../../src/tools/matrix-tool/MatrixTool.js'; 6 | import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; 7 | 8 | describe('MatrixTool output schema registration', () => { 9 | it('should have an output schema defined', () => { 10 | const { httpRequest } = setupHttpRequest(); 11 | const tool = new MatrixTool({ httpRequest }); 12 | expect(tool.outputSchema).toBeDefined(); 13 | expect(tool.outputSchema).toBeTruthy(); 14 | }); 15 | 16 | it('should register output schema with MCP server', () => { 17 | const { httpRequest } = setupHttpRequest(); 18 | const tool = new MatrixTool({ httpRequest }); 19 | 20 | // Mock the installTo method to verify it gets called with output schema 21 | const installToSpy = vi.spyOn(tool, 'installTo').mockImplementation(() => { 22 | // Verify that the tool has an output schema when being installed 23 | expect(tool.outputSchema).toBeDefined(); 24 | return {} as ReturnType; 25 | }); 26 | 27 | const mockServer = {} as Parameters[0]; 28 | tool.installTo(mockServer); 29 | 30 | expect(installToSpy).toHaveBeenCalledWith(mockServer); 31 | }); 32 | 33 | it('should validate response structure matches schema', () => { 34 | const { httpRequest } = setupHttpRequest(); 35 | const tool = new MatrixTool({ httpRequest }); 36 | const mockResponse = { 37 | code: 'Ok', 38 | durations: [ 39 | [0, 573, 1169.5], 40 | [573, 0, 597], 41 | [1169.5, 597, 0] 42 | ], 43 | distances: [ 44 | [0, 1200, 2400], 45 | [1200, 0, 1500], 46 | [2400, 1500, 0] 47 | ], 48 | sources: [ 49 | { 50 | name: 'Mission Street', 51 | location: [-122.418408, 37.751668], 52 | distance: 5 53 | }, 54 | { 55 | name: '22nd Street', 56 | location: [-122.422959, 37.755184], 57 | distance: 8 58 | }, 59 | { 60 | name: '', 61 | location: [-122.426911, 37.759695], 62 | distance: 10 63 | } 64 | ], 65 | destinations: [ 66 | { 67 | name: 'Mission Street', 68 | location: [-122.418408, 37.751668], 69 | distance: 5 70 | }, 71 | { 72 | name: '22nd Street', 73 | location: [-122.422959, 37.755184], 74 | distance: 8 75 | }, 76 | { 77 | name: '', 78 | location: [-122.426911, 37.759695], 79 | distance: 10 80 | } 81 | ] 82 | }; 83 | 84 | // This should not throw if the schema is correct 85 | expect(() => { 86 | if (tool.outputSchema) { 87 | tool.outputSchema.parse(mockResponse); 88 | } 89 | }).not.toThrow(); 90 | }); 91 | 92 | it('should handle null values in durations and distances matrices', () => { 93 | const { httpRequest } = setupHttpRequest(); 94 | const tool = new MatrixTool({ httpRequest }); 95 | const mockResponseWithNulls = { 96 | code: 'Ok', 97 | durations: [ 98 | [0, null, 1169.5], 99 | [573, 0, null], 100 | [null, 597, 0] 101 | ], 102 | distances: [ 103 | [0, null, 2400], 104 | [1200, 0, null], 105 | [null, 1500, 0] 106 | ], 107 | sources: [ 108 | { 109 | name: 'Start', 110 | location: [-122.418408, 37.751668], 111 | distance: 5 112 | } 113 | ], 114 | destinations: [ 115 | { 116 | name: 'End', 117 | location: [-122.422959, 37.755184], 118 | distance: 8 119 | } 120 | ] 121 | }; 122 | 123 | // This should not throw - null values are allowed in matrices 124 | expect(() => { 125 | if (tool.outputSchema) { 126 | tool.outputSchema.parse(mockResponseWithNulls); 127 | } 128 | }).not.toThrow(); 129 | }); 130 | 131 | it('should handle error responses with message field', () => { 132 | const { httpRequest } = setupHttpRequest(); 133 | const tool = new MatrixTool({ httpRequest }); 134 | const errorResponse = { 135 | code: 'InvalidInput', 136 | message: 'Invalid coordinates provided', 137 | sources: [], 138 | destinations: [] 139 | }; 140 | 141 | // This should not throw - error responses are valid 142 | expect(() => { 143 | if (tool.outputSchema) { 144 | tool.outputSchema.parse(errorResponse); 145 | } 146 | }).not.toThrow(); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /docs/claude-desktop-setup.md: -------------------------------------------------------------------------------- 1 | # Claude Desktop Setup 2 | 3 | This guide explains how to set up and configure Claude Desktop for use with the Mapbox MCP Server. 4 | 5 | ## Requirements 6 | 7 | - Claude Desktop application installed on your system 8 | 9 | ## Setup Instructions 10 | 11 | ### Install Claude Desktop 12 | 13 | [Download](https://claude.ai/download) and install the Claude Desktop application from the official page. 14 | 15 | ## Option 1: DXT Installation (Recommended) 16 | 17 | The easiest way to install the Mapbox MCP Server is using the pre-built DXT package. 18 | 19 | **⚠️ Important: Make sure you have the latest version of Claude Desktop installed. Older versions may not support DXT files and will show errors during installation.** 20 | 21 | 1. **Update Claude Desktop**: [Download the latest version](https://claude.ai/download) if you haven't already 22 | 2. **Download the DXT package**: [📦 mcp-server.dxt](https://github.com/mapbox/mcp-server/releases/latest/download/mcp-server.dxt) 23 | 3. **Open the file** with Claude Desktop (double-click or drag and drop) 24 | 4. **Follow the installation prompts** 25 | 5. **Provide your Mapbox access token** when prompted 26 | 27 | ## Option 2: Manual Configuration 28 | 29 | If you prefer manual configuration or want to use a local development version: 30 | 31 | ### Prerequisites for Manual Setup 32 | 33 | - Mapbox MCP Server built locally 34 | 35 | ```sh 36 | # from repository root: 37 | # using node 38 | npm run build 39 | 40 | # note your absolute path to node, you will need it for MCP config 41 | # For Mac/Linux 42 | which node 43 | # For Windows 44 | where node 45 | 46 | # or alternatively, using docker 47 | docker build -t mapbox-mcp-server . 48 | ``` 49 | 50 | ### Configure Claude to use Mapbox MCP Server 51 | 52 | 1. Open Claude Desktop settings 53 | ![Open settings](images/claude-desktop-settings.png) 54 | 1. Navigate to the Model Context Protocol section 55 | ![Navigate to MCP section](images/claude-mcp-section.png) 56 | 1. Modify claude_desktop_config.json to add new server, for example: 57 | 58 | - Using NPM package 59 | ```json 60 | { 61 | "mcpServers": { 62 | "MapboxServer": { 63 | "command": , 64 | "args": [ "-y", "@mapbox/mcp-server"], 65 | "env": { 66 | "MAPBOX_ACCESS_TOKEN": 67 | } 68 | } 69 | } 70 | } 71 | ``` 72 | - If you want to use local Node.js version (Need to clone and build from this repo) 73 | 74 | ```json 75 | { 76 | "mcpServers": { 77 | "MapboxServer": { 78 | "command": , 79 | "args": ["YOUR_PATH_TO_GIT_REPOSITORY/dist/esm/index.js"], 80 | "env": { 81 | "MAPBOX_ACCESS_TOKEN": "YOUR_TOKEN" 82 | } 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | - Alternatively, using docker: 89 | 90 | ```json 91 | { 92 | "mcpServers": { 93 | "MapboxServer": { 94 | "command": "docker", 95 | "args": [ 96 | "run", 97 | "-i", 98 | "--rm", 99 | "-e", 100 | "MAPBOX_ACCESS_TOKEN=YOUR_TOKEN", 101 | "mapbox-mcp-server" 102 | ] 103 | } 104 | } 105 | } 106 | ``` 107 | 108 | ### Using Mapbox Tools in Claude 109 | 110 | Once configured, you can use any of the Mapbox tools directly in your Claude conversations: 111 | 112 | - Request directions between locations 113 | - Search for points of interest 114 | - And more 115 | 116 | #### You should see Mapbox Server appear in tools menu 117 | 118 | ![Mapbox Server appears in tools menu](images/mapbox-server-tools-menu.png) 119 | 120 | #### You will be asked to approve access on first use 121 | 122 | ![Claude asking for permissions on first use](images/claude-permission-prompt.png) 123 | 124 | #### Example of working tools 125 | 126 | ![Example prompt](images/mapbox-tool-example-usage.png) 127 | 128 | Note, the results can vary based on current traffic conditions and exact values of parameters used. 129 | 130 | ## MCP-UI Support 131 | 132 | Claude Desktop does not currently support the MCP-UI specification for embedded interactive elements. When you use tools like `static_map_image_tool`, you'll receive: 133 | 134 | - ✅ **Base64-encoded map images** that Claude can display 135 | - ❌ **Interactive iframe embeds** (not supported by Claude Desktop) 136 | 137 | The server is fully backwards compatible - all tools work normally, you just won't see interactive map embeds. If you're interested in using MCP-UI features with inline map visualization, consider trying [Goose](https://github.com/block/goose), which supports MCP-UI. 138 | 139 | For more information about MCP-UI support in this server, see the [MCP-UI documentation](mcp-ui.md). 140 | -------------------------------------------------------------------------------- /test/tools/structured-content.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 5 | import { MapboxApiBasedTool } from '../../src/tools/MapboxApiBasedTool.js'; 6 | import type { OutputSchema } from '../../src/tools/MapboxApiBasedTool.output.schema.js'; 7 | import { z } from 'zod'; 8 | 9 | const TestInputSchema = z.object({ 10 | test: z.string() 11 | }); 12 | 13 | class TestTool extends MapboxApiBasedTool { 14 | name = 'test_tool'; 15 | description = 'Test tool for structured content'; 16 | annotations = { 17 | title: 'Test Tool', 18 | readOnlyHint: true, 19 | destructiveHint: false, 20 | idempotentHint: true, 21 | openWorldHint: true 22 | }; 23 | 24 | constructor() { 25 | super({ inputSchema: TestInputSchema }); 26 | } 27 | 28 | protected async execute( 29 | input: z.infer, 30 | _accessToken: string 31 | ): Promise> { 32 | // Return different types based on input 33 | if (input.test === 'object') { 34 | const data = { 35 | message: 'This is structured content', 36 | data: { value: 42, success: true }, 37 | timestamp: new Date().toISOString() 38 | }; 39 | return { 40 | content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], 41 | structuredContent: data, 42 | isError: false 43 | }; 44 | } 45 | if (input.test === 'content') { 46 | return { 47 | content: [{ type: 'text', text: 'This is direct content' }], 48 | isError: false 49 | }; 50 | } 51 | if (input.test === 'complete') { 52 | // Return complete OutputSchema 53 | return { 54 | content: [ 55 | { type: 'text', text: 'Custom content message' }, 56 | { type: 'text', text: 'Additional context' } 57 | ], 58 | structuredContent: { 59 | operation: 'complete_output', 60 | results: ['item1', 'item2'], 61 | metadata: { count: 2, status: 'success' } 62 | }, 63 | isError: false 64 | }; 65 | } 66 | return { 67 | content: [{ type: 'text', text: '"Simple string response"' }], 68 | isError: false 69 | }; 70 | } 71 | } 72 | 73 | describe('MapboxApiBasedTool Structured Content', () => { 74 | let tool: TestTool; 75 | 76 | beforeEach(() => { 77 | tool = new TestTool(); 78 | vi.stubEnv('MAPBOX_ACCESS_TOKEN', 'pk.test.token'); 79 | }); 80 | 81 | it('should return structured content for object responses', async () => { 82 | const result = await tool.run({ test: 'object' }); 83 | 84 | expect(result.isError).toBe(false); 85 | expect(result.content).toHaveLength(1); 86 | expect(result.content[0].type).toBe('text'); 87 | expect(result.structuredContent).toBeDefined(); 88 | expect(result.structuredContent).toHaveProperty( 89 | 'message', 90 | 'This is structured content' 91 | ); 92 | expect(result.structuredContent).toHaveProperty('data'); 93 | expect(result.structuredContent?.data).toEqual({ 94 | value: 42, 95 | success: true 96 | }); 97 | }); 98 | 99 | it('should return direct content without structured content', async () => { 100 | const result = await tool.run({ test: 'content' }); 101 | 102 | expect(result.isError).toBe(false); 103 | expect(result.content).toHaveLength(1); 104 | expect(result.content[0]).toEqual({ 105 | type: 'text', 106 | text: 'This is direct content' 107 | }); 108 | expect(result.structuredContent).toBeUndefined(); 109 | }); 110 | 111 | it('should return simple content for primitive responses', async () => { 112 | const result = await tool.run({ test: 'string' }); 113 | 114 | expect(result.isError).toBe(false); 115 | expect(result.content).toHaveLength(1); 116 | expect(result.content[0]).toEqual({ 117 | type: 'text', 118 | text: '"Simple string response"' 119 | }); 120 | expect(result.structuredContent).toBeUndefined(); 121 | }); 122 | 123 | it('should return complete OutputSchema when provided', async () => { 124 | const result = await tool.run({ test: 'complete' }); 125 | 126 | expect(result.isError).toBe(false); 127 | expect(result.content).toHaveLength(2); 128 | expect(result.content[0]).toEqual({ 129 | type: 'text', 130 | text: 'Custom content message' 131 | }); 132 | expect(result.content[1]).toEqual({ 133 | type: 'text', 134 | text: 'Additional context' 135 | }); 136 | expect(result.structuredContent).toBeDefined(); 137 | expect(result.structuredContent).toEqual({ 138 | operation: 'complete_output', 139 | results: ['item1', 'item2'], 140 | metadata: { count: 2, status: 'success' } 141 | }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /test/tools/version-tool/VersionTool.output.schema.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | process.env.MAPBOX_ACCESS_TOKEN = 5 | 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; 6 | 7 | import { describe, it, expect, vi } from 'vitest'; 8 | import { VersionTool } from '../../../src/tools/version-tool/VersionTool.js'; 9 | 10 | describe('VersionTool output schema registration', () => { 11 | it('should have an output schema defined', () => { 12 | const tool = new VersionTool(); 13 | expect(tool.outputSchema).toBeDefined(); 14 | expect(tool.outputSchema).toBeTruthy(); 15 | }); 16 | 17 | it('should register output schema with MCP server', () => { 18 | const tool = new VersionTool(); 19 | 20 | // Mock the installTo method to verify it gets called with output schema 21 | const mockInstallTo = vi.fn().mockImplementation(() => { 22 | // Verify that the tool has an output schema when being installed 23 | expect(tool.outputSchema).toBeDefined(); 24 | return tool; 25 | }); 26 | 27 | Object.defineProperty(tool, 'installTo', { 28 | value: mockInstallTo 29 | }); 30 | 31 | // Simulate server registration 32 | tool.installTo({} as never); 33 | expect(mockInstallTo).toHaveBeenCalled(); 34 | }); 35 | 36 | it('should validate valid version response structure', () => { 37 | const validResponse = { 38 | name: 'Mapbox MCP server', 39 | version: '0.5.5', 40 | sha: 'a64ffb6e4b4017c0f9ae7259be53bb372301fea5', 41 | tag: 'v0.5.5-1-ga64ffb6', 42 | branch: 'structured_content_public' 43 | }; 44 | 45 | const tool = new VersionTool(); 46 | 47 | // This should not throw if the schema is correct 48 | expect(() => { 49 | if (tool.outputSchema) { 50 | tool.outputSchema.parse(validResponse); 51 | } 52 | }).not.toThrow(); 53 | }); 54 | 55 | it('should validate minimal version response with unknown values', () => { 56 | const minimalResponse = { 57 | name: 'Mapbox MCP server', 58 | version: '0.0.0', 59 | sha: 'unknown', 60 | tag: 'unknown', 61 | branch: 'unknown' 62 | }; 63 | 64 | const tool = new VersionTool(); 65 | 66 | expect(() => { 67 | if (tool.outputSchema) { 68 | tool.outputSchema.parse(minimalResponse); 69 | } 70 | }).not.toThrow(); 71 | }); 72 | 73 | it('should validate development version response', () => { 74 | const devResponse = { 75 | name: 'Mapbox MCP server', 76 | version: '1.0.0-dev', 77 | sha: 'abc123def456', 78 | tag: 'dev-build', 79 | branch: 'feature/new-feature' 80 | }; 81 | 82 | const tool = new VersionTool(); 83 | 84 | expect(() => { 85 | if (tool.outputSchema) { 86 | tool.outputSchema.parse(devResponse); 87 | } 88 | }).not.toThrow(); 89 | }); 90 | 91 | it('should throw validation error for missing required fields', () => { 92 | const invalidResponse = { 93 | name: 'Mapbox MCP server', 94 | version: '0.5.5' 95 | // Missing sha, tag, branch fields 96 | }; 97 | 98 | const tool = new VersionTool(); 99 | 100 | expect(() => { 101 | if (tool.outputSchema) { 102 | tool.outputSchema.parse(invalidResponse); 103 | } 104 | }).toThrow(); 105 | }); 106 | 107 | it('should throw validation error for wrong field types', () => { 108 | const invalidTypeResponse = { 109 | name: 'Mapbox MCP server', 110 | version: 1.0, // Should be string, not number 111 | sha: 'abc123', 112 | tag: 'v1.0.0', 113 | branch: 'main' 114 | }; 115 | 116 | const tool = new VersionTool(); 117 | 118 | expect(() => { 119 | if (tool.outputSchema) { 120 | tool.outputSchema.parse(invalidTypeResponse); 121 | } 122 | }).toThrow(); 123 | }); 124 | 125 | it('should throw validation error for empty string fields', () => { 126 | const emptyFieldResponse = { 127 | name: '', // Empty string 128 | version: '0.5.5', 129 | sha: 'abc123', 130 | tag: 'v0.5.5', 131 | branch: 'main' 132 | }; 133 | 134 | const tool = new VersionTool(); 135 | 136 | // All fields are required and should be non-empty strings 137 | expect(() => { 138 | if (tool.outputSchema) { 139 | tool.outputSchema.parse(emptyFieldResponse); 140 | } 141 | }).not.toThrow(); // Actually, empty strings are valid strings in Zod 142 | }); 143 | 144 | it('should throw validation error when fields are null', () => { 145 | const nullFieldResponse = { 146 | name: 'Mapbox MCP server', 147 | version: null, // Should be string, not null 148 | sha: 'abc123', 149 | tag: 'v0.5.5', 150 | branch: 'main' 151 | }; 152 | 153 | const tool = new VersionTool(); 154 | 155 | expect(() => { 156 | if (tool.outputSchema) { 157 | tool.outputSchema.parse(nullFieldResponse); 158 | } 159 | }).toThrow(); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /test/tools/isochrone-tool/IsochroneTool.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | process.env.MAPBOX_ACCESS_TOKEN = 5 | 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; 6 | 7 | import { describe, it, expect, afterEach, vi } from 'vitest'; 8 | import { 9 | setupHttpRequest, 10 | assertHeadersSent 11 | } from '../../utils/httpPipelineUtils.js'; 12 | import { IsochroneTool } from '../../../src/tools/isochrone-tool/IsochroneTool.js'; 13 | 14 | describe('IsochroneTool', () => { 15 | afterEach(() => { 16 | vi.restoreAllMocks(); 17 | }); 18 | 19 | it('sends custom header', async () => { 20 | const { httpRequest, mockHttpRequest } = setupHttpRequest(); 21 | 22 | await new IsochroneTool({ httpRequest }).run({ 23 | coordinates: { longitude: -74.006, latitude: 40.7128 }, 24 | profile: 'mapbox/driving', 25 | contours_minutes: [10], 26 | generalize: 1000 27 | }); 28 | 29 | assertHeadersSent(mockHttpRequest); 30 | }); 31 | 32 | it('sends correct parameters', async () => { 33 | const { httpRequest, mockHttpRequest } = setupHttpRequest({ 34 | ok: true, 35 | json: async () => ({ type: 'FeatureCollection', features: [] }) 36 | }); 37 | 38 | await new IsochroneTool({ httpRequest }).run({ 39 | coordinates: { longitude: 27.534527, latitude: 53.9353451 }, 40 | profile: 'mapbox/driving', 41 | contours_minutes: [10, 20], 42 | contours_colors: ['ff0000', '00ff00'], 43 | polygons: true, 44 | denoise: 0.5, 45 | generalize: 1000, 46 | exclude: ['toll'], 47 | depart_at: '2025-06-02T12:00:00Z' 48 | }); 49 | 50 | assertHeadersSent(mockHttpRequest); 51 | const calledUrl = mockHttpRequest.mock.calls[0][0].toString(); 52 | 53 | expect(calledUrl).toContain( 54 | 'isochrone/v1/mapbox/driving/27.534527%2C53.9353451' 55 | ); 56 | 57 | expect(calledUrl).toContain('contours_minutes=10%2C20'); 58 | expect(calledUrl).toContain('contours_colors=ff0000%2C00ff00'); 59 | expect(calledUrl).toContain('polygons=true'); 60 | expect(calledUrl).toContain('denoise=0.5'); 61 | expect(calledUrl).toContain('generalize=1000'); 62 | expect(calledUrl).toContain('exclude=toll'); 63 | expect(calledUrl).toContain('depart_at=2025-06-02T12%3A00%3A00Z'); 64 | }); 65 | 66 | it('does not send empty parameters', async () => { 67 | const { httpRequest, mockHttpRequest } = setupHttpRequest({ 68 | ok: true, 69 | json: async () => ({ type: 'FeatureCollection', features: [] }) 70 | }); 71 | await new IsochroneTool({ httpRequest }).run({ 72 | coordinates: { longitude: 27.534527, latitude: 53.9353451 }, 73 | profile: 'mapbox/driving', 74 | contours_minutes: [10, 20], 75 | generalize: 1000 76 | }); 77 | const calledUrl = mockHttpRequest.mock.calls[0][0].toString(); 78 | expect(calledUrl).toContain( 79 | 'isochrone/v1/mapbox/driving/27.534527%2C53.9353451' 80 | ); 81 | expect(calledUrl).toContain('contours_minutes=10%2C20'); 82 | expect(calledUrl).not.toContain('contours_colors'); 83 | expect(calledUrl).not.toContain('polygons'); 84 | expect(calledUrl).not.toContain('denoise'); 85 | expect(calledUrl).not.toContain('exclude'); 86 | expect(calledUrl).not.toContain('depart_at'); 87 | }); 88 | 89 | it('returns geojson from API', async () => { 90 | const geojson = { type: 'FeatureCollection', features: [{ id: 42 }] }; 91 | const { httpRequest, mockHttpRequest } = setupHttpRequest({ 92 | ok: true, 93 | json: async () => geojson 94 | }); 95 | 96 | const result = await new IsochroneTool({ httpRequest }).run({ 97 | coordinates: { longitude: -74.006, latitude: 40.7128 }, 98 | profile: 'mapbox/walking', 99 | contours_minutes: [5], 100 | generalize: 1000 101 | }); 102 | 103 | assertHeadersSent(mockHttpRequest); 104 | expect(result.content[0].type).toEqual('text'); 105 | if (result.content[0].type == 'text') { 106 | expect(result.content[0].text).toEqual(JSON.stringify(geojson, null, 2)); 107 | } 108 | }); 109 | 110 | it('throws on invalid input', async () => { 111 | const { httpRequest } = setupHttpRequest(); 112 | const tool = new IsochroneTool({ httpRequest }); 113 | const result = await tool.run({ 114 | coordinates: { longitude: 0, latitude: 0 }, 115 | profile: 'invalid', 116 | contours_minutes: [5] 117 | }); 118 | 119 | expect(result.content[0].type).toEqual('text'); 120 | expect(result.isError).toBe(true); 121 | }); 122 | 123 | it('throws if neither contours_minutes nor contours_meters is specified', async () => { 124 | const { httpRequest } = setupHttpRequest(); 125 | const result = await new IsochroneTool({ httpRequest }).run({ 126 | coordinates: { longitude: -74.006, latitude: 40.7128 }, 127 | profile: 'mapbox/driving', 128 | generalize: 1000 129 | }); 130 | 131 | expect(result.content[0].type).toEqual('text'); 132 | expect(result.isError).toBe(true); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /test/tools/isochrone-tool/IsochroneTool.output.schema.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | process.env.MAPBOX_ACCESS_TOKEN = 'test-token'; 5 | 6 | import { describe, it, expect } from 'vitest'; 7 | import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; 8 | import { IsochroneTool } from '../../../src/tools/isochrone-tool/IsochroneTool.js'; 9 | 10 | describe('IsochroneTool output schema registration', () => { 11 | it('should have an output schema defined', () => { 12 | const { httpRequest } = setupHttpRequest(); 13 | const tool = new IsochroneTool({ httpRequest }); 14 | expect(tool.outputSchema).toBeDefined(); 15 | expect(tool.outputSchema).toBeTruthy(); 16 | }); 17 | 18 | it('should validate valid isochrone GeoJSON FeatureCollection', () => { 19 | const validResponse = { 20 | type: 'FeatureCollection', 21 | features: [ 22 | { 23 | type: 'Feature', 24 | properties: { 25 | contour: 10, 26 | metric: 'time' 27 | }, 28 | geometry: { 29 | type: 'Polygon', 30 | coordinates: [ 31 | [ 32 | [-74.01, 40.71], 33 | [-74.005, 40.71], 34 | [-74.005, 40.715], 35 | [-74.01, 40.715], 36 | [-74.01, 40.71] 37 | ] 38 | ] 39 | } 40 | } 41 | ] 42 | }; 43 | 44 | const { httpRequest } = setupHttpRequest(); 45 | const tool = new IsochroneTool({ httpRequest }); 46 | expect(() => { 47 | if (tool.outputSchema) { 48 | tool.outputSchema.parse(validResponse); 49 | } 50 | }).not.toThrow(); 51 | }); 52 | 53 | it('should validate multiple contour features', () => { 54 | const multiContourResponse = { 55 | type: 'FeatureCollection', 56 | features: [ 57 | { 58 | type: 'Feature', 59 | properties: { contour: 5, metric: 'time' }, 60 | geometry: { 61 | type: 'Polygon', 62 | coordinates: [ 63 | [ 64 | [-74.01, 40.71], 65 | [-74.005, 40.71], 66 | [-74.005, 40.715], 67 | [-74.01, 40.715], 68 | [-74.01, 40.71] 69 | ] 70 | ] 71 | } 72 | }, 73 | { 74 | type: 'Feature', 75 | properties: { contour: 10, metric: 'time' }, 76 | geometry: { 77 | type: 'Polygon', 78 | coordinates: [ 79 | [ 80 | [-74.02, 40.7], 81 | [-74.0, 40.7], 82 | [-74.0, 40.72], 83 | [-74.02, 40.72], 84 | [-74.02, 40.7] 85 | ] 86 | ] 87 | } 88 | } 89 | ] 90 | }; 91 | 92 | const { httpRequest } = setupHttpRequest(); 93 | const tool = new IsochroneTool({ httpRequest }); 94 | expect(() => { 95 | if (tool.outputSchema) { 96 | tool.outputSchema.parse(multiContourResponse); 97 | } 98 | }).not.toThrow(); 99 | }); 100 | 101 | it('should validate empty FeatureCollection', () => { 102 | const emptyResponse = { 103 | type: 'FeatureCollection', 104 | features: [] 105 | }; 106 | 107 | const { httpRequest } = setupHttpRequest(); 108 | const tool = new IsochroneTool({ httpRequest }); 109 | expect(() => { 110 | if (tool.outputSchema) { 111 | tool.outputSchema.parse(emptyResponse); 112 | } 113 | }).not.toThrow(); 114 | }); 115 | 116 | it('should throw validation error for invalid type', () => { 117 | const invalidResponse = { 118 | type: 'InvalidCollection', 119 | features: [] 120 | }; 121 | 122 | const { httpRequest } = setupHttpRequest(); 123 | const tool = new IsochroneTool({ httpRequest }); 124 | expect(() => { 125 | if (tool.outputSchema) { 126 | tool.outputSchema.parse(invalidResponse); 127 | } 128 | }).toThrow(); 129 | }); 130 | 131 | it('should throw validation error for missing features array', () => { 132 | const invalidResponse = { 133 | type: 'FeatureCollection' 134 | // Missing features array 135 | }; 136 | 137 | const { httpRequest } = setupHttpRequest(); 138 | const tool = new IsochroneTool({ httpRequest }); 139 | expect(() => { 140 | if (tool.outputSchema) { 141 | tool.outputSchema.parse(invalidResponse); 142 | } 143 | }).toThrow(); 144 | }); 145 | 146 | it('should throw validation error for invalid geometry type', () => { 147 | const invalidResponse = { 148 | type: 'FeatureCollection', 149 | features: [ 150 | { 151 | type: 'Feature', 152 | properties: { contour: 10, metric: 'time' }, 153 | geometry: { 154 | type: 'InvalidGeometry', 155 | coordinates: [] 156 | } 157 | } 158 | ] 159 | }; 160 | 161 | const { httpRequest } = setupHttpRequest(); 162 | const tool = new IsochroneTool({ httpRequest }); 163 | expect(() => { 164 | if (tool.outputSchema) { 165 | tool.outputSchema.parse(invalidResponse); 166 | } 167 | }).toThrow(); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /test/tools/category-list-tool/CategoryListTool.output.schema.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | process.env.MAPBOX_ACCESS_TOKEN = 5 | 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; 6 | 7 | import { describe, it, expect, vi } from 'vitest'; 8 | import { CategoryListTool } from '../../../src/tools/category-list-tool/CategoryListTool.js'; 9 | import { setupHttpRequest } from 'test/utils/httpPipelineUtils.js'; 10 | 11 | describe('CategoryListTool output schema registration', () => { 12 | it('should have an output schema defined', () => { 13 | const { httpRequest } = setupHttpRequest(); 14 | const tool = new CategoryListTool({ httpRequest }); 15 | expect(tool.outputSchema).toBeDefined(); 16 | expect(tool.outputSchema).toBeTruthy(); 17 | }); 18 | 19 | it('should register output schema with MCP server', () => { 20 | const { httpRequest } = setupHttpRequest(); 21 | const tool = new CategoryListTool({ httpRequest }); 22 | 23 | // Mock the installTo method to verify it gets called with output schema 24 | const mockInstallTo = vi.fn().mockImplementation(() => { 25 | // Verify that the tool has an output schema when being installed 26 | expect(tool.outputSchema).toBeDefined(); 27 | return tool; 28 | }); 29 | 30 | Object.defineProperty(tool, 'installTo', { 31 | value: mockInstallTo 32 | }); 33 | 34 | // Simulate server registration 35 | tool.installTo({} as never); 36 | expect(mockInstallTo).toHaveBeenCalled(); 37 | }); 38 | 39 | it('should validate valid category list response structure', () => { 40 | const validResponse = { 41 | listItems: [ 42 | 'services', 43 | 'shopping', 44 | 'food_and_drink', 45 | 'restaurant', 46 | 'lodging' 47 | ] 48 | }; 49 | 50 | const { httpRequest } = setupHttpRequest(); 51 | const tool = new CategoryListTool({ httpRequest }); 52 | 53 | // This should not throw if the schema is correct 54 | expect(() => { 55 | if (tool.outputSchema) { 56 | tool.outputSchema.parse(validResponse); 57 | } 58 | }).not.toThrow(); 59 | }); 60 | 61 | it('should validate minimal valid response with single category', () => { 62 | const minimalResponse = { 63 | listItems: ['food'] 64 | }; 65 | 66 | const { httpRequest } = setupHttpRequest(); 67 | const tool = new CategoryListTool({ httpRequest }); 68 | 69 | expect(() => { 70 | if (tool.outputSchema) { 71 | tool.outputSchema.parse(minimalResponse); 72 | } 73 | }).not.toThrow(); 74 | }); 75 | 76 | it('should validate empty list response', () => { 77 | const emptyResponse = { 78 | listItems: [] 79 | }; 80 | 81 | const { httpRequest } = setupHttpRequest(); 82 | const tool = new CategoryListTool({ httpRequest }); 83 | 84 | expect(() => { 85 | if (tool.outputSchema) { 86 | tool.outputSchema.parse(emptyResponse); 87 | } 88 | }).not.toThrow(); 89 | }); 90 | 91 | it('should validate response with multiple categories', () => { 92 | const multipleResponse = { 93 | listItems: [ 94 | 'health_services', 95 | 'office', 96 | 'education', 97 | 'nightlife', 98 | 'lodging', 99 | 'transportation', 100 | 'automotive', 101 | 'recreation', 102 | 'services', 103 | 'shopping' 104 | ] 105 | }; 106 | 107 | const { httpRequest } = setupHttpRequest(); 108 | const tool = new CategoryListTool({ httpRequest }); 109 | 110 | expect(() => { 111 | if (tool.outputSchema) { 112 | tool.outputSchema.parse(multipleResponse); 113 | } 114 | }).not.toThrow(); 115 | }); 116 | 117 | it('should throw validation error for invalid response missing listItems', () => { 118 | const invalidResponse = { 119 | // Missing required listItems field 120 | someOtherField: 'value' 121 | }; 122 | 123 | const { httpRequest } = setupHttpRequest(); 124 | const tool = new CategoryListTool({ httpRequest }); 125 | 126 | expect(() => { 127 | if (tool.outputSchema) { 128 | tool.outputSchema.parse(invalidResponse); 129 | } 130 | }).toThrow(); 131 | }); 132 | 133 | it('should throw validation error for non-string list items', () => { 134 | const malformedResponse = { 135 | listItems: [ 136 | 'valid_category', 137 | 123, // Invalid: should be string 138 | 'another_valid_category' 139 | ] 140 | }; 141 | 142 | const { httpRequest } = setupHttpRequest(); 143 | const tool = new CategoryListTool({ httpRequest }); 144 | 145 | expect(() => { 146 | if (tool.outputSchema) { 147 | tool.outputSchema.parse(malformedResponse); 148 | } 149 | }).toThrow(); 150 | }); 151 | 152 | it('should throw validation error when listItems is not an array', () => { 153 | const invalidTypeResponse = { 154 | listItems: 'not an array', 155 | attribution: 'Mapbox', 156 | version: '1.0.0' 157 | }; 158 | 159 | const { httpRequest } = setupHttpRequest(); 160 | const tool = new CategoryListTool({ httpRequest }); 161 | 162 | expect(() => { 163 | if (tool.outputSchema) { 164 | tool.outputSchema.parse(invalidTypeResponse); 165 | } 166 | }).toThrow(); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/tools/category-search-tool/CategorySearchTool.output.schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import { z } from 'zod'; 5 | 6 | // Context sub-object schemas for different geographic levels 7 | const ContextCountrySchema = z.object({ 8 | id: z.string().optional(), 9 | name: z.string(), 10 | country_code: z.string(), 11 | country_code_alpha_3: z.string() 12 | }); 13 | 14 | const ContextRegionSchema = z.object({ 15 | id: z.string().optional(), 16 | name: z.string(), 17 | region_code: z.string(), 18 | region_code_full: z.string() 19 | }); 20 | 21 | const ContextPostcodeSchema = z.object({ 22 | id: z.string().optional(), 23 | name: z.string() 24 | }); 25 | 26 | const ContextDistrictSchema = z.object({ 27 | id: z.string().optional(), 28 | name: z.string() 29 | }); 30 | 31 | const ContextPlaceSchema = z.object({ 32 | id: z.string().optional(), 33 | name: z.string() 34 | }); 35 | 36 | const ContextLocalitySchema = z.object({ 37 | id: z.string().optional(), 38 | name: z.string() 39 | }); 40 | 41 | const ContextNeighborhoodSchema = z.object({ 42 | id: z.string().optional(), 43 | name: z.string() 44 | }); 45 | 46 | const ContextAddressSchema = z.object({ 47 | id: z.string().optional(), 48 | name: z.string(), 49 | address_number: z.string().optional(), 50 | street_name: z.string().optional() 51 | }); 52 | 53 | const ContextStreetSchema = z.object({ 54 | id: z.string().optional(), 55 | name: z.string() 56 | }); 57 | 58 | // Context object schema 59 | const ContextSchema = z.object({ 60 | country: ContextCountrySchema.optional(), 61 | region: ContextRegionSchema.optional(), 62 | postcode: ContextPostcodeSchema.optional(), 63 | district: ContextDistrictSchema.optional(), 64 | place: ContextPlaceSchema.optional(), 65 | locality: ContextLocalitySchema.optional(), 66 | neighborhood: ContextNeighborhoodSchema.optional(), 67 | address: ContextAddressSchema.optional(), 68 | street: ContextStreetSchema.optional() 69 | }); 70 | 71 | // Routable point schema 72 | const RoutablePointSchema = z.object({ 73 | name: z.string(), 74 | latitude: z.number(), 75 | longitude: z.number(), 76 | note: z.string().optional() 77 | }); 78 | 79 | // Coordinates object schema 80 | const CoordinatesSchema = z.object({ 81 | longitude: z.number(), 82 | latitude: z.number(), 83 | accuracy: z 84 | .enum([ 85 | 'rooftop', 86 | 'parcel', 87 | 'point', 88 | 'interpolated', 89 | 'intersection', 90 | 'approximate', 91 | 'street' 92 | ]) 93 | .optional(), 94 | routable_points: z.array(RoutablePointSchema).optional() 95 | }); 96 | 97 | // Metadata schema for additional feature information 98 | const MetadataSchema = z.object({ 99 | // API sometimes returns string, sometimes array - accept both 100 | primary_photo: z.union([z.string(), z.array(z.string())]).optional(), 101 | reading: z 102 | .object({ 103 | ja_kana: z.string().optional(), 104 | ja_latin: z.string().optional() 105 | }) 106 | .optional() 107 | }); 108 | 109 | // Feature properties schema 110 | const FeaturePropertiesSchema = z.object({ 111 | name: z.string(), 112 | name_preferred: z.string().optional(), 113 | mapbox_id: z.string(), 114 | feature_type: z.enum([ 115 | 'poi', 116 | 'country', 117 | 'region', 118 | 'postcode', 119 | 'district', 120 | 'place', 121 | 'locality', 122 | 'neighborhood', 123 | 'address' 124 | ]), 125 | address: z.string().optional(), 126 | full_address: z.string().optional(), 127 | place_formatted: z.string().optional(), 128 | context: ContextSchema, 129 | coordinates: CoordinatesSchema, 130 | bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), 131 | language: z.string().optional(), 132 | maki: z.string().optional(), 133 | poi_category: z.array(z.string()).optional(), 134 | poi_category_ids: z.array(z.string()).optional(), 135 | brand: z.array(z.string()).optional(), 136 | brand_id: z.array(z.string()).optional(), 137 | external_ids: z.record(z.string()).optional(), 138 | metadata: MetadataSchema.optional(), 139 | distance: z.number().optional(), 140 | eta: z.number().optional(), 141 | added_distance: z.number().optional(), 142 | added_time: z.number().optional() 143 | }); 144 | 145 | // GeoJSON Point geometry schema 146 | const PointGeometrySchema = z.object({ 147 | type: z.literal('Point'), 148 | coordinates: z.tuple([z.number(), z.number()]) 149 | }); 150 | 151 | // Feature schema 152 | const FeatureSchema = z.object({ 153 | type: z.literal('Feature'), 154 | geometry: PointGeometrySchema, 155 | properties: FeaturePropertiesSchema 156 | }); 157 | 158 | // Main Search Box API Category Search response schema (FeatureCollection) 159 | export const CategorySearchResponseSchema = z.object({ 160 | type: z.literal('FeatureCollection'), 161 | features: z.array(FeatureSchema), 162 | attribution: z.string() 163 | }); 164 | 165 | export type CategorySearchResponse = z.infer< 166 | typeof CategorySearchResponseSchema 167 | >; 168 | export type CategorySearchFeature = z.infer; 169 | export type CategorySearchFeatureProperties = z.infer< 170 | typeof FeaturePropertiesSchema 171 | >; 172 | export type CategorySearchContext = z.infer; 173 | export type CategorySearchCoordinates = z.infer; 174 | export type CategorySearchMetadata = z.infer; 175 | -------------------------------------------------------------------------------- /src/tools/static-map-image-tool/StaticMapImageTool.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Mapbox, Inc. 2 | // Licensed under the MIT License. 3 | 4 | import type { z } from 'zod'; 5 | import { createUIResource } from '@mcp-ui/server'; 6 | import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; 7 | import type { HttpRequest } from '../../utils/types.js'; 8 | import { StaticMapImageInputSchema } from './StaticMapImageTool.input.schema.js'; 9 | import type { OverlaySchema } from './StaticMapImageTool.input.schema.js'; 10 | import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 11 | import { isMcpUiEnabled } from '../../config/toolConfig.js'; 12 | 13 | export class StaticMapImageTool extends MapboxApiBasedTool< 14 | typeof StaticMapImageInputSchema 15 | > { 16 | name = 'static_map_image_tool'; 17 | description = 18 | 'Generates a static map image from Mapbox Static Images API. Supports center coordinates, zoom level (0-22), image size (up to 1280x1280), various Mapbox styles, and overlays (markers, paths, GeoJSON). Returns PNG for vector styles, JPEG for raster-only styles.'; 19 | annotations = { 20 | title: 'Static Map Image Tool', 21 | readOnlyHint: true, 22 | destructiveHint: false, 23 | idempotentHint: true, 24 | openWorldHint: true 25 | }; 26 | 27 | constructor(params: { httpRequest: HttpRequest }) { 28 | super({ 29 | inputSchema: StaticMapImageInputSchema, 30 | httpRequest: params.httpRequest 31 | }); 32 | } 33 | 34 | private encodeOverlay(overlay: z.infer): string { 35 | switch (overlay.type) { 36 | case 'marker': { 37 | const size = overlay.size === 'large' ? 'pin-l' : 'pin-s'; 38 | let marker = size; 39 | 40 | if (overlay.label) { 41 | marker += `-${overlay.label}`; 42 | } 43 | 44 | if (overlay.color) { 45 | marker += `+${overlay.color}`; 46 | } 47 | 48 | return `${marker}(${overlay.longitude},${overlay.latitude})`; 49 | } 50 | 51 | case 'custom-marker': { 52 | const encodedUrl = encodeURIComponent(overlay.url); 53 | return `url-${encodedUrl}(${overlay.longitude},${overlay.latitude})`; 54 | } 55 | 56 | case 'path': { 57 | let path = `path-${overlay.strokeWidth}`; 58 | 59 | if (overlay.strokeColor) { 60 | path += `+${overlay.strokeColor}`; 61 | if (overlay.strokeOpacity !== undefined) { 62 | path += `-${overlay.strokeOpacity}`; 63 | } 64 | } 65 | 66 | if (overlay.fillColor) { 67 | path += `+${overlay.fillColor}`; 68 | if (overlay.fillOpacity !== undefined) { 69 | path += `-${overlay.fillOpacity}`; 70 | } 71 | } 72 | 73 | // URL encode the polyline to handle special characters 74 | return `${path}(${encodeURIComponent(overlay.encodedPolyline)})`; 75 | } 76 | 77 | case 'geojson': { 78 | const geojsonString = JSON.stringify(overlay.data); 79 | return `geojson(${encodeURIComponent(geojsonString)})`; 80 | } 81 | } 82 | } 83 | 84 | protected async execute( 85 | input: z.infer, 86 | accessToken: string 87 | ): Promise { 88 | const { longitude: lng, latitude: lat } = input.center; 89 | const { width, height } = input.size; 90 | 91 | // Build overlay string 92 | let overlayString = ''; 93 | if (input.overlays && input.overlays.length > 0) { 94 | const encodedOverlays = input.overlays.map((overlay) => { 95 | return this.encodeOverlay(overlay); 96 | }); 97 | overlayString = encodedOverlays.join(',') + '/'; 98 | } 99 | 100 | const density = input.highDensity ? '@2x' : ''; 101 | const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${input.style}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}?access_token=${accessToken}`; 102 | 103 | const response = await this.httpRequest(url); 104 | 105 | if (!response.ok) { 106 | return { 107 | content: [ 108 | { 109 | type: 'text', 110 | text: `Failed to fetch map image: ${response.status} ${response.statusText}` 111 | } 112 | ], 113 | isError: true 114 | }; 115 | } 116 | 117 | const buffer = await response.arrayBuffer(); 118 | 119 | const base64Data = Buffer.from(buffer).toString('base64'); 120 | 121 | // Determine MIME type based on style (raster-only styles return JPEG) 122 | const isRasterStyle = input.style.includes('satellite'); 123 | const mimeType = isRasterStyle ? 'image/jpeg' : 'image/png'; 124 | 125 | // Build content array with image data 126 | const content: CallToolResult['content'] = [ 127 | { 128 | type: 'image', 129 | data: base64Data, 130 | mimeType 131 | } 132 | ]; 133 | 134 | // Conditionally add MCP-UI resource if enabled 135 | if (isMcpUiEnabled()) { 136 | const uiResource = createUIResource({ 137 | uri: `ui://mapbox/static-map/${input.style}/${lng},${lat},${input.zoom}`, 138 | content: { 139 | type: 'externalUrl', 140 | iframeUrl: url 141 | }, 142 | encoding: 'text', 143 | uiMetadata: { 144 | 'preferred-frame-size': [`${width}px`, `${height}px`] 145 | } 146 | }); 147 | content.push(uiResource); 148 | } 149 | 150 | return { 151 | content, 152 | isError: false 153 | }; 154 | } 155 | } 156 | --------------------------------------------------------------------------------