Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
73 changes: 73 additions & 0 deletions src/tools/get-docs-index-tool/GetDocsIndexTool.input.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GetDocsIndexSchema>;
65 changes: 65 additions & 0 deletions src/tools/get-docs-index-tool/GetDocsIndexTool.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GetDocsIndexSchema> {
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<CallToolResult> {
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
};
}
}
}
2 changes: 2 additions & 0 deletions src/tools/toolRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -16,6 +17,7 @@ export type ToolInstance = BaseTool<ZodTypeAny>;
export const CORE_TOOLS: ToolInstance[] = [
new GetDocumentTool({ httpRequest }),
new BatchGetDocumentsTool({ httpRequest }),
new GetDocsIndexTool({ httpRequest }),
new SearchDocsTool({ httpRequest })
];

Expand Down
8 changes: 2 additions & 6 deletions src/utils/docFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}`);
Expand Down
91 changes: 91 additions & 0 deletions test/tools/get-docs-index-tool/GetDocsIndexTool.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 2 additions & 2 deletions test/utils/docFetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -171,7 +171,7 @@ describe('fetchDocContent', () => {
expect(httpRequest).toHaveBeenCalledTimes(1);
expect(httpRequest).toHaveBeenCalledWith(
'https://api.mapbox.com/geocoding/v5',
expect.objectContaining({ headers: expect.anything() })
{}
);
});

Expand Down