import express from 'express'; import { randomUUID } from 'crypto'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { initVaultwarden, vwGetPassword, vwGetItem, vwListItems, vwListOrgItems, vwCreateLogin, vwUpdatePassword } from './vaultwarden.js'; import { initGitea, giteaListRepos, giteaReadFile, giteaWikiList, giteaWikiRead, giteaWikiWrite, giteaListIssues } from './gitea.js'; import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from './homeassistant.js'; import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js'; import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js'; import { initKanboard, kbListProjects, kbGetProject, kbListTasks, kbMyTasks, kbGetTask, kbSearchTasks, kbListUsers, kbProjectActivity, kbCreateTask, kbUpdateTask, kbMoveTask, kbChangeTaskStatus, kbAssignTask, kbAddComment, kbCreateSubtask, kbUpdateSubtask, kbRemoveTask, kbRemoveComment } from './kanboard.js'; const PORT = parseInt(process.env.PORT || '3100'); // --- Init services --- async function init() { await initVaultwarden(); // Fetch all tokens from org to avoid "more than one result" on duplicates const orgItems = vwListOrgItems(); const orgToken = (name) => { const item = orgItems.find(i => i.name === name); if (!item) throw new Error(`Token not found in org: ${name}`); return item.login?.password; }; const gitea_token = orgToken('GITEA_TOKEN'); const ha_token = orgToken('HA_TOKEN'); const zabbix_token = orgToken('ZABBIX_TOKEN'); const radicale_password = orgToken('RADICALE_PASSWORD'); const kanboard_token = orgToken('KANBOARD_TOKEN'); initGitea(gitea_token); initHA(ha_token); initZabbix(zabbix_token); initRadicale(radicale_password); initKanboard(kanboard_token); } // --- MCP Server factory (one per session — McpServer can't share transports) --- function ok(text) { return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] }; } function err(e) { return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }; } function createServer() { const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' }); // --- Vaultwarden tools --- server.tool('vw_get_password', 'Get password for a Vaultwarden item by name', { name: z.string() }, async ({ name }) => { try { return ok(vwGetPassword(name)); } catch (e) { return err(e); } }); server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes)', { name: z.string() }, async ({ name }) => { try { const item = vwGetItem(name); return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes }); } catch (e) { return err(e); } }); server.tool('vw_list_items', 'List Vaultwarden items. Searches personal vault by default; set org=true to search org/AI collection', { search: z.string().optional(), org: z.boolean().optional(), }, async ({ search, org }) => { try { const items = org ? vwListOrgItems(search) : vwListItems(search); return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri }))); } catch (e) { return err(e); } }); server.tool('vw_create_login', 'Create a new login item in Vaultwarden AI collection', { name: z.string(), username: z.string().optional(), password: z.string(), url: z.string().optional(), notes: z.string().optional(), }, async (args) => { try { return ok(vwCreateLogin(args)); } catch (e) { return err(e); } }); server.tool('vw_update_password', 'Update the password of an existing Vaultwarden item', { name: z.string().describe('Item name or ID'), password: z.string(), }, async ({ name, password }) => { try { return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); } }); // --- Gitea tools --- server.tool('gitea_list_repos', 'List all Gitea repositories', {}, async () => { try { return ok(await giteaListRepos()); } catch (e) { return err(e); } }); server.tool('gitea_read_file', 'Read a file from a Gitea repository', { repo: z.string().describe('e.g. alvis/AgapHost'), path: z.string().describe('file path in repo'), ref: z.string().optional().describe('branch/tag/commit, default HEAD'), }, async ({ repo, path, ref }) => { try { return ok(await giteaReadFile(repo, path, ref)); } catch (e) { return err(e); } }); server.tool('gitea_wiki_list', 'List wiki pages', { repo: z.string().optional().describe('default: alvis/AgapHost'), }, async ({ repo }) => { try { return ok(await giteaWikiList(repo)); } catch (e) { return err(e); } }); server.tool('gitea_wiki_read', 'Read a wiki page', { page: z.string(), repo: z.string().optional().describe('default: alvis/AgapHost'), }, async ({ page, repo }) => { try { return ok(await giteaWikiRead(page, repo)); } catch (e) { return err(e); } }); server.tool('gitea_wiki_write', 'Write/update a wiki page', { page: z.string(), content: z.string(), message: z.string().optional().describe('commit message'), repo: z.string().optional().describe('default: alvis/AgapHost'), }, async ({ page, content, message, repo }) => { try { return ok(await giteaWikiWrite(page, content, message, repo)); } catch (e) { return err(e); } }); server.tool('gitea_list_issues', 'List issues for a Gitea repo', { repo: z.string().describe('e.g. alvis/AgapHost'), state: z.enum(['open', 'closed', 'all']).optional(), }, async ({ repo, state }) => { try { return ok(await giteaListIssues(repo, state)); } catch (e) { return err(e); } }); // --- Home Assistant tools --- server.tool('ha_get_state', 'Get current state of a Home Assistant entity', { entity_id: z.string().describe('e.g. light.living_room'), }, async ({ entity_id }) => { try { return ok(await haGetState(entity_id)); } catch (e) { return err(e); } }); server.tool('ha_list_entities', 'List Home Assistant entities, optionally filtered by domain', { domain: z.string().optional().describe('e.g. light, switch, sensor, binary_sensor'), }, async ({ domain }) => { try { return ok(await haListEntities(domain)); } catch (e) { return err(e); } }); server.tool('ha_call_service', 'Call a Home Assistant service', { domain: z.string().describe('e.g. light, switch, automation'), service: z.string().describe('e.g. turn_on, turn_off, toggle'), data: z.record(z.unknown()).optional().describe('service call data, e.g. {"entity_id": "light.x"}'), }, async ({ domain, service, data }) => { try { return ok(await haCallService(domain, service, data)); } catch (e) { return err(e); } }); server.tool('ha_get_history', 'Get state history for a Home Assistant entity', { entity_id: z.string(), hours: z.number().optional().describe('hours of history, default 24'), }, async ({ entity_id, hours }) => { try { return ok(await haGetHistory(entity_id, hours)); } catch (e) { return err(e); } }); // --- Zabbix tools --- server.tool('zabbix_get_problems', 'Get current active problems in Zabbix', {}, async () => { try { return ok(await zabbixGetProblems()); } catch (e) { return err(e); } }); server.tool('zabbix_get_hosts', 'List all monitored hosts in Zabbix with availability status', {}, async () => { try { return ok(await zabbixGetHosts()); } catch (e) { return err(e); } }); server.tool('zabbix_get_items', 'Get monitored items and their latest values for a Zabbix host', { hostid: z.string().describe('Zabbix host ID'), }, async ({ hostid }) => { try { return ok(await zabbixGetItems(hostid)); } catch (e) { return err(e); } }); server.tool('zabbix_get_triggers', 'Get triggers for a Zabbix host or all hosts', { hostid: z.string().optional().describe('Zabbix host ID, omit for all hosts'), }, async ({ hostid }) => { try { return ok(await zabbixGetTriggers(hostid)); } catch (e) { return err(e); } }); // --- Radicale (CalDAV) tools --- server.tool('radicale_list_calendars', 'List CalDAV calendars for a Radicale user (defaults to alvis)', { user: z.string().optional(), }, async ({ user }) => { try { return ok(await radicaleListCalendars(user)); } catch (e) { return err(e); } }); server.tool('radicale_list_events', 'List events in a Radicale calendar (returns filename, uid, summary, dtstart, dtend)', { calendar_id: z.string().describe('Calendar UUID from radicale_list_calendars'), user: z.string().optional(), }, async ({ calendar_id, user }) => { try { return ok(await radicaleListEvents(calendar_id, user)); } catch (e) { return err(e); } }); server.tool('radicale_get_event', 'Get raw iCalendar text for a single event', { calendar_id: z.string(), filename: z.string().describe('e.g. ABCD-1234.ics'), user: z.string().optional(), }, async ({ calendar_id, filename, user }) => { try { return ok(await radicaleGetEvent(calendar_id, filename, user)); } catch (e) { return err(e); } }); server.tool('radicale_create_calendar', 'Create a new CalDAV calendar (MKCALENDAR). Returns the new calendar id.', { displayname: z.string(), color: z.string().optional().describe('e.g. #33CC66ff'), components: z.array(z.string()).optional().describe('default ["VEVENT"]'), id: z.string().optional().describe('explicit collection id; otherwise UUID generated'), user: z.string().optional(), }, async (args) => { try { return ok(await radicaleCreateCalendar(args)); } catch (e) { return err(e); } }); server.tool('radicale_delete_calendar', 'Delete a Radicale calendar collection', { calendar_id: z.string(), user: z.string().optional(), }, async ({ calendar_id, user }) => { try { return ok(await radicaleDeleteCalendar(calendar_id, user)); } catch (e) { return err(e); } }); server.tool('radicale_put_event', 'PUT an iCalendar event into a calendar (create or replace)', { calendar_id: z.string(), filename: z.string().describe('Event filename, with or without .ics'), ics: z.string().describe('Full VCALENDAR body'), user: z.string().optional(), }, async ({ calendar_id, filename, ics, user }) => { try { return ok(await radicalePutEvent(calendar_id, filename, ics, user)); } catch (e) { return err(e); } }); server.tool('radicale_delete_event', 'Delete an event from a calendar', { calendar_id: z.string(), filename: z.string(), user: z.string().optional(), }, async ({ calendar_id, filename, user }) => { try { return ok(await radicaleDeleteEvent(calendar_id, filename, user)); } catch (e) { return err(e); } }); server.tool('radicale_move_event', 'Move an event between calendars (copy then delete original)', { from_calendar: z.string(), to_calendar: z.string(), filename: z.string(), user: z.string().optional(), }, async ({ from_calendar, to_calendar, filename, user }) => { try { return ok(await radicaleMoveEvent({ fromCalendar: from_calendar, toCalendar: to_calendar, filename, user })); } catch (e) { return err(e); } }); // --- Kanboard tools --- server.tool('kanboard_list_projects', 'List all Kanboard projects (id, name, active, owner)', {}, async () => { try { return ok(await kbListProjects()); } catch (e) { return err(e); } }); server.tool('kanboard_get_project', 'Get a Kanboard project with its columns and swimlanes (needed to move cards)', { project_id: z.number().describe('Project ID'), }, async ({ project_id }) => { try { return ok(await kbGetProject(project_id)); } catch (e) { return err(e); } }); server.tool('kanboard_list_tasks', 'List tasks in a Kanboard project', { project_id: z.number(), status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'), }, async ({ project_id, status }) => { try { return ok(await kbListTasks(project_id, status)); } catch (e) { return err(e); } }); server.tool('kanboard_my_tasks', "List tasks assigned to the bot user (claude) across all projects", { status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'), }, async ({ status }) => { try { return ok(await kbMyTasks(status)); } catch (e) { return err(e); } }); server.tool('kanboard_get_task', 'Get full detail of a Kanboard task, including its subtasks and comments', { task_id: z.number(), }, async ({ task_id }) => { try { return ok(await kbGetTask(task_id)); } catch (e) { return err(e); } }); server.tool('kanboard_search_tasks', 'Search tasks in a project using Kanboard query syntax (e.g. "assignee:claude status:open", "due:today", "color:red")', { project_id: z.number(), query: z.string(), }, async ({ project_id, query }) => { try { return ok(await kbSearchTasks(project_id, query)); } catch (e) { return err(e); } }); server.tool('kanboard_list_users', 'List all Kanboard users (id, username, name, role)', {}, async () => { try { return ok(await kbListUsers()); } catch (e) { return err(e); } }); server.tool('kanboard_project_activity', 'Get the recent activity stream for a project (who did what)', { project_id: z.number(), }, async ({ project_id }) => { try { return ok(await kbProjectActivity(project_id)); } catch (e) { return err(e); } }); server.tool('kanboard_create_task', 'Create a Kanboard task (authored by claude). At minimum provide title and project_id', { title: z.string(), project_id: z.number(), description: z.string().optional().describe('Markdown'), column_id: z.number().optional(), owner_id: z.number().optional().describe('Assignee user id'), color_id: z.string().optional().describe('e.g. yellow, blue, red, green'), date_due: z.string().optional().describe('YYYY-MM-DD'), priority: z.number().optional(), swimlane_id: z.number().optional(), }, async (args) => { try { return ok(await kbCreateTask(args)); } catch (e) { return err(e); } }); server.tool('kanboard_update_task', 'Update fields of a Kanboard task', { id: z.number().describe('Task id'), title: z.string().optional(), description: z.string().optional(), owner_id: z.number().optional(), color_id: z.string().optional(), date_due: z.string().optional().describe('YYYY-MM-DD'), priority: z.number().optional(), category_id: z.number().optional(), }, async (args) => { try { return ok(await kbUpdateTask(args)); } catch (e) { return err(e); } }); server.tool('kanboard_move_task', 'Move a task to a column/position (e.g. to "Work in progress" or "Done"). project_id and swimlane_id are auto-resolved if omitted', { task_id: z.number(), column_id: z.number().describe('Target column id (see kanboard_get_project)'), position: z.number().optional().describe('default 1 (top)'), swimlane_id: z.number().optional(), project_id: z.number().optional(), }, async (args) => { try { return ok(await kbMoveTask(args)); } catch (e) { return err(e); } }); server.tool('kanboard_change_task_status', 'Open or close (mark done/archive) a task', { task_id: z.number(), action: z.enum(['open', 'close']), }, async ({ task_id, action }) => { try { return ok(await kbChangeTaskStatus(task_id, action)); } catch (e) { return err(e); } }); server.tool('kanboard_assign_task', 'Assign a task to a user (username or numeric id). Use "claude" to take it', { task_id: z.number(), owner: z.union([z.string(), z.number()]).describe('Username or user id'), }, async ({ task_id, owner }) => { try { return ok(await kbAssignTask(task_id, owner)); } catch (e) { return err(e); } }); server.tool('kanboard_add_comment', 'Add a comment to a task (authored by claude by default)', { task_id: z.number(), content: z.string().describe('Markdown'), user_id: z.number().optional().describe('Override author; defaults to claude'), }, async ({ task_id, content, user_id }) => { try { return ok(await kbAddComment(task_id, content, user_id)); } catch (e) { return err(e); } }); server.tool('kanboard_create_subtask', 'Add a subtask to a task (good for tracking execution steps)', { task_id: z.number(), title: z.string(), user_id: z.number().optional().describe('Assignee; defaults to claude'), status: z.number().optional().describe('0=todo, 1=in progress, 2=done'), }, async (args) => { try { return ok(await kbCreateSubtask(args)); } catch (e) { return err(e); } }); server.tool('kanboard_update_subtask', 'Update a subtask (e.g. mark in-progress or done)', { id: z.number().describe('Subtask id'), task_id: z.number(), title: z.string().optional(), status: z.number().optional().describe('0=todo, 1=in progress, 2=done'), user_id: z.number().optional(), }, async (args) => { try { return ok(await kbUpdateSubtask(args)); } catch (e) { return err(e); } }); server.tool('kanboard_remove_task', 'Permanently delete a task (irreversible)', { task_id: z.number(), }, async ({ task_id }) => { try { return ok(await kbRemoveTask(task_id)); } catch (e) { return err(e); } }); server.tool('kanboard_remove_comment', 'Permanently delete a comment (irreversible)', { comment_id: z.number(), }, async ({ comment_id }) => { try { return ok(await kbRemoveComment(comment_id)); } catch (e) { return err(e); } }); return server; } // --- HTTP server (Streamable HTTP + legacy SSE) --- const app = express(); app.use(express.json()); const sseTransports = new Map(); // Streamable HTTP — stateless: fresh server per request, survives container restarts app.all('/mcp', async (req, res) => { try { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => transport.close()); await createServer().connect(transport); await transport.handleRequest(req, res, req.body); } catch (e) { console.error('MCP request error:', e.message); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null }); } } }); // Legacy SSE — kept for backward compatibility app.get('/sse', async (req, res) => { const transport = new SSEServerTransport('/messages', res); sseTransports.set(transport.sessionId, transport); res.on('close', () => sseTransports.delete(transport.sessionId)); await createServer().connect(transport); }); app.post('/messages', async (req, res) => { const transport = sseTransports.get(req.query.sessionId); if (!transport) return res.status(400).send('Unknown session'); await transport.handlePostMessage(req, res); }); app.get('/health', (_, res) => res.json({ status: 'ok', tools: 45 })); init() .then(() => { app.listen(PORT, () => console.log(`agap-mcp listening on :${PORT}`)); }) .catch(e => { console.error('Init failed:', e.message); process.exit(1); });