feat: Add /tools page with centralized tool registry management

- Add ToolsPage for managing MCP tools activation and persistence
- Centralize all tool handlers in services/tools/handlers/
- toolRegistry.ts is now the single source of truth for tool state
- Add tools store for pinned tools (persisted in localStorage)
- Tools can be pinned to stay active across page navigation
- Remove old individual tool files, replaced by centralized handlers
This commit is contained in:
2026-02-13 13:46:55 -06:00
parent da6111bd1f
commit 4450d1e034
22 changed files with 2386 additions and 1832 deletions

View File

@@ -14,7 +14,7 @@ const router = useRouter()
const showTerminal = ref(false)
const canvasStore = useCanvasStore()
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal'
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools'
onMounted(async () => {
// Initialize WebMCP connection

View File

@@ -125,6 +125,12 @@ onMounted(() => {
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</RouterLink>
<RouterLink to="/tools" class="toolbar-btn" :class="{ active: route.path === '/tools' }" title="Tools">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</RouterLink>
</div>
<div class="toolbar-divider"></div>

View File

@@ -0,0 +1,595 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useToolsStore } from '@/stores/tools'
import {
getCurrentPage,
isRegistryInitialized,
activateTool,
deactivateTool,
activateCategory,
deactivateCategory,
syncStoreWithActiveTools
} from '@/services/toolRegistry'
import {
ALL_TOOL_METAS,
CATEGORY_INFO,
type ToolCategory,
type ToolMeta
} from '@/services/tools/toolDefinitions'
const toolsStore = useToolsStore()
const { activeTools: activeToolsSet } = storeToRefs(toolsStore)
// State
const currentPage = ref<string | null>(null)
const isInitialized = ref(false)
const refreshInterval = ref<number | null>(null)
const activeTab = ref<'active' | 'all'>('all')
const expandedCategories = ref<Set<string>>(new Set(['global', 'canvas', 'component']))
// Computed - use reactive store
const activeTools = computed(() => Array.from(activeToolsSet.value))
const toolsByCategory = computed(() => {
const categories: Record<string, ToolMeta[]> = {}
for (const category of Object.keys(CATEGORY_INFO)) {
categories[category] = []
}
for (const tool of ALL_TOOL_METAS) {
categories[tool.category].push(tool)
}
// Sort each category by name
for (const category of Object.keys(categories)) {
categories[category].sort((a, b) => a.name.localeCompare(b.name))
}
return categories
})
const totalTools = computed(() => ALL_TOOL_METAS.length)
const totalActive = computed(() => activeTools.value.length)
const totalPinned = computed(() => toolsStore.getPinnedToolNames().length)
function refresh() {
syncStoreWithActiveTools()
currentPage.value = getCurrentPage()
isInitialized.value = isRegistryInitialized()
}
function toggleCategory(category: string) {
if (expandedCategories.value.has(category)) {
expandedCategories.value.delete(category)
} else {
expandedCategories.value.add(category)
}
}
async function handleToggleTool(tool: ToolMeta) {
if (activeTools.value.includes(tool.name)) {
deactivateTool(tool.name)
} else {
await activateTool(tool.name)
}
refresh()
}
async function handleTogglePin(tool: ToolMeta) {
toolsStore.togglePin(tool.name)
// Si pinneamos una tool inactiva, activarla
if (toolsStore.isToolPinned(tool.name) && !activeTools.value.includes(tool.name)) {
await activateTool(tool.name)
}
refresh()
}
async function handleActivateCategory(category: ToolCategory) {
await activateCategory(category)
refresh()
}
function handleDeactivateCategory(category: ToolCategory) {
deactivateCategory(category)
refresh()
}
function isToolActive(name: string): boolean {
return activeTools.value.includes(name)
}
function isToolPinned(name: string): boolean {
return toolsStore.isToolPinned(name)
}
function getCategoryStats(category: string) {
const tools = toolsByCategory.value[category]
if (!tools) return { active: 0, total: 0 }
const activeCount = tools.filter(t => activeTools.value.includes(t.name)).length
return {
active: activeCount,
total: tools.length
}
}
onMounted(() => {
refresh()
refreshInterval.value = window.setInterval(refresh, 2000)
})
onUnmounted(() => {
if (refreshInterval.value) {
window.clearInterval(refreshInterval.value)
}
})
</script>
<template>
<div class="tools-page">
<header class="page-header">
<div class="header-content">
<h1>Tool Registry</h1>
<p class="subtitle">Manage MCP tools activation and persistence</p>
</div>
<button class="refresh-btn" @click="refresh" title="Refresh">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
</svg>
</button>
</header>
<div class="status-bar">
<div class="status-item">
<span class="status-label">Status</span>
<span class="status-value" :class="{ active: isInitialized }">
{{ isInitialized ? 'Ready' : 'Not Initialized' }}
</span>
</div>
<div class="status-item">
<span class="status-label">Current Page</span>
<span class="status-value page">{{ currentPage || 'None' }}</span>
</div>
<div class="status-item">
<span class="status-label">Active</span>
<span class="status-value count">{{ totalActive }} / {{ totalTools }}</span>
</div>
<div class="status-item">
<span class="status-label">Pinned</span>
<span class="status-value pinned">{{ totalPinned }}</span>
</div>
</div>
<div class="tabs">
<button
:class="{ active: activeTab === 'all' }"
@click="activeTab = 'all'"
>
All Tools
</button>
<button
:class="{ active: activeTab === 'active' }"
@click="activeTab = 'active'"
>
Active Only
</button>
</div>
<main class="content">
<div class="categories">
<div
v-for="(info, category) in CATEGORY_INFO"
:key="category"
class="category-card"
:class="{ collapsed: !expandedCategories.has(category) }"
>
<div
class="category-header"
:style="{ '--cat-color': info.color }"
@click="toggleCategory(category)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path :d="info.icon"/>
</svg>
<h3>{{ info.label }}</h3>
<span class="tool-count">
{{ getCategoryStats(category).active }} / {{ getCategoryStats(category).total }}
</span>
<div class="category-actions" @click.stop>
<button
class="cat-btn activate"
@click="handleActivateCategory(category as ToolCategory)"
title="Activate all"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
<button
class="cat-btn deactivate"
@click="handleDeactivateCategory(category as ToolCategory)"
title="Deactivate all"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<svg
class="chevron"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div class="tool-list" v-show="expandedCategories.has(category)">
<template v-for="tool in toolsByCategory[category] || []" :key="tool.name">
<!-- Filter by activeTab -->
<div
v-if="activeTab === 'all' || isToolActive(tool.name)"
class="tool-item"
:class="{ active: isToolActive(tool.name), inactive: !isToolActive(tool.name) }"
>
<div class="tool-info">
<span class="tool-name">{{ tool.name }}</span>
<span class="tool-desc">{{ tool.description }}</span>
</div>
<div class="tool-actions">
<button
class="pin-btn"
:class="{ pinned: isToolPinned(tool.name) }"
@click="handleTogglePin(tool)"
:title="isToolPinned(tool.name) ? 'Unpin' : 'Pin (keep active)'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 17v5"/>
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v4.76z"/>
</svg>
</button>
<button
class="toggle-btn"
:class="{ active: isToolActive(tool.name) }"
@click="handleToggleTool(tool)"
:disabled="isToolPinned(tool.name)"
:title="isToolActive(tool.name) ? 'Deactivate' : 'Activate'"
>
<svg v-if="isToolActive(tool.name)" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Empty state for active tab -->
<div
v-if="activeTab === 'active' && getCategoryStats(category).active === 0"
class="empty-category"
>
No active tools in this category
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.tools-page {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-primary);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.header-content h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.subtitle {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--text-secondary);
}
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.refresh-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.status-bar {
display: flex;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.status-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.status-value {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
}
.status-value.active { color: #10b981; }
.status-value.page { color: #6366f1; font-family: monospace; }
.status-value.count { color: var(--text-primary); }
.status-value.pinned { color: #f59e0b; }
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.tabs button {
padding: 0.4rem 0.75rem;
background: transparent;
border: none;
border-radius: 5px;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.tabs button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tabs button.active {
background: var(--accent-muted);
color: var(--accent);
}
.content {
flex: 1;
overflow: auto;
padding: 1rem 1.5rem;
}
.categories {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.category-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.category-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1rem;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
color: var(--cat-color);
cursor: pointer;
user-select: none;
}
.category-card.collapsed .category-header {
border-bottom: none;
}
.category-header h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
flex: 1;
}
.tool-count {
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.4rem;
background: var(--bg-secondary);
border-radius: 8px;
color: var(--text-secondary);
}
.category-actions {
display: flex;
gap: 0.25rem;
}
.cat-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.cat-btn:hover {
background: var(--bg-hover);
}
.cat-btn.activate:hover {
color: #10b981;
border-color: #10b981;
}
.cat-btn.deactivate:hover {
color: #ef4444;
border-color: #ef4444;
}
.chevron {
color: var(--text-muted);
transition: transform 0.2s;
}
.category-card.collapsed .chevron {
transform: rotate(-90deg);
}
.tool-list {
padding: 0.25rem 0;
}
.tool-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
transition: background 0.15s;
}
.tool-item:hover {
background: var(--bg-hover);
}
.tool-item.inactive {
opacity: 0.6;
}
.tool-item.inactive:hover {
opacity: 1;
}
.tool-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
}
.tool-name {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-primary);
}
.tool-desc {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-actions {
display: flex;
gap: 0.35rem;
margin-left: 0.5rem;
}
.pin-btn,
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.pin-btn:hover {
color: #f59e0b;
border-color: #f59e0b;
}
.pin-btn.pinned {
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
border-color: #f59e0b;
}
.toggle-btn:hover {
color: var(--text-primary);
border-color: var(--text-secondary);
}
.toggle-btn.active {
color: #10b981;
background: rgba(16, 185, 129, 0.15);
border-color: #10b981;
}
.toggle-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-category {
padding: 0.75rem 1rem;
font-size: 0.75rem;
color: var(--text-muted);
font-style: italic;
}
</style>

View File

@@ -48,6 +48,11 @@ const router = createRouter({
path: '/terminal',
name: 'terminal',
component: () => import('../pages/TerminalPage.vue')
},
{
path: '/tools',
name: 'tools',
component: () => import('../pages/ToolsPage.vue')
}
]
})

View File

@@ -1,123 +1,128 @@
import { clearAllTools } from './webmcp'
import {
registerCanvasTools,
unregisterCanvasTools,
CANVAS_TOOLS
} from './tools/canvasTools'
import {
registerComponentTools,
unregisterComponentTools,
COMPONENT_TOOLS
} from './tools/componentTools'
import {
registerThemeTools,
unregisterThemeTools,
THEME_TOOLS
} from './tools/themeTools'
import {
registerGlobalTools,
setRouter,
GLOBAL_TOOLS
} from './tools/globalTools'
import {
registerProjectCanvasTools,
unregisterProjectCanvasTools,
PROJECT_CANVAS_TOOLS
} from './tools/projectCanvasTools'
import {
registerDatabaseTools,
unregisterDatabaseTools,
DATABASE_TOOLS
} from './tools/databaseTools'
import {
registerSourceCodeTools,
unregisterSourceCodeTools,
SOURCE_CODE_TOOLS
} from './tools/sourceCodeTools'
/**
* Tool Registry - Single source of truth for MCP tool management
*
* All tool registration/unregistration MUST go through this module.
* Other modules should NOT directly use webmcp registration functions.
*/
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal'
import {
initWebMCP,
getRegisteredTools as getWebMCPTools,
clearAllTools as clearWebMCPTools
} from './webmcp'
import { useToolsStore } from '../stores/tools'
import {
createGlobalHandlers,
createCanvasHandlers,
createComponentHandlers,
createThemeHandlers,
createDatabaseHandlers,
createProjectCanvasHandlers,
createSourceCodeHandlers,
type ToolConfig
} from './tools/handlers'
import { setRouter } from './tools/handlers/globalHandlers'
import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
import { ALL_TOOL_METAS, type ToolCategory } from './tools/toolDefinitions'
interface PageToolSet {
register: () => void
unregister: () => void
toolNames: string[]
export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools'
// Internal webmcp functions (not exported for external use)
let webmcpInstance: any = null
const registeredToolsSet = new Set<string>()
async function internalRegisterTool(config: ToolConfig): Promise<boolean> {
if (!webmcpInstance) {
webmcpInstance = await initWebMCP()
}
if (registeredToolsSet.has(config.name)) {
return false // Already registered
}
webmcpInstance.registerTool(config.name, config.description, config.schema, config.handler)
registeredToolsSet.add(config.name)
console.log(`[ToolRegistry] Registered: ${config.name}`)
return true
}
const pageTools: Record<PageName, PageToolSet> = {
home: {
register: () => {
registerCanvasTools()
registerComponentTools()
registerProjectCanvasTools()
},
unregister: () => {
unregisterCanvasTools()
unregisterComponentTools()
unregisterProjectCanvasTools()
},
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS, ...PROJECT_CANVAS_TOOLS]
},
canvas: {
register: () => {
registerCanvasTools()
registerComponentTools()
},
unregister: () => {
unregisterCanvasTools()
unregisterComponentTools()
},
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS]
},
'project-canvas': {
register: () => {
registerCanvasTools()
registerComponentTools()
registerProjectCanvasTools()
},
unregister: () => {
unregisterCanvasTools()
unregisterComponentTools()
unregisterProjectCanvasTools()
},
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS, ...PROJECT_CANVAS_TOOLS]
},
projects: {
register: registerProjectCanvasTools,
unregister: unregisterProjectCanvasTools,
toolNames: PROJECT_CANVAS_TOOLS
},
components: {
register: registerComponentTools,
unregister: unregisterComponentTools,
toolNames: COMPONENT_TOOLS
},
themes: {
register: registerThemeTools,
unregister: unregisterThemeTools,
toolNames: THEME_TOOLS
},
database: {
register: registerDatabaseTools,
unregister: unregisterDatabaseTools,
toolNames: DATABASE_TOOLS
},
source: {
register: registerSourceCodeTools,
unregister: unregisterSourceCodeTools,
toolNames: SOURCE_CODE_TOOLS
},
terminal: {
register: () => {},
unregister: () => {},
toolNames: []
function internalUnregisterTool(name: string): boolean {
if (!webmcpInstance || !registeredToolsSet.has(name)) {
return false
}
webmcpInstance.unregisterTool(name)
registeredToolsSet.delete(name)
console.log(`[ToolRegistry] Unregistered: ${name}`)
return true
}
function internalClearAllTools() {
if (!webmcpInstance) return
for (const name of registeredToolsSet) {
webmcpInstance.unregisterTool(name)
}
console.log(`[ToolRegistry] Cleared ${registeredToolsSet.size} tools`)
registeredToolsSet.clear()
}
// Tool configurations cache
let toolConfigsCache: Map<string, ToolConfig> | null = null
function getToolConfigs(): Map<string, ToolConfig> {
if (toolConfigsCache) return toolConfigsCache
toolConfigsCache = new Map()
// Create all handlers
const allHandlers = [
...createGlobalHandlers(() => Array.from(registeredToolsSet)),
...createCanvasHandlers(),
...createComponentHandlers(),
...createThemeHandlers(),
...createDatabaseHandlers(),
...createProjectCanvasHandlers(),
...createSourceCodeHandlers()
]
for (const config of allHandlers) {
toolConfigsCache.set(config.name, config)
}
return toolConfigsCache
}
// Category to tool names mapping
const categoryTools: Record<ToolCategory, string[]> = {
global: ['get_current_page', 'navigate_to', 'list_available_tools'],
canvas: ['render_html', 'render_vue_component'],
component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component'],
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],
project: ['list_canvases', 'create_canvas', 'get_canvas', 'update_canvas', 'delete_canvas', 'clone_canvas', 'add_component_to_canvas', 'remove_component_from_canvas', 'get_canvas_components']
}
// Page to categories mapping
const pageCategories: Record<PageName, ToolCategory[]> = {
home: ['global', 'canvas', 'component', 'project'],
canvas: ['global', 'canvas', 'component'],
'project-canvas': ['global', 'canvas', 'component', 'project'],
projects: ['global', 'project'],
components: ['global', 'component'],
themes: ['global', 'theme'],
database: ['global', 'database'],
source: ['global', 'source'],
terminal: ['global'],
tools: ['global']
}
let currentPage: PageName | null = null
let isInitialized = false
/**
* Inicializa el registry con el router de Vue
* Initialize the tool registry with Vue router
*/
export function initToolRegistry(router: any) {
setRouter(router)
@@ -125,80 +130,197 @@ export function initToolRegistry(router: any) {
}
/**
* Activa las tools para una página específica.
* Desregistra las tools de otras páginas primero.
* Set Gitea credentials for source code tools
*/
export function activatePageTools(pageName: PageName) {
export function setSourceCodeCredentials(creds: any) {
setGiteaCredentials(creds)
}
/**
* Clear Gitea credentials
*/
export function clearSourceCodeCredentials() {
clearGiteaCredentials()
}
/**
* Activate tools for a specific page
*/
export async function activatePageTools(pageName: PageName) {
if (!isInitialized) {
console.warn('[ToolRegistry] Not initialized. Call initToolRegistry first.')
console.warn('[ToolRegistry] Not initialized')
return
}
// Si ya estamos en esta página, no hacer nada
if (currentPage === pageName) {
console.log(`[ToolRegistry] Already on page "${pageName}", skipping`)
console.log(`[ToolRegistry] Already on "${pageName}", skipping`)
return
}
console.log(`[ToolRegistry] Switching from "${currentPage}" to "${pageName}"`)
// Desregistrar tools de la página anterior
if (currentPage && pageTools[currentPage]) {
pageTools[currentPage].unregister()
const toolsStore = useToolsStore()
const pinnedTools = toolsStore.getPinnedToolNames()
const configs = getToolConfigs()
// Get tools for old and new page
const oldCategories = currentPage ? pageCategories[currentPage] : []
const newCategories = pageCategories[pageName]
const oldTools = new Set(oldCategories.flatMap(cat => categoryTools[cat]))
const newTools = new Set(newCategories.flatMap(cat => categoryTools[cat]))
// Unregister old tools (except pinned)
for (const tool of oldTools) {
if (!newTools.has(tool) && !pinnedTools.includes(tool)) {
internalUnregisterTool(tool)
}
}
// Registrar tools de la nueva página
if (pageTools[pageName]) {
pageTools[pageName].register()
// Register new tools
for (const toolName of newTools) {
const config = configs.get(toolName)
if (config) {
await internalRegisterTool(config)
}
}
// Asegurar que las tools globales estén registradas
registerGlobalTools()
// Ensure pinned tools are registered
for (const toolName of pinnedTools) {
const config = configs.get(toolName)
if (config && !registeredToolsSet.has(toolName)) {
await internalRegisterTool(config)
}
}
currentPage = pageName
syncStoreWithActiveTools()
console.log(`[ToolRegistry] Page "${pageName}" tools activated`)
}
/**
* Inicializa las tools para un refresh de página.
* Limpia todo y registra las tools correctas.
* Initialize tools on page refresh
*/
export function initToolsOnRefresh(pageName: PageName) {
export async function initToolsOnRefresh(pageName: PageName) {
if (!isInitialized) {
console.warn('[ToolRegistry] Not initialized. Call initToolRegistry first.')
console.warn('[ToolRegistry] Not initialized')
return
}
console.log(`[ToolRegistry] Initializing on refresh for page "${pageName}"`)
console.log(`[ToolRegistry] Initializing on refresh for "${pageName}"`)
// Limpiar todas las tools existentes
clearAllTools()
// Reset current page tracking
internalClearAllTools()
currentPage = null
// Activar tools de la página actual
activatePageTools(pageName)
await activatePageTools(pageName)
}
/**
* Obtiene el nombre de la página actual
* Activate a single tool by name
*/
export async function activateTool(toolName: string): Promise<boolean> {
const configs = getToolConfigs()
const config = configs.get(toolName)
if (!config) {
console.warn(`[ToolRegistry] Tool "${toolName}" not found`)
return false
}
const result = await internalRegisterTool(config)
syncStoreWithActiveTools()
return result
}
/**
* Deactivate a single tool by name
*/
export function deactivateTool(toolName: string): boolean {
const toolsStore = useToolsStore()
if (toolsStore.isToolPinned(toolName)) {
console.warn(`[ToolRegistry] Cannot deactivate pinned tool "${toolName}"`)
return false
}
const result = internalUnregisterTool(toolName)
syncStoreWithActiveTools()
return result
}
/**
* Activate all tools in a category
*/
export async function activateCategory(category: ToolCategory) {
const configs = getToolConfigs()
const tools = categoryTools[category] || []
for (const toolName of tools) {
const config = configs.get(toolName)
if (config) {
await internalRegisterTool(config)
}
}
syncStoreWithActiveTools()
}
/**
* Deactivate all tools in a category (respecting pinned)
*/
export function deactivateCategory(category: ToolCategory) {
const toolsStore = useToolsStore()
const tools = categoryTools[category] || []
for (const toolName of tools) {
if (!toolsStore.isToolPinned(toolName)) {
internalUnregisterTool(toolName)
}
}
syncStoreWithActiveTools()
}
/**
* Sync the store with currently active tools
*/
export function syncStoreWithActiveTools() {
const toolsStore = useToolsStore()
toolsStore.setActiveTools(Array.from(registeredToolsSet))
}
/**
* Get current page name
*/
export function getCurrentPage(): PageName | null {
return currentPage
}
/**
* Obtiene los nombres de las tools para una página
* Get tool names for a page
*/
export function getPageToolNames(pageName: PageName): string[] {
return [...(pageTools[pageName]?.toolNames || []), ...GLOBAL_TOOLS]
const categories = pageCategories[pageName] || []
return categories.flatMap(cat => categoryTools[cat])
}
/**
* Verifica si el registry está inicializado
* Check if registry is initialized
*/
export function isRegistryInitialized(): boolean {
return isInitialized
}
/**
* Get all tool metadata
*/
export function getAllToolMetas() {
return ALL_TOOL_METAS
}
/**
* Get registered tools (for internal use)
*/
export function getRegisteredTools(): string[] {
return Array.from(registeredToolsSet)
}

View File

@@ -1,146 +0,0 @@
import { useCanvasStore } from '../../stores/canvas'
import { registerTool, unregisterTools } from '../webmcp'
import {
renderInlineComponent,
type VueComponentDefinition
} from '../dynamicComponents'
export const CANVAS_TOOLS = ['render_html', 'render_vue_component']
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
}
function emitComponentRendered(args: any) {
window.dispatchEvent(new CustomEvent('vue-component-rendered', {
detail: {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
}
}))
}
export function registerCanvasTools() {
const canvasStore = useCanvasStore()
// render_html
registerTool(
'render_html',
'Renderiza HTML en el canvas. Soporta <script> tags que se ejecutan automáticamente y <style> tags.',
{
type: 'object',
properties: {
html: {
type: 'string',
description: 'El código HTML a renderizar (puede incluir <script> y <style> tags)'
},
mode: {
type: 'string',
enum: ['replace', 'append', 'prepend'],
description: 'Modo: replace (reemplaza), append (agrega al final), prepend (al inicio)'
}
},
required: ['html']
},
(args: { html: string; mode?: string }) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const mode = args.mode || 'replace'
if (mode === 'replace') {
container.innerHTML = args.html
} else if (mode === 'append') {
container.insertAdjacentHTML('beforeend', args.html)
} else if (mode === 'prepend') {
container.insertAdjacentHTML('afterbegin', args.html)
}
// Ejecutar scripts inline
const scripts = container.querySelectorAll('script')
scripts.forEach((oldScript) => {
const newScript = document.createElement('script')
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value)
})
newScript.textContent = oldScript.textContent
oldScript.parentNode?.replaceChild(newScript, oldScript)
})
canvasStore.addToHistory({ tool: 'render_html', args, timestamp: Date.now() })
return 'HTML renderizado'
}
)
// render_vue_component
registerTool(
'render_vue_component',
'Renderiza un componente Vue 3 completo con acceso a ref, reactive, computed, watch, Pinia stores, etc.',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID único del componente' },
name: { type: 'string', description: 'Nombre del componente (ej: MyCounter)' },
template: { type: 'string', description: 'Template HTML del componente con sintaxis Vue' },
setup: { type: 'string', description: 'Código de la función setup (debe retornar un objeto con las propiedades reactivas)' },
style: { type: 'string', description: 'CSS del componente (opcional)' },
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props que acepta el componente' },
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue a importar: ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick, h' },
componentProps: { type: 'object', description: 'Valores para las props del componente' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'replace: limpia el canvas, append: agrega al final' }
},
required: ['id', 'name', 'template']
},
(args: {
id: string
name: string
template: string
setup?: string
style?: string
props?: string[]
imports?: string[]
componentProps?: Record<string, any>
mode?: string
}) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const definition: VueComponentDefinition = {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports || ['ref', 'reactive', 'computed']
}
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
;(window as any).__vueComponentUnmount = result.unmount
emitComponentRendered(args)
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
return `Componente Vue "${args.name}" renderizado correctamente`
}
)
}
export function unregisterCanvasTools() {
unregisterTools(CANVAS_TOOLS)
}

