Accumulated uncommitted infra changes: - Caddyfile: repoint HA/Zabbix to 192.168.1.4/.3, add ~20 new site routes - Immich: move media to /mnt/smsg, enable CUDA ML, mem limits, rewrite backup.sh - Add service stacks: agap-mcp, anki, family, freshrss, iperf3, kanboard, linkwarden, qbittorrent, radicale, syncthing, vikunja, windows - openwebui: enable API keys; ollama: drop CPU fallback - seafile/zabbix: extra_hosts entries; matrix: add user juris - Remove pihole stack and stale wiki/migrate.py - Ignore marketplace-mcp (standalone repo) and linkwarden runtime data Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
443 lines
19 KiB
JavaScript
443 lines
19 KiB
JavaScript
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);
|
|
});
|