diff --git a/frontend/src/components/Sidebar/Sidebar.tsx b/frontend/src/components/Sidebar/Sidebar.tsx index 181bf1d0..aaee2ad6 100644 --- a/frontend/src/components/Sidebar/Sidebar.tsx +++ b/frontend/src/components/Sidebar/Sidebar.tsx @@ -39,7 +39,6 @@ export default function Sidebar({ sources, isLoading }: SidebarProps) {
{currentSource.tools - .filter((tool) => tool.name !== 'search_objects') .map((tool) => ( {source.tools.length > 0 ? (
- {source.tools.map((tool, index) => ( -
- {index > 0 && ( -
- )} -
-
-
-
-
- {tool.name} + {source.tools.map((tool, index) => { + const boundSourceIdParam = tool.parameters.find((p) => p.name === 'source_id'); + const displayParams = tool.parameters.filter((p) => p.name !== 'source_id'); + const paramCount = displayParams.length + (boundSourceIdParam ? 1 : 0); + return ( +
+ {index > 0 && ( +
+ )} +
+
+
+
+
+ {tool.name} +
+ {tool.readonly && ( + + )} +
+
+ ({paramCount} {paramCount === 1 ? 'parameter' : 'parameters'})
- {tool.readonly && ( - - )}
-
- ({tool.parameters.length} {tool.parameters.length === 1 ? 'parameter' : 'parameters'}) +
+ {tool.description}
-
- {tool.description} -
-
- {tool.parameters.length > 0 && ( -
-
- Parameters -
-
    - {tool.parameters.map((param) => ( -
  • -
    - - {param.name} - - - {param.type} - - {param.required ? ( - - required + {(displayParams.length > 0 || boundSourceIdParam) && ( +
    +
    + Parameters +
    +
      + {boundSourceIdParam && ( +
    • +
      + + source_id + + + string - ) : ( - optional + bound: {source.id} - )} -
      - {param.description && ( +
    - {param.description} + {boundSourceIdParam.description}
    - )} -
  • - ))} -
-
- )} + + )} + {displayParams.map((param) => ( +
  • +
    + + {param.name} + + + {param.type} + + {param.required ? ( + + required + + ) : ( + + optional + + )} +
    + {param.description && ( +
    + {param.description} +
    + )} +
  • + ))} + +
    + )} +
    -
    - ))} + ); + })}
    ) : (
    diff --git a/frontend/src/components/views/ToolDetailView.tsx b/frontend/src/components/views/ToolDetailView.tsx index 77e1a103..4cffc252 100644 --- a/frontend/src/components/views/ToolDetailView.tsx +++ b/frontend/src/components/views/ToolDetailView.tsx @@ -1,13 +1,20 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; -import { useParams, Navigate, useSearchParams } from 'react-router-dom'; -import { fetchSource } from '../../api/sources'; -import { executeTool, type QueryResult } from '../../api/tools'; -import { ApiError } from '../../api/errors'; -import type { Tool } from '../../types/datasource'; -import { SqlEditor, ParameterForm, RunButton, ResultsTabs, type ResultTab, type SqlEditorHandle } from '../tool'; -import LockIcon from '../icons/LockIcon'; -import CopyIcon from '../icons/CopyIcon'; -import CheckIcon from '../icons/CheckIcon'; +import { useEffect, useState, useCallback, useRef } from "react"; +import { useParams, Navigate, useSearchParams } from "react-router-dom"; +import { fetchSource } from "../../api/sources"; +import { executeTool, type QueryResult } from "../../api/tools"; +import { ApiError } from "../../api/errors"; +import type { Tool } from "../../types/datasource"; +import { + SqlEditor, + ParameterForm, + RunButton, + ResultsTabs, + type ResultTab, + type SqlEditorHandle, +} from "../tool"; +import LockIcon from "../icons/LockIcon"; +import CopyIcon from "../icons/CopyIcon"; +import CheckIcon from "../icons/CheckIcon"; export default function ToolDetailView() { const { sourceId, toolName } = useParams<{ sourceId: string; toolName: string }>(); @@ -22,7 +29,7 @@ export default function ToolDetailView() { // Query state const [sql, setSql] = useState(() => { // Only for execute_sql tools - read from URL on mount - return searchParams.get('sql') || ''; + return searchParams.get("sql") || ""; }); const [params, setParams] = useState>({}); const [resultTabs, setResultTabs] = useState([]); @@ -52,41 +59,44 @@ export default function ToolDetailView() { }, [sourceId, toolName]); // Determine tool type - const getToolType = useCallback((): 'execute_sql' | 'search_objects' | 'custom' => { - if (!tool) return 'custom'; - if (tool.name.startsWith('execute_sql')) return 'execute_sql'; - if (tool.name.startsWith('search_objects')) return 'search_objects'; - return 'custom'; + const getToolType = useCallback((): "execute_sql" | "search_objects" | "custom" => { + if (!tool) return "custom"; + if (tool.name.startsWith("execute_sql")) return "execute_sql"; + if (tool.name.startsWith("search_objects")) return "search_objects"; + return "custom"; }, [tool]); const toolType = getToolType(); // Coerce URL parameter values to correct types based on parameter schema - const coerceParamValue = useCallback((value: string, paramName: string): any => { - if (!tool) return value; + const coerceParamValue = useCallback( + (value: string, paramName: string): any => { + if (!tool) return value; - const paramDef = tool.parameters.find(p => p.name === paramName); - if (!paramDef) return undefined; // Invalid param - will be filtered out + const paramDef = tool.parameters.find((p) => p.name === paramName); + if (!paramDef) return undefined; // Invalid param - will be filtered out - // Type coercion based on parameter schema - if (paramDef.type === 'number' || paramDef.type === 'integer' || paramDef.type === 'float') { - const num = Number(value); - return isNaN(num) ? undefined : num; // Exclude invalid numbers entirely - } - if (paramDef.type === 'boolean') { - return value === 'true'; - } - return value; // string type - }, [tool]); + // Type coercion based on parameter schema + if (paramDef.type === "number" || paramDef.type === "integer" || paramDef.type === "float") { + const num = Number(value); + return isNaN(num) ? undefined : num; // Exclude invalid numbers entirely + } + if (paramDef.type === "boolean") { + return value === "true"; + } + return value; // string type + }, + [tool] + ); // Coerce URL params to correct types after tool is loaded useEffect(() => { - if (!tool || toolType !== 'custom') return; + if (!tool || toolType !== "custom") return; // Only coerce params from URL on initial mount const urlParams: Record = {}; searchParams.forEach((value, key) => { - if (key !== 'sql') { + if (key !== "sql") { const coerced = coerceParamValue(String(value), key); if (coerced !== undefined) { urlParams[key] = coerced; @@ -102,20 +112,23 @@ export default function ToolDetailView() { // Update URL when sql changes (debounced for execute_sql tools) useEffect(() => { - if (toolType !== 'execute_sql') return; + if (toolType !== "execute_sql") return; const timer = setTimeout(() => { - setSearchParams((currentParams) => { - const newParams = new URLSearchParams(currentParams); - - if (sql.trim()) { - newParams.set('sql', sql); - } else { - newParams.delete('sql'); - } + setSearchParams( + (currentParams) => { + const newParams = new URLSearchParams(currentParams); + + if (sql.trim()) { + newParams.set("sql", sql); + } else { + newParams.delete("sql"); + } - return newParams; - }, { replace: true }); + return newParams; + }, + { replace: true } + ); }, 300); return () => clearTimeout(timer); @@ -123,28 +136,31 @@ export default function ToolDetailView() { // Update URL when params change (debounced for custom tools) useEffect(() => { - if (toolType !== 'custom') return; + if (toolType !== "custom") return; const timer = setTimeout(() => { - setSearchParams((currentParams) => { - const newParams = new URLSearchParams(currentParams); - - // Clear all non-reserved params first - Array.from(newParams.keys()).forEach(key => { - if (key !== 'sql') { - newParams.delete(key); - } - }); + setSearchParams( + (currentParams) => { + const newParams = new URLSearchParams(currentParams); + + // Clear all non-reserved params first + Array.from(newParams.keys()).forEach((key) => { + if (key !== "sql") { + newParams.delete(key); + } + }); - // Add current param values - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - newParams.set(key, String(value)); - } - }); + // Add current param values + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + newParams.set(key, String(value)); + } + }); - return newParams; - }, { replace: true }); + return newParams; + }, + { replace: true } + ); }, 300); return () => clearTimeout(timer); @@ -152,7 +168,7 @@ export default function ToolDetailView() { // Transform statement placeholders to named format const transformedStatement = useCallback((): string => { - if (!tool?.statement) return ''; + if (!tool?.statement) return ""; let transformedSql = tool.statement; let questionMarkIndex = 0; @@ -169,7 +185,7 @@ export default function ToolDetailView() { param = tool.parameters[questionMarkIndex]; questionMarkIndex++; } - return param ? `:${param.name}` : ':?'; + return param ? `:${param.name}` : ":?"; }); return transformedSql; @@ -180,25 +196,29 @@ export default function ToolDetailView() { let sqlText = transformedStatement(); Object.entries(params).forEach(([name, value]) => { - if (value !== undefined && value !== '') { + if (value !== undefined && value !== "") { const displayValue = - typeof value === 'string' - ? `'${value.replace(/'/g, "''")}'` - : String(value); - sqlText = sqlText.replace(new RegExp(`:${name}\\b`, 'g'), displayValue); + typeof value === "string" ? `'${value.replace(/'/g, "''")}'` : String(value); + sqlText = sqlText.replace(new RegExp(`:${name}\\b`, "g"), displayValue); } }); return sqlText; }, [transformedStatement, params]); + // Determine if tool has a bound source_id param (multi-source mode) + const hasBoundSourceId = tool?.parameters.some((p) => p.name === "source_id") ?? false; + + // Parameters the user can actually fill in (excludes bound source_id) + const editableParameters = tool?.parameters.filter((p) => p.name !== "source_id") ?? []; + // Check if all required params are filled const allRequiredParamsFilled = useCallback((): boolean => { if (!tool) return false; - return tool.parameters + return editableParameters .filter((p) => p.required) - .every((p) => params[p.name] !== undefined && params[p.name] !== ''); - }, [tool, params]); + .every((p) => params[p.name] !== undefined && params[p.name] !== ""); + }, [tool, editableParameters, params]); // Run query const handleRun = useCallback(async () => { @@ -212,10 +232,17 @@ export default function ToolDetailView() { let queryResult: QueryResult; let sqlToExecute: string; - if (toolType === 'execute_sql') { + if (toolType === "execute_sql") { // Get selected SQL from editor (returns selection if any, otherwise full content) sqlToExecute = sqlEditorRef.current?.getSelectedSql() ?? sql; - queryResult = await executeTool(toolName, { sql: sqlToExecute }); + const executeArgs: Record = { sql: sqlToExecute }; + if (hasBoundSourceId && sourceId) executeArgs.source_id = sourceId; + queryResult = await executeTool(toolName, executeArgs); + } else if (toolType === "search_objects") { + const searchArgs: Record = { ...params }; + if (hasBoundSourceId && sourceId) searchArgs.source_id = sourceId; + sqlToExecute = getSqlPreview(); + queryResult = await executeTool(toolName, searchArgs); } else { sqlToExecute = getSqlPreview(); queryResult = await executeTool(toolName, params); @@ -232,51 +259,53 @@ export default function ToolDetailView() { executedSql: sqlToExecute, executionTimeMs: duration, }; - setResultTabs(prev => [newTab, ...prev]); + setResultTabs((prev) => [newTab, ...prev]); setActiveTabId(newTab.id); } catch (err) { const errorTab: ResultTab = { id: crypto.randomUUID(), timestamp: new Date(), result: null, - error: err instanceof Error ? err.message : 'Query failed', - executedSql: toolType === 'execute_sql' ? sql : getSqlPreview(), + error: err instanceof Error ? err.message : "Query failed", + executedSql: toolType === "execute_sql" ? sql : getSqlPreview(), executionTimeMs: 0, }; - setResultTabs(prev => [errorTab, ...prev]); + setResultTabs((prev) => [errorTab, ...prev]); setActiveTabId(errorTab.id); } finally { setIsRunning(false); } - }, [tool, toolName, toolType, sql, params, getSqlPreview]); + }, [tool, toolName, toolType, sql, params, getSqlPreview, hasBoundSourceId, sourceId]); // Compute disabled state for run button - const isRunDisabled = - toolType === 'execute_sql' ? !sql.trim() : !allRequiredParamsFilled(); + const isRunDisabled = toolType === "execute_sql" ? !sql.trim() : !allRequiredParamsFilled(); // Copy SQL to clipboard const handleCopy = useCallback(async () => { - const sqlToCopy = toolType === 'execute_sql' ? sql : getSqlPreview(); + const sqlToCopy = toolType === "execute_sql" ? sql : getSqlPreview(); await navigator.clipboard.writeText(sqlToCopy); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [toolType, sql, getSqlPreview]); - const handleTabClose = useCallback((idToClose: string) => { - setResultTabs(prev => { - const index = prev.findIndex(tab => tab.id === idToClose); - const newTabs = prev.filter(tab => tab.id !== idToClose); - - if (idToClose === activeTabId && newTabs.length > 0) { - const nextIndex = Math.min(index, newTabs.length - 1); - setActiveTabId(newTabs[nextIndex].id); - } else if (newTabs.length === 0) { - setActiveTabId(null); - } + const handleTabClose = useCallback( + (idToClose: string) => { + setResultTabs((prev) => { + const index = prev.findIndex((tab) => tab.id === idToClose); + const newTabs = prev.filter((tab) => tab.id !== idToClose); + + if (idToClose === activeTabId && newTabs.length > 0) { + const nextIndex = Math.min(index, newTabs.length - 1); + setActiveTabId(newTabs[nextIndex].id); + } else if (newTabs.length === 0) { + setActiveTabId(null); + } - return newTabs; - }); - }, [activeTabId]); + return newTabs; + }); + }, + [activeTabId] + ); if (!sourceId || !toolName) { return ; @@ -310,7 +339,7 @@ export default function ToolDetailView() { } // search_objects placeholder - if (toolType === 'search_objects') { + if (toolType === "search_objects") { return (
    @@ -324,10 +353,24 @@ export default function ToolDetailView() { )}

    {tool.description}

    + {hasBoundSourceId && sourceId && ( +
    +
    + Parameters +
    +
    + source_id + + string + + + bound: {sourceId} + +
    +
    + )}
    -

    - Interactive UI for this tool is coming soon. -

    +

    Interactive UI for this tool is coming soon.

    @@ -351,16 +394,30 @@ export default function ToolDetailView() {

    {tool.description}

    + {/* Bound source_id display (execute_sql in multi-source mode) */} + {toolType === "execute_sql" && hasBoundSourceId && sourceId && ( +
    + +
    +
    + source_id + + string + + + bound: {sourceId} + +
    +
    +
    + )} + {/* Parameter Form (custom tools only, show before SQL) */} - {toolType === 'custom' && tool.parameters.length > 0 && ( + {toolType === "custom" && editableParameters.length > 0 && (
    - +
    )} @@ -368,9 +425,7 @@ export default function ToolDetailView() { {/* SQL Editor */}
    - +
    {/* Run Button */} - + {/* Results */} { }); }); - it('should include execute_sql tools with correct naming', async () => { + it('should use generic execute_sql tool name across all sources in multi-source mode', async () => { const response = await fetch(`${BASE_URL}/api/sources`); const sources = (await response.json()) as DataSource[]; - // Find sources by ID to avoid relying on array order - const readonlySource = sources.find(s => s.id === 'readonly_limited'); - const writableSource = sources.find(s => s.id === 'writable_limited'); - const unlimitedSource = sources.find(s => s.id === 'writable_unlimited'); - - expect(readonlySource?.tools[0].name).toBe('execute_sql_readonly_limited'); - expect(writableSource?.tools[0].name).toBe('execute_sql_writable_limited'); - expect(unlimitedSource?.tools[0].name).toBe('execute_sql_writable_unlimited'); + // In multi-source mode, all sources share the generic execute_sql tool name + sources.forEach((source) => { + expect(source.tools[0].name).toBe('execute_sql'); + }); }); - it('should include source ID and type in tool descriptions', async () => { + it('should include source_id parameter in execute_sql tool for multi-source mode', async () => { const response = await fetch(`${BASE_URL}/api/sources`); const sources = (await response.json()) as DataSource[]; + // In multi-source mode, execute_sql has a source_id param instead of source-specific description sources.forEach((source) => { const tool = source.tools[0]; - expect(tool.description).toContain(source.id); - expect(tool.description).toContain(source.type); + const sourceIdParam = tool.parameters.find((p) => p.name === 'source_id'); + expect(sourceIdParam).toBeDefined(); + expect(sourceIdParam!.type).toBe('string'); }); }); @@ -291,13 +289,12 @@ describe('Data Sources API Integration Tests', () => { expect(source.tools.length).toBeGreaterThan(0); }); - it('should include correct tool name for specific source', async () => { + it('should use generic execute_sql tool name in multi-source mode', async () => { const response = await fetch(`${BASE_URL}/api/sources/writable_limited`); const source = (await response.json()) as DataSource; - expect(source.tools[0].name).toBe('execute_sql_writable_limited'); - expect(source.tools[0].description).toContain('writable_limited'); - expect(source.tools[0].description).toContain('sqlite'); + // In multi-source mode, generic execute_sql tool name is used + expect(source.tools[0].name).toBe('execute_sql'); }); it('should include complete tool metadata in single source response', async () => { @@ -305,7 +302,7 @@ describe('Data Sources API Integration Tests', () => { const source = (await response.json()) as DataSource; const tool = source.tools[0]; - expect(tool.name).toBe('execute_sql_readonly_limited'); + expect(tool.name).toBe('execute_sql'); expect(tool.description).toBeDefined(); expect(tool.parameters).toBeDefined(); expect(Array.isArray(tool.parameters)).toBe(true); diff --git a/src/config/toml-loader.ts b/src/config/toml-loader.ts index b716c59b..f0e0be83 100644 --- a/src/config/toml-loader.ts +++ b/src/config/toml-loader.ts @@ -5,7 +5,7 @@ import toml from "@iarna/toml"; import type { SourceConfig, TomlConfig, ToolConfig } from "../types/config.js"; import { parseCommandLineArgs } from "./env.js"; import { parseConnectionInfoFromDSN, getDefaultPortForType } from "../utils/dsn-obfuscate.js"; -import { BUILTIN_TOOLS, BUILTIN_TOOL_EXECUTE_SQL, BUILTIN_TOOL_SEARCH_OBJECTS } from "../tools/builtin-tools.js"; +import { BUILTIN_AND_META_TOOLS, BUILTIN_TOOL_EXECUTE_SQL, BUILTIN_TOOL_SEARCH_OBJECTS } from "../tools/builtin-tools.js"; /** * Load and parse TOML configuration file @@ -177,7 +177,7 @@ function validateToolsConfig( } // Validate based on tool type (built-in vs custom) - const isBuiltin = (BUILTIN_TOOLS as readonly string[]).includes(tool.name); + const isBuiltin = (BUILTIN_AND_META_TOOLS as readonly string[]).includes(tool.name); const isExecuteSql = tool.name === BUILTIN_TOOL_EXECUTE_SQL; if (isBuiltin) { diff --git a/src/tools/__tests__/execute-sql.test.ts b/src/tools/__tests__/execute-sql.test.ts index 2ebaeeae..adcd475e 100644 --- a/src/tools/__tests__/execute-sql.test.ts +++ b/src/tools/__tests__/execute-sql.test.ts @@ -259,4 +259,37 @@ describe('execute-sql tool', () => { expect(parseToolResponse(result).success).toBe(true); }); }); + + describe('multi-source mode (boundSourceId undefined, source_id from args)', () => { + it('uses source_id from args when handler has no boundSourceId', async () => { + const mockResult: SQLResult = { rows: [{ id: 1 }], rowCount: 1 }; + vi.mocked(mockConnector.executeSQL).mockResolvedValue(mockResult); + + const handler = createExecuteSqlToolHandler(undefined); + const result = await handler({ sql: 'SELECT 1', source_id: 'arg_source' }, null); + + expect(parseToolResponse(result).success).toBe(true); + expect(ConnectorManager.getCurrentConnector).toHaveBeenCalledWith('arg_source'); + }); + + it('boundSourceId takes precedence over source_id in args', async () => { + const mockResult: SQLResult = { rows: [], rowCount: 0 }; + vi.mocked(mockConnector.executeSQL).mockResolvedValue(mockResult); + + const handler = createExecuteSqlToolHandler('bound_source'); + await handler({ sql: 'SELECT 1', source_id: 'arg_source' }, null); + + expect(ConnectorManager.getCurrentConnector).toHaveBeenCalledWith('bound_source'); + }); + + it('falls back to default connector when no boundSourceId and no source_id arg', async () => { + const mockResult: SQLResult = { rows: [], rowCount: 0 }; + vi.mocked(mockConnector.executeSQL).mockResolvedValue(mockResult); + + const handler = createExecuteSqlToolHandler(undefined); + await handler({ sql: 'SELECT 1' }, null); + + expect(ConnectorManager.getCurrentConnector).toHaveBeenCalledWith(undefined); + }); + }); }); diff --git a/src/tools/__tests__/list-sources.test.ts b/src/tools/__tests__/list-sources.test.ts new file mode 100644 index 00000000..f43fcc3e --- /dev/null +++ b/src/tools/__tests__/list-sources.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { registerListSourcesTool } from '../list-sources.js'; +import { ConnectorManager } from '../../connectors/manager.js'; + +vi.mock('../../connectors/manager.js'); + +const parseToolResponse = (result: any) => JSON.parse(result.content[0].text); + +describe('list_sources tool', () => { + const mockServer = { registerTool: vi.fn() }; + + beforeEach(() => { + vi.mocked(ConnectorManager.getAvailableSourceIds).mockReturnValue(['db_a', 'db_b']); + vi.mocked(ConnectorManager.getSourceConfig).mockImplementation( + (id) => ({ id, type: id === 'db_a' ? 'postgres' : 'mysql' }) as any + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('registers with correct name and annotations', () => { + registerListSourcesTool(mockServer as any); + + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'list_sources', + expect.objectContaining({ + description: expect.stringContaining('source_id'), + inputSchema: {}, + annotations: expect.objectContaining({ + title: 'List Database Sources', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }), + }), + expect.any(Function) + ); + }); + + it('handler returns all sources with id and type', async () => { + registerListSourcesTool(mockServer as any); + const handler = mockServer.registerTool.mock.calls[0][2]; + + const result = await handler(); + const parsed = parseToolResponse(result); + + expect(parsed.success).toBe(true); + expect(parsed.data.sources).toEqual([ + { id: 'db_a', type: 'postgres' }, + { id: 'db_b', type: 'mysql' }, + ]); + }); + + it('handler returns empty array when no sources', async () => { + vi.mocked(ConnectorManager.getAvailableSourceIds).mockReturnValue([]); + + registerListSourcesTool(mockServer as any); + const handler = mockServer.registerTool.mock.calls[0][2]; + + const result = await handler(); + const parsed = parseToolResponse(result); + + expect(parsed.data.sources).toEqual([]); + }); + + it('handler includes type from source config', async () => { + vi.mocked(ConnectorManager.getAvailableSourceIds).mockReturnValue(['sqlite_src']); + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue({ type: 'sqlite' } as any); + + registerListSourcesTool(mockServer as any); + const handler = mockServer.registerTool.mock.calls[0][2]; + + const result = await handler(); + const parsed = parseToolResponse(result); + + expect(parsed.data.sources[0]).toEqual({ id: 'sqlite_src', type: 'sqlite' }); + }); +}); diff --git a/src/tools/__tests__/registry.test.ts b/src/tools/__tests__/registry.test.ts new file mode 100644 index 00000000..b26c5737 --- /dev/null +++ b/src/tools/__tests__/registry.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ToolRegistry } from '../registry.js'; +import { ConnectorManager } from '../../connectors/manager.js'; +import type { TomlConfig } from '../../types/config.js'; + +vi.mock('../../connectors/manager.js'); + +const makeConfig = (sourceId: string, tools: any[] = []): TomlConfig => ({ + sources: [{ id: sourceId, type: 'sqlite', dsn: `sqlite:///:memory:` }], + tools, +}); + +describe('ToolRegistry', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getCustomToolsForSource', () => { + it('returns empty array for source with only built-in tools', () => { + const registry = new ToolRegistry(makeConfig('src_a')); + expect(registry.getCustomToolsForSource('src_a')).toEqual([]); + }); + + it('filters out execute_sql and search_objects', () => { + const registry = new ToolRegistry(makeConfig('src_a')); + const tools = registry.getEnabledToolConfigs('src_a'); + const builtinNames = tools.map((t) => t.name); + expect(builtinNames).toContain('execute_sql'); + expect(builtinNames).toContain('search_objects'); + + // Custom tools filter should exclude both + expect(registry.getCustomToolsForSource('src_a')).toHaveLength(0); + }); + }); + + describe('list_sources conflict detection', () => { + beforeEach(() => { + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue({ + type: 'sqlite', + } as any); + }); + + it('rejects custom tool starting with list_sources_', () => { + const config: TomlConfig = { + sources: [{ id: 'src_a', type: 'sqlite', dsn: 'sqlite:///:memory:' }], + tools: [ + { + name: 'list_sources_extra', + description: 'Conflicts with meta-tool', + source: 'src_a', + statement: 'SELECT 1', + }, + ], + }; + + expect(() => new ToolRegistry(config)).toThrow(/list_sources/); + }); + }); + + describe('isBuiltinTool includes list_sources', () => { + it('getBuiltinToolConfig returns undefined for list_sources (not per-source)', () => { + const registry = new ToolRegistry(makeConfig('src_a')); + // list_sources is a meta-tool, not stored per-source in the registry + const result = registry.getBuiltinToolConfig('list_sources', 'src_a'); + expect(result).toBeUndefined(); + }); + + it('getEnabledBuiltinToolNames does not include list_sources (it is not per-source)', () => { + const registry = new ToolRegistry(makeConfig('src_a')); + const names = registry.getEnabledBuiltinToolNames(); + // list_sources is not a per-source tool so not in enabled builtins + expect(names).not.toContain('list_sources'); + expect(names).toContain('execute_sql'); + expect(names).toContain('search_objects'); + }); + }); +}); diff --git a/src/tools/builtin-tools.ts b/src/tools/builtin-tools.ts index 4ebceebb..d9dc130a 100644 --- a/src/tools/builtin-tools.ts +++ b/src/tools/builtin-tools.ts @@ -3,6 +3,7 @@ * Central location for built-in tool names used throughout the codebase */ +export const BUILTIN_TOOL_LIST_SOURCES = "list_sources"; // meta-tool, not per-source export const BUILTIN_TOOL_EXECUTE_SQL = "execute_sql"; export const BUILTIN_TOOL_SEARCH_OBJECTS = "search_objects"; @@ -10,3 +11,9 @@ export const BUILTIN_TOOLS = [ BUILTIN_TOOL_EXECUTE_SQL, BUILTIN_TOOL_SEARCH_OBJECTS, ] as const; + +// Includes meta-tools (list_sources) that are not per-source +export const BUILTIN_AND_META_TOOLS = [ + BUILTIN_TOOL_LIST_SOURCES, + ...BUILTIN_TOOLS, +] as const; diff --git a/src/tools/execute-sql.ts b/src/tools/execute-sql.ts index b6def2e6..e401774f 100644 --- a/src/tools/execute-sql.ts +++ b/src/tools/execute-sql.ts @@ -5,10 +5,7 @@ import { isReadOnlySQL, allowedKeywords } from "../utils/allowed-keywords.js"; import { ConnectorType } from "../connectors/interface.js"; import { getToolRegistry } from "./registry.js"; import { BUILTIN_TOOL_EXECUTE_SQL } from "./builtin-tools.js"; -import { - getEffectiveSourceId, - trackToolRequest, -} from "../utils/tool-handler-helpers.js"; +import { getEffectiveSourceId, trackToolRequest } from "../utils/tool-handler-helpers.js"; import { splitSQLStatements } from "../utils/sql-parser.js"; // Schema for execute_sql tool @@ -16,6 +13,11 @@ export const executeSqlSchema = { sql: z.string().describe("SQL to execute (multiple statements separated by ;)"), }; +export const executeSqlMultiSourceSchema = { + sql: z.string().describe("SQL to execute (multiple statements separated by ;)"), + source_id: z.string().describe("Database source ID. Use list_sources to discover available IDs."), +}; + /** * Check if all SQL statements in a multi-statement query are read-only * @param sql The SQL string (possibly containing multiple statements) @@ -24,29 +26,30 @@ export const executeSqlSchema = { */ function areAllStatementsReadOnly(sql: string, connectorType: ConnectorType): boolean { const statements = splitSQLStatements(sql, connectorType); - return statements.every(statement => isReadOnlySQL(statement, connectorType)); + return statements.every((statement) => isReadOnlySQL(statement, connectorType)); } /** * Create an execute_sql tool handler for a specific source - * @param sourceId - The source ID this handler is bound to (undefined for single-source mode) + * @param boundSourceId - The source ID this handler is bound to (undefined for single-source mode) * @returns A handler function bound to the specified source */ -export function createExecuteSqlToolHandler(sourceId?: string) { +export function createExecuteSqlToolHandler(boundSourceId?: string) { return async (args: any, extra: any) => { - const { sql } = args as { sql: string }; + const { sql, source_id: argSourceId } = args as { sql: string; source_id?: string }; + const resolvedSourceId = boundSourceId ?? argSourceId; const startTime = Date.now(); - const effectiveSourceId = getEffectiveSourceId(sourceId); + const effectiveSourceId = getEffectiveSourceId(resolvedSourceId); let success = true; let errorMessage: string | undefined; let result: any; try { // Ensure source is connected (handles lazy connections) - await ConnectorManager.ensureConnected(sourceId); + await ConnectorManager.ensureConnected(resolvedSourceId); // Get connector for the specified source (or default) - const connector = ConnectorManager.getCurrentConnector(sourceId); + const connector = ConnectorManager.getCurrentConnector(resolvedSourceId); const actualSourceId = connector.getId(); // Get tool-specific configuration (tool is already registered, so it's enabled) @@ -86,7 +89,7 @@ export function createExecuteSqlToolHandler(sourceId?: string) { trackToolRequest( { sourceId: effectiveSourceId, - toolName: effectiveSourceId === "default" ? "execute_sql" : `execute_sql_${effectiveSourceId}`, + toolName: "execute_sql", sql, }, startTime, diff --git a/src/tools/index.ts b/src/tools/index.ts index 79af249e..197e3a1c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { createExecuteSqlToolHandler } from "./execute-sql.js"; -import { createSearchDatabaseObjectsToolHandler, searchDatabaseObjectsSchema } from "./search-objects.js"; +import { createSearchDatabaseObjectsToolHandler } from "./search-objects.js"; import { ConnectorManager } from "../connectors/manager.js"; import { getExecuteSqlMetadata, getSearchObjectsMetadata } from "../utils/tool-metadata.js"; import { isReadOnlySQL } from "../utils/allowed-keywords.js"; @@ -8,6 +8,7 @@ import { createCustomToolHandler, buildZodSchemaFromParameters } from "./custom- import type { ToolConfig } from "../types/config.js"; import { getToolRegistry } from "./registry.js"; import { BUILTIN_TOOL_EXECUTE_SQL, BUILTIN_TOOL_SEARCH_OBJECTS } from "./builtin-tools.js"; +import { registerListSourcesTool } from "./list-sources.js"; /** * Register all tool handlers with the MCP server @@ -23,18 +24,26 @@ export function registerTools(server: McpServer): void { const registry = getToolRegistry(); - // Register all enabled tools (both built-in and custom) for each source - for (const sourceId of sourceIds) { - const enabledTools = registry.getEnabledToolConfigs(sourceId); + if (sourceIds.length === 1) { + const enabledTools = registry.getEnabledToolConfigs(sourceIds[0]); for (const toolConfig of enabledTools) { - // Register based on tool name (built-in vs custom) if (toolConfig.name === BUILTIN_TOOL_EXECUTE_SQL) { - registerExecuteSqlTool(server, sourceId); + registerExecuteSqlTool(server, sourceIds[0]); } else if (toolConfig.name === BUILTIN_TOOL_SEARCH_OBJECTS) { - registerSearchObjectsTool(server, sourceId); + registerSearchObjectsTool(server, sourceIds[0]); } else { - // Custom tool + registerCustomTool(server, sourceIds[0], toolConfig); + } + } + } else { + // Multi-source: 3 generic tools + per-source custom tools + registerListSourcesTool(server); + registerExecuteSqlTool(server, undefined); + registerSearchObjectsTool(server, undefined); + + for (const sourceId of sourceIds) { + for (const toolConfig of registry.getCustomToolsForSource(sourceId)) { registerCustomTool(server, sourceId, toolConfig); } } @@ -42,12 +51,9 @@ export function registerTools(server: McpServer): void { } /** - * Register execute_sql tool for a source + * Register execute_sql tool for a source (or generic multi-source tool if sourceId is undefined) */ -function registerExecuteSqlTool( - server: McpServer, - sourceId: string -): void { +function registerExecuteSqlTool(server: McpServer, sourceId?: string): void { const metadata = getExecuteSqlMetadata(sourceId); server.registerTool( metadata.name, @@ -61,26 +67,17 @@ function registerExecuteSqlTool( } /** - * Register search_objects tool for a source + * Register search_objects tool for a source (or generic multi-source tool if sourceId is undefined) */ -function registerSearchObjectsTool( - server: McpServer, - sourceId: string -): void { +function registerSearchObjectsTool(server: McpServer, sourceId?: string): void { const metadata = getSearchObjectsMetadata(sourceId); server.registerTool( metadata.name, { description: metadata.description, - inputSchema: searchDatabaseObjectsSchema, - annotations: { - title: metadata.title, - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, + inputSchema: metadata.schema, + annotations: metadata.annotations, }, createSearchDatabaseObjectsToolHandler(sourceId) ); @@ -89,11 +86,7 @@ function registerSearchObjectsTool( /** * Register a custom tool */ -function registerCustomTool( - server: McpServer, - sourceId: string, - toolConfig: ToolConfig -): void { +function registerCustomTool(server: McpServer, sourceId: string, toolConfig: ToolConfig): void { const sourceConfig = ConnectorManager.getSourceConfig(sourceId)!; const dbType = sourceConfig.type; diff --git a/src/tools/list-sources.ts b/src/tools/list-sources.ts new file mode 100644 index 00000000..845b3517 --- /dev/null +++ b/src/tools/list-sources.ts @@ -0,0 +1,36 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ConnectorManager } from "../connectors/manager.js"; +import { createToolSuccessResponse } from "../utils/response-formatter.js"; + +/** + * Register the list_sources meta-tool with the MCP server. + * Only registered in multi-source mode. + */ +export function registerListSourcesTool(server: McpServer): void { + server.registerTool( + "list_sources", + { + description: + "List all available database sources. Call this first to discover source IDs, then pass source_id to execute_sql or search_objects.", + inputSchema: {}, + annotations: { + title: "List Database Sources", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => { + const sourceIds = ConnectorManager.getAvailableSourceIds(); + const sources = sourceIds.map((id) => { + const config = ConnectorManager.getSourceConfig(id); + return { + id, + type: config?.type, + }; + }); + return createToolSuccessResponse({ sources }); + } + ); +} diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 445565da..7d5e4589 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -4,7 +4,7 @@ */ import type { TomlConfig, ToolConfig, ExecuteSqlToolConfig, SearchObjectsToolConfig, ParameterConfig } from "../types/config.js"; -import { BUILTIN_TOOLS } from "./builtin-tools.js"; +import { BUILTIN_TOOLS, BUILTIN_AND_META_TOOLS } from "./builtin-tools.js"; import { ConnectorManager } from "../connectors/manager.js"; import { validateParameters } from "../utils/parameter-mapper.js"; @@ -23,7 +23,7 @@ export class ToolRegistry { * Check if a tool name is a built-in tool */ private isBuiltinTool(toolName: string): boolean { - return BUILTIN_TOOLS.includes(toolName); + return (BUILTIN_AND_META_TOOLS as readonly string[]).includes(toolName); } /** @@ -116,14 +116,14 @@ export class ToolRegistry { } // 3. Validate tool name doesn't conflict with built-in tools - for (const builtinName of BUILTIN_TOOLS) { + for (const builtinName of BUILTIN_AND_META_TOOLS) { if ( toolConfig.name === builtinName || toolConfig.name.startsWith(`${builtinName}_`) ) { throw new Error( `Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. ` + - `Custom tools cannot use names starting with: ${BUILTIN_TOOLS.join(", ")}` + `Custom tools cannot use names starting with: ${BUILTIN_AND_META_TOOLS.join(", ")}` ); } } @@ -184,7 +184,6 @@ export class ToolRegistry { for (const source of config.sources) { if (!registry.has(source.id)) { const defaultTools: ToolConfig[] = BUILTIN_TOOLS.map((name) => { - // Create properly typed tool configs based on the tool name if (name === 'execute_sql') { return { name: 'execute_sql', source: source.id } satisfies ExecuteSqlToolConfig; } else { @@ -247,6 +246,15 @@ export class ToolRegistry { return this.getAllTools().filter((tool) => !this.isBuiltinTool(tool.name)); } + /** + * Get custom tools (non-builtin) for a specific source + */ + getCustomToolsForSource(sourceId: string): ToolConfig[] { + return this.getEnabledToolConfigs(sourceId).filter( + (tool) => !(BUILTIN_AND_META_TOOLS as readonly string[]).includes(tool.name) + ); + } + /** * Get all built-in tool names that are enabled across any source */ diff --git a/src/tools/search-objects.ts b/src/tools/search-objects.ts index 8077ffef..a6a71920 100644 --- a/src/tools/search-objects.ts +++ b/src/tools/search-objects.ts @@ -52,6 +52,14 @@ export const searchDatabaseObjectsSchema = { .describe("Max results (default: 100, max: 1000)"), }; +// Schema for multi-source mode — source_id is required +export const searchDatabaseObjectsMultiSourceSchema = { + ...searchDatabaseObjectsSchema, + source_id: z.string().describe( + "Database source ID. Use list_sources to discover available IDs." + ), +}; + /** * Convert SQL LIKE pattern to JavaScript regex * Supports % (any chars) and _ (single char) @@ -489,8 +497,10 @@ async function searchIndexes( /** * Create a search_database_objects tool handler + * @param boundSourceId - The source ID this handler is bound to (undefined for single-source mode) + * @returns A handler function bound to the specified source */ -export function createSearchDatabaseObjectsToolHandler(sourceId?: string) { +export function createSearchDatabaseObjectsToolHandler(boundSourceId?: string) { return async (args: any, extra: any) => { const { object_type, @@ -499,6 +509,7 @@ export function createSearchDatabaseObjectsToolHandler(sourceId?: string) { table, detail_level = "names", limit = 100, + source_id: argSourceId, } = args as { object_type: DatabaseObjectType; pattern?: string; @@ -506,18 +517,20 @@ export function createSearchDatabaseObjectsToolHandler(sourceId?: string) { table?: string; detail_level: DetailLevel; limit: number; + source_id?: string; }; + const resolvedSourceId = boundSourceId ?? argSourceId; const startTime = Date.now(); - const effectiveSourceId = getEffectiveSourceId(sourceId); + const effectiveSourceId = getEffectiveSourceId(resolvedSourceId); let success = true; let errorMessage: string | undefined; try { // Ensure source is connected (handles lazy connections) - await ConnectorManager.ensureConnected(sourceId); + await ConnectorManager.ensureConnected(resolvedSourceId); - const connector = ConnectorManager.getCurrentConnector(sourceId); + const connector = ConnectorManager.getCurrentConnector(resolvedSourceId); // Tool is already registered, so it's enabled (no need to check) @@ -595,7 +608,7 @@ export function createSearchDatabaseObjectsToolHandler(sourceId?: string) { trackToolRequest( { sourceId: effectiveSourceId, - toolName: effectiveSourceId === "default" ? "search_objects" : `search_objects_${effectiveSourceId}`, + toolName: "search_objects", sql: `search_objects(object_type=${object_type}, pattern=${pattern}, schema=${schema || "all"}, table=${table || "all"}, detail_level=${detail_level})`, }, startTime, diff --git a/src/utils/__tests__/tool-metadata.test.ts b/src/utils/__tests__/tool-metadata.test.ts new file mode 100644 index 00000000..8f559102 --- /dev/null +++ b/src/utils/__tests__/tool-metadata.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import { zodToParameters, getExecuteSqlMetadata, getSearchObjectsMetadata } from '../tool-metadata.js'; +import { ConnectorManager } from '../../connectors/manager.js'; +import { getToolRegistry } from '../../tools/registry.js'; + +vi.mock('../../connectors/manager.js'); +vi.mock('../../tools/registry.js'); + +describe('zodToParameters', () => { + it('marks ZodString as required string', () => { + const schema = { name: z.string().describe('A name') }; + const [param] = zodToParameters(schema); + expect(param).toEqual({ name: 'name', type: 'string', required: true, description: 'A name' }); + }); + + it('marks ZodOptional as not required', () => { + const schema = { pattern: z.string().optional().describe('Optional pattern') }; + const [param] = zodToParameters(schema); + expect(param).toEqual({ name: 'pattern', type: 'string', required: false, description: 'Optional pattern' }); + }); + + it('marks ZodDefault as not required', () => { + const schema = { limit: z.number().default(100).describe('Max results') }; + const [param] = zodToParameters(schema); + expect(param).toEqual({ name: 'limit', type: 'number', required: false, description: 'Max results' }); + }); + + it('resolves ZodEnum as string type', () => { + const schema = { level: z.enum(['names', 'summary', 'full']).describe('Detail level') }; + const [param] = zodToParameters(schema); + expect(param).toEqual({ name: 'level', type: 'string', required: true, description: 'Detail level' }); + }); + + it('preserves description from outermost type before unwrapping', () => { + const schema = { val: z.string().optional().describe('Outer description') }; + const [param] = zodToParameters(schema); + expect(param).toEqual({ name: 'val', type: 'string', required: false, description: 'Outer description' }); + }); + + it('handles multiple params with mixed types', () => { + const schema = { + sql: z.string().describe('SQL query'), + source_id: z.string().describe('Source ID'), + }; + const params = zodToParameters(schema); + expect(params).toEqual([ + { name: 'sql', type: 'string', required: true, description: 'SQL query' }, + { name: 'source_id', type: 'string', required: true, description: 'Source ID' }, + ]); + }); +}); + +describe('getExecuteSqlMetadata', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('single-source mode (sourceId provided)', () => { + beforeEach(() => { + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue({ type: 'postgres' } as any); + vi.mocked(getToolRegistry).mockReturnValue({ + getBuiltinToolConfig: vi.fn().mockReturnValue({}), + } as any); + }); + + it('returns execute_sql name and includes sourceId and dbType in description', () => { + const meta = getExecuteSqlMetadata('my_db'); + expect(meta.name).toBe('execute_sql'); + expect(meta.description).toContain('my_db'); + expect(meta.description).toContain('postgres'); + }); + + it('schema does not include source_id param', () => { + const meta = getExecuteSqlMetadata('my_db'); + expect(meta.schema).not.toHaveProperty('source_id'); + expect(meta.schema).toHaveProperty('sql'); + }); + + it('annotations reflect readonly=false by default', () => { + const meta = getExecuteSqlMetadata('my_db'); + expect(meta.annotations.readOnlyHint).toBe(false); + expect(meta.annotations.destructiveHint).toBe(true); + }); + + it('annotations reflect readonly=true when configured', () => { + vi.mocked(getToolRegistry).mockReturnValue({ + getBuiltinToolConfig: vi.fn().mockReturnValue({ readonly: true }), + } as any); + const meta = getExecuteSqlMetadata('my_db'); + expect(meta.annotations.readOnlyHint).toBe(true); + expect(meta.annotations.destructiveHint).toBe(false); + expect(meta.description).toContain('READ-ONLY MODE'); + }); + + it('description includes max_rows note when configured', () => { + vi.mocked(getToolRegistry).mockReturnValue({ + getBuiltinToolConfig: vi.fn().mockReturnValue({ max_rows: 500 }), + } as any); + const meta = getExecuteSqlMetadata('my_db'); + expect(meta.description).toContain('500'); + }); + }); + + describe('multi-source mode (sourceId undefined)', () => { + it('returns execute_sql name and description mentions list_sources for discovery', () => { + const meta = getExecuteSqlMetadata(undefined); + expect(meta.name).toBe('execute_sql'); + expect(meta.description).toContain('list_sources'); + }); + + it('schema includes source_id param', () => { + const meta = getExecuteSqlMetadata(undefined); + expect(meta.schema).toHaveProperty('source_id'); + expect(meta.schema).toHaveProperty('sql'); + }); + + it('annotations are non-readonly (generic tool can write)', () => { + const meta = getExecuteSqlMetadata(undefined); + expect(meta.annotations.readOnlyHint).toBe(false); + expect(meta.annotations.destructiveHint).toBe(true); + }); + + it('does not call ConnectorManager', () => { + getExecuteSqlMetadata(undefined); + expect(ConnectorManager.getSourceConfig).not.toHaveBeenCalled(); + }); + }); +}); + +describe('getSearchObjectsMetadata', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('single-source mode (sourceId provided)', () => { + beforeEach(() => { + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue({ type: 'sqlite' } as any); + }); + + it('returns search_objects name and includes sourceId and dbType in description', () => { + const meta = getSearchObjectsMetadata('local_db'); + expect(meta.name).toBe('search_objects'); + expect(meta.description).toContain('local_db'); + expect(meta.description).toContain('sqlite'); + }); + + it('schema does not include source_id param', () => { + const meta = getSearchObjectsMetadata('local_db'); + expect(meta.schema).not.toHaveProperty('source_id'); + }); + + it('annotations are read-only', () => { + const meta = getSearchObjectsMetadata('local_db'); + expect(meta.annotations.readOnlyHint).toBe(true); + expect(meta.annotations.destructiveHint).toBe(false); + }); + }); + + describe('multi-source mode (sourceId undefined)', () => { + it('returns search_objects name and description mentions list_sources for discovery', () => { + const meta = getSearchObjectsMetadata(undefined); + expect(meta.name).toBe('search_objects'); + expect(meta.description).toContain('list_sources'); + }); + + it('schema includes source_id param', () => { + const meta = getSearchObjectsMetadata(undefined); + expect(meta.schema).toHaveProperty('source_id'); + }); + + it('annotations are read-only', () => { + const meta = getSearchObjectsMetadata(undefined); + expect(meta.annotations.readOnlyHint).toBe(true); + expect(meta.annotations.destructiveHint).toBe(false); + }); + + it('does not call ConnectorManager', () => { + getSearchObjectsMetadata(undefined); + expect(ConnectorManager.getSourceConfig).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utils/tool-metadata.ts b/src/utils/tool-metadata.ts index 710a6285..1ea089c2 100644 --- a/src/utils/tool-metadata.ts +++ b/src/utils/tool-metadata.ts @@ -1,8 +1,11 @@ import { z } from "zod"; import { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import { ConnectorManager } from "../connectors/manager.js"; -import { normalizeSourceId } from "./normalize-id.js"; -import { executeSqlSchema } from "../tools/execute-sql.js"; +import { executeSqlSchema, executeSqlMultiSourceSchema } from "../tools/execute-sql.js"; +import { + searchDatabaseObjectsSchema, + searchDatabaseObjectsMultiSourceSchema, +} from "../tools/search-objects.js"; import { getToolRegistry } from "../tools/registry.js"; import { BUILTIN_TOOL_EXECUTE_SQL } from "../tools/builtin-tools.js"; import type { ParameterConfig, ToolConfig } from "../types/config.js"; @@ -48,24 +51,38 @@ export function zodToParameters(schema: Record>): ToolPar const parameters: ToolParameter[] = []; for (const [key, zodType] of Object.entries(schema)) { - // Extract description from Zod schema + // Read description from the outermost type before unwrapping const description = zodType.description || ""; - // Determine if required (Zod types are required by default unless optional) - const required = !(zodType instanceof z.ZodOptional); + let innerType = zodType; + let required = true; - // Determine type from Zod type - let type = "string"; // default - if (zodType instanceof z.ZodString) { + // Unwrap ZodDefault/ZodOptional + if (innerType instanceof z.ZodDefault) { + required = false; + innerType = (innerType as z.ZodDefault)._def.innerType; + } + + if (innerType instanceof z.ZodOptional) { + required = false; + innerType = (innerType as z.ZodOptional).unwrap(); + } + + // Determine type from unwrapped inner type + let type = "string"; + + if (innerType instanceof z.ZodString) { type = "string"; - } else if (zodType instanceof z.ZodNumber) { + } else if (innerType instanceof z.ZodNumber) { type = "number"; - } else if (zodType instanceof z.ZodBoolean) { + } else if (innerType instanceof z.ZodBoolean) { type = "boolean"; - } else if (zodType instanceof z.ZodArray) { + } else if (innerType instanceof z.ZodArray) { type = "array"; - } else if (zodType instanceof z.ZodObject) { + } else if (innerType instanceof z.ZodObject) { type = "object"; + } else if (innerType instanceof z.ZodEnum) { + type = "string"; } parameters.push({ @@ -81,82 +98,87 @@ export function zodToParameters(schema: Record>): ToolPar /** * Get execute_sql tool metadata for a specific source - * @param sourceId - The source ID to get tool metadata for + * @param boundSourceId - The source ID this metadata is bound to (undefined for multi-source mode) * @returns Tool metadata with name, description, and Zod schema */ -export function getExecuteSqlMetadata(sourceId: string): ToolMetadata { - const sourceIds = ConnectorManager.getAvailableSourceIds(); - const sourceConfig = ConnectorManager.getSourceConfig(sourceId)!; +export function getExecuteSqlMetadata(boundSourceId?: string): ToolMetadata { + if (boundSourceId === undefined) { + return { + name: "execute_sql", + description: + "Execute SQL queries on a database source. Use list_sources to discover available source IDs, then pass source_id.", + schema: executeSqlMultiSourceSchema, + annotations: { + title: "Execute SQL", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }; + } + + const sourceConfig = ConnectorManager.getSourceConfig(boundSourceId)!; const dbType = sourceConfig.type; - const isSingleSource = sourceIds.length === 1; - // Get tool configuration from registry to extract readonly/max_rows const registry = getToolRegistry(); - const toolConfig = registry.getBuiltinToolConfig(BUILTIN_TOOL_EXECUTE_SQL, sourceId); - const executeOptions = { - readonly: toolConfig?.readonly, - maxRows: toolConfig?.max_rows, - }; - - // Determine tool name based on single vs multi-source configuration - const toolName = isSingleSource ? "execute_sql" : `execute_sql_${normalizeSourceId(sourceId)}`; - - // Determine title (human-readable display name) - const title = isSingleSource - ? `Execute SQL (${dbType})` - : `Execute SQL on ${sourceId} (${dbType})`; + const toolConfig = registry.getBuiltinToolConfig(BUILTIN_TOOL_EXECUTE_SQL, boundSourceId); + const isReadonly = toolConfig?.readonly === true; + const maxRows = toolConfig?.max_rows; - // Determine description with more context - const readonlyNote = executeOptions.readonly ? " [READ-ONLY MODE]" : ""; - const maxRowsNote = executeOptions.maxRows ? ` (limited to ${executeOptions.maxRows} rows)` : ""; - const description = isSingleSource - ? `Execute SQL queries on the ${dbType} database${readonlyNote}${maxRowsNote}` - : `Execute SQL queries on the '${sourceId}' ${dbType} database${readonlyNote}${maxRowsNote}`; - - // Build annotations object with all standard MCP hints - const isReadonly = executeOptions.readonly === true; - const annotations = { - title, - readOnlyHint: isReadonly, - destructiveHint: !isReadonly, // Can be destructive if not readonly - // In readonly mode, queries are more predictable (though still not strictly idempotent due to data changes) - // In write mode, queries are definitely not idempotent - idempotentHint: false, - // Database operations are always against internal/closed systems, not open-world - openWorldHint: false, - }; + const readonlyNote = isReadonly ? " [READ-ONLY MODE]" : ""; + const maxRowsNote = maxRows ? ` (limited to ${maxRows} rows)` : ""; return { - name: toolName, - description, + name: "execute_sql", + description: `Execute SQL queries on the '${boundSourceId}' ${dbType} database${readonlyNote}${maxRowsNote}`, schema: executeSqlSchema, - annotations, + annotations: { + title: `Execute SQL - ${boundSourceId} (${dbType})`, + readOnlyHint: isReadonly, + destructiveHint: !isReadonly, + idempotentHint: false, + openWorldHint: false, + }, }; } /** * Get search_objects tool metadata for a specific source - * @param sourceId - The source ID to get tool metadata for - * @returns Tool name, description, and annotations + * @param boundSourceId - The source ID this metadata is bound to (undefined for multi-source mode) + * @returns Tool metadata with name, description, schema, and annotations */ -export function getSearchObjectsMetadata(sourceId: string): { name: string; description: string; title: string } { - const sourceIds = ConnectorManager.getAvailableSourceIds(); - const sourceConfig = ConnectorManager.getSourceConfig(sourceId)!; - const dbType = sourceConfig.type; - const isSingleSource = sourceIds.length === 1; +export function getSearchObjectsMetadata(boundSourceId?: string): ToolMetadata { + if (boundSourceId === undefined) { + return { + name: "search_objects", + description: + "Search and list database objects (schemas, tables, columns, procedures, functions, indexes). Use list_sources to discover source IDs, then pass source_id.", + schema: searchDatabaseObjectsMultiSourceSchema, + annotations: { + title: "Search Database Objects", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }; + } - const toolName = isSingleSource ? "search_objects" : `search_objects_${normalizeSourceId(sourceId)}`; - const title = isSingleSource - ? `Search Database Objects (${dbType})` - : `Search Database Objects on ${sourceId} (${dbType})`; - const description = isSingleSource - ? `Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the ${dbType} database` - : `Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the '${sourceId}' ${dbType} database`; + const sourceConfig = ConnectorManager.getSourceConfig(boundSourceId)!; + const dbType = sourceConfig.type; return { - name: toolName, - description, - title, + name: "search_objects", + description: `Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the '${boundSourceId}' ${dbType} database`, + schema: searchDatabaseObjectsSchema, + annotations: { + title: `Search Database Objects - ${boundSourceId} (${dbType})`, + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, }; } @@ -181,14 +203,14 @@ function customParamsToToolParams(params: ParameterConfig[] | undefined): ToolPa /** * Build execute_sql tool metadata for API response */ -function buildExecuteSqlTool(sourceId: string, toolConfig?: ToolConfig): Tool { - const executeSqlMetadata = getExecuteSqlMetadata(sourceId); +function buildExecuteSqlTool(boundSourceId?: string, toolConfig?: ToolConfig): Tool { + const executeSqlMetadata = getExecuteSqlMetadata(boundSourceId); const executeSqlParameters = zodToParameters(executeSqlMetadata.schema); // Extract readonly and max_rows from toolConfig // ToolConfig is a union type, but ExecuteSqlToolConfig and CustomToolConfig both have these fields - const readonly = toolConfig && 'readonly' in toolConfig ? toolConfig.readonly : undefined; - const max_rows = toolConfig && 'max_rows' in toolConfig ? toolConfig.max_rows : undefined; + const readonly = toolConfig && "readonly" in toolConfig ? toolConfig.readonly : undefined; + const max_rows = toolConfig && "max_rows" in toolConfig ? toolConfig.max_rows : undefined; return { name: executeSqlMetadata.name, @@ -202,50 +224,13 @@ function buildExecuteSqlTool(sourceId: string, toolConfig?: ToolConfig): Tool { /** * Build search_objects tool metadata for API response */ -function buildSearchObjectsTool(sourceId: string): Tool { - const searchMetadata = getSearchObjectsMetadata(sourceId); +function buildSearchObjectsTool(boundSourceId?: string): Tool { + const searchMetadata = getSearchObjectsMetadata(boundSourceId); return { name: searchMetadata.name, description: searchMetadata.description, - parameters: [ - { - name: "object_type", - type: "string", - required: true, - description: "Object type to search", - }, - { - name: "pattern", - type: "string", - required: false, - description: "LIKE pattern (% = any chars, _ = one char). Default: %", - }, - { - name: "schema", - type: "string", - required: false, - description: "Filter to schema", - }, - { - name: "table", - type: "string", - required: false, - description: "Filter to table (requires schema; column/index only)", - }, - { - name: "detail_level", - type: "string", - required: false, - description: "Detail: names (minimal), summary (metadata), full (all)", - }, - { - name: "limit", - type: "integer", - required: false, - description: "Max results (default: 100, max: 1000)", - }, - ], + parameters: zodToParameters(searchMetadata.schema), readonly: true, // search_objects is always readonly }; } @@ -274,14 +259,16 @@ export function getToolsForSource(sourceId: string): Tool[] { // Get enabled tools from registry const registry = getToolRegistry(); const enabledToolConfigs = registry.getEnabledToolConfigs(sourceId); + const isSingleSource = ConnectorManager.getAvailableSourceIds().length === 1; // Uniform iteration: map each enabled tool config to its API representation return enabledToolConfigs.map((toolConfig) => { // Dispatch based on tool name if (toolConfig.name === "execute_sql") { - return buildExecuteSqlTool(sourceId, toolConfig); + // In multi-source mode, use generic metadata (undefined = generic tool with source_id param) + return buildExecuteSqlTool(isSingleSource ? sourceId : undefined, toolConfig); } else if (toolConfig.name === "search_objects") { - return buildSearchObjectsTool(sourceId); + return buildSearchObjectsTool(isSingleSource ? sourceId : undefined); } else { // Custom tool return buildCustomTool(toolConfig);