View File

@@ -1,147 +0,0 @@
import { useCanvasStore } from '../../stores/canvas'
import { registerTool, unregisterTools } from '../webmcp'
import {
renderInlineComponent,
componentsApi,
type VueComponentDefinition
} from '../dynamicComponents'
export const COMPONENT_TOOLS = [
'save_vue_component',
'load_vue_component',
'list_vue_components',
'delete_vue_component'
]
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
}
export function registerComponentTools() {
const canvasStore = useCanvasStore()
// save_vue_component
registerTool(
'save_vue_component',
'Guarda un componente Vue en la base de datos para reutilizarlo después',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID único del componente (se genera automáticamente si no se proporciona)' },
name: { type: 'string', description: 'Nombre del componente' },
template: { type: 'string', description: 'Template HTML del componente' },
setup: { type: 'string', description: 'Código de la función setup' },
style: { type: 'string', description: 'CSS del componente' },
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props' },
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue necesarias' }
},
required: ['name', 'template']
},
async (args: Omit<VueComponentDefinition, 'id'> & { id?: string }) => {
try {
const result = await componentsApi.save({
id: args.id || `comp-${Date.now()}`,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
})
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
return `Componente "${args.name}" guardado con ID: ${result.id}`
} catch (e: any) {
return `Error al guardar: ${e.message}`
}
}
)
// load_vue_component
registerTool(
'load_vue_component',
'Carga un componente Vue guardado desde la base de datos y lo renderiza',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del componente a cargar' },
componentProps: { type: 'object', description: 'Props para pasar al componente' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'replace: limpia el canvas, append: agrega al final' }
},
required: ['id']
},
async (args: { id: string; componentProps?: Record<string, any>; mode?: string }) => {
try {
const definition = await componentsApi.getById(args.id)
if (!definition) {
return `Error: Componente con ID "${args.id}" no encontrado`
}
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
;(window as any).__vueComponentUnmount = result.unmount
canvasStore.addToHistory({ tool: 'load_vue_component', args, timestamp: Date.now() })
return `Componente "${definition.name}" cargado y renderizado`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// list_vue_components
registerTool(
'list_vue_components',
'Lista todos los componentes Vue guardados en la base de datos',
{
type: 'object',
properties: {}
},
async () => {
try {
const components = await componentsApi.getAll()
if (components.length === 0) {
return 'No hay componentes guardados'
}
const list = components.map(c => `- ${c.id}: ${c.name}`).join('\n')
return `Componentes guardados:\n${list}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// delete_vue_component
registerTool(
'delete_vue_component',
'Elimina un componente Vue de la base de datos',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del componente a eliminar' }
},
required: ['id']
},
async (args: { id: string }) => {
try {
await componentsApi.delete(args.id)
return `Componente "${args.id}" eliminado`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterComponentTools() {
unregisterTools(COMPONENT_TOOLS)
}

View File

@@ -1,231 +0,0 @@
import { registerTool, unregisterTools } from '../webmcp'
export const DATABASE_TOOLS = [
'list_tables',
'get_table_schema',
'get_table_data',
'get_database_stats',
'execute_query'
]
const API_BASE = 'http://localhost:4101'
export function registerDatabaseTools() {
// list_tables
registerTool(
'list_tables',
'Lista todas las tablas de la base de datos SQLite con su conteo de registros',
{
type: 'object',
properties: {}
},
async () => {
try {
const res = await fetch(`${API_BASE}/api/database/tables`)
if (!res.ok) throw new Error('Failed to fetch tables')
const tables = await res.json()
if (tables.length === 0) {
return 'No hay tablas en la base de datos'
}
const tableList = tables.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
const total = tables.reduce((sum: number, t: any) => sum + t.count, 0)
return `Tablas en la base de datos (${tables.length}):\n\n${tableList}\n\nTotal de registros: ${total}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_table_schema
registerTool(
'get_table_schema',
'Obtiene el esquema (columnas y tipos) de una tabla',
{
type: 'object',
properties: {
table: {
type: 'string',
description: 'Nombre de la tabla'
}
},
required: ['table']
},
async (args: { table: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/tables/${args.table}/schema`)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch schema')
}
const schema = await res.json()
if (schema.length === 0) {
return `La tabla "${args.table}" no tiene columnas definidas`
}
const columns = schema.map((col: any) => {
const flags = []
if (col.pk) flags.push('PRIMARY KEY')
if (col.notnull) flags.push('NOT NULL')
const flagStr = flags.length > 0 ? ` (${flags.join(', ')})` : ''
return ` - ${col.name}: ${col.type}${flagStr}`
}).join('\n')
return `Esquema de la tabla "${args.table}":\n\n${columns}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_table_data
registerTool(
'get_table_data',
'Obtiene los datos de una tabla con paginacion',
{
type: 'object',
properties: {
table: {
type: 'string',
description: 'Nombre de la tabla'
},
limit: {
type: 'number',
description: 'Numero de registros a retornar (default: 20, max: 100)'
},
offset: {
type: 'number',
description: 'Registros a saltar para paginacion (default: 0)'
}
},
required: ['table']
},
async (args: { table: string; limit?: number; offset?: number }) => {
try {
const limit = Math.min(args.limit || 20, 100)
const offset = args.offset || 0
const res = await fetch(
`${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}`
)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch data')
}
const result = await res.json()
if (result.rows.length === 0) {
return `La tabla "${args.table}" no tiene registros`
}
// Format as readable table
const rows = result.rows.map((row: any, idx: number) => {
const entries = Object.entries(row).map(([k, v]) => {
let value = v
if (typeof v === 'string' && v.length > 50) {
value = v.substring(0, 50) + '...'
} else if (typeof v === 'object') {
value = JSON.stringify(v).substring(0, 50) + '...'
}
return `${k}: ${value}`
}).join(', ')
return `[${offset + idx + 1}] ${entries}`
}).join('\n')
return `Datos de "${args.table}" (${offset + 1}-${offset + result.rows.length} de ${result.total}):\n\n${rows}\n\nUsa offset=${offset + limit} para ver mas registros`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_database_stats
registerTool(
'get_database_stats',
'Obtiene estadisticas generales de la base de datos',
{
type: 'object',
properties: {}
},
async () => {
try {
const res = await fetch(`${API_BASE}/api/database/stats`)
if (!res.ok) throw new Error('Failed to fetch stats')
const stats = await res.json()
return `Estadisticas de la base de datos:\n\n` +
` Tamano: ${stats.size}\n` +
` Tablas: ${stats.tables}\n` +
` Registros totales: ${stats.totalRecords}\n\n` +
`Desglose por tabla:\n` +
stats.breakdown.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// execute_query
registerTool(
'execute_query',
'Ejecuta una consulta SQL SELECT en la base de datos (solo lectura)',
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Consulta SQL (solo SELECT permitido)'
}
},
required: ['query']
},
async (args: { query: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: args.query })
})
const result = await res.json()
if (!res.ok) {
return `Error en la consulta: ${result.error}`
}
if (result.rows.length === 0) {
return 'La consulta no retorno resultados'
}
// Format results
const columns = Object.keys(result.rows[0])
const header = columns.join(' | ')
const separator = columns.map(() => '---').join(' | ')
const rows = result.rows.slice(0, 50).map((row: any) => {
return columns.map(col => {
let value = row[col]
if (value === null) return 'NULL'
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string' && value.length > 40) {
return value.substring(0, 40) + '...'
}
return String(value)
}).join(' | ')
}).join('\n')
const truncated = result.rows.length > 50 ? `\n\n... y ${result.rows.length - 50} filas mas` : ''
return `Resultados (${result.rows.length} filas):\n\n${header}\n${separator}\n${rows}${truncated}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterDatabaseTools() {
unregisterTools(DATABASE_TOOLS)
}

View File

@@ -1,108 +0,0 @@
import { registerTool, unregisterTools, getRegisteredTools } from '../webmcp'
export const GLOBAL_TOOLS = [
'get_current_page',
'navigate_to',
'list_available_tools'
]
let routerInstance: any = null
export function setRouter(router: any) {
routerInstance = router
}
export function registerGlobalTools() {
// get_current_page
registerTool(
'get_current_page',
'Obtiene la página actualmente activa en Agent UI',
{
type: 'object',
properties: {}
},
() => {
if (!routerInstance) {
return 'Error: Router no disponible'
}
const route = routerInstance.currentRoute.value
const pageInfo: Record<string, string> = {
canvas: 'Canvas - Renderiza componentes Vue y HTML dinámicamente',
components: 'Components - Gestiona componentes guardados en la base de datos',
themes: 'Themes - Editor visual de temas y design tokens'
}
const pageName = route.name as string || 'unknown'
const description = pageInfo[pageName] || 'Página desconocida'
return `Página actual: ${pageName}\n` +
`Ruta: ${route.path}\n` +
`Descripción: ${description}\n\n` +
`Herramientas disponibles en esta página:\n${getRegisteredTools().map(t => ` - ${t}`).join('\n')}`
}
)
// navigate_to
registerTool(
'navigate_to',
'Navega a una página específica de Agent UI',
{
type: 'object',
properties: {
page: {
type: 'string',
enum: ['canvas', 'dynamic-canvas', 'components', 'themes'],
description: 'Página a la que navegar'
}
},
required: ['page']
},
async (args: { page: string }) => {
if (!routerInstance) {
return 'Error: Router no disponible'
}
const routes: Record<string, string> = {
canvas: '/',
'dynamic-canvas': '/dynamic/canvas',
components: '/components',
themes: '/themes'
}
const path = routes[args.page]
if (!path) {
return `Error: Página "${args.page}" no válida. Opciones: canvas, dynamic-canvas, components, themes`
}
try {
await routerInstance.push(path)
return `Navegando a ${args.page} (${path})\n\n` +
`Nota: Las herramientas MCP se actualizarán automáticamente para esta página.`
} catch (e: any) {
return `Error al navegar: ${e.message}`
}
}
)
// list_available_tools
registerTool(
'list_available_tools',
'Lista todas las herramientas MCP actualmente disponibles',
{
type: 'object',
properties: {}
},
() => {
const tools = getRegisteredTools()
if (tools.length === 0) {
return 'No hay herramientas registradas'
}
return `Herramientas MCP disponibles (${tools.length}):\n${tools.map(t => ` - ${t}`).join('\n')}`
}
)
}
export function unregisterGlobalTools() {
unregisterTools(GLOBAL_TOOLS)
}

View File

@@ -0,0 +1,141 @@
import type { ToolConfig } from './index'
import { useCanvasStore } from '../../../stores/canvas'
import {
renderInlineComponent,
type VueComponentDefinition
} from '../../dynamicComponents'
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
}
function emitComponentRendered(args: any) {
window.dispatchEvent(new CustomEvent('vue-component-rendered', {
detail: {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
}
}))
}
export function createCanvasHandlers(): ToolConfig[] {
return [
{
name: 'render_html',
description: 'Renderiza HTML en el canvas. Soporta <script> y <style> tags.',
category: 'canvas',
schema: {
type: 'object',
properties: {
html: {
type: 'string',
description: 'El codigo HTML a renderizar'
},
mode: {
type: 'string',
enum: ['replace', 'append', 'prepend'],
description: 'Modo: replace, append, prepend'
}
},
required: ['html']
},
handler: (args: { html: string; mode?: string }) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const mode = args.mode || 'replace'
if (mode === 'replace') {
container.innerHTML = args.html
} else if (mode === 'append') {
container.insertAdjacentHTML('beforeend', args.html)
} else if (mode === 'prepend') {
container.insertAdjacentHTML('afterbegin', args.html)
}
// Ejecutar scripts inline
const scripts = container.querySelectorAll('script')
scripts.forEach((oldScript) => {
const newScript = document.createElement('script')
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value)
})
newScript.textContent = oldScript.textContent
oldScript.parentNode?.replaceChild(newScript, oldScript)
})
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'render_html', args, timestamp: Date.now() })
return 'HTML renderizado'
}
},
{
name: 'render_vue_component',
description: 'Renderiza un componente Vue 3 completo con ref, reactive, computed, etc.',
category: 'canvas',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID unico del componente' },
name: { type: 'string', description: 'Nombre del componente' },
template: { type: 'string', description: 'Template HTML con sintaxis Vue' },
setup: { type: 'string', description: 'Codigo de la funcion setup' },
style: { type: 'string', description: 'CSS del componente' },
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props' },
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue a importar' },
componentProps: { type: 'object', description: 'Valores para las props' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo de renderizado' }
},
required: ['id', 'name', 'template']
},
handler: (args: {
id: string
name: string
template: string
setup?: string
style?: string
props?: string[]
imports?: string[]
componentProps?: Record<string, any>
mode?: string
}) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const definition: VueComponentDefinition = {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports || ['ref', 'reactive', 'computed']
}
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
;(window as any).__vueComponentUnmount = result.unmount
emitComponentRendered(args)
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
return `Componente Vue "${args.name}" renderizado`
}
}
]
}

View File

@@ -0,0 +1,135 @@
import type { ToolConfig } from './index'
import { useCanvasStore } from '../../../stores/canvas'
import {
renderInlineComponent,
componentsApi,
type VueComponentDefinition
} from '../../dynamicComponents'
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
}
export function createComponentHandlers(): ToolConfig[] {
return [
{
name: 'save_vue_component',
description: 'Guarda un componente Vue en la base de datos',
category: 'component',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID unico del componente' },
name: { type: 'string', description: 'Nombre del componente' },
template: { type: 'string', description: 'Template HTML' },
setup: { type: 'string', description: 'Codigo de setup' },
style: { type: 'string', description: 'CSS' },
props: { type: 'array', items: { type: 'string' }, description: 'Props' },
imports: { type: 'array', items: { type: 'string' }, description: 'Imports de Vue' }
},
required: ['name', 'template']
},
handler: async (args: Omit<VueComponentDefinition, 'id'> & { id?: string }) => {
try {
const result = await componentsApi.save({
id: args.id || `comp-${Date.now()}`,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
})
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
return `Componente "${args.name}" guardado con ID: ${result.id}`
} catch (e: any) {
return `Error al guardar: ${e.message}`
}
}
},
{
name: 'load_vue_component',
description: 'Carga un componente guardado y lo renderiza',
category: 'component',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del componente' },
componentProps: { type: 'object', description: 'Props para el componente' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo' }
},
required: ['id']
},
handler: async (args: { id: string; componentProps?: Record<string, any>; mode?: string }) => {
try {
const definition = await componentsApi.getById(args.id)
if (!definition) {
return `Error: Componente "${args.id}" no encontrado`
}
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
;(window as any).__vueComponentUnmount = result.unmount
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'load_vue_component', args, timestamp: Date.now() })
return `Componente "${definition.name}" cargado y renderizado`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'list_vue_components',
description: 'Lista todos los componentes guardados',
category: 'component',
schema: {
type: 'object',
properties: {}
},
handler: async () => {
try {
const components = await componentsApi.getAll()
if (components.length === 0) {
return 'No hay componentes guardados'
}
const list = components.map(c => `- ${c.id}: ${c.name}`).join('\n')
return `Componentes guardados:\n${list}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'delete_vue_component',
description: 'Elimina un componente de la base de datos',
category: 'component',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del componente' }
},
required: ['id']
},
handler: async (args: { id: string }) => {
try {
await componentsApi.delete(args.id)
return `Componente "${args.id}" eliminado`
} catch (e: any) {
return `Error: ${e.message}`
}
}
}
]
}

View File

@@ -0,0 +1,186 @@
import type { ToolConfig } from './index'
const API_BASE = 'http://localhost:4101'
export function createDatabaseHandlers(): ToolConfig[] {
return [
{
name: 'list_tables',
description: 'Lista todas las tablas de la base de datos',
category: 'database',
schema: { type: 'object', properties: {} },
handler: async () => {
try {
const res = await fetch(`${API_BASE}/api/database/tables`)
if (!res.ok) throw new Error('Failed to fetch tables')
const tables = await res.json()
if (tables.length === 0) return 'No hay tablas'
const tableList = tables.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
const total = tables.reduce((sum: number, t: any) => sum + t.count, 0)
return `Tablas (${tables.length}):\n\n${tableList}\n\nTotal: ${total} registros`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_table_schema',
description: 'Obtiene el esquema de una tabla',
category: 'database',
schema: {
type: 'object',
properties: {
table: { type: 'string', description: 'Nombre de la tabla' }
},
required: ['table']
},
handler: async (args: { table: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/tables/${args.table}/schema`)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch schema')
}
const schema = await res.json()
if (schema.length === 0) return `La tabla "${args.table}" no tiene columnas`
const columns = schema.map((col: any) => {
const flags = []
if (col.pk) flags.push('PK')
if (col.notnull) flags.push('NOT NULL')
const flagStr = flags.length > 0 ? ` (${flags.join(', ')})` : ''
return ` - ${col.name}: ${col.type}${flagStr}`
}).join('\n')
return `Esquema de "${args.table}":\n\n${columns}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_table_data',
description: 'Obtiene datos de una tabla',
category: 'database',
schema: {
type: 'object',
properties: {
table: { type: 'string', description: 'Nombre de la tabla' },
limit: { type: 'number', description: 'Limite de registros (max 100)' },
offset: { type: 'number', description: 'Offset para paginacion' }
},
required: ['table']
},
handler: async (args: { table: string; limit?: number; offset?: number }) => {
try {
const limit = Math.min(args.limit || 20, 100)
const offset = args.offset || 0
const res = await fetch(
`${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}`
)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch data')
}
const result = await res.json()
if (result.rows.length === 0) return `La tabla "${args.table}" no tiene registros`
const rows = result.rows.map((row: any, idx: number) => {
const entries = Object.entries(row).map(([k, v]) => {
let value = v
if (typeof v === 'string' && v.length > 50) {
value = v.substring(0, 50) + '...'
} else if (typeof v === 'object') {
value = JSON.stringify(v).substring(0, 50) + '...'
}
return `${k}: ${value}`
}).join(', ')
return `[${offset + idx + 1}] ${entries}`
}).join('\n')
return `Datos de "${args.table}" (${offset + 1}-${offset + result.rows.length} de ${result.total}):\n\n${rows}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_database_stats',
description: 'Obtiene estadisticas de la base de datos',
category: 'database',
schema: { type: 'object', properties: {} },
handler: async () => {
try {
const res = await fetch(`${API_BASE}/api/database/stats`)
if (!res.ok) throw new Error('Failed to fetch stats')
const stats = await res.json()
return `Estadisticas:\n\n` +
` Tamano: ${stats.size}\n` +
` Tablas: ${stats.tables}\n` +
` Registros: ${stats.totalRecords}\n\n` +
`Desglose:\n` +
stats.breakdown.map((t: any) => ` - ${t.name}: ${t.count}`).join('\n')
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'execute_query',
description: 'Ejecuta una consulta SQL SELECT',
category: 'database',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Consulta SQL (solo SELECT)' }
},
required: ['query']
},
handler: async (args: { query: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: args.query })
})
const result = await res.json()
if (!res.ok) {
return `Error: ${result.error}`
}
if (result.rows.length === 0) return 'Sin resultados'
const columns = Object.keys(result.rows[0])
const header = columns.join(' | ')
const separator = columns.map(() => '---').join(' | ')
const rows = result.rows.slice(0, 50).map((row: any) => {
return columns.map(col => {
let value = row[col]
if (value === null) return 'NULL'
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string' && value.length > 40) {
return value.substring(0, 40) + '...'
}
return String(value)
}).join(' | ')
}).join('\n')
const truncated = result.rows.length > 50 ? `\n\n... y ${result.rows.length - 50} filas mas` : ''
return `Resultados (${result.rows.length}):\n\n${header}\n${separator}\n${rows}${truncated}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
}
]
}

View File

@@ -0,0 +1,108 @@
import type { ToolConfig } from './index'
let routerInstance: any = null
export function setRouter(router: any) {
routerInstance = router
}
export function createGlobalHandlers(getRegisteredTools: () => string[]): ToolConfig[] {
return [
{
name: 'get_current_page',
description: 'Obtiene la pagina actualmente activa en Agent UI',
category: 'global',
schema: {
type: 'object',
properties: {}
},
handler: () => {
if (!routerInstance) {
return 'Error: Router no disponible'
}
const route = routerInstance.currentRoute.value
const pageInfo: Record<string, string> = {
home: 'Home - Canvas principal con componentes',
canvas: 'Canvas - Renderiza componentes Vue y HTML',
components: 'Components - Gestiona componentes guardados',
themes: 'Themes - Editor visual de temas',
database: 'Database - Explorador de base de datos',
source: 'Source - Navegador de codigo fuente',
projects: 'Projects - Gestiona proyectos',
terminal: 'Terminal - Consola de comandos',
tools: 'Tools - Gestion de herramientas MCP'
}
const pageName = route.name as string || 'unknown'
const description = pageInfo[pageName] || 'Pagina desconocida'
return `Pagina actual: ${pageName}\n` +
`Ruta: ${route.path}\n` +
`Descripcion: ${description}\n\n` +
`Herramientas disponibles:\n${getRegisteredTools().map(t => ` - ${t}`).join('\n')}`
}
},
{
name: 'navigate_to',
description: 'Navega a una pagina especifica de Agent UI',
category: 'global',
schema: {
type: 'object',
properties: {
page: {
type: 'string',
enum: ['home', 'canvas', 'components', 'themes', 'database', 'source', 'projects', 'terminal', 'tools'],
description: 'Pagina a la que navegar'
}
},
required: ['page']
},
handler: async (args: { page: string }) => {
if (!routerInstance) {
return 'Error: Router no disponible'
}
const routes: Record<string, string> = {
home: '/',
canvas: '/dynamic/canvas',
components: '/components',
themes: '/themes',
database: '/database',
source: '/source',
projects: '/projects',
terminal: '/terminal',
tools: '/tools'
}
const path = routes[args.page]
if (!path) {
return `Error: Pagina "${args.page}" no valida. Opciones: ${Object.keys(routes).join(', ')}`
}
try {
await routerInstance.push(path)
return `Navegando a ${args.page} (${path})`
} catch (e: any) {
return `Error al navegar: ${e.message}`
}
}
},
{
name: 'list_available_tools',
description: 'Lista todas las herramientas MCP actualmente disponibles',
category: 'global',
schema: {
type: 'object',
properties: {}
},
handler: () => {
const tools = getRegisteredTools()
if (tools.length === 0) {
return 'No hay herramientas registradas'
}
return `Herramientas MCP disponibles (${tools.length}):\n${tools.map(t => ` - ${t}`).join('\n')}`
}
}
]
}

View File

@@ -0,0 +1,22 @@
/**
* Centralized tool handlers registry
* All tool handlers are defined here and exported for the toolRegistry
*/
export { createGlobalHandlers } from './globalHandlers'
export { createCanvasHandlers } from './canvasHandlers'
export { createComponentHandlers } from './componentHandlers'
export { createThemeHandlers } from './themeHandlers'
export { createDatabaseHandlers } from './databaseHandlers'
export { createProjectCanvasHandlers } from './projectCanvasHandlers'
export { createSourceCodeHandlers } from './sourceCodeHandlers'
export type ToolHandler = (args: any) => string | Promise<string>
export interface ToolConfig {
name: string
description: string
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project'
schema: object
handler: ToolHandler
}

View File

@@ -0,0 +1,223 @@
import type { ToolConfig } from './index'
import { useProjectCanvasStore } from '../../../stores/projectCanvas'
export function createProjectCanvasHandlers(): ToolConfig[] {
return [
{
name: 'list_canvases',
description: 'Lista todos los canvas disponibles',
category: 'project',
schema: {
type: 'object',
properties: {
type: { type: 'string', enum: ['all', 'project', 'system'], description: 'Filtrar por tipo' }
}
},
handler: async (args: { type?: string }) => {
const store = useProjectCanvasStore()
await store.fetchCanvases()
let canvases = store.canvases
if (args.type === 'project') {
canvases = store.projectCanvases
} else if (args.type === 'system') {
canvases = store.systemCanvases
}
return JSON.stringify(canvases.map(c => ({
id: c.id,
name: c.name,
type: c.type,
description: c.description
})), null, 2)
}
},
{
name: 'create_canvas',
description: 'Crea un nuevo canvas',
category: 'project',
schema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Nombre del canvas' },
description: { type: 'string', description: 'Descripcion' },
theme_id: { type: 'string', description: 'ID del tema' },
config: { type: 'object', description: 'Configuracion' }
},
required: ['name']
},
handler: async (args: { name: string; description?: string; theme_id?: string; config?: object }) => {
const store = useProjectCanvasStore()
const id = await store.createCanvas({
name: args.name,
description: args.description,
theme_id: args.theme_id,
config: args.config as any,
type: 'project'
})
if (id) {
return `Canvas "${args.name}" creado. ID: ${id}`
}
return `Error: ${store.error}`
}
},
{
name: 'get_canvas',
description: 'Obtiene detalles de un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas' }
},
required: ['id']
},
handler: async (args: { id: string }) => {
const store = useProjectCanvasStore()
const canvas = await store.fetchCanvasById(args.id)
if (!canvas) {
return `Canvas "${args.id}" no encontrado`
}
return JSON.stringify(canvas, null, 2)
}
},
{
name: 'update_canvas',
description: 'Actualiza un canvas existente',
category: 'project',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas' },
name: { type: 'string', description: 'Nuevo nombre' },
description: { type: 'string', description: 'Nueva descripcion' },
theme_id: { type: 'string', description: 'Nuevo tema' },
config: { type: 'object', description: 'Nueva configuracion' }
},
required: ['id']
},
handler: async (args: { id: string; name?: string; description?: string; theme_id?: string; config?: object }) => {
const store = useProjectCanvasStore()
const { id, ...data } = args
const success = await store.updateCanvas(id, data as any)
if (success) {
return `Canvas "${id}" actualizado`
}
return `Error: ${store.error}`
}
},
{
name: 'delete_canvas',
description: 'Elimina un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas' }
},
required: ['id']
},
handler: async (args: { id: string }) => {
const store = useProjectCanvasStore()
const success = await store.deleteCanvas(args.id)
if (success) {
return `Canvas "${args.id}" eliminado`
}
return `Error: ${store.error}`
}
},
{
name: 'clone_canvas',
description: 'Clona un canvas existente',
category: 'project',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas a clonar' },
name: { type: 'string', description: 'Nombre para el nuevo canvas' }
},
required: ['id']
},
handler: async (args: { id: string; name?: string }) => {
const store = useProjectCanvasStore()
const newId = await store.cloneCanvas(args.id, args.name)
if (newId) {
return `Canvas clonado. Nuevo ID: ${newId}`
}
return `Error: ${store.error}`
}
},
{
name: 'add_component_to_canvas',
description: 'Agrega un componente a un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' },
component_id: { type: 'string', description: 'ID del componente' },
props: { type: 'object', description: 'Props para el componente' },
position: { type: 'number', description: 'Posicion' }
},
required: ['canvas_id', 'component_id']
},
handler: async (args: { canvas_id: string; component_id: string; props?: object; position?: number }) => {
const store = useProjectCanvasStore()
const success = await store.addComponentToCanvas(
args.canvas_id,
args.component_id,
args.props as Record<string, any>,
args.position
)
if (success) {
return `Componente agregado al canvas`
}
return 'Error al agregar componente'
}
},
{
name: 'remove_component_from_canvas',
description: 'Remueve un componente de un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' },
component_id: { type: 'string', description: 'ID del componente' }
},
required: ['canvas_id', 'component_id']
},
handler: async (args: { canvas_id: string; component_id: string }) => {
const store = useProjectCanvasStore()
const success = await store.removeComponentFromCanvas(args.canvas_id, args.component_id)
if (success) {
return `Componente removido del canvas`
}
return 'Error al remover componente'
}
},
{
name: 'get_canvas_components',
description: 'Obtiene los componentes de un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' }
},
required: ['canvas_id']
},
handler: async (args: { canvas_id: string }) => {
const store = useProjectCanvasStore()
await store.fetchCanvasComponents(args.canvas_id)
return JSON.stringify(store.activeCanvasComponents, null, 2)
}
}
]
}

