diff --git a/package-lock.json b/package-lock.json index 37b70c1..b26410f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mapbox/mcp-devkit-server", - "version": "0.2.2", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mapbox/mcp-devkit-server", - "version": "0.2.2", + "version": "0.3.0", "license": "BSD-3-Clause", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index 1e7effa..1ea9560 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mapbox/mcp-devkit-server", - "version": "0.2.3", + "version": "0.3.0", "description": "Mapbox MCP devkit server", "main": "dist/index.js", "module": "dist/index-esm.js", diff --git a/plop-templates/mapbox-api-tool.hbs b/plop-templates/mapbox-api-tool.hbs index ef78f8b..30b7a99 100644 --- a/plop-templates/mapbox-api-tool.hbs +++ b/plop-templates/mapbox-api-tool.hbs @@ -36,14 +36,15 @@ export class {{pascalCase name}}Tool extends MapboxApiBasedTool< } protected async execute( - input: {{pascalCase name}}Input + input: {{pascalCase name}}Input, + accessToken?: string ): Promise<{ type: 'text'; text: string }> { try { // TODO: Implement your Mapbox API call here // Example implementation: // const username = MapboxApiBasedTool.getUserNameFromToken(); - // const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}/${input.styleId}?access_token=${MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN}`; + // const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; // // const response = await fetch(url); // diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index 560dc46..ed48a83 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -2,6 +2,7 @@ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp'; +import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { z, ZodTypeAny } from 'zod'; const ContentItemSchema = z.union([ @@ -38,10 +39,15 @@ export abstract class BaseTool { /** * Validates and runs the tool logic. */ - async run(rawInput: unknown): Promise> { + async run( + rawInput: unknown, + extra?: RequestHandlerExtra + ): Promise> { try { const input = this.inputSchema.parse(rawInput); - const result = await this.execute(input); + const accessToken = + extra?.authInfo?.token || process.env.MAPBOX_ACCESS_TOKEN; + const result = await this.execute(input, accessToken); // Check if result is already a content object (image or text) if ( @@ -86,7 +92,8 @@ export abstract class BaseTool { * Tool logic to be implemented by subclasses. */ protected abstract execute( - _input: z.infer + _input: z.infer, + accessToken?: string ): Promise; /** @@ -99,7 +106,7 @@ export abstract class BaseTool { this.description, (this.inputSchema as unknown as z.ZodObject>) .shape, - this.run.bind(this) + (args, extra) => this.run(args, extra) ); } diff --git a/src/tools/MapboxApiBasedTool.test.ts b/src/tools/MapboxApiBasedTool.test.ts index 5bfe6cb..9f0cb41 100644 --- a/src/tools/MapboxApiBasedTool.test.ts +++ b/src/tools/MapboxApiBasedTool.test.ts @@ -79,7 +79,7 @@ describe('MapboxApiBasedTool', () => { }); expect(() => MapboxApiBasedTool.getUserNameFromToken()).toThrow( - 'MAPBOX_ACCESS_TOKEN is not set' + 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' ); } finally { Object.defineProperty(MapboxApiBasedTool, 'MAPBOX_ACCESS_TOKEN', { diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 74c4930..4af8479 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -1,3 +1,4 @@ +import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { z, ZodTypeAny } from 'zod'; import { BaseTool, OutputSchema } from './BaseTool.js'; @@ -17,14 +18,19 @@ export abstract class MapboxApiBasedTool< * Mapbox tokens are JWT tokens where the payload contains the username. * @throws Error if the token is not set, invalid, or doesn't contain username */ - static getUserNameFromToken(): string { - if (!MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN) { - throw new Error('MAPBOX_ACCESS_TOKEN is not set'); + static getUserNameFromToken(access_token?: string): string { + if (!access_token) { + if (!MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN) { + throw new Error( + 'No access token provided. Please set MAPBOX_ACCESS_TOKEN environment variable or pass it as an argument.' + ); + } + access_token = MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN; } try { // JWT format: header.payload.signature - const parts = MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN.split('.'); + const parts = access_token.split('.'); if (parts.length !== 3) { throw new Error('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); } @@ -68,19 +74,30 @@ export abstract class MapboxApiBasedTool< /** * Validates Mapbox token and runs the tool logic. */ - async run(rawInput: unknown): Promise> { + async run( + rawInput: unknown, + extra?: RequestHandlerExtra + ): Promise> { try { - if (!MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN) { - throw new Error('MAPBOX_ACCESS_TOKEN is not set'); + // First check if token is provided via authentication context + // Check both standard token field and accessToken in extra for compatibility + // In the streamableHttp, the authInfo is injected into extra from `req.auth` + // https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/server/streamableHttp.ts#L405 + const authToken = extra?.authInfo?.token; + const accessToken = authToken || MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN; + if (!accessToken) { + throw new Error( + 'No access token available. Please provide via Bearer auth or MAPBOX_ACCESS_TOKEN env var' + ); } // Validate that the token has the correct JWT format - if (!this.isValidJwtFormat(MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN)) { + if (!this.isValidJwtFormat(accessToken)) { throw new Error('MAPBOX_ACCESS_TOKEN is not in valid JWT format'); } // Call parent run method which handles the rest - return await super.run(rawInput); + return await super.run(rawInput, extra); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/tools/create-style-tool/CreateStyleTool.ts b/src/tools/create-style-tool/CreateStyleTool.ts index 3c46502..8c4e113 100644 --- a/src/tools/create-style-tool/CreateStyleTool.ts +++ b/src/tools/create-style-tool/CreateStyleTool.ts @@ -14,9 +14,12 @@ export class CreateStyleTool extends MapboxApiBasedTool< super({ inputSchema: CreateStyleSchema }); } - protected async execute(input: CreateStyleInput): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(); - const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}?access_token=${MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN}`; + protected async execute( + input: CreateStyleInput, + accessToken?: string + ): Promise { + const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}?access_token=${accessToken}`; const payload = { name: input.name, diff --git a/src/tools/create-token-tool/CreateTokenTool.test.ts b/src/tools/create-token-tool/CreateTokenTool.test.ts index 9d05cf0..25a3d8b 100644 --- a/src/tools/create-token-tool/CreateTokenTool.test.ts +++ b/src/tools/create-token-tool/CreateTokenTool.test.ts @@ -79,17 +79,31 @@ describe('CreateTokenTool', () => { it('throws error when unable to extract username from token', async () => { const originalToken = MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN; + const originalEnvToken = process.env.MAPBOX_ACCESS_TOKEN; try { // Set a token without username in payload const invalidPayload = Buffer.from( JSON.stringify({ sub: 'test' }) ).toString('base64'); + const invalidToken = `eyJhbGciOiJIUzI1NiJ9.${invalidPayload}.signature`; + Object.defineProperty(MapboxApiBasedTool, 'MAPBOX_ACCESS_TOKEN', { - value: `eyJhbGciOiJIUzI1NiJ9.${invalidPayload}.signature`, + value: invalidToken, writable: true, configurable: true }); + process.env.MAPBOX_ACCESS_TOKEN = invalidToken; + + // Setup fetch mock to prevent actual API calls + const fetchMock = setupFetch(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => ({ token: 'test-token' }) + } as Response); const toolWithInvalidToken = new CreateTokenTool(); toolWithInvalidToken['log'] = jest.fn(); @@ -112,6 +126,7 @@ describe('CreateTokenTool', () => { writable: true, configurable: true }); + process.env.MAPBOX_ACCESS_TOKEN = originalEnvToken; } }); }); diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index 313aabe..a7342e8 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -17,13 +17,14 @@ export class CreateTokenTool extends MapboxApiBasedTool< } protected async execute( - input: CreateTokenInput + input: CreateTokenInput, + accessToken?: string ): Promise<{ type: 'text'; text: string }> { - if (!MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN) { + if (!accessToken) { throw new Error('MAPBOX_ACCESS_TOKEN is not set'); } - const username = MapboxApiBasedTool.getUserNameFromToken(); + const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); this.log( 'info', @@ -47,7 +48,7 @@ export class CreateTokenTool extends MapboxApiBasedTool< ); } - const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}tokens/v2/${username}?access_token=${MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN}`; + const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}tokens/v2/${username}?access_token=${accessToken}`; const body: { note: string; diff --git a/src/tools/delete-style-tool/DeleteStyleTool.ts b/src/tools/delete-style-tool/DeleteStyleTool.ts index 0576255..276027e 100644 --- a/src/tools/delete-style-tool/DeleteStyleTool.ts +++ b/src/tools/delete-style-tool/DeleteStyleTool.ts @@ -14,9 +14,12 @@ export class DeleteStyleTool extends MapboxApiBasedTool< super({ inputSchema: DeleteStyleSchema }); } - protected async execute(input: DeleteStyleInput): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(); - const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}/${input.styleId}?access_token=${MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN}`; + protected async execute( + input: DeleteStyleInput, + accessToken?: string + ): Promise { + const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; const response = await fetch(url, { method: 'DELETE' diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index 1b83f61..0c62ba9 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -11,15 +11,18 @@ export class ListStylesTool extends MapboxApiBasedTool< super({ inputSchema: ListStylesSchema }); } - protected async execute(input: ListStylesInput): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(); + protected async execute( + input: ListStylesInput, + accessToken?: string + ): Promise { + const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); // Build query parameters const params = new URLSearchParams(); - if (!MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN) { + if (!accessToken) { throw new Error('MAPBOX_ACCESS_TOKEN is not set'); } - params.append('access_token', MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN); + params.append('access_token', accessToken); if (input.limit) { params.append('limit', input.limit.toString()); diff --git a/src/tools/list-tokens-tool/ListTokensTool.test.ts b/src/tools/list-tokens-tool/ListTokensTool.test.ts index 4639ae6..e55f05a 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.test.ts @@ -70,17 +70,31 @@ describe('ListTokensTool', () => { it('throws error when unable to extract username from token', async () => { const originalToken = MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN; + const originalEnvToken = process.env.MAPBOX_ACCESS_TOKEN; try { // Set a token without username in payload const invalidPayload = Buffer.from( JSON.stringify({ sub: 'test' }) ).toString('base64'); + const invalidToken = `eyJhbGciOiJIUzI1NiJ9.${invalidPayload}.signature`; + Object.defineProperty(MapboxApiBasedTool, 'MAPBOX_ACCESS_TOKEN', { - value: `eyJhbGciOiJIUzI1NiJ9.${invalidPayload}.signature`, + value: invalidToken, writable: true, configurable: true }); + process.env.MAPBOX_ACCESS_TOKEN = invalidToken; + + // Setup fetch mock to prevent actual API calls + const fetchMock = setupFetch(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => [] + } as Response); const toolWithInvalidToken = new ListTokensTool(); toolWithInvalidToken['log'] = jest.fn(); @@ -100,6 +114,7 @@ describe('ListTokensTool', () => { writable: true, configurable: true }); + process.env.MAPBOX_ACCESS_TOKEN = originalEnvToken; } }); }); diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index e990906..03dd8d2 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -13,13 +13,14 @@ export class ListTokensTool extends MapboxApiBasedTool< } protected async execute( - input: ListTokensInput + input: ListTokensInput, + accessToken?: string ): Promise<{ type: 'text'; text: string }> { - if (!MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN) { + if (!accessToken) { throw new Error('MAPBOX_ACCESS_TOKEN is not set'); } - const username = MapboxApiBasedTool.getUserNameFromToken(); + const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); this.log( 'info', @@ -28,7 +29,7 @@ export class ListTokensTool extends MapboxApiBasedTool< // Build initial query parameters const params = new URLSearchParams(); - params.append('access_token', MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN); + params.append('access_token', accessToken); if (input.default !== undefined) { params.append('default', String(input.default)); @@ -103,10 +104,7 @@ export class ListTokensTool extends MapboxApiBasedTool< // Ensure the next URL includes the access token const nextUrl = new URL(links.next); if (!nextUrl.searchParams.has('access_token')) { - nextUrl.searchParams.append( - 'access_token', - MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN - ); + nextUrl.searchParams.append('access_token', accessToken); } const nextUrlString = nextUrl.toString(); diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts index 6408790..b0c59bf 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts @@ -14,9 +14,12 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< super({ inputSchema: RetrieveStyleSchema }); } - protected async execute(input: RetrieveStyleInput): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(); - const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}/${input.styleId}?access_token=${MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN}`; + protected async execute( + input: RetrieveStyleInput, + accessToken?: string + ): Promise { + const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; const response = await fetch(url); diff --git a/src/tools/update-style-tool/UpdateStyleTool.ts b/src/tools/update-style-tool/UpdateStyleTool.ts index 1561202..56f9f6a 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.ts @@ -14,9 +14,12 @@ export class UpdateStyleTool extends MapboxApiBasedTool< super({ inputSchema: UpdateStyleSchema }); } - protected async execute(input: UpdateStyleInput): Promise { - const username = MapboxApiBasedTool.getUserNameFromToken(); - const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}/${input.styleId}?access_token=${MapboxApiBasedTool.MAPBOX_ACCESS_TOKEN}`; + protected async execute( + input: UpdateStyleInput, + accessToken?: string + ): Promise { + const username = MapboxApiBasedTool.getUserNameFromToken(accessToken); + const url = `${MapboxApiBasedTool.MAPBOX_API_ENDPOINT}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; const payload: any = {}; if (input.name) payload.name = input.name;