diff --git a/docker-compose.yml b/docker-compose.yml index 6999b6d..22633f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,7 +45,7 @@ services: build: ./mcp restart: unless-stopped environment: - PLANILLA_API_URL: "http://api:4000" + PLANILLA_API_URL: "http://planilla-api:4000" depends_on: - api ports: diff --git a/mcp/createServer.js b/mcp/createServer.js new file mode 100644 index 0000000..cd8442f --- /dev/null +++ b/mcp/createServer.js @@ -0,0 +1,16 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import registerPlanillas from "./modules/planillas.js"; +import registerEmpleados from "./modules/empleados.js"; +import registerAsistencias from "./modules/asistencias.js"; +import registerTareas from "./modules/tareas.js"; + +export function createServer() { + const server = new McpServer({ name: "planilla-mcp", version: "0.1.0" }); + + registerPlanillas(server); + registerEmpleados(server); + registerAsistencias(server); + registerTareas(server); + + return server; +} diff --git a/mcp/index.js b/mcp/index.js index b063bfa..8ca99f0 100644 --- a/mcp/index.js +++ b/mcp/index.js @@ -1,164 +1,9 @@ -// 🚨 Nuevos logs en puntos clave para saber qué se pide y cuándo -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import express from "express"; -import { z } from "zod"; +import { createServer } from "./createServer.js"; -// ------------------------------- -// 🔧 VARIABLES Y HELPERS GLOBALES -// ------------------------------- -const API_BASE_URL = process.env.PLANILLA_API_URL || "http://localhost:4000"; -const log = (...args) => console.log("[MCP]", ...args); -const bootLog = (...args) => console.log("[BOOT]", ...args); - -// Imprime detalles del runtime apenas carga el módulo -(function printBootInfo () { - bootLog("Iniciando Planilla MCP v0.1.0"); - bootLog(`Node : ${process.version} (${process.platform}-${process.arch})`); - bootLog(`Working directory : ${process.cwd()}`); - bootLog(`PLANILLA_API_URL : ${API_BASE_URL}`); - bootLog(`PORT (HTTP) : ${process.env.PORT ?? "5000 (default)"}`); - bootLog(`Args : ${process.argv.slice(2).join(" ") || ""}`); - const rss = (process.memoryUsage().rss / 1024 ** 2).toFixed(1); - bootLog(`Boot RSS Memory : ${rss} MB`); -})(); - -// -------------------------------- -// ⛑️ FETCH HELPER CON LOGGING -// -------------------------------- -async function fetchJSON (path, options = {}) { - const method = options.method || "GET"; - console.log(`[API] ${method} ${API_BASE_URL}${path}`); - const res = await fetch(`${API_BASE_URL}${path}`, { - headers: { "Content-Type": "application/json" }, - ...options, - }); - if (!res.ok) { - const txt = await res.text(); - throw new Error(`API request failed (${res.status}): ${txt}`); - } - return res.json(); -} - -// ------------------------------- -// 🏗️ CREACIÓN DEL MCP SERVER -// ------------------------------- -function createServer () { - const server = new McpServer({ name: "planilla-mcp", version: "0.1.0" }); - log("Servidor MCP instanciado"); - - // ----- Resources ----- - server.resource("planilla-list", "planilla://list", async (uri) => { - log("Recurso solicitado", "planilla-list"); - const planillas = await fetchJSON("/api/planillas"); - return { contents: [{ uri: uri.href, text: JSON.stringify(planillas) }] }; - }); - - server.resource( - "planilla", - new ResourceTemplate("planilla://{id}", { list: undefined }), - async (uri, { id }) => { - log("Recurso solicitado", `planilla ${id}`); - const planilla = await fetchJSON(`/api/planillas/${id}`); - return { contents: [{ uri: uri.href, text: JSON.stringify(planilla) }] }; - } - ); - - // ----- Tools ----- - server.tool( - "create-planilla", - "Crea una planilla", - { - empleado_id: z.number(), - fecha_desde: z.string(), - fecha_hasta: z.string(), - titulo: z.string(), - total: z.number().optional(), - estado: z.string().optional(), - fecha_anulado:z.string().optional(), - creador_id: z.number().optional(), - anulador_id: z.number().optional(), - }, - async (params) => { - log("Tool invocada", "create-planilla", params); - const planilla = await fetchJSON("/api/planillas", { - method: "POST", - body: JSON.stringify(params), - }); - return { content: [{ type: "text", text: JSON.stringify(planilla) }] }; - } - ); - - server.tool( - "update-planilla", - "Actualiza una planilla existente", - { - id: z.number(), - empleado_id: z.number().optional(), - fecha_desde: z.string().optional(), - fecha_hasta: z.string().optional(), - titulo: z.string().optional(), - total: z.number().optional(), - estado: z.string().optional(), - fecha_anulado:z.string().optional(), - anulador_id: z.number().optional(), - }, - async ({ id, ...updates }) => { - log("Tool invocada", "update-planilla", { id, ...updates }); - const planilla = await fetchJSON(`/api/planillas/${id}`, { - method: "PUT", - body: JSON.stringify(updates), - }); - return { content: [{ type: "text", text: JSON.stringify(planilla) }] }; - } - ); - - server.tool( - "delete-planilla", - "Elimina una planilla", - { id: z.number() }, - async ({ id }) => { - log("Tool invocada", "delete-planilla", { id }); - await fetchJSON(`/api/planillas/${id}`, { method: "DELETE" }); - return { content: [{ type: "text", text: `Planilla ${id} eliminada` }] }; - } - ); - - server.tool( - "search-planillas", - "Busca planillas. `q` es un texto libre que matchea id, empleado_id, título o estado. Si no mandás ningún argumento devuelve los primeros 100 registros.", - { - q: z.string().optional(), - empleado_id: z.number().optional(), - estado: z.string().optional(), - titulo: z.string().optional(), - fecha_desde_desde: z.string().optional(), - fecha_desde_hasta: z.string().optional(), - fecha_hasta_desde: z.string().optional(), - fecha_hasta_hasta: z.string().optional(), - }, - async (params) => { - log("Tool invocada", "search-planillas", params); - const qs = new URLSearchParams( - Object.entries(params).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)]) - ); - - // 👇 Si no hay filtros, limit=100 - if (qs.toString() === "") qs.append("limit", "100"); - - const planillas = await fetchJSON(`/api/planillas/search?${qs.toString()}`); - return { content: [{ type: "text", text: JSON.stringify(planillas) }] }; - } - ); - - return server; -} - -// -------------------------------- -// 🚀 PUNTO DE ENTRADA -// -------------------------------- -async function main () { +async function main() { const useStdio = process.argv.includes("--stdio"); if (useStdio) { bootLog("Modo transporte: stdio"); @@ -171,7 +16,6 @@ async function main () { const app = express(); app.use(express.json()); - // 🌐 Log de cada request HTTP entrante app.use((req, _res, next) => { console.log(`[HTTP] ${req.method} ${req.originalUrl}`); next(); diff --git a/mcp/lib/api.js b/mcp/lib/api.js new file mode 100644 index 0000000..d7364f1 --- /dev/null +++ b/mcp/lib/api.js @@ -0,0 +1,15 @@ +export const API_BASE_URL = process.env.PLANILLA_API_URL || "http://localhost:4000"; + +export async function fetchJSON(path, options = {}) { + const method = options.method || "GET"; + console.log(`[API] ${method} ${API_BASE_URL}${path}`); + const res = await fetch(`${API_BASE_URL}${path}`, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`API request failed (${res.status}): ${txt}`); + } + return res.json(); +} diff --git a/mcp/modules/asistencias.js b/mcp/modules/asistencias.js new file mode 100644 index 0000000..dfb5465 --- /dev/null +++ b/mcp/modules/asistencias.js @@ -0,0 +1,109 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { fetchJSON } from "../lib/api.js"; + +const log = (...args) => console.log("[MCP]", ...args); + +export default function registerAsistencias(server) { + server.resource("asistencia-list", "asistencia://list", async (uri) => { + log("Recurso solicitado", "asistencia-list"); + const asistencias = await fetchJSON("/api/asistencias"); + return { contents: [{ uri: uri.href, text: JSON.stringify(asistencias) }] }; + }); + + server.resource( + "asistencia", + new ResourceTemplate("asistencia://{id}", { list: undefined }), + async (uri, { id }) => { + log("Recurso solicitado", `asistencia ${id}`); + const asistencia = await fetchJSON(`/api/asistencias/${id}`); + return { contents: [{ uri: uri.href, text: JSON.stringify(asistencia) }] }; + } + ); + + server.tool( + "create-asistencia", + "Crea una asistencia", + { + empleado_id: z.number(), + entrada: z.string().optional(), + salida: z.string().optional(), + historial: z.string().optional(), + observacion: z.string().optional(), + estado: z.string().optional(), + fecha_anulado: z.string().optional(), + creador_id: z.number().optional(), + modificado_id: z.number().optional(), + anulador_id: z.number().optional(), + }, + async (params) => { + log("Tool invocada", "create-asistencia", params); + const asistencia = await fetchJSON("/api/asistencias", { + method: "POST", + body: JSON.stringify(params), + }); + return { content: [{ type: "text", text: JSON.stringify(asistencia) }] }; + } + ); + + server.tool( + "update-asistencia", + "Actualiza una asistencia", + { + id: z.number(), + empleado_id: z.number().optional(), + entrada: z.string().optional(), + salida: z.string().optional(), + historial: z.string().optional(), + observacion: z.string().optional(), + estado: z.string().optional(), + fecha_anulado: z.string().optional(), + modificado_id: z.number().optional(), + anulador_id: z.number().optional(), + }, + async ({ id, ...updates }) => { + log("Tool invocada", "update-asistencia", { id, ...updates }); + const asistencia = await fetchJSON(`/api/asistencias/${id}`, { + method: "PUT", + body: JSON.stringify(updates), + }); + return { content: [{ type: "text", text: JSON.stringify(asistencia) }] }; + } + ); + + server.tool( + "delete-asistencia", + "Elimina una asistencia", + { id: z.number() }, + async ({ id }) => { + log("Tool invocada", "delete-asistencia", { id }); + await fetchJSON(`/api/asistencias/${id}`, { method: "DELETE" }); + return { content: [{ type: "text", text: `Asistencia ${id} eliminada` }] }; + } + ); + + server.tool( + "search-asistencias", + "Busca asistencias. `q` matchea id o empleado_id. Si no se envían argumentos se devuelven los primeros 100 registros.", + { + q: z.string().optional(), + empleado_id: z.number().optional(), + estado: z.string().optional(), + entrada_desde: z.string().optional(), + entrada_hasta: z.string().optional(), + salida_desde: z.string().optional(), + salida_hasta: z.string().optional(), + }, + async (params) => { + log("Tool invocada", "search-asistencias", params); + const qs = new URLSearchParams( + Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]) + ); + if (qs.toString() === "") qs.append("limit", "100"); + const asistencias = await fetchJSON(`/api/asistencias/search?${qs.toString()}`); + return { content: [{ type: "text", text: JSON.stringify(asistencias) }] }; + } + ); +} diff --git a/mcp/modules/empleados.js b/mcp/modules/empleados.js new file mode 100644 index 0000000..316287f --- /dev/null +++ b/mcp/modules/empleados.js @@ -0,0 +1,104 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { fetchJSON } from "../lib/api.js"; + +const log = (...args) => console.log("[MCP]", ...args); + +export default function registerEmpleados(server) { + server.resource("empleado-list", "empleado://list", async (uri) => { + log("Recurso solicitado", "empleado-list"); + const empleados = await fetchJSON("/api/empleados"); + return { contents: [{ uri: uri.href, text: JSON.stringify(empleados) }] }; + }); + + server.resource( + "empleado", + new ResourceTemplate("empleado://{id}", { list: undefined }), + async (uri, { id }) => { + log("Recurso solicitado", `empleado ${id}`); + const empleado = await fetchJSON(`/api/empleados/${id}`); + return { contents: [{ uri: uri.href, text: JSON.stringify(empleado) }] }; + } + ); + + server.tool( + "create-empleado", + "Crea un empleado", + { + name: z.string(), + cedula: z.number(), + telefono: z.string().optional(), + ubicacion: z.string().optional(), + grupo_estudio: z.string().optional(), + avatar_url: z.string().optional(), + idciat: z.number().optional(), + }, + async (params) => { + log("Tool invocada", "create-empleado", params); + const empleado = await fetchJSON("/api/empleados", { + method: "POST", + body: JSON.stringify(params), + }); + return { content: [{ type: "text", text: JSON.stringify(empleado) }] }; + } + ); + + server.tool( + "update-empleado", + "Actualiza un empleado existente", + { + id: z.number(), + name: z.string().optional(), + cedula: z.number().optional(), + telefono: z.string().optional(), + ubicacion: z.string().optional(), + grupo_estudio: z.string().optional(), + avatar_url: z.string().optional(), + idciat: z.number().optional(), + }, + async ({ id, ...updates }) => { + log("Tool invocada", "update-empleado", { id, ...updates }); + const empleado = await fetchJSON(`/api/empleados/${id}`, { + method: "PUT", + body: JSON.stringify(updates), + }); + return { content: [{ type: "text", text: JSON.stringify(empleado) }] }; + } + ); + + server.tool( + "delete-empleado", + "Elimina un empleado", + { id: z.number() }, + async ({ id }) => { + log("Tool invocada", "delete-empleado", { id }); + await fetchJSON(`/api/empleados/${id}`, { method: "DELETE" }); + return { content: [{ type: "text", text: `Empleado ${id} eliminado` }] }; + } + ); + + server.tool( + "search-empleados", + "Busca empleados. `q` matchea id, cédula o nombre. Si no envías argumentos se devuelven los primeros 100 registros.", + { + q: z.string().optional(), + name: z.string().optional(), + cedula: z.number().optional(), + telefono: z.string().optional(), + ubicacion: z.string().optional(), + grupo_estudio: z.string().optional(), + idciat: z.number().optional(), + }, + async (params) => { + log("Tool invocada", "search-empleados", params); + const qs = new URLSearchParams( + Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]) + ); + if (qs.toString() === "") qs.append("limit", "100"); + const empleados = await fetchJSON(`/api/empleados/search?${qs.toString()}`); + return { content: [{ type: "text", text: JSON.stringify(empleados) }] }; + } + ); +} diff --git a/mcp/modules/planillas.js b/mcp/modules/planillas.js new file mode 100644 index 0000000..2c4ba32 --- /dev/null +++ b/mcp/modules/planillas.js @@ -0,0 +1,110 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { fetchJSON } from "../lib/api.js"; + +const log = (...args) => console.log("[MCP]", ...args); + +export default function registerPlanillas(server) { + // ----- Resources ----- + server.resource("planilla-list", "planilla://list", async (uri) => { + log("Recurso solicitado", "planilla-list"); + const planillas = await fetchJSON("/api/planillas"); + return { contents: [{ uri: uri.href, text: JSON.stringify(planillas) }] }; + }); + + server.resource( + "planilla", + new ResourceTemplate("planilla://{id}", { list: undefined }), + async (uri, { id }) => { + log("Recurso solicitado", `planilla ${id}`); + const planilla = await fetchJSON(`/api/planillas/${id}`); + return { contents: [{ uri: uri.href, text: JSON.stringify(planilla) }] }; + } + ); + + // ----- Tools ----- + server.tool( + "create-planilla", + "Crea una planilla", + { + empleado_id: z.number(), + fecha_desde: z.string(), + fecha_hasta: z.string(), + titulo: z.string(), + total: z.number().optional(), + estado: z.string().optional(), + fecha_anulado: z.string().optional(), + creador_id: z.number().optional(), + anulador_id: z.number().optional(), + }, + async (params) => { + log("Tool invocada", "create-planilla", params); + const planilla = await fetchJSON("/api/planillas", { + method: "POST", + body: JSON.stringify(params), + }); + return { content: [{ type: "text", text: JSON.stringify(planilla) }] }; + } + ); + + server.tool( + "update-planilla", + "Actualiza una planilla existente", + { + id: z.number(), + empleado_id: z.number().optional(), + fecha_desde: z.string().optional(), + fecha_hasta: z.string().optional(), + titulo: z.string().optional(), + total: z.number().optional(), + estado: z.string().optional(), + fecha_anulado: z.string().optional(), + anulador_id: z.number().optional(), + }, + async ({ id, ...updates }) => { + log("Tool invocada", "update-planilla", { id, ...updates }); + const planilla = await fetchJSON(`/api/planillas/${id}`, { + method: "PUT", + body: JSON.stringify(updates), + }); + return { content: [{ type: "text", text: JSON.stringify(planilla) }] }; + } + ); + + server.tool( + "delete-planilla", + "Elimina una planilla", + { id: z.number() }, + async ({ id }) => { + log("Tool invocada", "delete-planilla", { id }); + await fetchJSON(`/api/planillas/${id}`, { method: "DELETE" }); + return { content: [{ type: "text", text: `Planilla ${id} eliminada` }] }; + } + ); + + server.tool( + "search-planillas", + "Busca planillas. `q` es un texto libre que matchea id, empleado_id, título o estado. Si no mandás ningún argumento devuelve los primeros 100 registros.", + { + q: z.string().optional(), + empleado_id: z.number().optional(), + estado: z.string().optional(), + titulo: z.string().optional(), + fecha_desde_desde: z.string().optional(), + fecha_desde_hasta: z.string().optional(), + fecha_hasta_desde: z.string().optional(), + fecha_hasta_hasta: z.string().optional(), + }, + async (params) => { + log("Tool invocada", "search-planillas", params); + const qs = new URLSearchParams( + Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]) + ); + if (qs.toString() === "") qs.append("limit", "100"); + const planillas = await fetchJSON(`/api/planillas/search?${qs.toString()}`); + return { content: [{ type: "text", text: JSON.stringify(planillas) }] }; + } + ); +} diff --git a/mcp/modules/tareas.js b/mcp/modules/tareas.js new file mode 100644 index 0000000..553f8dc --- /dev/null +++ b/mcp/modules/tareas.js @@ -0,0 +1,111 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { fetchJSON } from "../lib/api.js"; + +const log = (...args) => console.log("[MCP]", ...args); + +export default function registerTareas(server) { + server.resource("tarea-list", "tarea://list", async (uri) => { + log("Recurso solicitado", "tarea-list"); + const tareas = await fetchJSON("/api/tareas"); + return { contents: [{ uri: uri.href, text: JSON.stringify(tareas) }] }; + }); + + server.resource( + "tarea", + new ResourceTemplate("tarea://{id}", { list: undefined }), + async (uri, { id }) => { + log("Recurso solicitado", `tarea ${id}`); + const tarea = await fetchJSON(`/api/tareas/${id}`); + return { contents: [{ uri: uri.href, text: JSON.stringify(tarea) }] }; + } + ); + + server.tool( + "create-tarea", + "Crea una tarea", + { + empleado_id: z.number(), + planilla_id: z.number().optional(), + titulo: z.string(), + precio: z.number().optional(), + estado: z.string().optional(), + observacion: z.string().optional(), + fecha: z.string(), + tipo: z.string().optional(), + fecha_anulado: z.string().optional(), + creador_id: z.number().optional(), + anulador_id: z.number().optional(), + }, + async (params) => { + log("Tool invocada", "create-tarea", params); + const tarea = await fetchJSON("/api/tareas", { + method: "POST", + body: JSON.stringify(params), + }); + return { content: [{ type: "text", text: JSON.stringify(tarea) }] }; + } + ); + + server.tool( + "update-tarea", + "Actualiza una tarea", + { + id: z.number(), + empleado_id: z.number().optional(), + planilla_id: z.number().optional(), + titulo: z.string().optional(), + precio: z.number().optional(), + estado: z.string().optional(), + observacion: z.string().optional(), + fecha: z.string().optional(), + tipo: z.string().optional(), + fecha_anulado: z.string().optional(), + anulador_id: z.number().optional(), + }, + async ({ id, ...updates }) => { + log("Tool invocada", "update-tarea", { id, ...updates }); + const tarea = await fetchJSON(`/api/tareas/${id}`, { + method: "PUT", + body: JSON.stringify(updates), + }); + return { content: [{ type: "text", text: JSON.stringify(tarea) }] }; + } + ); + + server.tool( + "delete-tarea", + "Elimina una tarea", + { id: z.number() }, + async ({ id }) => { + log("Tool invocada", "delete-tarea", { id }); + await fetchJSON(`/api/tareas/${id}`, { method: "DELETE" }); + return { content: [{ type: "text", text: `Tarea ${id} eliminada` }] }; + } + ); + + server.tool( + "search-tareas", + "Busca tareas. `q` matchea id, empleado_id, planilla_id o título. Si no mandas filtros devuelve los primeros 100 registros.", + { + q: z.string().optional(), + empleado_id: z.number().optional(), + planilla_id: z.number().optional(), + estado: z.string().optional(), + titulo: z.string().optional(), + fecha_desde: z.string().optional(), + fecha_hasta: z.string().optional(), + }, + async (params) => { + log("Tool invocada", "search-tareas", params); + const qs = new URLSearchParams( + Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]) + ); + if (qs.toString() === "") qs.append("limit", "100"); + const tareas = await fetchJSON(`/api/tareas/search?${qs.toString()}`); + return { content: [{ type: "text", text: JSON.stringify(tareas) }] }; + } + ); +} diff --git a/ui/src/apiClient.js b/ui/src/apiClient.js index 9319cfd..9ba4c2f 100644 --- a/ui/src/apiClient.js +++ b/ui/src/apiClient.js @@ -1,7 +1,7 @@ import axios from 'axios'; // forzar subida const apiClient = axios.create({ - baseURL: 'https://planilla.interno.com', // Using the container name and API port + baseURL: 'http://planilla.interno.com', // Using the container name and API port headers: { 'Content-Type': 'application/json', },