Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ facturascripts/
.claude/
/mysql/

# Módulos locales del usuario (nunca se suben al repositorio)
src/modules-local/

# OpenAPI files - keep the current one, exclude the old one
/facturascripts.openapi.json
# Keep the current OpenAPI definition file
Expand Down
39 changes: 38 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ import { tarifasToolDefinition, tarifasToolImplementation } from './modules/conf
import { totalModelesToolDefinition, totalModelesToolImplementation } from './modules/system/totalmodeles/index.js';
import { variantesToolDefinition, variantesToolImplementation } from './modules/core-business/variantes/index.js';
import { workEventesToolDefinition, workEventesToolImplementation } from './modules/system/workeventes/index.js';
import { loadLocalModules } from './local-loader.js';
import type { LoadedLocalModule } from './types/local-module.js';

const server = new Server(
{
Expand Down Expand Up @@ -203,6 +205,8 @@ const subcuentasResource = new SubcuentasResource(fsClient);
const tarifasResource = new TarifasResource(fsClient);
const totalModelesResource = new TotalModelesResource(fsClient);
const workEventesResource = new WorkEventesResource(fsClient);
// Módulos locales — se cargan en runServer() antes de conectar
let localModules: LoadedLocalModule[] = [];

server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
Expand Down Expand Up @@ -2095,6 +2099,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
},
},
// Módulos locales (cargados dinámicamente desde src/modules-local/)
...localModules.map(m => m.toolDefinition),
],
};
});
Expand Down Expand Up @@ -2570,6 +2576,13 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => {
description: 'Lista de eventos y trabajos del sistema para monitoreo y seguimiento de procesos',
mimeType: 'application/json',
},
// Módulos locales (cargados dinámicamente desde src/modules-local/)
...localModules.map(m => ({
uri: `facturascripts://${m.resourceName}`,
name: `FacturaScripts Local — ${m.resourceName}`,
description: m.resourceDescription,
mimeType: 'application/json',
})),
],
};
});
Expand Down Expand Up @@ -2889,6 +2902,13 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
return await workEventesResource.getResource(uri);
}

// Módulos locales
for (const localModule of localModules) {
if (localModule.instance.matchesUri(uri)) {
return await localModule.instance.getResource(uri);
Comment on lines +2907 to +2908
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aquí se asume que localModule.instance.matchesUri existe y es callable. Si un módulo local está mal implementado, esto puede tirar abajo el handler con TypeError. Para robustez, añade un type-guard (comprobar que matchesUri/getResource son funciones) o garantiza esa validación en loadLocalModules() antes de añadir el módulo a localModules.

Suggested change
if (localModule.instance.matchesUri(uri)) {
return await localModule.instance.getResource(uri);
const instance = (localModule as any).instance;
if (
instance &&
typeof instance.matchesUri === 'function' &&
typeof instance.getResource === 'function' &&
instance.matchesUri(uri)
) {
return await instance.getResource(uri);

Copilot uses AI. Check for mistakes.
}
}

throw new Error(`Resource not found: ${uri}`);
});

Expand Down Expand Up @@ -3836,8 +3856,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
};
}

default:

default: {
// Módulos locales
const localMod = localModules.find(m => m.toolDefinition.name === name);
if (localMod) {
const uri = buildUri(localMod.resourceName);
const result = await localMod.instance.getResource(uri);
return {
content: [
{
type: 'text',
text: (result as any).contents?.[0]?.text || 'No data',
},
],
};
}
Comment on lines +3860 to +3874
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

La ejecución de herramientas locales en el default ignora la semántica de la tool: siempre llama a getResource(buildUri(resourceName)) y devuelve solo contents[0].text. Esto limita mucho a módulos locales (no pueden implementar acciones ni consumir argumentos arbitrarios fuera de limit/offset/filter/order y prefijos filter_/operation_/sort_ del buildUri). Considera extender LocalModuleConfig con una toolImplementation(args) (o similar) y delegar ahí la ejecución, o al menos pasar todos los args relevantes a la lógica del módulo local.

Copilot uses AI. Check for mistakes.
throw new Error(`Unknown tool: ${name}`);
}
}
} catch (error) {
return {
Expand All @@ -3853,6 +3889,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
});

