Skip to main content
Glama
ToolDetailView.tsx14.3 kB
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 }>(); const [searchParams, setSearchParams] = useSearchParams(); const [tool, setTool] = useState<Tool | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<ApiError | null>(null); // Ref to access SqlEditor's selection const sqlEditorRef = useRef<SqlEditorHandle>(null); // Query state const [sql, setSql] = useState(() => { // Only for execute_sql tools - read from URL on mount return searchParams.get('sql') || ''; }); const [params, setParams] = useState<Record<string, any>>({}); const [resultTabs, setResultTabs] = useState<ResultTab[]>([]); const [activeTabId, setActiveTabId] = useState<string | null>(null); const [isRunning, setIsRunning] = useState(false); const [copied, setCopied] = useState(false); useEffect(() => { if (!sourceId || !toolName) return; setIsLoading(true); setError(null); // Reset result state when switching tools setResultTabs([]); setActiveTabId(null); fetchSource(sourceId) .then((sourceData) => { const foundTool = sourceData.tools.find((t) => t.name === toolName); setTool(foundTool || null); setIsLoading(false); }) .catch((err: ApiError) => { setError(err); setIsLoading(false); }); }, [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'; }, [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 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]); // Coerce URL params to correct types after tool is loaded useEffect(() => { if (!tool || toolType !== 'custom') return; // Only coerce params from URL on initial mount const urlParams: Record<string, any> = {}; searchParams.forEach((value, key) => { if (key !== 'sql') { const coerced = coerceParamValue(String(value), key); if (coerced !== undefined) { urlParams[key] = coerced; } } }); // Only update if we have URL params to coerce if (Object.keys(urlParams).length > 0) { setParams(urlParams); } }, [tool, toolType, searchParams, coerceParamValue]); // Update URL when sql changes (debounced for execute_sql tools) useEffect(() => { 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'); } return newParams; }, { replace: true }); }, 300); return () => clearTimeout(timer); }, [sql, toolType, setSearchParams]); // Removed searchParams from dependencies // Update URL when params change (debounced for custom tools) useEffect(() => { 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); } }); // 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 }); }, 300); return () => clearTimeout(timer); }, [params, toolType, setSearchParams]); // No searchParams dependency // Transform statement placeholders to named format const transformedStatement = useCallback((): string => { if (!tool?.statement) return ''; let transformedSql = tool.statement; let questionMarkIndex = 0; // Replace $1, $2, etc. or ? with :param_name // For $N placeholders, use the number to look up the parameter (1-indexed) // For ? placeholders, use sequential order transformedSql = transformedSql.replace(/\$(\d+)|\?/g, (_match, num) => { let param; if (num) { // $N placeholder - use the number (1-indexed) to find parameter param = tool.parameters[parseInt(num, 10) - 1]; } else { // ? placeholder - use sequential order param = tool.parameters[questionMarkIndex]; questionMarkIndex++; } return param ? `:${param.name}` : ':?'; }); return transformedSql; }, [tool]); // Get SQL with values substituted for preview const getSqlPreview = useCallback((): string => { let sqlText = transformedStatement(); Object.entries(params).forEach(([name, value]) => { if (value !== undefined && value !== '') { const displayValue = typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : String(value); sqlText = sqlText.replace(new RegExp(`:${name}\\b`, 'g'), displayValue); } }); return sqlText; }, [transformedStatement, params]); // Check if all required params are filled const allRequiredParamsFilled = useCallback((): boolean => { if (!tool) return false; return tool.parameters .filter((p) => p.required) .every((p) => params[p.name] !== undefined && params[p.name] !== ''); }, [tool, params]); // Run query const handleRun = useCallback(async () => { if (!tool || !toolName) return; setIsRunning(true); const startTime = performance.now(); try { let queryResult: QueryResult; let sqlToExecute: string; 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 }); } else { sqlToExecute = getSqlPreview(); queryResult = await executeTool(toolName, params); } const endTime = performance.now(); const duration = endTime - startTime; const newTab: ResultTab = { id: crypto.randomUUID(), timestamp: new Date(), result: queryResult, error: null, executedSql: sqlToExecute, executionTimeMs: duration, }; 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(), executionTimeMs: 0, }; setResultTabs(prev => [errorTab, ...prev]); setActiveTabId(errorTab.id); } finally { setIsRunning(false); } }, [tool, toolName, toolType, sql, params, getSqlPreview]); // Compute disabled state for run button const isRunDisabled = toolType === 'execute_sql' ? !sql.trim() : !allRequiredParamsFilled(); // Copy SQL to clipboard const handleCopy = useCallback(async () => { 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); } return newTabs; }); }, [activeTabId]); if (!sourceId || !toolName) { return <Navigate to="/" replace />; } if (isLoading) { return ( <div className="container mx-auto px-8 py-12"> <div className="text-muted-foreground">Loading tool details...</div> </div> ); } if (error) { if (error.status === 404) { return <Navigate to="/404" replace />; } return ( <div className="container mx-auto px-8 py-12"> <div className="bg-destructive/10 border border-destructive/20 rounded-lg p-6"> <h2 className="text-lg font-semibold text-destructive mb-2">Error</h2> <p className="text-destructive/90">{error.message}</p> </div> </div> ); } if (!tool) { return <Navigate to="/404" replace />; } // search_objects placeholder if (toolType === 'search_objects') { return ( <div className="container mx-auto px-8 py-12 max-w-4xl"> <div className="space-y-4"> <div className="flex items-center gap-3"> <h1 className="text-3xl font-bold text-foreground font-mono">{tool.name}</h1> {tool.readonly && ( <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"> <LockIcon className="w-3 h-3" /> Read-Only </span> )} </div> <p className="text-muted-foreground leading-relaxed">{tool.description}</p> <div className="border border-border rounded-lg bg-card p-8 text-center"> <p className="text-muted-foreground"> Interactive UI for this tool is coming soon. </p> </div> </div> </div> ); } return ( <div className="container mx-auto px-8 py-12 max-w-4xl"> <div className="space-y-6"> {/* Header */} <div className="space-y-2"> <div className="flex items-center gap-3"> <h1 className="text-3xl font-bold text-foreground font-mono">{tool.name}</h1> {tool.readonly && ( <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"> <LockIcon className="w-3 h-3" /> Read-Only </span> )} </div> <p className="text-muted-foreground leading-relaxed">{tool.description}</p> </div> {/* Parameter Form (custom tools only, show before SQL) */} {toolType === 'custom' && tool.parameters.length > 0 && ( <div className="space-y-2"> <label className="text-sm font-medium text-foreground">Parameters</label> <div className="border border-border rounded-lg bg-card p-4"> <ParameterForm parameters={tool.parameters} values={params} onChange={setParams} /> </div> </div> )} {/* SQL Editor */} <div className="space-y-2"> <div className="flex items-center justify-between"> <label className="text-sm font-medium text-foreground"> SQL Statement </label> <button onClick={handleCopy} className="inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted rounded cursor-pointer transition-colors" title="Copy SQL" > {copied ? ( <> <CheckIcon className="w-3.5 h-3.5" /> Copied </> ) : ( <> <CopyIcon className="w-3.5 h-3.5" /> Copy </> )} </button> </div> <SqlEditor ref={sqlEditorRef} value={toolType === 'execute_sql' ? sql : getSqlPreview()} onChange={toolType === 'execute_sql' ? setSql : undefined} onRunShortcut={handleRun} disabled={isRunDisabled || isRunning} readOnly={toolType !== 'execute_sql'} placeholder={ toolType === 'execute_sql' ? 'SELECT * FROM table_name LIMIT 10;' : undefined } /> </div> {/* Run Button */} <RunButton onClick={handleRun} disabled={isRunDisabled} loading={isRunning} /> {/* Results */} <ResultsTabs tabs={resultTabs} activeTabId={activeTabId} onTabSelect={setActiveTabId} onTabClose={handleTabClose} isLoading={isRunning} /> </div> </div> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bytebase/dbhub'

If you have feedback or need assistance with the MCP directory API, please join our Discord server