Files
AgapHost/agap-mcp/src/server.js
Alvis b8efe4732d Sync infra config: HA/Zabbix relocation, Immich storage move, new services
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
2026-07-04 13:28:04 +00:00

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);
});