From b63f6841903e25abc7b6e7cda9241c983c6c0eda Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 20 Mar 2026 16:55:49 +0200 Subject: [PATCH] feat: Add MCP Integration with client-agnostic instructions (#953) --- backend/.env.example | 3 + backend/app.js | 2 + backend/modules/feature-flags/service.js | 1 + backend/modules/mcp/controller.js | 121 ++++ backend/modules/mcp/httpTransport.js | 113 +++ backend/modules/mcp/index.js | 6 + backend/modules/mcp/middleware.js | 70 ++ backend/modules/mcp/routes.js | 42 ++ backend/modules/mcp/server.js | 146 ++++ backend/modules/mcp/toolRegistry.js | 21 + backend/modules/mcp/tools/inboxTools.js | 124 ++++ backend/modules/mcp/tools/miscTools.js | 212 ++++++ backend/modules/mcp/tools/projectTools.js | 321 +++++++++ backend/modules/mcp/tools/taskTools.js | 646 ++++++++++++++++++ frontend/components/Navbar.tsx | 1 + .../components/Profile/ProfileSettings.tsx | 34 +- frontend/components/Profile/tabs/McpTab.tsx | 288 ++++++++ frontend/components/Profile/tabs/TabsNav.tsx | 1 + frontend/components/Sidebar.tsx | 1 + frontend/components/Sidebar/SidebarNav.tsx | 1 + frontend/utils/featureFlags.ts | 4 + package-lock.json | 500 +++++++++++++- package.json | 5 +- 23 files changed, 2656 insertions(+), 7 deletions(-) create mode 100644 backend/modules/mcp/controller.js create mode 100644 backend/modules/mcp/httpTransport.js create mode 100644 backend/modules/mcp/index.js create mode 100644 backend/modules/mcp/middleware.js create mode 100644 backend/modules/mcp/routes.js create mode 100644 backend/modules/mcp/server.js create mode 100644 backend/modules/mcp/toolRegistry.js create mode 100644 backend/modules/mcp/tools/inboxTools.js create mode 100644 backend/modules/mcp/tools/miscTools.js create mode 100644 backend/modules/mcp/tools/projectTools.js create mode 100644 backend/modules/mcp/tools/taskTools.js create mode 100644 frontend/components/Profile/tabs/McpTab.tsx diff --git a/backend/.env.example b/backend/.env.example index 3e45fe6..0874d86 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,4 +27,7 @@ REGISTRATION_TOKEN_EXPIRY_HOURS=24 DISABLE_SCHEDULER=false DISABLE_TELEGRAM=false +# Feature Flags +FF_ENABLE_MCP=false + # TUDUDI_TRUST_PROXY=true diff --git a/backend/app.js b/backend/app.js index 3900899..47763be 100644 --- a/backend/app.js +++ b/backend/app.js @@ -136,6 +136,7 @@ const telegramModule = require('./modules/telegram'); const urlModule = require('./modules/url'); const usersModule = require('./modules/users'); const viewsModule = require('./modules/views'); +const mcpModule = require('./modules/mcp'); // Swagger documentation - enabled by default, protected by authentication // Mounted on /api-docs to avoid conflicts with API routes @@ -216,6 +217,7 @@ const registerApiRoutes = (basePath) => { app.use(basePath, searchModule.routes); app.use(basePath, viewsModule.routes); app.use(basePath, notificationsModule.routes); + app.use(basePath, mcpModule.routes); }; // Register routes at both /api and /api/v1 (if versioned) to maintain backwards compatibility diff --git a/backend/modules/feature-flags/service.js b/backend/modules/feature-flags/service.js index 5b4d11d..f09d023 100644 --- a/backend/modules/feature-flags/service.js +++ b/backend/modules/feature-flags/service.js @@ -9,6 +9,7 @@ class FeatureFlagsService { backups: process.env.FF_ENABLE_BACKUPS === 'true', calendar: process.env.FF_ENABLE_CALENDAR === 'true', habits: process.env.FF_ENABLE_HABITS === 'true', + mcp: process.env.FF_ENABLE_MCP === 'true', }; } } diff --git a/backend/modules/mcp/controller.js b/backend/modules/mcp/controller.js new file mode 100644 index 0000000..1a1eb98 --- /dev/null +++ b/backend/modules/mcp/controller.js @@ -0,0 +1,121 @@ +'use strict'; + +const { handleMcpHttpRequest } = require('./httpTransport'); + +/** + * Get MCP configuration for Claude Desktop + * Returns JSON that user can paste into Claude Desktop config + */ +async function getMcpConfig(req, res) { + try { + // Get base URL from request + const protocol = req.protocol; + const host = req.get('host'); + const baseUrl = `${protocol}://${host}`; + + // Generate HTTP-based config for remote access + const claudeConfig = { + mcpServers: { + tududi: { + command: 'npx', + args: [ + '-y', + 'mcp-remote', + `${baseUrl}/api/mcp`, + '--header', + 'Authorization:Bearer ${TUDUDI_API_TOKEN}', + ], + env: { + TUDUDI_API_TOKEN: 'YOUR_API_TOKEN_HERE', + }, + }, + }, + }; + + res.json(claudeConfig); + } catch (error) { + console.error('Error generating MCP config:', error); + res.status(500).json({ + error: 'Failed to generate MCP configuration', + message: error.message, + }); + } +} + +/** + * Handle MCP protocol message + * This is called by the POST /api/mcp endpoint + */ +async function handleMcpMessage(req, res) { + try { + const user = req.mcpUser; + const apiToken = req.mcpApiToken; + + // Delegate to HTTP transport handler + await handleMcpHttpRequest(req, res, user, apiToken); + } catch (error) { + console.error('Error handling MCP message:', error); + + // Only send response if headers haven't been sent + if (!res.headersSent) { + res.status(500).json({ + error: 'Failed to process MCP message', + message: error.message, + }); + } + } +} + +/** + * Get MCP feature flag status + */ +async function getMcpStatus(req, res) { + const mcpEnabled = process.env.FF_ENABLE_MCP === 'true'; + res.json({ enabled: mcpEnabled }); +} + +/** + * List available MCP tools + */ +async function listMcpTools(req, res) { + const tools = [ + { + category: 'Tasks', + count: 8, + tools: [ + 'list_tasks', + 'get_task', + 'create_task', + 'update_task', + 'complete_task', + 'delete_task', + 'add_subtask', + 'get_task_metrics', + ], + }, + { + category: 'Projects', + count: 3, + tools: ['list_projects', 'create_project', 'update_project'], + }, + { + category: 'Inbox', + count: 2, + tools: ['list_inbox', 'add_to_inbox'], + }, + { + category: 'Misc', + count: 3, + tools: ['list_areas', 'list_tags', 'search'], + }, + ]; + + res.json({ tools }); +} + +module.exports = { + getMcpConfig, + getMcpStatus, + listMcpTools, + handleMcpMessage, +}; diff --git a/backend/modules/mcp/httpTransport.js b/backend/modules/mcp/httpTransport.js new file mode 100644 index 0000000..9a2707f --- /dev/null +++ b/backend/modules/mcp/httpTransport.js @@ -0,0 +1,113 @@ +'use strict'; + +const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); +const { + StreamableHTTPServerTransport, +} = require('@modelcontextprotocol/sdk/server/streamableHttp.js'); +const { + CallToolRequestSchema, + ListToolsRequestSchema, +} = require('@modelcontextprotocol/sdk/types.js'); +const { registerAllTools } = require('./toolRegistry'); + +/** + * Handle MCP HTTP request + * This creates a stateless MCP server for each request + */ +async function handleMcpHttpRequest(req, res, user, apiToken) { + try { + // Create context for tools + const context = { + userId: user.id, + user: user, + apiToken: apiToken, + }; + + // Initialize MCP server + const server = new Server( + { + name: process.env.MCP_SERVER_NAME || 'tududi', + version: process.env.MCP_SERVER_VERSION || '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + // Store tools registry + const tools = []; + + // Register list tools handler + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: tools, + }; + }); + + // Register call tool handler + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const toolName = request.params.name; + const tool = tools.find((t) => t.name === toolName); + + if (!tool) { + throw new Error(`Unknown tool: ${toolName}`); + } + + try { + const result = await tool.handler( + request.params.arguments || {}, + context + ); + return result; + } catch (error) { + console.error(`Error executing tool ${toolName}:`, error); + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } + }); + + // Register all tools + registerAllTools(server, context, tools); + + // Create HTTP transport (stateless mode) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // Stateless mode + }); + + // Connect server to transport + await server.connect(transport); + + // Handle the HTTP request + await transport.handleRequest(req, res, req.body); + + // Update token last_used_at (fire and forget) + apiToken + .update({ last_used_at: new Date() }) + .catch((err) => + console.error('Failed to update token last_used_at:', err) + ); + } catch (error) { + console.error('MCP HTTP handler error:', error); + + // Only send response if headers haven't been sent + if (!res.headersSent) { + res.status(500).json({ + error: 'Internal server error', + message: error.message, + }); + } + } +} + +module.exports = { + handleMcpHttpRequest, +}; diff --git a/backend/modules/mcp/index.js b/backend/modules/mcp/index.js new file mode 100644 index 0000000..b471c40 --- /dev/null +++ b/backend/modules/mcp/index.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = { + routes: require('./routes'), + startMcpServer: require('./server'), +}; diff --git a/backend/modules/mcp/middleware.js b/backend/modules/mcp/middleware.js new file mode 100644 index 0000000..84d23d6 --- /dev/null +++ b/backend/modules/mcp/middleware.js @@ -0,0 +1,70 @@ +'use strict'; + +const { findValidTokenByValue } = require('../users/apiTokenService'); +const { User } = require('../../models'); + +/** + * Middleware to authenticate MCP requests using Bearer token + * Validates the Authorization header and attaches user context to req + */ +async function authenticateMcpRequest(req, res, next) { + try { + // Extract Bearer token from Authorization header + const authHeader = req.headers.authorization; + + if (!authHeader) { + return res.status(401).json({ + error: 'Unauthorized', + message: + 'Missing Authorization header. Include: Authorization: Bearer YOUR_API_TOKEN', + }); + } + + // Parse Bearer token + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + return res.status(401).json({ + error: 'Unauthorized', + message: + 'Invalid Authorization header format. Use: Authorization: Bearer YOUR_API_TOKEN', + }); + } + + const apiToken = parts[1]; + + // Validate token + const tokenRecord = await findValidTokenByValue(apiToken); + if (!tokenRecord) { + return res.status(401).json({ + error: 'Unauthorized', + message: + 'Invalid or expired API token. Generate a new token in Profile → API Keys.', + }); + } + + // Get user + const user = await User.findByPk(tokenRecord.user_id); + if (!user) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'User not found for the provided token.', + }); + } + + // Attach to request + req.mcpUser = user; + req.mcpApiToken = tokenRecord; + + next(); + } catch (error) { + console.error('MCP authentication error:', error); + return res.status(500).json({ + error: 'Authentication error', + message: error.message, + }); + } +} + +module.exports = { + authenticateMcpRequest, +}; diff --git a/backend/modules/mcp/routes.js b/backend/modules/mcp/routes.js new file mode 100644 index 0000000..6fd92d1 --- /dev/null +++ b/backend/modules/mcp/routes.js @@ -0,0 +1,42 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const controller = require('./controller'); +const { authenticateMcpRequest } = require('./middleware'); + +/** + * Middleware to check if MCP feature is enabled + */ +const checkMcpEnabled = (req, res, next) => { + const mcpEnabled = process.env.FF_ENABLE_MCP === 'true'; + if (!mcpEnabled) { + return res.status(403).json({ + error: 'MCP feature is not enabled', + message: + 'Set FF_ENABLE_MCP=true in your .env file to enable this feature', + }); + } + next(); +}; + +// Get MCP feature flag status (no feature flag check needed) +// Note: requireAuth is already applied in app.js for authenticated routes +router.get('/mcp/status', controller.getMcpStatus); + +// Get MCP configuration for Claude Desktop (requires feature flag) +router.get('/mcp/config', checkMcpEnabled, controller.getMcpConfig); + +// List available MCP tools (requires feature flag) +router.get('/mcp/tools', checkMcpEnabled, controller.listMcpTools); + +// MCP protocol endpoint - uses Bearer token auth, not session auth +// This endpoint handles actual MCP protocol messages from remote clients +router.post( + '/mcp', + checkMcpEnabled, + authenticateMcpRequest, + controller.handleMcpMessage +); + +module.exports = router; diff --git a/backend/modules/mcp/server.js b/backend/modules/mcp/server.js new file mode 100644 index 0000000..6d1e00e --- /dev/null +++ b/backend/modules/mcp/server.js @@ -0,0 +1,146 @@ +'use strict'; + +const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); +const { + StdioServerTransport, +} = require('@modelcontextprotocol/sdk/server/stdio.js'); +const { + CallToolRequestSchema, + ListToolsRequestSchema, +} = require('@modelcontextprotocol/sdk/types.js'); +const { findValidTokenByValue } = require('../users/apiTokenService'); +const { User } = require('../../models'); +const { registerAllTools } = require('./toolRegistry'); + +/** + * Start the MCP server + * Validates authentication token and initializes stdio transport + */ +async function startMcpServer() { + try { + // Validate environment + const apiToken = process.env.TUDUDI_API_TOKEN; + if (!apiToken) { + throw new Error( + 'TUDUDI_API_TOKEN environment variable is required. ' + + 'Generate a token in Profile → API Keys and add it to your Claude Desktop config.' + ); + } + + // Validate token and get user context + const tokenRecord = await findValidTokenByValue(apiToken); + if (!tokenRecord) { + throw new Error( + 'Invalid or expired TUDUDI_API_TOKEN. ' + + 'Please generate a new token in Profile → API Keys.' + ); + } + + const user = await User.findByPk(tokenRecord.user_id); + if (!user) { + throw new Error('User not found for the provided token.'); + } + + // Create context for all tools + const context = { + userId: user.id, + user: user, + apiToken: tokenRecord, + }; + + // Initialize MCP server + const server = new Server( + { + name: process.env.MCP_SERVER_NAME || 'tududi', + version: process.env.MCP_SERVER_VERSION || '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + // Store tools registry for request handlers + const tools = []; + + // Register list tools handler + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: tools, + }; + }); + + // Register call tool handler + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const toolName = request.params.name; + const tool = tools.find((t) => t.name === toolName); + + if (!tool) { + throw new Error(`Unknown tool: ${toolName}`); + } + + try { + // Call the tool handler with params and context + const result = await tool.handler( + request.params.arguments || {}, + context + ); + return result; + } catch (error) { + console.error(`Error executing tool ${toolName}:`, error); + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } + }); + + // Register all tools with context + registerAllTools(server, context, tools); + + // Start stdio transport + const transport = new StdioServerTransport(); + await server.connect(transport); + + console.error('Tududi MCP server running on stdio'); + console.error(`Authenticated as: ${user.email} (ID: ${user.id})`); + console.error(`Available tools: ${tools.length}`); + + // Update token last_used_at (fire and forget) + tokenRecord + .update({ last_used_at: new Date() }) + .catch((err) => + console.error('Failed to update token last_used_at:', err) + ); + } catch (error) { + console.error('Fatal MCP server error:', error.message); + process.exit(1); + } +} + +// Start server if running directly +if (require.main === module) { + // Load environment variables + require('dotenv').config(); + + // Initialize database connection + const { sequelize } = require('../../models'); + sequelize + .authenticate() + .then(() => { + console.error('Database connection established'); + return startMcpServer(); + }) + .catch((error) => { + console.error('Failed to connect to database:', error); + process.exit(1); + }); +} + +module.exports = startMcpServer; diff --git a/backend/modules/mcp/toolRegistry.js b/backend/modules/mcp/toolRegistry.js new file mode 100644 index 0000000..f659d49 --- /dev/null +++ b/backend/modules/mcp/toolRegistry.js @@ -0,0 +1,21 @@ +'use strict'; + +const { registerTaskTools } = require('./tools/taskTools'); +const { registerProjectTools } = require('./tools/projectTools'); +const { registerInboxTools } = require('./tools/inboxTools'); +const { registerMiscTools } = require('./tools/miscTools'); + +/** + * Register all MCP tools with the server + * @param {Object} server - MCP server instance + * @param {Object} context - User context {userId, user, apiToken} + * @param {Array} tools - Tools registry array + */ +function registerAllTools(server, context, tools) { + registerTaskTools(server, context, tools); + registerProjectTools(server, context, tools); + registerInboxTools(server, context, tools); + registerMiscTools(server, context, tools); +} + +module.exports = { registerAllTools }; diff --git a/backend/modules/mcp/tools/inboxTools.js b/backend/modules/mcp/tools/inboxTools.js new file mode 100644 index 0000000..6cbc672 --- /dev/null +++ b/backend/modules/mcp/tools/inboxTools.js @@ -0,0 +1,124 @@ +'use strict'; + +const { Inbox } = require('../../../models'); + +/** + * Register all inbox-related MCP tools + */ +function registerInboxTools(server, context, tools) { + // 1. list_inbox - List inbox items + tools.push({ + name: 'list_inbox', + description: 'List items from inbox with pagination', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Maximum number of items to return', + default: 20, + }, + offset: { + type: 'number', + description: 'Number of items to skip', + default: 0, + }, + }, + }, + handler: async (params) => { + const limit = params.limit || 20; + const offset = params.offset || 0; + + const items = await Inbox.findAll({ + where: { user_id: context.userId }, + limit: limit, + offset: offset, + order: [['created_at', 'DESC']], + }); + + const serialized = items.map((item) => ({ + id: item.id, + uid: item.uid, + content: item.content, + source: item.source, + processed: item.processed, + created_at: item.created_at, + updated_at: item.updated_at, + })); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + count: serialized.length, + items: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 2. add_to_inbox - Add item to inbox + tools.push({ + name: 'add_to_inbox', + description: 'Add a new item to the inbox for quick capture', + inputSchema: { + type: 'object', + properties: { + content: { + type: 'string', + description: 'Inbox item content (required)', + }, + source: { + type: 'string', + description: 'Source of the item (default: mcp)', + default: 'mcp', + }, + }, + required: ['content'], + }, + handler: async (params) => { + const inboxData = { + user_id: context.userId, + content: params.content, + source: params.source || 'mcp', + processed: false, + }; + + const item = await Inbox.create(inboxData); + + const serialized = { + id: item.id, + uid: item.uid, + content: item.content, + source: item.source, + processed: item.processed, + created_at: item.created_at, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + message: 'Item added to inbox successfully', + item: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); +} + +module.exports = { registerInboxTools }; diff --git a/backend/modules/mcp/tools/miscTools.js b/backend/modules/mcp/tools/miscTools.js new file mode 100644 index 0000000..dab11ca --- /dev/null +++ b/backend/modules/mcp/tools/miscTools.js @@ -0,0 +1,212 @@ +'use strict'; + +const { Area, Tag, Task, Project, Note } = require('../../../models'); +const { Op } = require('sequelize'); + +/** + * Register miscellaneous MCP tools + */ +function registerMiscTools(server, context, tools) { + // 1. list_areas - List all areas + tools.push({ + name: 'list_areas', + description: 'List all organizational areas', + inputSchema: { + type: 'object', + properties: {}, + }, + handler: async (params) => { + const areas = await Area.findAll({ + where: { user_id: context.userId }, + order: [['name', 'ASC']], + }); + + const serialized = areas.map((area) => ({ + id: area.id, + uid: area.uid, + name: area.name, + description: area.description, + created_at: area.created_at, + })); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + count: serialized.length, + areas: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 2. list_tags - List all tags + tools.push({ + name: 'list_tags', + description: 'List all available tags', + inputSchema: { + type: 'object', + properties: {}, + }, + handler: async (params) => { + const tags = await Tag.findAll({ + where: { user_id: context.userId }, + order: [['name', 'ASC']], + }); + + const serialized = tags.map((tag) => ({ + id: tag.id, + uid: tag.uid, + name: tag.name, + created_at: tag.created_at, + })); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + count: serialized.length, + tags: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 3. search - Universal search across tasks, projects, notes + tools.push({ + name: 'search', + description: 'Search across tasks, projects, and notes', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query (required)', + }, + type: { + type: 'string', + enum: ['task', 'project', 'note', 'all'], + description: 'Resource type to search (default: all)', + default: 'all', + }, + limit: { + type: 'number', + description: 'Maximum results per type', + default: 10, + }, + }, + required: ['query'], + }, + handler: async (params) => { + const query = params.query; + const type = params.type || 'all'; + const limit = params.limit || 10; + const results = {}; + + const searchCondition = { + [Op.or]: [ + { name: { [Op.like]: `%${query}%` } }, + { note: { [Op.like]: `%${query}%` } }, + ], + }; + + // Search tasks + if (type === 'task' || type === 'all') { + const tasks = await Task.findAll({ + where: { + user_id: context.userId, + [Op.or]: [ + { name: { [Op.like]: `%${query}%` } }, + { note: { [Op.like]: `%${query}%` } }, + ], + }, + limit: limit, + order: [['created_at', 'DESC']], + }); + + results.tasks = tasks.map((t) => ({ + id: t.id, + uid: t.uid, + name: t.name, + status: t.status, + priority: t.priority, + })); + } + + // Search projects + if (type === 'project' || type === 'all') { + const projects = await Project.findAll({ + where: { + user_id: context.userId, + [Op.or]: [ + { name: { [Op.like]: `%${query}%` } }, + { description: { [Op.like]: `%${query}%` } }, + ], + }, + limit: limit, + order: [['created_at', 'DESC']], + }); + + results.projects = projects.map((p) => ({ + id: p.id, + uid: p.uid, + name: p.name, + status: p.status, + })); + } + + // Search notes + if (type === 'note' || type === 'all') { + const notes = await Note.findAll({ + where: { + user_id: context.userId, + [Op.or]: [ + { name: { [Op.like]: `%${query}%` } }, + { content: { [Op.like]: `%${query}%` } }, + ], + }, + limit: limit, + order: [['created_at', 'DESC']], + }); + + results.notes = notes.map((n) => ({ + id: n.id, + uid: n.uid, + name: n.name, + })); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + query: query, + results: results, + }, + null, + 2 + ), + }, + ], + }; + }, + }); +} + +module.exports = { registerMiscTools }; diff --git a/backend/modules/mcp/tools/projectTools.js b/backend/modules/mcp/tools/projectTools.js new file mode 100644 index 0000000..e735cb7 --- /dev/null +++ b/backend/modules/mcp/tools/projectTools.js @@ -0,0 +1,321 @@ +'use strict'; + +const { Project, Area, Tag } = require('../../../models'); +const { Op } = require('sequelize'); + +/** + * Register all project-related MCP tools + */ +function registerProjectTools(server, context, tools) { + // 1. list_projects - List projects + tools.push({ + name: 'list_projects', + description: 'List projects from tududi with optional filtering', + inputSchema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: [ + 'not_started', + 'planned', + 'in_progress', + 'waiting', + 'done', + 'cancelled', + 'all', + ], + description: 'Filter by project status', + }, + area_id: { + type: 'number', + description: 'Filter by area ID', + }, + limit: { + type: 'number', + description: 'Maximum number of projects to return', + default: 30, + }, + }, + }, + handler: async (params) => { + const where = { user_id: context.userId }; + const limit = params.limit || 30; + + // Apply status filter + if (params.status && params.status !== 'all') { + where.status = params.status; + } + + // Apply area filter + if (params.area_id) { + where.area_id = params.area_id; + } + + const projects = await Project.findAll({ + where: where, + include: [ + { model: Area, as: 'Area' }, + { model: Tag, as: 'Tags' }, + ], + limit: limit, + order: [['created_at', 'DESC']], + }); + + const serialized = projects.map((p) => { + const proj = p.toJSON(); + return { + id: proj.id, + uid: proj.uid, + name: proj.name, + description: proj.description, + status: proj.status, + priority: proj.priority, + area: proj.Area ? proj.Area.name : null, + tags: proj.Tags ? proj.Tags.map((t) => t.name) : [], + due_date_at: proj.due_date_at, + pin_to_sidebar: proj.pin_to_sidebar, + created_at: proj.created_at, + updated_at: proj.updated_at, + }; + }); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + count: serialized.length, + projects: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 2. create_project - Create new project + tools.push({ + name: 'create_project', + description: 'Create a new project in tududi', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Project name (required)', + }, + description: { + type: 'string', + description: 'Project description', + }, + priority: { + type: 'number', + description: 'Priority (0=low, 1=medium, 2=high)', + }, + status: { + type: 'string', + enum: [ + 'not_started', + 'planned', + 'in_progress', + 'waiting', + 'done', + 'cancelled', + ], + }, + area_id: { + type: 'number', + description: 'Area ID', + }, + due_date_at: { + type: 'string', + description: 'Due date (ISO 8601)', + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Array of tag names', + }, + }, + required: ['name'], + }, + handler: async (params) => { + const projectData = { + user_id: context.userId, + name: params.name, + description: params.description || '', + priority: params.priority !== undefined ? params.priority : 1, + status: params.status || 'not_started', + area_id: params.area_id || null, + due_date_at: params.due_date_at || null, + }; + + const project = await Project.create(projectData); + + // Handle tags if provided + if (params.tags && params.tags.length > 0) { + const tagInstances = await Promise.all( + params.tags.map(async (tagName) => { + const [tag] = await Tag.findOrCreate({ + where: { name: tagName, user_id: context.userId }, + }); + return tag; + }) + ); + await project.setTags(tagInstances); + } + + // Reload with associations + const reloadedProject = await Project.findByPk(project.id, { + include: [ + { model: Area, as: 'Area' }, + { model: Tag, as: 'Tags' }, + ], + }); + + const serialized = { + id: reloadedProject.id, + uid: reloadedProject.uid, + name: reloadedProject.name, + description: reloadedProject.description, + status: reloadedProject.status, + priority: reloadedProject.priority, + area: reloadedProject.Area ? reloadedProject.Area.name : null, + tags: reloadedProject.Tags + ? reloadedProject.Tags.map((t) => t.name) + : [], + due_date_at: reloadedProject.due_date_at, + created_at: reloadedProject.created_at, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + message: 'Project created successfully', + project: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 3. update_project - Update existing project + tools.push({ + name: 'update_project', + description: 'Update an existing project', + inputSchema: { + type: 'object', + properties: { + uid: { + type: 'string', + description: 'Project UID (required)', + }, + name: { type: 'string', description: 'New project name' }, + description: { + type: 'string', + description: 'New description', + }, + priority: { + type: 'number', + description: 'New priority', + }, + status: { + type: 'string', + enum: [ + 'not_started', + 'planned', + 'in_progress', + 'waiting', + 'done', + 'cancelled', + ], + }, + area_id: { + type: 'number', + description: 'New area ID', + }, + pinned: { + type: 'boolean', + description: 'Pin to sidebar', + }, + }, + required: ['uid'], + }, + handler: async (params) => { + const project = await Project.findOne({ + where: { uid: params.uid, user_id: context.userId }, + }); + + if (!project) { + throw new Error(`Project not found: ${params.uid}`); + } + + const updates = {}; + if (params.name !== undefined) updates.name = params.name; + if (params.description !== undefined) + updates.description = params.description; + if (params.priority !== undefined) + updates.priority = params.priority; + if (params.status !== undefined) updates.status = params.status; + if (params.area_id !== undefined) updates.area_id = params.area_id; + if (params.pinned !== undefined) + updates.pin_to_sidebar = params.pinned; + + await project.update(updates); + + // Reload with associations + const reloadedProject = await Project.findByPk(project.id, { + include: [ + { model: Area, as: 'Area' }, + { model: Tag, as: 'Tags' }, + ], + }); + + const serialized = { + id: reloadedProject.id, + uid: reloadedProject.uid, + name: reloadedProject.name, + description: reloadedProject.description, + status: reloadedProject.status, + priority: reloadedProject.priority, + area: reloadedProject.Area ? reloadedProject.Area.name : null, + tags: reloadedProject.Tags + ? reloadedProject.Tags.map((t) => t.name) + : [], + due_date_at: reloadedProject.due_date_at, + pin_to_sidebar: reloadedProject.pin_to_sidebar, + updated_at: reloadedProject.updated_at, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + message: 'Project updated successfully', + project: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); +} + +module.exports = { registerProjectTools }; diff --git a/backend/modules/mcp/tools/taskTools.js b/backend/modules/mcp/tools/taskTools.js new file mode 100644 index 0000000..36d80fb --- /dev/null +++ b/backend/modules/mcp/tools/taskTools.js @@ -0,0 +1,646 @@ +'use strict'; + +const taskRepository = require('../../tasks/repository'); +const { + serializeTask, + serializeTasks, +} = require('../../tasks/core/serializers'); +const { buildTaskAttributes } = require('../../tasks/core/builders'); +const { Op } = require('sequelize'); +const { Task, Project, Tag } = require('../../../models'); + +/** + * Helper to find task by ID or UID + */ +async function findTaskByIdentifier(identifier, userId) { + const isNumeric = !isNaN(identifier); + + if (isNumeric) { + return await taskRepository.findByIdAndUser( + parseInt(identifier), + userId, + { + include: [ + { model: Project, as: 'Project' }, + { model: Tag, as: 'Tags' }, + ], + } + ); + } else { + return await taskRepository.findByUid(identifier, { + include: [ + { model: Project, as: 'Project' }, + { model: Tag, as: 'Tags' }, + ], + }); + } +} + +/** + * Register all task-related MCP tools + */ +function registerTaskTools(server, context, tools) { + // 1. list_tasks - List tasks with filtering + tools.push({ + name: 'list_tasks', + description: 'List tasks from tududi with optional filtering', + inputSchema: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['today', 'upcoming', 'completed', 'archived', 'all'], + description: 'Filter tasks by type', + }, + status: { + type: 'string', + enum: ['pending', 'in_progress', 'completed', 'archived'], + description: 'Filter by status', + }, + project_id: { + type: 'number', + description: 'Filter by project ID', + }, + limit: { + type: 'number', + description: 'Maximum number of tasks to return', + default: 50, + }, + }, + }, + handler: async (params) => { + const where = { user_id: context.userId }; + const limit = params.limit || 50; + + // Apply status filter + if (params.status) { + const statusMap = { + pending: 0, + in_progress: 1, + completed: 2, + archived: 6, + }; + where.status = statusMap[params.status]; + } + + // Apply project filter + if (params.project_id) { + where.project_id = params.project_id; + } + + // Apply type filter + if (params.type === 'completed') { + where.status = 2; + } else if (params.type === 'archived') { + where.status = 6; + } else if (params.type === 'today' || params.type === 'upcoming') { + where.status = { [Op.ne]: 6 }; // Not archived + } + + const tasks = await taskRepository.findAll(where, { + include: [ + { model: Project, as: 'Project' }, + { model: Tag, as: 'Tags' }, + ], + limit: limit, + order: [['created_at', 'DESC']], + }); + + const serializedTasks = await serializeTasks( + tasks, + context.user.timezone + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + count: serializedTasks.length, + tasks: serializedTasks, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 2. get_task - Get single task by ID or UID + tools.push({ + name: 'get_task', + description: 'Get a specific task by ID or UID with full details', + inputSchema: { + type: 'object', + properties: { + id: { + type: ['number', 'string'], + description: 'Task ID (number) or UID (string)', + }, + }, + required: ['id'], + }, + handler: async (params) => { + const task = await findTaskByIdentifier(params.id, context.userId); + + if (!task) { + throw new Error(`Task not found: ${params.id}`); + } + + // Check ownership + if (task.user_id !== context.userId) { + throw new Error('Access denied'); + } + + const serialized = await serializeTask(task, context.user.timezone); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(serialized, null, 2), + }, + ], + }; + }, + }); + + // 3. create_task - Create new task + tools.push({ + name: 'create_task', + description: 'Create a new task in tududi', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Task name (required)', + }, + description: { + type: 'string', + description: 'Task description/note', + }, + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + description: 'Task priority', + }, + due_date: { + type: 'string', + description: 'Due date (ISO 8601 format)', + }, + project_id: { + type: 'number', + description: 'Project ID to assign task to', + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Array of tag names', + }, + }, + required: ['name'], + }, + handler: async (params) => { + const priorityMap = { low: 0, medium: 1, high: 2 }; + + const taskData = { + user_id: context.userId, + name: params.name, + note: params.description || '', + priority: params.priority ? priorityMap[params.priority] : 1, + status: 0, // pending + due_date: params.due_date || null, + project_id: params.project_id || null, + }; + + const task = await taskRepository.create(taskData); + + // Handle tags if provided + if (params.tags && params.tags.length > 0) { + const tagInstances = await Promise.all( + params.tags.map(async (tagName) => { + const [tag] = await Tag.findOrCreate({ + where: { name: tagName, user_id: context.userId }, + }); + return tag; + }) + ); + await task.setTags(tagInstances); + } + + // Reload with associations + const reloadedTask = await taskRepository.findByIdAndUser( + task.id, + context.userId, + { + include: [ + { model: Project, as: 'Project' }, + { model: Tag, as: 'Tags' }, + ], + } + ); + + const serialized = await serializeTask( + reloadedTask, + context.user.timezone + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + message: 'Task created successfully', + task: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 4. update_task - Update existing task + tools.push({ + name: 'update_task', + description: 'Update an existing task', + inputSchema: { + type: 'object', + properties: { + id: { + type: ['number', 'string'], + description: 'Task ID or UID', + }, + name: { type: 'string', description: 'New task name' }, + description: { type: 'string', description: 'New description' }, + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + }, + status: { + type: 'string', + enum: ['pending', 'in_progress', 'completed', 'archived'], + }, + due_date: { type: 'string', description: 'New due date' }, + today: { + type: 'boolean', + description: 'Add to Today list', + }, + }, + required: ['id'], + }, + handler: async (params) => { + const task = await findTaskByIdentifier(params.id, context.userId); + + if (!task) { + throw new Error(`Task not found: ${params.id}`); + } + + if (task.user_id !== context.userId) { + throw new Error('Access denied'); + } + + const updates = {}; + if (params.name !== undefined) updates.name = params.name; + if (params.description !== undefined) + updates.note = params.description; + if (params.priority) { + const priorityMap = { low: 0, medium: 1, high: 2 }; + updates.priority = priorityMap[params.priority]; + } + if (params.status) { + const statusMap = { + pending: 0, + in_progress: 1, + completed: 2, + archived: 6, + }; + updates.status = statusMap[params.status]; + } + if (params.due_date !== undefined) + updates.due_date = params.due_date; + if (params.today !== undefined) updates.today = params.today; + + await task.update(updates); + + // Reload with associations + const reloadedTask = await taskRepository.findByIdAndUser( + task.id, + context.userId, + { + include: [ + { model: Project, as: 'Project' }, + { model: Tag, as: 'Tags' }, + ], + } + ); + + const serialized = await serializeTask( + reloadedTask, + context.user.timezone + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + message: 'Task updated successfully', + task: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 5. complete_task - Toggle task completion + tools.push({ + name: 'complete_task', + description: 'Mark a task as completed or reopen it', + inputSchema: { + type: 'object', + properties: { + id: { + type: ['number', 'string'], + description: 'Task ID or UID', + }, + }, + required: ['id'], + }, + handler: async (params) => { + const task = await findTaskByIdentifier(params.id, context.userId); + + if (!task) { + throw new Error(`Task not found: ${params.id}`); + } + + if (task.user_id !== context.userId) { + throw new Error('Access denied'); + } + + // Toggle completion + const newStatus = task.status === 2 ? 0 : 2; // 2 = completed, 0 = pending + const updates = { + status: newStatus, + completed_at: newStatus === 2 ? new Date() : null, + }; + + await task.update(updates); + + const reloadedTask = await taskRepository.findByIdAndUser( + task.id, + context.userId, + { + include: [ + { model: Project, as: 'Project' }, + { model: Tag, as: 'Tags' }, + ], + } + ); + + const serialized = await serializeTask( + reloadedTask, + context.user.timezone + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + message: + newStatus === 2 + ? 'Task completed' + : 'Task reopened', + task: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 6. delete_task - Delete task + tools.push({ + name: 'delete_task', + description: 'Permanently delete a task', + inputSchema: { + type: 'object', + properties: { + id: { + type: ['number', 'string'], + description: 'Task ID or UID', + }, + }, + required: ['id'], + }, + handler: async (params) => { + const task = await findTaskByIdentifier(params.id, context.userId); + + if (!task) { + throw new Error(`Task not found: ${params.id}`); + } + + if (task.user_id !== context.userId) { + throw new Error('Access denied'); + } + + await task.destroy(); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + message: 'Task deleted successfully', + task_id: params.id, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 7. add_subtask - Add subtask to parent + tools.push({ + name: 'add_subtask', + description: 'Add a subtask to an existing task', + inputSchema: { + type: 'object', + properties: { + parent_id: { + type: ['number', 'string'], + description: 'Parent task ID or UID', + }, + name: { + type: 'string', + description: 'Subtask name', + }, + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + }, + due_date: { + type: 'string', + description: 'Due date', + }, + }, + required: ['parent_id', 'name'], + }, + handler: async (params) => { + const parentTask = await findTaskByIdentifier( + params.parent_id, + context.userId + ); + + if (!parentTask) { + throw new Error(`Parent task not found: ${params.parent_id}`); + } + + if (parentTask.user_id !== context.userId) { + throw new Error('Access denied'); + } + + const priorityMap = { low: 0, medium: 1, high: 2 }; + + const subtaskData = { + user_id: context.userId, + name: params.name, + parent_task_id: parentTask.id, + priority: params.priority ? priorityMap[params.priority] : 1, + status: 0, + due_date: params.due_date || null, + project_id: parentTask.project_id, // Inherit parent's project + }; + + const subtask = await taskRepository.create(subtaskData); + + const reloadedSubtask = await taskRepository.findByIdAndUser( + subtask.id, + context.userId, + { + include: [ + { model: Project, as: 'Project' }, + { model: Tag, as: 'Tags' }, + ], + } + ); + + const serialized = await serializeTask( + reloadedSubtask, + context.user.timezone + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + message: 'Subtask created successfully', + subtask: serialized, + }, + null, + 2 + ), + }, + ], + }; + }, + }); + + // 8. get_task_metrics - Get task statistics + tools.push({ + name: 'get_task_metrics', + description: 'Get task statistics and productivity metrics', + inputSchema: { + type: 'object', + properties: {}, + }, + handler: async (params) => { + // Get counts by status + const openCount = await taskRepository.count({ + user_id: context.userId, + status: { [Op.in]: [0, 1] }, // pending or in_progress + }); + + const completedCount = await taskRepository.count({ + user_id: context.userId, + status: 2, // completed + }); + + // Get overdue tasks + const now = new Date(); + const overdueCount = await taskRepository.count({ + user_id: context.userId, + status: { [Op.in]: [0, 1] }, + due_date: { [Op.lt]: now }, + }); + + // Get in_progress tasks + const inProgressCount = await taskRepository.count({ + user_id: context.userId, + status: 1, + }); + + // Get today's completions + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + const todayCompletions = await taskRepository.count({ + user_id: context.userId, + status: 2, + completed_at: { [Op.gte]: startOfDay }, + }); + + // Get this week's completions + const startOfWeek = new Date(); + startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); + startOfWeek.setHours(0, 0, 0, 0); + const weekCompletions = await taskRepository.count({ + user_id: context.userId, + status: 2, + completed_at: { [Op.gte]: startOfWeek }, + }); + + const metrics = { + open_tasks: openCount, + completed_tasks: completedCount, + overdue_tasks: overdueCount, + in_progress_tasks: inProgressCount, + completed_today: todayCompletions, + completed_this_week: weekCompletions, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(metrics, null, 2), + }, + ], + }; + }, + }); +} + +module.exports = { registerTaskTools }; diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index 4c89f7d..5022053 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -43,6 +43,7 @@ const Navbar: React.FC = ({ backups: false, calendar: false, habits: false, + mcp: false, }); const dropdownRef = useRef(null); const navigate = useNavigate(); diff --git a/frontend/components/Profile/ProfileSettings.tsx b/frontend/components/Profile/ProfileSettings.tsx index b1525ad..f5363ca 100644 --- a/frontend/components/Profile/ProfileSettings.tsx +++ b/frontend/components/Profile/ProfileSettings.tsx @@ -17,6 +17,7 @@ import { CheckIcon, BellIcon, CommandLineIcon, + CpuChipIcon, } from '@heroicons/react/24/outline'; import TelegramIcon from '../Shared/Icons/TelegramIcon'; import { useToast } from '../Shared/ToastContext'; @@ -44,7 +45,12 @@ import TelegramTab from './tabs/TelegramTab'; import AiTab from './tabs/AiTab'; import NotificationsTab from './tabs/NotificationsTab'; import KeyboardShortcutsTab from './tabs/KeyboardShortcutsTab'; +import McpTab from './tabs/McpTab'; import { getDefaultConfig } from '../../utils/keyboardShortcutsService'; +import { + getFeatureFlags, + type FeatureFlags, +} from '../../utils/featureFlags'; import type { ProfileSettingsProps, Profile, @@ -88,6 +94,7 @@ const ProfileSettings: React.FC = ({ 'ai', 'notifications', 'keyboard-shortcuts', + 'mcp', ]; return section && validTabs.includes(section) ? section : 'general'; }, [location.search]); @@ -127,6 +134,12 @@ const ProfileSettings: React.FC = ({ }); const [loading, setLoading] = useState(true); const [updateKey, setUpdateKey] = useState(0); + const [featureFlags, setFeatureFlags] = useState({ + backups: false, + calendar: false, + habits: false, + mcp: false, + }); const [isChangingLanguage, setIsChangingLanguage] = useState(false); const [isPolling, setIsPolling] = useState(false); const [telegramSetupStatus, setTelegramSetupStatus] = useState< @@ -465,6 +478,11 @@ const ProfileSettings: React.FC = ({ const fetchProfile = async () => { try { setLoading(true); + + // Load feature flags + const flags = await getFeatureFlags(); + setFeatureFlags(flags); + const response = await fetch(getApiPath('profile')); if (!response.ok) { @@ -1127,8 +1145,20 @@ const ProfileSettings: React.FC = ({ name: t('profile.tabs.keyboardShortcuts', 'Shortcuts'), icon: , }, + { + id: 'mcp', + name: t('profile.tabs.mcp', 'MCP Integration'), + icon: , + featureFlag: 'mcp', + }, ]; + // Filter tabs based on feature flags + const visibleTabs = tabs.filter((tab) => { + if (!tab.featureFlag) return true; + return featureFlags[tab.featureFlag as keyof FeatureFlags]; + }); + return ( <>
= ({
+ + ); +}; + +export default McpTab; diff --git a/frontend/components/Profile/tabs/TabsNav.tsx b/frontend/components/Profile/tabs/TabsNav.tsx index c13bfe4..6519551 100644 --- a/frontend/components/Profile/tabs/TabsNav.tsx +++ b/frontend/components/Profile/tabs/TabsNav.tsx @@ -4,6 +4,7 @@ interface TabConfig { id: string; name: string; icon: React.ReactNode; + featureFlag?: string; // Optional feature flag to conditionally show tab } interface TabsNavProps { diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 5e236e1..bc56d17 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -57,6 +57,7 @@ const Sidebar: React.FC = ({ backups: false, calendar: false, habits: false, + mcp: false, }); const toggleDropdown = () => { diff --git a/frontend/components/Sidebar/SidebarNav.tsx b/frontend/components/Sidebar/SidebarNav.tsx index 55dc863..5e7e0db 100644 --- a/frontend/components/Sidebar/SidebarNav.tsx +++ b/frontend/components/Sidebar/SidebarNav.tsx @@ -31,6 +31,7 @@ const SidebarNav: React.FC = ({ backups: false, calendar: false, habits: false, + mcp: false, }); const inboxItemsCount = store.inboxStore.pagination.total; diff --git a/frontend/utils/featureFlags.ts b/frontend/utils/featureFlags.ts index 532c0d0..aa8bc99 100644 --- a/frontend/utils/featureFlags.ts +++ b/frontend/utils/featureFlags.ts @@ -4,6 +4,7 @@ export interface FeatureFlags { backups: boolean; calendar: boolean; habits: boolean; + mcp: boolean; } let cachedFeatureFlags: FeatureFlags | null = null; @@ -24,6 +25,7 @@ export const getFeatureFlags = async (): Promise => { backups: false, calendar: false, habits: false, + mcp: false, }; } @@ -32,6 +34,7 @@ export const getFeatureFlags = async (): Promise => { backups: false, calendar: false, habits: false, + mcp: false, }; cachedFeatureFlags = { ...defaultFlags, @@ -44,6 +47,7 @@ export const getFeatureFlags = async (): Promise => { backups: false, calendar: false, habits: false, + mcp: false, }; } }; diff --git a/package-lock.json b/package-lock.json index 92e8e3e..0e419d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "tududi", - "version": "v0.89.0-rc.2", + "version": "v1.0.0-dev.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tududi", - "version": "v0.89.0-rc.2", + "version": "v1.0.0-dev.2", "license": "ISC", "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", "@playwright/test": "^1.57.0", "bcrypt": "~6.0.0", "compression": "~1.8.0", @@ -2288,6 +2289,18 @@ "react": ">= 16 || ^19.0.0-rc" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3045,6 +3058,382 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -8702,6 +9091,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8900,7 +9310,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -8975,7 +9384,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -9946,6 +10354,15 @@ "license": "MIT", "peer": true }, + "node_modules/hono": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -11055,6 +11472,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -12315,6 +12738,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -12496,6 +12928,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -15581,6 +16019,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", @@ -16923,7 +17370,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -17205,6 +17651,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -21155,6 +21627,24 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zustand": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", diff --git a/package.json b/package.json index 1f2da73..d809784 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,9 @@ "format": "npm run frontend:format && npm run backend:format", "format:fix": "npm run frontend:format:fix && npm run backend:format:fix", "docker:test-build": "bash scripts/test-docker-build.sh", - "kill:all": "lsof -ti:8080,3002 | xargs kill -9 2>/dev/null || true" + "kill:all": "lsof -ti:8080,3002 | xargs kill -9 2>/dev/null || true", + "mcp:start": "cd backend && node modules/mcp/server.js", + "mcp:dev": "cd backend && nodemon modules/mcp/server.js" }, "keywords": [], "author": "", @@ -134,6 +136,7 @@ "zustand": "^5.0.3" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", "@playwright/test": "^1.57.0", "bcrypt": "~6.0.0", "compression": "~1.8.0",