diff --git a/README.md b/README.md index 8d7a47e..85143d6 100644 --- a/README.md +++ b/README.md @@ -1304,6 +1304,29 @@ node dist/esm/index.js --disable-mcp-ui **Note:** You typically don't need to disable this. The implementation is fully backwards compatible and doesn't affect clients that don't support MCP-UI. See [mcpui.dev](https://mcpui.dev) for compatible clients. +#### CLIENT_NEEDS_RESOURCE_FALLBACK + +**Resource Fallback Tools (Opt-In for Non-Compliant Clients)** + +Resources are a core MCP feature supported by most clients (Claude Desktop, VS Code, MCP Inspector, etc.). However, some clients (like smolagents) don't support resources at all. For these clients, the server can provide "resource fallback tools" that deliver the same content as resources but via tool calls. + +**Fallback Tools:** + +- `get_reference_tool` - Access to style layers, Streets v8 fields, token scopes, layer type mapping +- `get_latest_mapbox_docs_tool` - Access to Mapbox documentation + +**By default, these tools are NOT included** (assumes your client supports resources). If your client doesn't support resources, enable the fallback tools: + +```bash +export CLIENT_NEEDS_RESOURCE_FALLBACK=true +``` + +**When to set this:** + +- ✅ Set to `true` if using smolagents or other clients without resource support +- ❌ Leave unset (default) if using Claude Desktop, VS Code, MCP Inspector, or any resource-capable client +- ❌ Leave unset if unsure (most clients support resources) + ## Troubleshooting **Issue:** Tools fail with authentication errors diff --git a/src/index.ts b/src/index.ts index e0d6150..627b1a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { parseToolConfigFromArgs, filterTools } from './config/toolConfig.js'; -import { getAllTools } from './tools/toolRegistry.js'; +import { + getCoreTools, + getElicitationTools, + getResourceFallbackTools +} from './tools/toolRegistry.js'; import { getAllResources } from './resources/resourceRegistry.js'; import { getAllPrompts } from './prompts/promptRegistry.js'; import { getVersionInfo } from './utils/versionUtils.js'; @@ -54,8 +58,14 @@ const versionInfo = getVersionInfo(); const config = parseToolConfigFromArgs(); // Get and filter tools based on configuration -const allTools = getAllTools(); -const enabledTools = filterTools(allTools, config); +// Split into categories for capability-aware registration +const coreTools = getCoreTools(); +const elicitationTools = getElicitationTools(); +const resourceFallbackTools = getResourceFallbackTools(); + +const enabledCoreTools = filterTools(coreTools, config); +const enabledElicitationTools = filterTools(elicitationTools, config); +const enabledResourceFallbackTools = filterTools(resourceFallbackTools, config); // Create an MCP server const server = new McpServer( @@ -65,15 +75,18 @@ const server = new McpServer( }, { capabilities: { - tools: {}, + tools: { + listChanged: true // Advertise support for dynamic tool registration + }, resources: {}, prompts: {} } } ); -// Register enabled tools to the server -enabledTools.forEach((tool) => { +// Register only core tools before connection +// Capability-dependent tools will be registered dynamically after connection +enabledCoreTools.forEach((tool) => { tool.installTo(server); }); @@ -210,6 +223,80 @@ async function main() { // Start receiving messages on stdin and sending messages on stdout const transport = new StdioServerTransport(); await server.connect(transport); + + // After connection, dynamically register capability-dependent tools + const clientCapabilities = server.server.getClientCapabilities(); + + // Debug: Log what capabilities we detected + server.server.sendLoggingMessage({ + level: 'info', + data: `Client capabilities detected: ${JSON.stringify(clientCapabilities, null, 2)}` + }); + + let toolsAdded = false; + + // Register elicitation tools if client supports elicitation + if (clientCapabilities?.elicitation && enabledElicitationTools.length > 0) { + server.server.sendLoggingMessage({ + level: 'info', + data: `Client supports elicitation. Registering ${enabledElicitationTools.length} elicitation-dependent tools` + }); + + enabledElicitationTools.forEach((tool) => { + tool.installTo(server); + }); + toolsAdded = true; + } else if (enabledElicitationTools.length > 0) { + server.server.sendLoggingMessage({ + level: 'debug', + data: `Client does not support elicitation. Skipping ${enabledElicitationTools.length} elicitation-dependent tools` + }); + } + + // Register resource fallback tools for clients that don't support resources + // Note: Resources are a core MCP feature supported by most clients. + // However, some clients (like smolagents) don't support resources at all. + // These fallback tools provide the same content as resources but via tool calls instead. + // + // Configuration via CLIENT_NEEDS_RESOURCE_FALLBACK environment variable: + // - unset (default) = Skip fallback tools (assume client supports resources) + // - "true" = Provide fallback tools (client does NOT support resources) + const clientNeedsResourceFallback = + process.env.CLIENT_NEEDS_RESOURCE_FALLBACK?.toLowerCase() === 'true'; + + if (clientNeedsResourceFallback && enabledResourceFallbackTools.length > 0) { + server.server.sendLoggingMessage({ + level: 'info', + data: `CLIENT_NEEDS_RESOURCE_FALLBACK=true. Registering ${enabledResourceFallbackTools.length} resource fallback tools` + }); + + enabledResourceFallbackTools.forEach((tool) => { + tool.installTo(server); + }); + toolsAdded = true; + } else if (enabledResourceFallbackTools.length > 0) { + server.server.sendLoggingMessage({ + level: 'debug', + data: `CLIENT_NEEDS_RESOURCE_FALLBACK not set or false. Skipping ${enabledResourceFallbackTools.length} resource fallback tools (client supports resources)` + }); + } + + // Notify client about tool list changes if any tools were added + if (toolsAdded) { + try { + server.sendToolListChanged(); + + server.server.sendLoggingMessage({ + level: 'debug', + data: 'Sent notifications/tools/list_changed to client' + }); + } catch (error) { + server.server.sendLoggingMessage({ + level: 'warning', + data: `Failed to send tool list change notification: ${error instanceof Error ? error.message : String(error)}` + }); + } + } } // Ensure cleanup interval is cleared when the process exits diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 61ac7d1..481ea7e 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -28,8 +28,11 @@ import { ValidateGeojsonTool } from './validate-geojson-tool/ValidateGeojsonTool import { ValidateStyleTool } from './validate-style-tool/ValidateStyleTool.js'; import { httpRequest } from '../utils/httpPipeline.js'; -// Central registry of all tools -export const ALL_TOOLS = [ +/** + * Core tools that work in all MCP clients without requiring special capabilities + * These tools are registered immediately during server startup + */ +export const CORE_TOOLS = [ new ListStylesTool({ httpRequest }), new CreateStyleTool({ httpRequest }), new RetrieveStyleTool({ httpRequest }), @@ -41,6 +44,7 @@ export const ALL_TOOLS = [ new CheckColorContrastTool(), new CompareStylesTool(), new OptimizeStyleTool(), + new StyleComparisonTool(), new CreateTokenTool({ httpRequest }), new ListTokensTool({ httpRequest }), new BoundingBoxTool(), @@ -48,21 +52,83 @@ export const ALL_TOOLS = [ new CoordinateConversionTool(), new GetFeedbackTool({ httpRequest }), new ListFeedbackTool({ httpRequest }), - new GetMapboxDocSourceTool({ httpRequest }), - new GetReferenceTool(), - new StyleComparisonTool(), new TilequeryTool({ httpRequest }), new ValidateExpressionTool(), new ValidateGeojsonTool(), new ValidateStyleTool() ] as const; +/** + * Tools that require elicitation capability for optimal functionality + * These tools use elicitInput() for secure token management + * Registered only if client supports elicitation + * + * Currently empty - elicitation support will be added in a future PR. + * This category is ready for tools that require the elicitation capability. + */ +export const ELICITATION_TOOLS = [] as const; + +/** + * Tools that serve as bridges for clients without resource support + * These tools are only registered if CLIENT_NEEDS_RESOURCE_FALLBACK env var is set to "true" + * + * Context: Most MCP clients support resources (Claude Desktop, VS Code, Inspector, etc.). + * However, some clients (like smolagents) don't support resources at all. + * These tools provide the same content as resources but via tool calls instead. + * + * Configuration: + * - Leave unset (default) = Skip these tools (assumes client supports resources) + * - Set CLIENT_NEEDS_RESOURCE_FALLBACK=true = Include these tools (for smolagents, etc.) + * + * Tools: + * - GetReferenceTool: Provides access to reference resources (style layers, Streets v8 fields, token scopes, layer type mapping) + * - GetMapboxDocSourceTool: Provides access to Mapbox documentation (resource://mapbox-documentation) + */ +export const RESOURCE_FALLBACK_TOOLS = [ + new GetReferenceTool(), + new GetMapboxDocSourceTool({ httpRequest }) +] as const; + +/** + * All tools combined (for backward compatibility and testing) + */ +export const ALL_TOOLS = [ + ...CORE_TOOLS, + ...ELICITATION_TOOLS, + ...RESOURCE_FALLBACK_TOOLS +] as const; + export type ToolInstance = (typeof ALL_TOOLS)[number]; +/** + * Get all tools (for backward compatibility) + * @deprecated Use getCoreTools(), getElicitationTools(), etc. instead for capability-aware registration + */ export function getAllTools(): readonly ToolInstance[] { return ALL_TOOLS; } +/** + * Get tools that work in all MCP clients + */ +export function getCoreTools(): readonly ToolInstance[] { + return CORE_TOOLS; +} + +/** + * Get tools that require elicitation capability + */ +export function getElicitationTools(): readonly ToolInstance[] { + return ELICITATION_TOOLS; +} + +/** + * Get tools that serve as fallbacks when client doesn't support resources + */ +export function getResourceFallbackTools(): readonly ToolInstance[] { + return RESOURCE_FALLBACK_TOOLS; +} + export function getToolByName(name: string): ToolInstance | undefined { return ALL_TOOLS.find((tool) => tool.name === name); } diff --git a/test/tools/toolRegistry.test.ts b/test/tools/toolRegistry.test.ts new file mode 100644 index 0000000..0acc821 --- /dev/null +++ b/test/tools/toolRegistry.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { + getCoreTools, + getElicitationTools, + getResourceFallbackTools, + getAllTools +} from '../../src/tools/toolRegistry.js'; + +describe('Tool Registry', () => { + describe('getCoreTools', () => { + it('should return an array of core tools', () => { + const coreTools = getCoreTools(); + expect(Array.isArray(coreTools)).toBe(true); + expect(coreTools.length).toBeGreaterThan(0); + }); + + it('should include expected core tools', () => { + const coreTools = getCoreTools(); + const toolNames = coreTools.map((tool) => tool.name); + + // Verify some expected core tools + expect(toolNames).toContain('list_styles_tool'); + expect(toolNames).toContain('create_style_tool'); + expect(toolNames).toContain('style_builder_tool'); + expect(toolNames).toContain('validate_style_tool'); + }); + + it('should include preview and comparison tools (until elicitation support is added)', () => { + const coreTools = getCoreTools(); + const toolNames = coreTools.map((tool) => tool.name); + + // These tools are currently in CORE_TOOLS + // They will move to ELICITATION_TOOLS when elicitation support is added + expect(toolNames).toContain('preview_style_tool'); + expect(toolNames).toContain('style_comparison_tool'); + }); + + it('should not include resource fallback tools', () => { + const coreTools = getCoreTools(); + const toolNames = coreTools.map((tool) => tool.name); + + // Resource fallback tools should not be in core + expect(toolNames).not.toContain('get_reference_tool'); + expect(toolNames).not.toContain('get_latest_mapbox_docs_tool'); + }); + }); + + describe('getElicitationTools', () => { + it('should return an array of elicitation tools', () => { + const elicitationTools = getElicitationTools(); + expect(Array.isArray(elicitationTools)).toBe(true); + }); + + it('should currently be empty (elicitation support pending)', () => { + const elicitationTools = getElicitationTools(); + expect(elicitationTools.length).toBe(0); + }); + + it('should be ready for future elicitation-dependent tools', () => { + // This test documents that the infrastructure is in place + // When elicitation support is added, tools can be moved here + const elicitationTools = getElicitationTools(); + expect(Array.isArray(elicitationTools)).toBe(true); + }); + }); + + describe('getResourceFallbackTools', () => { + it('should return an array of resource fallback tools', () => { + const resourceFallbackTools = getResourceFallbackTools(); + expect(Array.isArray(resourceFallbackTools)).toBe(true); + expect(resourceFallbackTools.length).toBe(2); + }); + + it('should include get_reference_tool', () => { + const resourceFallbackTools = getResourceFallbackTools(); + const toolNames = resourceFallbackTools.map((tool) => tool.name); + expect(toolNames).toContain('get_reference_tool'); + }); + + it('should include get_latest_mapbox_docs_tool', () => { + const resourceFallbackTools = getResourceFallbackTools(); + const toolNames = resourceFallbackTools.map((tool) => tool.name); + expect(toolNames).toContain('get_latest_mapbox_docs_tool'); + }); + }); + + describe('getAllTools', () => { + it('should return all tools combined', () => { + const allTools = getAllTools(); + const coreTools = getCoreTools(); + const elicitationTools = getElicitationTools(); + const resourceFallbackTools = getResourceFallbackTools(); + + expect(allTools.length).toBe( + coreTools.length + + elicitationTools.length + + resourceFallbackTools.length + ); + }); + + it('should have no duplicate tools', () => { + const allTools = getAllTools(); + const toolNames = allTools.map((tool) => tool.name); + const uniqueToolNames = new Set(toolNames); + + expect(toolNames.length).toBe(uniqueToolNames.size); + }); + + it('should include tools from all categories', () => { + const allTools = getAllTools(); + const toolNames = allTools.map((tool) => tool.name); + + // Core tools + expect(toolNames).toContain('list_styles_tool'); + expect(toolNames).toContain('preview_style_tool'); + // Resource fallback tools + expect(toolNames).toContain('get_reference_tool'); + expect(toolNames).toContain('get_latest_mapbox_docs_tool'); + // Note: No elicitation tools yet (empty array) + }); + }); + + describe('Tool categorization consistency', () => { + it('should have no overlap between core and elicitation tools', () => { + const coreToolNames = getCoreTools().map((tool) => tool.name); + const elicitationToolNames = getElicitationTools().map( + (tool) => tool.name + ); + + const overlap = coreToolNames.filter((name) => + elicitationToolNames.includes(name) + ); + expect(overlap).toEqual([]); + }); + + it('should have no overlap between core and resource fallback tools', () => { + const coreToolNames = getCoreTools().map((tool) => tool.name); + const resourceFallbackToolNames = getResourceFallbackTools().map( + (tool) => tool.name + ); + + const overlap = coreToolNames.filter((name) => + resourceFallbackToolNames.includes(name) + ); + expect(overlap).toEqual([]); + }); + + it('should have no overlap between elicitation and resource fallback tools', () => { + const elicitationToolNames = getElicitationTools().map( + (tool) => tool.name + ); + const resourceFallbackToolNames = getResourceFallbackTools().map( + (tool) => tool.name + ); + + const overlap = elicitationToolNames.filter((name) => + resourceFallbackToolNames.includes(name) + ); + expect(overlap).toEqual([]); + }); + }); +});