From 06fba855d83806948435432076b166e15099b1b6 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 14 Apr 2026 14:11:25 -0400 Subject: [PATCH 1/2] feat: add get_mapbox_docs_index_tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tool that fetches the llms.txt documentation index for any Mapbox product — model can autonomously pull the right index without the user manually attaching a resource. Supports 13 product keys, results cached via docCache. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 8 ++ .../GetDocsIndexTool.input.schema.ts | 73 +++++++++++++++ .../get-docs-index-tool/GetDocsIndexTool.ts | 65 +++++++++++++ src/tools/toolRegistry.ts | 2 + .../GetDocsIndexTool.test.ts | 91 +++++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 src/tools/get-docs-index-tool/GetDocsIndexTool.input.schema.ts create mode 100644 src/tools/get-docs-index-tool/GetDocsIndexTool.ts create mode 100644 test/tools/get-docs-index-tool/GetDocsIndexTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc47e2..3fed1e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Unreleased +### Add `get_mapbox_docs_index_tool` + +New tool that fetches the `llms.txt` documentation index for any Mapbox product directly — no manual resource attachment required. The model can autonomously discover and fetch the right index without user intervention. + +- Supports 13 products: `api-reference`, `mapbox-gl-js`, `help-guides`, `style-spec`, `studio-manual`, `search-js`, `ios-maps`, `android-maps`, `ios-navigation`, `android-navigation`, `tiling-service`, `tilesets`, and `catalog` (root index) +- Results are cached via `docCache` — first fetch hits the network, subsequent calls are instant +- Complements `search_mapbox_docs_tool` (keyword search) and `get_document_tool` (full page fetch): use this when you know which product you need + ### Resources — use sublevel `llms.txt` per product docs.mapbox.com restructured its documentation so that `llms.txt` files now exist at every product level (e.g. `docs.mapbox.com/api/llms.txt`, `docs.mapbox.com/help/llms.txt`, `docs.mapbox.com/mapbox-gl-js/llms.txt`) alongside `llms-full.txt` files containing full page content. The root `docs.mapbox.com/llms.txt` is now a pure index of links to these sublevel files rather than a monolithic content file. The previous resources all filtered the root file by category keyword — now that the root contains only link lists, they were effectively returning empty or useless content. diff --git a/src/tools/get-docs-index-tool/GetDocsIndexTool.input.schema.ts b/src/tools/get-docs-index-tool/GetDocsIndexTool.input.schema.ts new file mode 100644 index 0000000..1151afa --- /dev/null +++ b/src/tools/get-docs-index-tool/GetDocsIndexTool.input.schema.ts @@ -0,0 +1,73 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const PRODUCT_SOURCES = { + 'api-reference': { + url: 'https://docs.mapbox.com/api/llms.txt', + label: 'Mapbox REST API Reference' + }, + 'mapbox-gl-js': { + url: 'https://docs.mapbox.com/mapbox-gl-js/llms.txt', + label: 'Mapbox GL JS' + }, + 'help-guides': { + url: 'https://docs.mapbox.com/help/llms.txt', + label: 'Help Center & Guides' + }, + 'style-spec': { + url: 'https://docs.mapbox.com/style-spec/llms.txt', + label: 'Style Specification' + }, + 'studio-manual': { + url: 'https://docs.mapbox.com/studio-manual/llms.txt', + label: 'Mapbox Studio Manual' + }, + 'search-js': { + url: 'https://docs.mapbox.com/mapbox-search-js/llms.txt', + label: 'Mapbox Search JS' + }, + 'ios-maps': { + url: 'https://docs.mapbox.com/ios/maps/llms.txt', + label: 'Maps SDK for iOS' + }, + 'android-maps': { + url: 'https://docs.mapbox.com/android/maps/llms.txt', + label: 'Maps SDK for Android' + }, + 'ios-navigation': { + url: 'https://docs.mapbox.com/ios/navigation/llms.txt', + label: 'Navigation SDK for iOS' + }, + 'android-navigation': { + url: 'https://docs.mapbox.com/android/navigation/llms.txt', + label: 'Navigation SDK for Android' + }, + 'tiling-service': { + url: 'https://docs.mapbox.com/mapbox-tiling-service/llms.txt', + label: 'Mapbox Tiling Service' + }, + tilesets: { + url: 'https://docs.mapbox.com/data/tilesets/llms.txt', + label: 'Tilesets' + }, + catalog: { + url: 'https://docs.mapbox.com/llms.txt', + label: 'Full Product Catalog' + } +} as const; + +export type ProductKey = keyof typeof PRODUCT_SOURCES; + +export const GetDocsIndexSchema = z.object({ + product: z + .enum(Object.keys(PRODUCT_SOURCES) as [ProductKey, ...ProductKey[]]) + .describe( + 'Which Mapbox documentation index to fetch. ' + + 'Use "catalog" to discover all available products and their llms.txt URLs. ' + + "Use a specific product key to get a structured index of that product's pages." + ) +}); + +export type GetDocsIndexInput = z.infer; diff --git a/src/tools/get-docs-index-tool/GetDocsIndexTool.ts b/src/tools/get-docs-index-tool/GetDocsIndexTool.ts new file mode 100644 index 0000000..7ba939e --- /dev/null +++ b/src/tools/get-docs-index-tool/GetDocsIndexTool.ts @@ -0,0 +1,65 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { fetchCachedText } from '../../utils/docFetcher.js'; +import { BaseTool } from '../BaseTool.js'; +import { + GetDocsIndexSchema, + GetDocsIndexInput, + PRODUCT_SOURCES +} from './GetDocsIndexTool.input.schema.js'; + +export class GetDocsIndexTool extends BaseTool { + name = 'get_mapbox_docs_index_tool'; + description = + 'Fetch the documentation index (llms.txt) for a specific Mapbox product. ' + + "Returns a structured list of all pages in that product's documentation with titles, " + + 'URLs, and descriptions — ready to use with get_document_tool to fetch full page content. ' + + 'Use "catalog" to discover all available Mapbox products. ' + + 'Prefer this over search_mapbox_docs_tool when you know which product you need.'; + readonly annotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + title: 'Get Mapbox Docs Index Tool' + }; + + private httpRequest: HttpRequest; + + constructor(params: { httpRequest: HttpRequest }) { + super({ inputSchema: GetDocsIndexSchema }); + this.httpRequest = params.httpRequest; + } + + protected async execute(input: GetDocsIndexInput): Promise { + const source = PRODUCT_SOURCES[input.product]; + + try { + const content = await fetchCachedText(source.url, this.httpRequest); + return { + content: [ + { + type: 'text', + text: `# ${source.label}\n\n${content}` + } + ], + isError: false + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown error occurred'; + return { + content: [ + { + type: 'text', + text: `Failed to fetch ${source.label} index: ${message}` + } + ], + isError: true + }; + } + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 292ceb9..3276612 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -5,6 +5,7 @@ import { ZodTypeAny } from 'zod'; import { BaseTool } from './BaseTool.js'; import { BatchGetDocumentsTool } from './batch-get-documents-tool/BatchGetDocumentsTool.js'; import { GetDocumentTool } from './get-document-tool/GetDocumentTool.js'; +import { GetDocsIndexTool } from './get-docs-index-tool/GetDocsIndexTool.js'; import { SearchDocsTool } from './search-docs-tool/SearchDocsTool.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -16,6 +17,7 @@ export type ToolInstance = BaseTool; export const CORE_TOOLS: ToolInstance[] = [ new GetDocumentTool({ httpRequest }), new BatchGetDocumentsTool({ httpRequest }), + new GetDocsIndexTool({ httpRequest }), new SearchDocsTool({ httpRequest }) ]; diff --git a/test/tools/get-docs-index-tool/GetDocsIndexTool.test.ts b/test/tools/get-docs-index-tool/GetDocsIndexTool.test.ts new file mode 100644 index 0000000..0522aeb --- /dev/null +++ b/test/tools/get-docs-index-tool/GetDocsIndexTool.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GetDocsIndexTool } from '../../../src/tools/get-docs-index-tool/GetDocsIndexTool.js'; +import { PRODUCT_SOURCES } from '../../../src/tools/get-docs-index-tool/GetDocsIndexTool.input.schema.js'; +import { docCache } from '../../../src/utils/docCache.js'; + +beforeEach(() => { + docCache.clear(); +}); + +function makeResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { 'content-type': 'text/plain' } + }); +} + +describe('GetDocsIndexTool', () => { + it('fetches the correct URL for a given product key', async () => { + const httpRequest = vi + .fn() + .mockResolvedValue( + makeResponse( + '- [Geocoding](https://docs.mapbox.com/api/search/geocoding/): Geocoding API' + ) + ); + const tool = new GetDocsIndexTool({ httpRequest }); + + const result = await tool.run({ product: 'api-reference' }); + + expect(httpRequest).toHaveBeenCalledWith( + PRODUCT_SOURCES['api-reference'].url, + expect.any(Object) + ); + expect(result.isError).toBe(false); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain('Mapbox REST API Reference'); + expect(text).toContain('Geocoding'); + }); + + it('serves subsequent requests from cache without re-fetching', async () => { + const httpRequest = vi + .fn() + .mockResolvedValue(makeResponse('index content')); + const tool = new GetDocsIndexTool({ httpRequest }); + + await tool.run({ product: 'mapbox-gl-js' }); + await tool.run({ product: 'mapbox-gl-js' }); + + expect(httpRequest).toHaveBeenCalledTimes(1); + }); + + it('returns an error result on HTTP failure', async () => { + const httpRequest = vi + .fn() + .mockResolvedValue( + new Response('Not Found', { status: 404, statusText: 'Not Found' }) + ); + const tool = new GetDocsIndexTool({ httpRequest }); + + const result = await tool.run({ product: 'help-guides' }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { text: string }).text; + expect(text).toMatch(/failed to fetch/i); + }); + + it('returns an error result on network error', async () => { + const httpRequest = vi.fn().mockRejectedValue(new Error('Network error')); + const tool = new GetDocsIndexTool({ httpRequest }); + + const result = await tool.run({ product: 'catalog' }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain( + 'Network error' + ); + }); + + it('rejects unknown product keys', async () => { + const httpRequest = vi.fn(); + const tool = new GetDocsIndexTool({ httpRequest }); + + const result = await tool.run({ product: 'not-a-real-product' }); + + expect(result.isError).toBe(true); + expect(httpRequest).not.toHaveBeenCalled(); + }); +}); From 01d76b6505ea5ea0096e77722b58c759748e1aa6 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 14 Apr 2026 14:17:28 -0400 Subject: [PATCH 2/2] fix: remove Accept header from fetchCachedText to avoid CDN 403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom 'Accept: text/markdown, text/plain;q=0.9, */*;q=0.8' header was triggering a 403 Forbidden from the docs.mapbox.com CDN. Removing it (and the equivalent in fetchDocContent's fallback path) fixes the issue — the CDN serves the file correctly without an explicit Accept header. Co-Authored-By: Claude Sonnet 4.6 --- src/utils/docFetcher.ts | 8 ++------ test/utils/docFetcher.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/utils/docFetcher.ts b/src/utils/docFetcher.ts index 5613514..fbd5338 100644 --- a/src/utils/docFetcher.ts +++ b/src/utils/docFetcher.ts @@ -73,9 +73,7 @@ export async function fetchCachedText( const cached = docCache.get(url); if (cached) return cached; - const response = await httpRequest(url, { - headers: { Accept: 'text/markdown, text/plain;q=0.9, */*;q=0.8' } - }); + const response = await httpRequest(url, {}); if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.statusText}`); @@ -108,9 +106,7 @@ export async function fetchDocContent( } const fetchUrl = applyHostOverride(url); - const response = await httpRequest(fetchUrl, { - headers: { Accept: 'text/markdown, text/plain;q=0.9, */*;q=0.8' } - }); + const response = await httpRequest(fetchUrl, {}); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); diff --git a/test/utils/docFetcher.test.ts b/test/utils/docFetcher.test.ts index 9df4931..fadf8f6 100644 --- a/test/utils/docFetcher.test.ts +++ b/test/utils/docFetcher.test.ts @@ -158,7 +158,7 @@ describe('fetchDocContent', () => { expect(httpRequest).toHaveBeenNthCalledWith( 2, 'https://docs.mapbox.com/accounts/guides', - expect.objectContaining({ headers: expect.anything() }) + {} ); expect(content).toBe(html); }); @@ -171,7 +171,7 @@ describe('fetchDocContent', () => { expect(httpRequest).toHaveBeenCalledTimes(1); expect(httpRequest).toHaveBeenCalledWith( 'https://api.mapbox.com/geocoding/v5', - expect.objectContaining({ headers: expect.anything() }) + {} ); });