View File

@@ -0,0 +1,238 @@
import type { ToolConfig } from './index'
const API_BASE = 'http://localhost:4101'
// Store credentials in memory
let giteaCredentials: {
giteaUrl: string
username: string
password: string
owner: string
repo: string
branch: string
} | null = null
export function setGiteaCredentials(creds: typeof giteaCredentials) {
giteaCredentials = creds
}
export function clearGiteaCredentials() {
giteaCredentials = null
}
export function createSourceCodeHandlers(): ToolConfig[] {
return [
{
name: 'get_repo_info',
description: 'Obtiene info del repositorio Gitea',
category: 'source',
schema: { type: 'object', properties: {} },
handler: async () => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/repo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(giteaCredentials)
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const repo = data.repo
return `Repositorio: ${repo.owner.login}/${repo.name}\n` +
`Descripcion: ${repo.description || 'Sin descripcion'}\n` +
`Rama default: ${repo.default_branch}\n` +
`Stars: ${repo.stars_count}\n` +
`Forks: ${repo.forks_count}\n` +
`Ramas: ${data.branches.join(', ')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'list_repo_files',
description: 'Lista archivos del repositorio',
category: 'source',
schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Ruta dentro del repositorio' }
}
},
handler: async (args: { path?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path || '' })
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const path = args.path || '/'
if (data.tree.length === 0) return `No hay archivos en ${path}`
const folders = data.tree.filter((f: any) => f.type === 'dir')
const files = data.tree.filter((f: any) => f.type === 'file')
let result = `Contenido de ${path}:\n\n`
if (folders.length > 0) {
result += `Carpetas (${folders.length}):\n`
result += folders.map((f: any) => ` [DIR] ${f.name}/`).join('\n')
result += '\n\n'
}
if (files.length > 0) {
result += `Archivos (${files.length}):\n`
result += files.map((f: any) => ` ${f.name}`).join('\n')
}
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'read_repo_file',
description: 'Lee contenido de un archivo',
category: 'source',
schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Ruta del archivo' },
lines: { type: 'number', description: 'Lineas maximas (default: 100)' }
},
required: ['path']
},
handler: async (args: { path: string; lines?: number }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path })
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
let content = data.content
const maxLines = args.lines || 100
const lines = content.split('\n')
if (lines.length > maxLines) {
content = lines.slice(0, maxLines).join('\n')
content += `\n\n... (${lines.length - maxLines} lineas mas)`
}
return `Archivo: ${args.path}\nTamano: ${data.size} bytes\n\n${content}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'search_repo_code',
description: 'Busca codigo en el repositorio',
category: 'source',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Texto a buscar' },
path: { type: 'string', description: 'Ruta donde buscar' },
extension: { type: 'string', description: 'Extension de archivos' }
},
required: ['query']
},
handler: async (args: { query: string; path?: string; extension?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea'
}
try {
const treeRes = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path || '' })
})
if (!treeRes.ok) return 'Error al obtener lista de archivos'
const treeData = await treeRes.json()
const files = treeData.tree.filter((f: any) => {
if (f.type !== 'file') return false
if (args.extension) {
return f.name.endsWith(`.${args.extension}`)
}
return true
})
const results: string[] = []
const maxFiles = 10
for (const file of files.slice(0, maxFiles)) {
try {
const fileRes = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: file.path })
})
if (fileRes.ok) {
const fileData = await fileRes.json()
const lines = fileData.content.split('\n')
const matches: string[] = []
lines.forEach((line: string, idx: number) => {
if (line.toLowerCase().includes(args.query.toLowerCase())) {
matches.push(` L${idx + 1}: ${line.trim().substring(0, 80)}`)
}
})
if (matches.length > 0) {
results.push(`${file.path}:\n${matches.slice(0, 5).join('\n')}`)
}
}
} catch (e) {
// Skip file errors
}
}
if (results.length === 0) {
return `No se encontro "${args.query}"`
}
return `Busqueda: "${args.query}"\n\n${results.join('\n\n')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
}
]
}

View File

@@ -0,0 +1,211 @@
import type { ToolConfig } from './index'
import { useThemeStore } from '../../../stores/theme'
export function createThemeHandlers(): ToolConfig[] {
return [
{
name: 'get_design_tokens',
description: 'Obtiene los design tokens del tema activo',
category: 'theme',
schema: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['all', 'colors', 'text', 'accent', 'semantic', 'spacing', 'typography', 'effects'],
description: 'Categoria de tokens'
}
}
},
handler: async (args: { category?: string }) => {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) {
return 'No hay tema activo'
}
const category = args.category || 'all'
const variables = theme.variables
if (category !== 'all' && variables[category as keyof typeof variables]) {
const categoryVars = variables[category as keyof typeof variables]
const tokenList = Object.entries(categoryVars)
.map(([name, value]) => `--${name}: ${value}`)
.join('\n')
return `Design Tokens - ${category.toUpperCase()}:\n\n${tokenList}`
}
const allTokens = Object.entries(variables)
.map(([cat, vars]) => {
const tokenList = Object.entries(vars as Record<string, string>)
.map(([name, value]) => ` --${name}: ${value}`)
.join('\n')
return `[${cat.toUpperCase()}]\n${tokenList}`
})
.join('\n\n')
return `Design Tokens del tema "${theme.name}":\n\n${allTokens}`
}
},
{
name: 'get_active_theme',
description: 'Obtiene info del tema activo',
category: 'theme',
schema: { type: 'object', properties: {} },
handler: () => {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) return 'No hay tema activo'
return `Tema activo: "${theme.name}"\n` +
`ID: ${theme.id}\n` +
`Sistema: ${theme.is_system ? 'Si' : 'No'}\n` +
`Default: ${theme.is_default ? 'Si' : 'No'}`
}
},
{
name: 'set_theme_variable',
description: 'Modifica una variable CSS del tema',
category: 'theme',
schema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Nombre de la variable sin --' },
value: { type: 'string', description: 'Nuevo valor' }
},
required: ['name', 'value']
},
handler: (args: { name: string; value: string }) => {
const themeStore = useThemeStore()
const root = document.documentElement
const varName = args.name.startsWith('--') ? args.name : `--${args.name}`
const keyName = args.name.startsWith('--') ? args.name.slice(2) : args.name
const currentValue = getComputedStyle(root).getPropertyValue(varName).trim()
root.style.setProperty(varName, args.value)
if (themeStore.activeTheme) {
const variables = themeStore.activeTheme.variables
for (const category of Object.keys(variables) as (keyof typeof variables)[]) {
if (keyName in variables[category]) {
themeStore.updateVariable(category, keyName, args.value)
break
}
}
}
return `Variable ${varName} cambiada:\n Anterior: ${currentValue || '(no definida)'}\n Nuevo: ${args.value}`
}
},
{
name: 'save_theme',
description: 'Guarda el tema actual',
category: 'theme',
schema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Nombre del tema' },
description: { type: 'string', description: 'Descripcion' },
setAsDefault: { type: 'boolean', description: 'Establecer como default' }
},
required: ['name']
},
handler: async (args: { name: string; description?: string; setAsDefault?: boolean }) => {
const themeStore = useThemeStore()
const variablesToSave = themeStore.previewTheme || themeStore.activeTheme?.variables
if (!variablesToSave) {
return 'Error: No hay variables para guardar'
}
const result = await themeStore.saveTheme({
name: args.name,
description: args.description || `Tema creado el ${new Date().toLocaleString()}`,
variables: variablesToSave,
metadata: { author: 'Claude', version: '1.0', base: themeStore.activeTheme?.id || null }
})
if (args.setAsDefault && result.id) {
await themeStore.setDefaultTheme(result.id)
return `Tema "${args.name}" guardado y establecido como default. ID: ${result.id}`
}
return `Tema "${args.name}" guardado. ID: ${result.id}`
}
},
{
name: 'list_themes',
description: 'Lista todos los temas disponibles',
category: 'theme',
schema: { type: 'object', properties: {} },
handler: async () => {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const themes = themeStore.themes
if (themes.length === 0) return 'No hay temas disponibles'
const systemThemes = themes.filter(t => t.is_system)
const userThemes = themes.filter(t => !t.is_system)
let result = `Temas disponibles (${themes.length}):\n\n`
if (systemThemes.length > 0) {
result += `[SISTEMA]\n`
result += systemThemes.map(t => ` - ${t.name}${t.is_default ? ' [DEFAULT]' : ''}`).join('\n')
result += '\n\n'
}
if (userThemes.length > 0) {
result += `[PERSONALIZADOS]\n`
result += userThemes.map(t => ` - ${t.name}${t.is_default ? ' [DEFAULT]' : ''}`).join('\n')
}
return result
}
},
{
name: 'switch_theme',
description: 'Cambia al tema especificado',
category: 'theme',
schema: {
type: 'object',
properties: {
theme: { type: 'string', description: 'Nombre o ID del tema' }
},
required: ['theme']
},
handler: async (args: { theme: string }) => {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
return `Tema "${args.theme}" no encontrado`
}
themeStore.selectTheme(theme)
return `Tema cambiado a "${theme.name}"`
}
},
{
name: 'reset_theme',
description: 'Descarta cambios no guardados',
category: 'theme',
schema: { type: 'object', properties: {} },
handler: () => {
const themeStore = useThemeStore()
if (!themeStore.previewTheme) {
return 'No hay cambios pendientes'
}
themeStore.resetPreview()
return 'Cambios descartados'
}
}
]
}

View File

@@ -1,246 +0,0 @@
import { registerTool, unregisterTools } from '../webmcp'
import { useProjectCanvasStore } from '../../stores/projectCanvas'
export const PROJECT_CANVAS_TOOLS = [
'list_canvases',
'create_canvas',
'get_canvas',
'update_canvas',
'delete_canvas',
'clone_canvas',
'add_component_to_canvas',
'remove_component_from_canvas',
'get_canvas_components'
]
export function registerProjectCanvasTools() {
const store = useProjectCanvasStore()
// list_canvases
registerTool(
'list_canvases',
'Lista todos los canvas disponibles (proyectos, sistema)',
{
type: 'object',
properties: {
type: {
type: 'string',
enum: ['all', 'project', 'system'],
description: 'Filtrar por tipo de canvas'
}
}
},
async (args: { type?: string }) => {
await store.fetchCanvases()
let canvases = store.canvases
if (args.type === 'project') {
canvases = store.projectCanvases
} else if (args.type === 'system') {
canvases = store.systemCanvases
}
return JSON.stringify(canvases.map(c => ({
id: c.id,
name: c.name,
type: c.type,
description: c.description,
is_system: c.is_system
})), null, 2)
}
)
// create_canvas
registerTool(
'create_canvas',
'Crea un nuevo project canvas',
{
type: 'object',
properties: {
name: { type: 'string', description: 'Nombre del canvas' },
description: { type: 'string', description: 'Descripcion del canvas' },
theme_id: { type: 'string', description: 'ID del tema a usar (opcional)' },
config: {
type: 'object',
description: 'Configuracion del canvas (layout, settings, permissions)'
}
},
required: ['name']
},
async (args: { name: string; description?: string; theme_id?: string; config?: object }) => {
const id = await store.createCanvas({
name: args.name,
description: args.description,
theme_id: args.theme_id,
config: args.config as any,
type: 'project'
})
if (id) {
return `Canvas "${args.name}" creado con ID: ${id}`
}
return `Error al crear canvas: ${store.error}`
}
)
// get_canvas
registerTool(
'get_canvas',
'Obtiene los detalles de un canvas por ID',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas' }
},
required: ['id']
},
async (args: { id: string }) => {
const canvas = await store.fetchCanvasById(args.id)
if (!canvas) {
return `Canvas con ID "${args.id}" no encontrado`
}
return JSON.stringify(canvas, null, 2)
}
)
// update_canvas
registerTool(
'update_canvas',
'Actualiza un canvas existente',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas a actualizar' },
name: { type: 'string', description: 'Nuevo nombre' },
description: { type: 'string', description: 'Nueva descripcion' },
theme_id: { type: 'string', description: 'Nuevo tema' },
config: { type: 'object', description: 'Nueva configuracion' }
},
required: ['id']
},
async (args: { id: string; name?: string; description?: string; theme_id?: string; config?: object }) => {
const { id, ...data } = args
const success = await store.updateCanvas(id, data as any)
if (success) {
return `Canvas "${id}" actualizado`
}
return `Error al actualizar canvas: ${store.error}`
}
)
// delete_canvas
registerTool(
'delete_canvas',
'Elimina un canvas (no se pueden eliminar canvas del sistema)',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas a eliminar' }
},
required: ['id']
},
async (args: { id: string }) => {
const success = await store.deleteCanvas(args.id)
if (success) {
return `Canvas "${args.id}" eliminado`
}
return `Error al eliminar canvas: ${store.error}`
}
)
// clone_canvas
registerTool(
'clone_canvas',
'Clona un canvas existente (incluyendo sus componentes)',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas a clonar' },
name: { type: 'string', description: 'Nombre para el nuevo canvas (opcional)' }
},
required: ['id']
},
async (args: { id: string; name?: string }) => {
const newId = await store.cloneCanvas(args.id, args.name)
if (newId) {
return `Canvas clonado con nuevo ID: ${newId}`
}
return `Error al clonar canvas: ${store.error}`
}
)
// add_component_to_canvas
registerTool(
'add_component_to_canvas',
'Agrega un componente guardado a un canvas',
{
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' },
component_id: { type: 'string', description: 'ID del componente a agregar' },
props: { type: 'object', description: 'Props para el componente en este canvas' },
position: { type: 'number', description: 'Posicion del componente (orden de renderizado)' }
},
required: ['canvas_id', 'component_id']
},
async (args: { canvas_id: string; component_id: string; props?: object; position?: number }) => {
const success = await store.addComponentToCanvas(
args.canvas_id,
args.component_id,
args.props as Record<string, any>,
args.position
)
if (success) {
return `Componente "${args.component_id}" agregado al canvas "${args.canvas_id}"`
}
return 'Error al agregar componente al canvas'
}
)
// remove_component_from_canvas
registerTool(
'remove_component_from_canvas',
'Remueve un componente de un canvas',
{
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' },
component_id: { type: 'string', description: 'ID del componente a remover' }
},
required: ['canvas_id', 'component_id']
},
async (args: { canvas_id: string; component_id: string }) => {
const success = await store.removeComponentFromCanvas(args.canvas_id, args.component_id)
if (success) {
return `Componente "${args.component_id}" removido del canvas "${args.canvas_id}"`
}
return 'Error al remover componente del canvas'
}
)
// get_canvas_components
registerTool(
'get_canvas_components',
'Obtiene los componentes de un canvas con sus definiciones',
{
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' }
},
required: ['canvas_id']
},
async (args: { canvas_id: string }) => {
await store.fetchCanvasComponents(args.canvas_id)
return JSON.stringify(store.activeCanvasComponents, null, 2)
}
)
}
export function unregisterProjectCanvasTools() {
unregisterTools(PROJECT_CANVAS_TOOLS)
}

View File

@@ -1,290 +0,0 @@
import { registerTool, unregisterTools } from '../webmcp'
export const SOURCE_CODE_TOOLS = [
'get_repo_info',
'list_repo_files',
'read_repo_file',
'search_repo_code'
]
const API_BASE = 'http://localhost:4101'
// Store credentials in memory (not persisted)
let giteaCredentials: {
giteaUrl: string
username: string
password: string
owner: string
repo: string
branch: string
} | null = null
export function setGiteaCredentials(creds: typeof giteaCredentials) {
giteaCredentials = creds
}
export function registerSourceCodeTools() {
// get_repo_info
registerTool(
'get_repo_info',
'Obtiene informacion del repositorio conectado en Gitea',
{
type: 'object',
properties: {}
},
async () => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/repo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(giteaCredentials)
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const repo = data.repo
return `Repositorio: ${repo.owner.login}/${repo.name}\n` +
`Descripcion: ${repo.description || 'Sin descripcion'}\n` +
`Rama default: ${repo.default_branch}\n` +
`Stars: ${repo.stars_count}\n` +
`Forks: ${repo.forks_count}\n` +
`Ramas disponibles: ${data.branches.join(', ')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// list_repo_files
registerTool(
'list_repo_files',
'Lista archivos y carpetas en una ruta del repositorio',
{
type: 'object',
properties: {
path: {
type: 'string',
description: 'Ruta dentro del repositorio (vacio para raiz)'
}
}
},
async (args: { path?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path || ''
})
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const path = args.path || '/'
if (data.tree.length === 0) {
return `No hay archivos en ${path}`
}
const folders = data.tree.filter((f: any) => f.type === 'dir')
const files = data.tree.filter((f: any) => f.type === 'file')
let result = `Contenido de ${path}:\n\n`
if (folders.length > 0) {
result += `Carpetas (${folders.length}):\n`
result += folders.map((f: any) => ` [DIR] ${f.name}/`).join('\n')
result += '\n\n'
}
if (files.length > 0) {
result += `Archivos (${files.length}):\n`
result += files.map((f: any) => ` ${f.name}`).join('\n')
}
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// read_repo_file
registerTool(
'read_repo_file',
'Lee el contenido de un archivo del repositorio',
{
type: 'object',
properties: {
path: {
type: 'string',
description: 'Ruta del archivo dentro del repositorio'
},
lines: {
type: 'number',
description: 'Numero maximo de lineas a retornar (default: 100)'
}
},
required: ['path']
},
async (args: { path: string; lines?: number }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path
})
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
let content = data.content
// Limit lines if specified
const maxLines = args.lines || 100
const lines = content.split('\n')
if (lines.length > maxLines) {
content = lines.slice(0, maxLines).join('\n')
content += `\n\n... (${lines.length - maxLines} lineas mas)`
}
return `Archivo: ${args.path}\nTamano: ${data.size} bytes\n\n${content}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// search_repo_code
registerTool(
'search_repo_code',
'Busca codigo en el repositorio (busqueda simple en archivos)',
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Texto a buscar en los archivos'
},
path: {
type: 'string',
description: 'Ruta donde buscar (default: raiz)'
},
extension: {
type: 'string',
description: 'Extension de archivos a buscar (ej: ts, vue, js)'
}
},
required: ['query']
},
async (args: { query: string; path?: string; extension?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
// First get the file tree
const treeRes = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path || ''
})
})
if (!treeRes.ok) {
return 'Error al obtener lista de archivos'
}
const treeData = await treeRes.json()
const files = treeData.tree.filter((f: any) => {
if (f.type !== 'file') return false
if (args.extension) {
return f.name.endsWith(`.${args.extension}`)
}
return true
})
const results: string[] = []
const maxFiles = 10
let filesSearched = 0
for (const file of files.slice(0, maxFiles)) {
try {
const fileRes = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: file.path
})
})
if (fileRes.ok) {
const fileData = await fileRes.json()
const lines = fileData.content.split('\n')
const matches: string[] = []
lines.forEach((line: string, idx: number) => {
if (line.toLowerCase().includes(args.query.toLowerCase())) {
matches.push(` L${idx + 1}: ${line.trim().substring(0, 80)}`)
}
})
if (matches.length > 0) {
results.push(`${file.path}:\n${matches.slice(0, 5).join('\n')}`)
}
}
filesSearched++
} catch (e) {
// Skip file errors
}
}
if (results.length === 0) {
return `No se encontro "${args.query}" en los primeros ${filesSearched} archivos`
}
return `Busqueda: "${args.query}"\n` +
`Archivos buscados: ${filesSearched}\n` +
`Coincidencias:\n\n${results.join('\n\n')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterSourceCodeTools() {
unregisterTools(SOURCE_CODE_TOOLS)
giteaCredentials = null
}

View File

@@ -1,524 +0,0 @@
import { useThemeStore } from '../../stores/theme'
import { registerTool, unregisterTools } from '../webmcp'
export const THEME_TOOLS = [
'get_design_tokens',
'get_active_theme',
'set_theme_variable',
'save_theme',
'update_theme',
'list_themes',
'switch_theme',
'set_default_theme',
'delete_theme',
'reset_theme',
'export_theme'
]
export function registerThemeTools() {
// get_design_tokens
registerTool(
'get_design_tokens',
'Obtiene los design tokens y guía de estilos del tema activo. Usa esto para crear componentes con estilos consistentes.',
{
type: 'object',
properties: {
category: {
type: 'string',
enum: ['all', 'colors', 'text', 'accent', 'semantic', 'spacing', 'typography', 'effects'],
description: 'Categoría específica de tokens. Por defecto "all" retorna todos.'
}
}
},
async (args: { category?: string }) => {
try {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) {
return 'No hay tema activo. Usa las variables CSS por defecto.'
}
const category = args.category || 'all'
const variables = theme.variables
if (category !== 'all' && variables[category as keyof typeof variables]) {
const categoryVars = variables[category as keyof typeof variables]
const tokenList = Object.entries(categoryVars)
.map(([name, value]) => `--${name}: ${value}`)
.join('\n')
return `Design Tokens - ${category.toUpperCase()}:\n\n${tokenList}\n\nUsa estas variables CSS en tus estilos para mantener consistencia con el tema.`
}
// Return all tokens organized by category
const allTokens = Object.entries(variables)
.map(([cat, vars]) => {
const tokenList = Object.entries(vars as Record<string, string>)
.map(([name, value]) => ` --${name}: ${value}`)
.join('\n')
return `[${cat.toUpperCase()}]\n${tokenList}`
})
.join('\n\n')
return `Design Tokens del tema "${theme.name}":\n\n${allTokens}\n\n` +
`GUÍA DE USO:\n` +
`- Usa var(--nombre-variable) en CSS\n` +
`- Los componentes dinámicos tienen acceso a $theme.getVariable('nombre')\n` +
`- Puedes modificar temporalmente con $theme.setVariable('nombre', 'valor')\n` +
`- Colores semánticos: success, warning, error, info (con -bg para fondos)\n` +
`- Radius: radius-sm (4px), radius-md (8px), radius-lg (12px), radius-full (9999px)`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_active_theme
registerTool(
'get_active_theme',
'Obtiene información del tema actualmente activo',
{
type: 'object',
properties: {}
},
() => {
try {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) {
return 'No hay tema activo'
}
return `Tema activo: "${theme.name}"\n` +
`ID: ${theme.id}\n` +
`Sistema: ${theme.is_system ? 'Sí' : 'No'}\n` +
`Default: ${theme.is_default ? 'Sí' : 'No'}\n` +
`Descripción: ${theme.description || 'Sin descripción'}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// set_theme_variable
registerTool(
'set_theme_variable',
'Modifica una variable CSS del tema en tiempo real (cambio temporal hasta que uses save_theme)',
{
type: 'object',
properties: {
name: {
type: 'string',
description: 'Nombre de la variable sin el prefijo -- (ej: "accent", "bg-primary")'
},
value: {
type: 'string',
description: 'Nuevo valor para la variable (ej: "#ff0000", "12px")'
}
},
required: ['name', 'value']
},
(args: { name: string; value: string }) => {
try {
const themeStore = useThemeStore()
const root = document.documentElement
const varName = args.name.startsWith('--') ? args.name : `--${args.name}`
const keyName = args.name.startsWith('--') ? args.name.slice(2) : args.name
// Get current value for feedback
const currentValue = getComputedStyle(root).getPropertyValue(varName).trim()
// Set new value in DOM
root.style.setProperty(varName, args.value)
// Update the store's previewTheme to track changes
if (themeStore.activeTheme) {
// Find which category this variable belongs to
const variables = themeStore.activeTheme.variables
for (const category of Object.keys(variables) as (keyof typeof variables)[]) {
if (keyName in variables[category]) {
themeStore.updateVariable(category, keyName, args.value)
break
}
}
}
return `Variable ${varName} cambiada:\n` +
` Anterior: ${currentValue || '(no definida)'}\n` +
` Nuevo: ${args.value}\n\n` +
`Usa save_theme para guardar los cambios permanentemente.`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// save_theme
registerTool(
'save_theme',
'Guarda el tema actual con los cambios realizados permanentemente en la base de datos',
{
type: 'object',
properties: {
name: {
type: 'string',
description: 'Nombre para el nuevo tema'
},
description: {
type: 'string',
description: 'Descripción opcional del tema'
},
setAsDefault: {
type: 'boolean',
description: 'Si es true, establece este tema como el activo por defecto'
}
},
required: ['name']
},
async (args: { name: string; description?: string; setAsDefault?: boolean }) => {
try {
const themeStore = useThemeStore()
// Get variables to save (preview has the modified values, or use active)
const variablesToSave = themeStore.previewTheme || themeStore.activeTheme?.variables
if (!variablesToSave) {
return 'Error: No hay tema con variables para guardar'
}
// Save the theme
const result = await themeStore.saveTheme({
name: args.name,
description: args.description || `Tema creado el ${new Date().toLocaleString()}`,
variables: variablesToSave,
metadata: {
author: 'Claude',
version: '1.0',
base: themeStore.activeTheme?.id || null
}
})
// Set as default if requested
if (args.setAsDefault && result.id) {
await themeStore.setDefaultTheme(result.id)
return `Tema "${args.name}" guardado y establecido como default.\nID: ${result.id}`
}
return `Tema "${args.name}" guardado permanentemente en la base de datos.\nID: ${result.id}`
} catch (e: any) {
return `Error al guardar tema: ${e.message}`
}
}
)
// update_theme
registerTool(
'update_theme',
'Actualiza un tema existente (nombre, descripción o variables)',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a actualizar'
},
name: {
type: 'string',
description: 'Nuevo nombre para el tema (opcional)'
},
description: {
type: 'string',
description: 'Nueva descripción para el tema (opcional)'
},
saveCurrentVariables: {
type: 'boolean',
description: 'Si es true, guarda las variables actuales (con los cambios de set_theme_variable) en este tema'
}
},
required: ['theme']
},
async (args: { theme: string; name?: string; description?: string; saveCurrentVariables?: boolean }) => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
// Find theme by ID or name
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
const available = themeStore.themes.map(t => t.name).join(', ')
return `Tema "${args.theme}" no encontrado.\nDisponibles: ${available}`
}
if (theme.is_system) {
return `No se puede modificar "${theme.name}" porque es un tema del sistema. Usa save_theme para crear una copia.`
}
// Build update data
const updateData: { name?: string; description?: string; variables?: any } = {}
if (args.name) {
updateData.name = args.name
}
if (args.description !== undefined) {
updateData.description = args.description
}
if (args.saveCurrentVariables) {
const variablesToSave = themeStore.previewTheme || themeStore.activeTheme?.variables
if (variablesToSave) {
updateData.variables = variablesToSave
}
}
if (Object.keys(updateData).length === 0) {
return 'No se especificaron cambios. Usa name, description o saveCurrentVariables.'
}
await themeStore.updateTheme(theme.id, updateData)
const changes = []
if (args.name) changes.push(`nombre: "${args.name}"`)
if (args.description !== undefined) changes.push('descripción actualizada')
if (args.saveCurrentVariables) changes.push('variables guardadas')
return `Tema "${theme.name}" actualizado:\n ${changes.join('\n ')}`
} catch (e: any) {
return `Error al actualizar tema: ${e.message}`
}
}
)
// list_themes
registerTool(
'list_themes',
'Lista todos los temas disponibles (del sistema y personalizados)',
{
type: 'object',
properties: {}
},
async () => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const themes = themeStore.themes
if (themes.length === 0) {
return 'No hay temas disponibles'
}
const systemThemes = themes.filter(t => t.is_system)
const userThemes = themes.filter(t => !t.is_system)
let result = `Temas disponibles (${themes.length}):\n\n`
if (systemThemes.length > 0) {
result += `[SISTEMA]\n`
result += systemThemes.map(t =>
` - ${t.name} (${t.id})${t.is_default ? ' [DEFAULT]' : ''}`
).join('\n')
result += '\n\n'
}
if (userThemes.length > 0) {
result += `[PERSONALIZADOS]\n`
result += userThemes.map(t =>
` - ${t.name} (${t.id})${t.is_default ? ' [DEFAULT]' : ''}`
).join('\n')
}
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// switch_theme
registerTool(
'switch_theme',
'Cambia al tema especificado por nombre o ID',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a activar'
}
},
required: ['theme']
},
async (args: { theme: string }) => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
// Find theme by ID or name
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
const available = themeStore.themes.map(t => t.name).join(', ')
return `Tema "${args.theme}" no encontrado.\nDisponibles: ${available}`
}
themeStore.selectTheme(theme)
return `Tema cambiado a "${theme.name}"\nID: ${theme.id}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// set_default_theme
registerTool(
'set_default_theme',
'Establece un tema como el default (se cargará automáticamente al iniciar)',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a establecer como default'
}
},
required: ['theme']
},
async (args: { theme: string }) => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
const available = themeStore.themes.map(t => t.name).join(', ')
return `Tema "${args.theme}" no encontrado.\nDisponibles: ${available}`
}
await themeStore.setDefaultTheme(theme.id)
return `Tema "${theme.name}" establecido como default.\nSe cargará automáticamente al iniciar la aplicación.`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// delete_theme
registerTool(
'delete_theme',
'Elimina un tema personalizado (no se pueden eliminar temas del sistema)',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a eliminar'
}
},
required: ['theme']
},
async (args: { theme: string }) => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
return `Tema "${args.theme}" no encontrado`
}
if (theme.is_system) {
return `No se puede eliminar "${theme.name}" porque es un tema del sistema`
}
const success = await themeStore.deleteTheme(theme.id)
if (success) {
return `Tema "${theme.name}" eliminado correctamente`
} else {
return `Error al eliminar el tema "${theme.name}"`
}
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// reset_theme
registerTool(
'reset_theme',
'Descarta todos los cambios no guardados y restaura el tema activo original',
{
type: 'object',
properties: {}
},
() => {
try {
const themeStore = useThemeStore()
if (!themeStore.previewTheme) {
return 'No hay cambios pendientes para descartar'
}
const themeName = themeStore.activeTheme?.name || 'desconocido'
themeStore.resetPreview()
return `Cambios descartados. Tema "${themeName}" restaurado a su estado original.`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// export_theme
registerTool(
'export_theme',
'Exporta un tema como JSON para respaldo o compartir',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a exportar. Si no se especifica, exporta el tema activo.'
}
}
},
async (args: { theme?: string }) => {
try {
const themeStore = useThemeStore()
let theme = themeStore.activeTheme
if (args.theme) {
await themeStore.fetchThemes()
theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme!.toLowerCase()
) || null
}
if (!theme) {
return args.theme
? `Tema "${args.theme}" no encontrado`
: 'No hay tema activo para exportar'
}
const exported = themeStore.exportTheme(theme)
return `Tema "${theme.name}" exportado:\n\n${exported}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterThemeTools() {
unregisterTools(THEME_TOOLS)
}

View File

@@ -0,0 +1,84 @@
export type ToolCategory = 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project'
export interface ToolMeta {
name: string
description: string
category: ToolCategory
}
// All tool metadata (name, description, category)
export const ALL_TOOL_METAS: ToolMeta[] = [
// Global tools
{ name: 'get_current_page', description: 'Obtiene la pagina actualmente activa', category: 'global' },
{ name: 'navigate_to', description: 'Navega a una pagina especifica', category: 'global' },
{ name: 'list_available_tools', description: 'Lista todas las herramientas MCP disponibles', category: 'global' },
// Canvas tools
{ name: 'render_html', description: 'Renderiza HTML en el canvas', category: 'canvas' },
{ name: 'render_vue_component', description: 'Renderiza un componente Vue 3 completo', category: 'canvas' },
// Component tools
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
{ name: 'load_vue_component', description: 'Carga y renderiza un componente guardado', category: 'component' },
{ name: 'list_vue_components', description: 'Lista componentes guardados', category: 'component' },
{ name: 'delete_vue_component', description: 'Elimina un componente', category: 'component' },
// Theme tools
{ name: 'get_design_tokens', description: 'Obtiene los design tokens del tema activo', category: 'theme' },
{ name: 'get_active_theme', description: 'Obtiene info del tema activo', category: 'theme' },
{ name: 'set_theme_variable', description: 'Modifica una variable CSS del tema', category: 'theme' },
{ name: 'save_theme', description: 'Guarda el tema actual', category: 'theme' },
{ name: 'list_themes', description: 'Lista todos los temas disponibles', category: 'theme' },
{ name: 'switch_theme', description: 'Cambia al tema especificado', category: 'theme' },
{ name: 'reset_theme', description: 'Descarta cambios no guardados', category: 'theme' },
// Database tools
{ name: 'list_tables', description: 'Lista todas las tablas de la base de datos', category: 'database' },
{ name: 'get_table_schema', description: 'Obtiene el esquema de una tabla', category: 'database' },
{ name: 'get_table_data', description: 'Obtiene los datos de una tabla', category: 'database' },
{ name: 'get_database_stats', description: 'Obtiene estadisticas de la base de datos', category: 'database' },
{ name: 'execute_query', description: 'Ejecuta una consulta SQL SELECT', category: 'database' },
// Source code tools
{ name: 'get_repo_info', description: 'Obtiene info del repositorio Gitea', category: 'source' },
{ name: 'list_repo_files', description: 'Lista archivos del repositorio', category: 'source' },
{ name: 'read_repo_file', description: 'Lee contenido de un archivo', category: 'source' },
{ name: 'search_repo_code', description: 'Busca codigo en el repositorio', category: 'source' },
// Project canvas tools
{ name: 'list_canvases', description: 'Lista todos los canvas disponibles', category: 'project' },
{ name: 'create_canvas', description: 'Crea un nuevo project canvas', category: 'project' },
{ name: 'get_canvas', description: 'Obtiene detalles de un canvas', category: 'project' },
{ name: 'update_canvas', description: 'Actualiza un canvas existente', category: 'project' },
{ name: 'delete_canvas', description: 'Elimina un canvas', category: 'project' },
{ name: 'clone_canvas', description: 'Clona un canvas existente', category: 'project' },
{ name: 'add_component_to_canvas', description: 'Agrega un componente a un canvas', category: 'project' },
{ name: 'remove_component_from_canvas', description: 'Remueve un componente de un canvas', category: 'project' },
{ name: 'get_canvas_components', description: 'Obtiene los componentes de un canvas', category: 'project' }
]
// Get all tool names
export function getAllToolNames(): string[] {
return ALL_TOOL_METAS.map(t => t.name)
}
// Get tool metadata by name
export function getToolMeta(name: string): ToolMeta | undefined {
return ALL_TOOL_METAS.find(t => t.name === name)
}
// Get tools by category
export function getToolsByCategory(category: ToolCategory): ToolMeta[] {
return ALL_TOOL_METAS.filter(t => t.category === category)
}
// Category display info
export const CATEGORY_INFO: Record<ToolCategory, { label: string; color: string; icon: string }> = {
global: { label: 'Global', color: '#6366f1', icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2z' },
canvas: { label: 'Canvas', color: '#10b981', icon: 'M3 3h18v18H3V3z' },
component: { label: 'Component', color: '#f59e0b', icon: 'M21 16V8l-7-4-7 4v8l7 4 7-4z' },
theme: { label: 'Theme', color: '#ec4899', icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10' },
database: { label: 'Database', color: '#3b82f6', icon: 'M12 2C7 2 3 3.5 3 5v14c0 1.5 4 3 9 3s9-1.5 9-3V5c0-1.5-4-3-9-3z' },
source: { label: 'Source', color: '#8b5cf6', icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
project: { label: 'Project', color: '#06b6d4', icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z' }
}

View File

@@ -0,0 +1,170 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface ToolDefinition {
name: string
description: string
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project'
schema: object
handler: Function
}
export const useToolsStore = defineStore('tools', () => {
// All available tool definitions
const toolDefinitions = ref<Map<string, ToolDefinition>>(new Map())
// Pinned tools persist across page changes (use array for reactivity)
const pinnedToolsArray = ref<string[]>([])
// Currently active tools (use array for reactivity)
const activeToolsArray = ref<string[]>([])
// Computed
const allTools = computed(() => Array.from(toolDefinitions.value.values()))
const activeToolsDefs = computed(() =>
allTools.value.filter(t => activeToolsArray.value.includes(t.name))
)
const inactiveToolsDefs = computed(() =>
allTools.value.filter(t => !activeToolsArray.value.includes(t.name))
)
const pinnedToolsDefs = computed(() =>
allTools.value.filter(t => pinnedToolsArray.value.includes(t.name))
)
const toolsByCategory = computed(() => {
const categories: Record<string, { active: ToolDefinition[], inactive: ToolDefinition[] }> = {}
for (const tool of allTools.value) {
if (!categories[tool.category]) {
categories[tool.category] = { active: [], inactive: [] }
}
if (activeToolsArray.value.includes(tool.name)) {
categories[tool.category].active.push(tool)
} else {
categories[tool.category].inactive.push(tool)
}
}
return categories
})
// Actions
function registerToolDefinition(tool: ToolDefinition) {
toolDefinitions.value.set(tool.name, tool)
}
function registerToolDefinitions(tools: ToolDefinition[]) {
for (const tool of tools) {
toolDefinitions.value.set(tool.name, tool)
}
}
function setToolActive(name: string, active: boolean) {
const index = activeToolsArray.value.indexOf(name)
if (active && index === -1) {
activeToolsArray.value.push(name)
} else if (!active && index !== -1) {
activeToolsArray.value.splice(index, 1)
}
}
function pinTool(name: string) {
if (!pinnedToolsArray.value.includes(name)) {
pinnedToolsArray.value.push(name)
savePinnedTools()
}
}
function unpinTool(name: string) {
const index = pinnedToolsArray.value.indexOf(name)
if (index !== -1) {
pinnedToolsArray.value.splice(index, 1)
savePinnedTools()
}
}
function togglePin(name: string) {
if (pinnedToolsArray.value.includes(name)) {
unpinTool(name)
} else {
pinTool(name)
}
}
function isToolPinned(name: string): boolean {
return pinnedToolsArray.value.includes(name)
}
function isToolActive(name: string): boolean {
return activeToolsArray.value.includes(name)
}
function getPinnedToolNames(): string[] {
return [...pinnedToolsArray.value]
}
function getToolDefinition(name: string): ToolDefinition | undefined {
return toolDefinitions.value.get(name)
}
// Persistence
function savePinnedTools() {
localStorage.setItem('pinnedTools', JSON.stringify(pinnedToolsArray.value))
}
function loadPinnedTools() {
try {
const saved = localStorage.getItem('pinnedTools')
if (saved) {
const parsed = JSON.parse(saved)
pinnedToolsArray.value = parsed
}
} catch (e) {
console.error('[ToolsStore] Failed to load pinned tools:', e)
}
}
function clearActiveTools() {
activeToolsArray.value = []
}
function setActiveTools(names: string[]) {
activeToolsArray.value = [...names]
}
// Initialize
loadPinnedTools()
return {
// State (reactive arrays)
activeTools: activeToolsArray,
pinnedTools: pinnedToolsArray,
toolDefinitions,
// Computed
allTools,
activeToolsDefs,
inactiveToolsDefs,
pinnedToolsDefs,
toolsByCategory,
// Actions
registerToolDefinition,
registerToolDefinitions,
setToolActive,
pinTool,
unpinTool,
togglePin,
isToolPinned,
isToolActive,
getPinnedToolNames,
getToolDefinition,
clearActiveTools,
setActiveTools,
loadPinnedTools
}
})