async function runServer() {
localModules = await loadLocalModules(fsClient);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP FacturaScripts server running on stdio');
Expand Down
81 changes: 81 additions & 0 deletions src/local-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { readdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { FacturaScriptsClient } from './fs/client.js';
import type { LocalModuleConfig, LoadedLocalModule } from './types/local-module.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

/**
* Carga dinámicamente todos los módulos locales de la carpeta modules-local/.
*
* Cada subcarpeta de modules-local/ debe tener un index.js (compilado desde index.ts)
* que exporte `moduleConfig` siguiendo la interfaz LocalModuleConfig.
*
* La carpeta src/modules-local/ está en .gitignore, por lo que los módulos
* locales nunca se subirán al repositorio.
*/
export async function loadLocalModules(fsClient: FacturaScriptsClient): Promise<LoadedLocalModule[]> {
const localModulesDir = join(__dirname, 'modules-local');

if (!existsSync(localModulesDir)) {
return [];
}

const loaded: LoadedLocalModule[] = [];
Comment on lines +19 to +26
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Falta cobertura de tests para loadLocalModules(). Hay unit tests en el repo (Vitest) para otros componentes, y este loader tiene bastante lógica de IO/validación; añadir tests (p. ej. directorio inexistente => [], módulo sin moduleConfig, carga correcta, etc.) ayudaría a evitar regresiones.

Copilot uses AI. Check for mistakes.

let entries: import('fs').Dirent<string>[];
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import('fs').Dirent<string>[] probablemente no compila: en @types/node la clase Dirent no es genérica, así que TypeScript suele dar Type 'Dirent' is not generic. Cambia el tipo a import('fs').Dirent[] (o simplemente infiérelo desde readdirSync).

Suggested change
let entries: import('fs').Dirent<string>[];
let entries: import('fs').Dirent[];

Copilot uses AI. Check for mistakes.
try {
entries = readdirSync(localModulesDir, { withFileTypes: true, encoding: 'utf8' });
} catch {
console.error('[local-loader] No se pudo leer el directorio modules-local/');
return [];
}

const dirs = entries.filter(e => e.isDirectory());

for (const dir of dirs) {
const indexPath = join(localModulesDir, dir.name as string, 'index.js');

if (!existsSync(indexPath)) {
console.error(`[local-loader] Módulo "${dir.name}" no tiene index.js compilado — ejecuta npm run build`);
Comment on lines +39 to +42
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

El loader solo busca index.js. Con npm run dev:ts (que ejecuta tsx src/index.ts) __dirname apunta a src/ y en módulos locales normalmente habrá index.ts, no index.js, así que en modo dev no se cargarán. Para que funcione en ambos modos, contempla resolver index.ts cuando se ejecute desde TS (o buscar primero index.js y si no existe, probar index.ts).

Suggested change
const indexPath = join(localModulesDir, dir.name as string, 'index.js');
if (!existsSync(indexPath)) {
console.error(`[local-loader] Módulo "${dir.name}" no tiene index.js compilado — ejecuta npm run build`);
const jsIndexPath = join(localModulesDir, dir.name as string, 'index.js');
const tsIndexPath = join(localModulesDir, dir.name as string, 'index.ts');
let indexPath: string;
if (existsSync(jsIndexPath)) {
indexPath = jsIndexPath;
} else if (existsSync(tsIndexPath)) {
indexPath = tsIndexPath;
} else {
console.error(
`[local-loader] Módulo "${dir.name}" no tiene ni index.js compilado ni index.ts — ` +
'ejecuta npm run build o crea un index.ts en modules-local/'
);

Copilot uses AI. Check for mistakes.
continue;
}

try {
const moduleUrl = pathToFileURL(indexPath).href;
const mod = await import(moduleUrl);
const config: LocalModuleConfig = mod.moduleConfig;

if (!config) {
console.error(`[local-loader] Módulo "${dir.name}" no exporta "moduleConfig"`);
continue;
}

if (!config.resourceName || !config.Resource || !config.toolDefinition) {
console.error(`[local-loader] Módulo "${dir.name}": moduleConfig incompleto (faltan resourceName, Resource o toolDefinition)`);
continue;
}

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Antes de instanciar y registrar el módulo, convendría validar que config.Resource realmente expone matchesUri() y getResource() (o que la instancia las tiene como funciones). Ahora mismo, si un módulo local exporta una clase incorrecta, el servidor puede reventar más tarde al atender ReadResource/CallTool. Considera hacer esta validación aquí y saltarte el módulo con un log claro.

Suggested change
// Validar que el Resource exportado tiene la forma esperada antes de instanciarlo.
const resourceProto = (config.Resource as any)?.prototype;
if (
!resourceProto ||
typeof resourceProto.matchesUri !== 'function' ||
typeof resourceProto.getResource !== 'function'
) {
console.error(
`[local-loader] Módulo "${dir.name}": Resource no implementa matchesUri()/getResource(), módulo ignorado`
);
continue;
}

Copilot uses AI. Check for mistakes.
const instance = new config.Resource(fsClient);

loaded.push({
instance,
toolDefinition: config.toolDefinition,
resourceName: config.resourceName,
resourceDescription: config.resourceDescription ?? '',
});

console.error(`[local-loader] ✓ Módulo local cargado: ${dir.name} (tool: ${config.toolDefinition.name})`);
} catch (err) {
console.error(`[local-loader] Error al cargar módulo "${dir.name}":`, err);
}
}

if (loaded.length > 0) {
console.error(`[local-loader] ${loaded.length} módulo(s) local(es) cargado(s)`);
}

return loaded;
}
39 changes: 39 additions & 0 deletions src/types/local-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { FacturaScriptsClient } from '../fs/client.js';

/**
* Interfaz que debe exportar cada módulo local como `moduleConfig`.
*
* Ejemplo de uso en src/modules-local/mi-modulo/index.ts:
*
* import { MiResource } from './resource.js';
* import { toolDefinition } from './tool.js';
* import type { LocalModuleConfig } from '../../types/local-module.js';
*
* export const moduleConfig: LocalModuleConfig = {
* resourceName: 'mi-recurso',
* resourceDescription: 'Descripción de mi recurso local',
* Resource: MiResource,
* toolDefinition,
* };
*/
export interface LocalModuleConfig {
/** Nombre del recurso, usado en la URI: facturascripts://{resourceName} */
resourceName: string;
/** Descripción visible en el listado de recursos MCP */
resourceDescription: string;
/** Clase del recurso. Debe implementar getResource(uri) y matchesUri(uri) */
Resource: new (client: FacturaScriptsClient) => any;
/** Definición de la herramienta MCP (name, description, inputSchema) */
toolDefinition: {
name: string;
description: string;
inputSchema: object;
};
Comment on lines +24 to +31
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LocalModuleConfig.Resource y LoadedLocalModule.instance están tipados como any, lo que hace que se pierda el contrato de matchesUri()/getResource() y que errores se detecten solo en runtime. Sería más seguro definir un tipo/interfaz (p. ej. LocalResource { matchesUri(uri: string): boolean; getResource(uri: string): Promise<Resource>; }) y usarlo aquí para tener checks en compilación.

Copilot uses AI. Check for mistakes.
}

export interface LoadedLocalModule {
instance: any;
toolDefinition: LocalModuleConfig['toolDefinition'];
resourceName: string;
resourceDescription: string;
}
Loading