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
This commit is contained in:
14
agap-mcp/Dockerfile
Normal file
14
agap-mcp/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
RUN npm install -g @bitwarden/cli
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
COPY src/ ./src/
|
||||
COPY start.sh ./
|
||||
RUN chmod +x start.sh
|
||||
CMD ["./start.sh"]
|
||||
27
agap-mcp/docker-compose.yml
Normal file
27
agap-mcp/docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
agap-mcp:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
- PORT=3100
|
||||
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
- HTTPS_PROXY=
|
||||
- HTTP_PROXY=
|
||||
- ALL_PROXY=
|
||||
- https_proxy=
|
||||
- http_proxy=
|
||||
- all_proxy=
|
||||
- BITWARDENCLI_APPDATA_DIR=/bw-data
|
||||
- BW_EMAIL=allogn@gmail.com
|
||||
- BW_PASSWORD=${BW_PASSWORD}
|
||||
- VAULTWARDEN_URL=http://localhost:8041
|
||||
- GITEA_URL=http://localhost:3000
|
||||
- HA_URL=http://192.168.1.4:8123
|
||||
- ZABBIX_URL=http://192.168.1.4:81
|
||||
- RADICALE_URL=http://localhost:5232
|
||||
- RADICALE_USER=alvis
|
||||
- KANBOARD_URL=http://localhost:4800
|
||||
- KANBOARD_BOT_USER=claude
|
||||
volumes:
|
||||
- /home/alvis/.config/Bitwarden CLI:/bw-data
|
||||
10
agap-mcp/package.json
Normal file
10
agap-mcp/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "agap-mcp",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"express": "^4.19.0",
|
||||
"zod": "^3.23.0"
|
||||
}
|
||||
}
|
||||
73
agap-mcp/src/gitea.js
Normal file
73
agap-mcp/src/gitea.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const BASE = () => process.env.GITEA_URL || 'http://localhost:3000';
|
||||
let _token = null;
|
||||
|
||||
export function initGitea(token) {
|
||||
_token = token;
|
||||
console.log('Gitea: ready');
|
||||
}
|
||||
|
||||
function token() {
|
||||
if (!_token) throw new Error('Gitea not initialized');
|
||||
return _token;
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(`${BASE()}/api/v1${path}`, {
|
||||
...opts,
|
||||
headers: { Authorization: `token ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function giteaListRepos() {
|
||||
return api('/repos/search?limit=50').then(r => r.data.map(r => ({
|
||||
name: r.full_name, description: r.description, stars: r.stars_count,
|
||||
})));
|
||||
}
|
||||
|
||||
export async function giteaReadFile(repo, path, ref = 'HEAD') {
|
||||
const data = await api(`/repos/${repo}/contents/${path}?ref=${ref}`);
|
||||
return Buffer.from(data.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
export async function giteaWikiList(repo = 'alvis/AgapHost') {
|
||||
const data = await api(`/repos/${repo}/wiki/pages?limit=50`);
|
||||
return data.map(p => ({ name: p.title, updated: p.last_commit?.created }));
|
||||
}
|
||||
|
||||
export async function giteaWikiRead(page, repo = 'alvis/AgapHost') {
|
||||
const data = await api(`/repos/${repo}/wiki/page/${encodeURIComponent(page)}`);
|
||||
return Buffer.from(data.content_base64, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
export async function giteaWikiWrite(page, content, message, repo = 'alvis/AgapHost') {
|
||||
const dir = join(tmpdir(), 'agap-mcp-wiki');
|
||||
const wikiUrl = `${BASE().replace('http://', `http://alvis:${token()}@`)}/alvis/AgapHost.wiki.git`;
|
||||
|
||||
const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com', GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com' };
|
||||
|
||||
try {
|
||||
execSync(`git -C ${dir} pull ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' });
|
||||
} catch {
|
||||
execSync(`git clone ${wikiUrl} ${dir}`, { env: gitEnv, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
const file = join(dir, `${page}.md`);
|
||||
writeFileSync(file, content);
|
||||
execSync(`git -C ${dir} add "${page}.md"`, { env: gitEnv, stdio: 'pipe' });
|
||||
execSync(`git -C ${dir} commit -m "${message || `Update ${page}`}"`, { env: gitEnv, stdio: 'pipe' });
|
||||
execSync(`git -C ${dir} push ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' });
|
||||
return `${page} updated`;
|
||||
}
|
||||
|
||||
export async function giteaListIssues(repo, state = 'open') {
|
||||
return api(`/repos/${repo}/issues?state=${state}&type=issues&limit=50`).then(issues =>
|
||||
issues.map(i => ({ number: i.number, title: i.title, state: i.state, labels: i.labels.map(l => l.name) }))
|
||||
);
|
||||
}
|
||||
45
agap-mcp/src/homeassistant.js
Normal file
45
agap-mcp/src/homeassistant.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const BASE = () => process.env.HA_URL || 'http://192.168.1.4:8123';
|
||||
let _token = null;
|
||||
|
||||
export function initHA(token) {
|
||||
_token = token;
|
||||
console.log('Home Assistant: ready');
|
||||
}
|
||||
|
||||
function token() {
|
||||
if (!_token) throw new Error('HA not initialized');
|
||||
return _token;
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(`${BASE()}/api${path}`, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HA API ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function haGetState(entityId) {
|
||||
const s = await api(`/states/${entityId}`);
|
||||
return { entity_id: s.entity_id, state: s.state, attributes: s.attributes, last_changed: s.last_changed };
|
||||
}
|
||||
|
||||
export async function haListEntities(domain) {
|
||||
const states = await api('/states');
|
||||
const filtered = domain ? states.filter(s => s.entity_id.startsWith(`${domain}.`)) : states;
|
||||
return filtered.map(s => ({ entity_id: s.entity_id, state: s.state, friendly_name: s.attributes.friendly_name }));
|
||||
}
|
||||
|
||||
export async function haCallService(domain, service, data = {}) {
|
||||
return api(`/services/${domain}/${service}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function haGetHistory(entityId, hours = 24) {
|
||||
const start = new Date(Date.now() - hours * 3600 * 1000).toISOString();
|
||||
const data = await api(`/history/period/${start}?filter_entity_id=${entityId}&minimal_response=true`);
|
||||
return data[0] || [];
|
||||
}
|
||||
215
agap-mcp/src/kanboard.js
Normal file
215
agap-mcp/src/kanboard.js
Normal file
@@ -0,0 +1,215 @@
|
||||
const BASE = () => process.env.KANBOARD_URL || 'http://localhost:4800';
|
||||
const BOT_USER = () => process.env.KANBOARD_BOT_USER || 'claude';
|
||||
|
||||
let _token = null;
|
||||
let _botId = null;
|
||||
|
||||
export function initKanboard(token) {
|
||||
_token = token;
|
||||
console.log('Kanboard: ready');
|
||||
}
|
||||
|
||||
function token() {
|
||||
if (!_token) throw new Error('Kanboard not initialized');
|
||||
return _token;
|
||||
}
|
||||
|
||||
// JSON-RPC over the app-wide token (login "jsonrpc:<token>") — full access to all projects.
|
||||
async function rpc(method, params = {}) {
|
||||
const auth = Buffer.from(`jsonrpc:${token()}`).toString('base64');
|
||||
const res = await fetch(`${BASE()}/jsonrpc.php`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method, id: 1, params }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Kanboard ${method}: ${res.status} ${await res.text()}`);
|
||||
const json = await res.json();
|
||||
if (json.error) throw new Error(`Kanboard ${method}: ${json.error.message || JSON.stringify(json.error)}`);
|
||||
return json.result;
|
||||
}
|
||||
|
||||
// Resolve + cache the bot user's id (default author for writes, filter for "my tasks").
|
||||
async function botUserId() {
|
||||
if (_botId != null) return _botId;
|
||||
const user = await rpc('getUserByName', { username: BOT_USER() });
|
||||
if (!user || !user.id) throw new Error(`Bot user not found in Kanboard: ${BOT_USER()}`);
|
||||
_botId = parseInt(user.id);
|
||||
return _botId;
|
||||
}
|
||||
|
||||
const STATUS = { open: 1, closed: 0 };
|
||||
|
||||
// --- Read / monitor ---
|
||||
|
||||
export async function kbListProjects() {
|
||||
const projects = await rpc('getAllProjects');
|
||||
return projects.map(p => ({
|
||||
id: parseInt(p.id), name: p.name, is_active: parseInt(p.is_active),
|
||||
is_private: parseInt(p.is_private), owner_id: parseInt(p.owner_id),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function kbGetProject(projectId) {
|
||||
const [project, columns, swimlanes] = await Promise.all([
|
||||
rpc('getProjectById', { project_id: projectId }),
|
||||
rpc('getColumns', { project_id: projectId }),
|
||||
rpc('getAllSwimlanes', { project_id: projectId }),
|
||||
]);
|
||||
if (!project) throw new Error(`Project not found: ${projectId}`);
|
||||
return {
|
||||
id: parseInt(project.id), name: project.name, description: project.description,
|
||||
is_active: parseInt(project.is_active), owner_id: parseInt(project.owner_id),
|
||||
columns: columns.map(c => ({ id: parseInt(c.id), title: c.title, position: parseInt(c.position) })),
|
||||
swimlanes: swimlanes.map(s => ({ id: parseInt(s.id), name: s.name, is_active: parseInt(s.is_active) })),
|
||||
};
|
||||
}
|
||||
|
||||
function compactTask(t) {
|
||||
return {
|
||||
id: parseInt(t.id), title: t.title, project_id: parseInt(t.project_id),
|
||||
column_id: parseInt(t.column_id), swimlane_id: parseInt(t.swimlane_id),
|
||||
owner_id: parseInt(t.owner_id), is_active: parseInt(t.is_active),
|
||||
color_id: t.color_id, priority: parseInt(t.priority || 0),
|
||||
date_due: parseInt(t.date_due) || 0, reference: t.reference || '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function kbListTasks(projectId, status = 'open') {
|
||||
let tasks = [];
|
||||
if (status === 'all') {
|
||||
const [open, closed] = await Promise.all([
|
||||
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.open }),
|
||||
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.closed }),
|
||||
]);
|
||||
tasks = [...open, ...closed];
|
||||
} else {
|
||||
tasks = await rpc('getAllTasks', { project_id: projectId, status_id: STATUS[status] ?? STATUS.open });
|
||||
}
|
||||
return tasks.map(compactTask);
|
||||
}
|
||||
|
||||
// Tasks assigned to the bot across every project. status: open | closed | all
|
||||
export async function kbMyTasks(status = 'open') {
|
||||
const botId = await botUserId();
|
||||
const projects = await rpc('getAllProjects');
|
||||
const out = [];
|
||||
for (const p of projects) {
|
||||
const tasks = await kbListTasks(parseInt(p.id), status);
|
||||
for (const t of tasks) {
|
||||
if (t.owner_id === botId) out.push({ ...t, project_name: p.name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function kbGetTask(taskId) {
|
||||
const task = await rpc('getTask', { task_id: taskId });
|
||||
if (!task) throw new Error(`Task not found: ${taskId}`);
|
||||
const [subtasks, comments] = await Promise.all([
|
||||
rpc('getAllSubtasks', { task_id: taskId }),
|
||||
rpc('getAllComments', { task_id: taskId }),
|
||||
]);
|
||||
return {
|
||||
task,
|
||||
subtasks: (subtasks || []).map(s => ({
|
||||
id: parseInt(s.id), title: s.title, status: parseInt(s.status),
|
||||
status_name: s.status_name, user_id: parseInt(s.user_id) || 0, assignee: s.name || s.username || null,
|
||||
})),
|
||||
comments: (comments || []).map(c => ({
|
||||
id: parseInt(c.id), user_id: parseInt(c.user_id), author: c.name || c.username,
|
||||
date: parseInt(c.date_creation), content: c.comment,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function kbSearchTasks(projectId, query) {
|
||||
const tasks = await rpc('searchTasks', { project_id: projectId, query });
|
||||
return (tasks || []).map(compactTask);
|
||||
}
|
||||
|
||||
export async function kbListUsers() {
|
||||
const users = await rpc('getAllUsers');
|
||||
return users.map(u => ({
|
||||
id: parseInt(u.id), username: u.username, name: u.name,
|
||||
role: u.role, is_active: parseInt(u.is_active), email: u.email,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function kbProjectActivity(projectId) {
|
||||
return rpc('getProjectActivity', { project_id: projectId });
|
||||
}
|
||||
|
||||
// --- Write (creation/comments authored by the bot user) ---
|
||||
|
||||
export async function kbCreateTask(params) {
|
||||
const body = { ...params };
|
||||
if (body.creator_id == null) body.creator_id = await botUserId();
|
||||
const id = await rpc('createTask', body);
|
||||
if (!id) throw new Error('createTask returned false (check required fields: title, project_id)');
|
||||
return { task_id: parseInt(id) };
|
||||
}
|
||||
|
||||
export async function kbUpdateTask(params) {
|
||||
const ok = await rpc('updateTask', params);
|
||||
return { updated: !!ok };
|
||||
}
|
||||
|
||||
// Move a card. project_id and swimlane_id are resolved from the task when omitted.
|
||||
export async function kbMoveTask({ task_id, column_id, position = 1, swimlane_id, project_id }) {
|
||||
if (project_id == null || swimlane_id == null) {
|
||||
const task = await rpc('getTask', { task_id });
|
||||
if (!task) throw new Error(`Task not found: ${task_id}`);
|
||||
if (project_id == null) project_id = parseInt(task.project_id);
|
||||
if (swimlane_id == null) swimlane_id = parseInt(task.swimlane_id);
|
||||
}
|
||||
const ok = await rpc('moveTaskPosition', { project_id, task_id, column_id, position, swimlane_id });
|
||||
return { moved: !!ok };
|
||||
}
|
||||
|
||||
export async function kbChangeTaskStatus(taskId, action) {
|
||||
const method = action === 'close' ? 'closeTask' : 'openTask';
|
||||
const ok = await rpc(method, { task_id: taskId });
|
||||
return { [action === 'close' ? 'closed' : 'opened']: !!ok };
|
||||
}
|
||||
|
||||
// Assign a task to a user (username or numeric id).
|
||||
export async function kbAssignTask(taskId, owner) {
|
||||
let ownerId = owner;
|
||||
if (typeof owner === 'string' && !/^\d+$/.test(owner)) {
|
||||
const user = await rpc('getUserByName', { username: owner });
|
||||
if (!user || !user.id) throw new Error(`User not found: ${owner}`);
|
||||
ownerId = parseInt(user.id);
|
||||
}
|
||||
const ok = await rpc('updateTask', { id: taskId, owner_id: parseInt(ownerId) });
|
||||
return { assigned: !!ok, owner_id: parseInt(ownerId) };
|
||||
}
|
||||
|
||||
export async function kbAddComment(taskId, content, userId) {
|
||||
const uid = userId != null ? userId : await botUserId();
|
||||
const id = await rpc('createComment', { task_id: taskId, user_id: uid, content });
|
||||
if (!id) throw new Error('createComment returned false');
|
||||
return { comment_id: parseInt(id) };
|
||||
}
|
||||
|
||||
export async function kbCreateSubtask(params) {
|
||||
const body = { ...params };
|
||||
if (body.user_id == null) body.user_id = await botUserId();
|
||||
const id = await rpc('createSubtask', body);
|
||||
if (!id) throw new Error('createSubtask returned false (required: task_id, title)');
|
||||
return { subtask_id: parseInt(id) };
|
||||
}
|
||||
|
||||
export async function kbUpdateSubtask(params) {
|
||||
const ok = await rpc('updateSubtask', params);
|
||||
return { updated: !!ok };
|
||||
}
|
||||
|
||||
export async function kbRemoveTask(taskId) {
|
||||
const ok = await rpc('removeTask', { task_id: taskId });
|
||||
return { removed: !!ok };
|
||||
}
|
||||
|
||||
export async function kbRemoveComment(commentId) {
|
||||
const ok = await rpc('removeComment', { comment_id: commentId });
|
||||
return { removed: !!ok };
|
||||
}
|
||||
198
agap-mcp/src/radicale.js
Normal file
198
agap-mcp/src/radicale.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const BASE = () => (process.env.RADICALE_URL || 'http://localhost:5232').replace(/\/$/, '');
|
||||
const DEFAULT_USER = () => process.env.RADICALE_USER || 'alvis';
|
||||
|
||||
let _password = null;
|
||||
|
||||
export function initRadicale(password, _user) {
|
||||
_password = password;
|
||||
console.log('Radicale: ready');
|
||||
}
|
||||
|
||||
function authHeader(user) {
|
||||
if (!_password) throw new Error('Radicale not initialized');
|
||||
const u = user || DEFAULT_USER();
|
||||
return 'Basic ' + Buffer.from(`${u}:${_password}`).toString('base64');
|
||||
}
|
||||
|
||||
async function dav(method, path, { user, headers = {}, body } = {}) {
|
||||
const res = await fetch(`${BASE()}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: authHeader(user), ...headers },
|
||||
body,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok && res.status !== 207) {
|
||||
throw new Error(`Radicale ${method} ${path}: ${res.status} ${text}`);
|
||||
}
|
||||
return { status: res.status, text, headers: res.headers };
|
||||
}
|
||||
|
||||
function userPath(user) {
|
||||
return `/${encodeURIComponent(user || DEFAULT_USER())}`;
|
||||
}
|
||||
|
||||
// Extract <response> blocks; for each, pull href + displayname + resourcetype components
|
||||
function parseMultistatus(xml) {
|
||||
const responses = [];
|
||||
const re = /<(?:[a-zA-Z0-9]+:)?response\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/g;
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) {
|
||||
const block = m[1];
|
||||
const href = (block.match(/<(?:[a-zA-Z0-9]+:)?href\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?href>/) || [])[1];
|
||||
const displayname = (block.match(/<(?:[a-zA-Z0-9]+:)?displayname\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?displayname>/) || [])[1];
|
||||
const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\b/.test(block);
|
||||
const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\b/.test(block);
|
||||
const isCollection = /<(?:[a-zA-Z0-9]+:)?collection\b/.test(block);
|
||||
const getetag = (block.match(/<(?:[a-zA-Z0-9]+:)?getetag\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?getetag>/) || [])[1];
|
||||
const componentSet = [];
|
||||
const csRe = /<(?:[a-zA-Z0-9]+:)?comp\b[^>]*\sname="([^"]+)"/g;
|
||||
let cm;
|
||||
while ((cm = csRe.exec(block)) !== null) componentSet.push(cm[1]);
|
||||
responses.push({ href, displayname, isCalendar, isAddressbook, isCollection, getetag, componentSet });
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
const PROPFIND_COLLECTIONS = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:resourcetype/>
|
||||
<c:supported-calendar-component-set/>
|
||||
<ic:calendar-color/>
|
||||
</d:prop>
|
||||
</d:propfind>`;
|
||||
|
||||
const PROPFIND_EVENTS = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>`;
|
||||
|
||||
export async function radicaleListCalendars(user) {
|
||||
const { text } = await dav('PROPFIND', `${userPath(user)}/`, {
|
||||
user,
|
||||
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
||||
body: PROPFIND_COLLECTIONS,
|
||||
});
|
||||
const items = parseMultistatus(text);
|
||||
const out = [];
|
||||
for (const r of items) {
|
||||
if (!r.href || !r.isCalendar) continue;
|
||||
// skip the parent (the user principal itself, which usually has no displayname)
|
||||
const id = decodeURIComponent(r.href.replace(/\/$/, '').split('/').pop());
|
||||
if (!id || id === (user || DEFAULT_USER())) continue;
|
||||
out.push({
|
||||
id,
|
||||
displayname: r.displayname || null,
|
||||
components: r.componentSet,
|
||||
href: r.href,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseIcsField(ics, field, { component = 'VEVENT' } = {}) {
|
||||
// restrict search to the named component block (default VEVENT)
|
||||
const block = (() => {
|
||||
if (!component) return ics;
|
||||
const re = new RegExp(`BEGIN:${component}([\\s\\S]*?)END:${component}`);
|
||||
const m = ics.match(re);
|
||||
return m ? m[1] : ics;
|
||||
})();
|
||||
const re = new RegExp(`(?:^|\\n)${field}(?:;[^:\\n]*)?:([^\\n\\r]*)`);
|
||||
const m = block.match(re);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
export async function radicaleListEvents(calendarId, user) {
|
||||
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/`;
|
||||
const { text } = await dav('PROPFIND', path, {
|
||||
user,
|
||||
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
||||
body: PROPFIND_EVENTS,
|
||||
});
|
||||
const items = parseMultistatus(text);
|
||||
const out = [];
|
||||
for (const r of items) {
|
||||
if (!r.href || !r.href.endsWith('.ics')) continue;
|
||||
const filename = decodeURIComponent(r.href.split('/').pop());
|
||||
// GET event to extract summary + start time
|
||||
const ev = await dav('GET', r.href, { user });
|
||||
const summary = parseIcsField(ev.text, 'SUMMARY');
|
||||
const dtstart = parseIcsField(ev.text, 'DTSTART');
|
||||
const dtend = parseIcsField(ev.text, 'DTEND');
|
||||
const uid = parseIcsField(ev.text, 'UID');
|
||||
out.push({ filename, uid, summary, dtstart, dtend, etag: r.getetag, href: r.href });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function radicaleGetEvent(calendarId, filename, user) {
|
||||
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(filename)}`;
|
||||
const { text } = await dav('GET', path, { user });
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function radicaleCreateCalendar({ displayname, color, components = ['VEVENT'], user, id }) {
|
||||
if (!displayname) throw new Error('displayname required');
|
||||
const calId = id || randomUUID();
|
||||
const compXml = components.map(c => `<c:comp name="${c}"/>`).join('');
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:displayname>${escapeXml(displayname)}</d:displayname>
|
||||
<c:supported-calendar-component-set>${compXml}</c:supported-calendar-component-set>
|
||||
${color ? `<ic:calendar-color>${escapeXml(color)}</ic:calendar-color>` : ''}
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</c:mkcalendar>`;
|
||||
const path = `${userPath(user)}/${calId}/`;
|
||||
await dav('MKCALENDAR', path, {
|
||||
user,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
body,
|
||||
});
|
||||
return { id: calId, displayname, components };
|
||||
}
|
||||
|
||||
export async function radicaleDeleteCalendar(calendarId, user) {
|
||||
await dav('DELETE', `${userPath(user)}/${encodeURIComponent(calendarId)}/`, { user });
|
||||
return { deleted: calendarId };
|
||||
}
|
||||
|
||||
export async function radicalePutEvent(calendarId, filename, ics, user) {
|
||||
const name = filename.endsWith('.ics') ? filename : `${filename}.ics`;
|
||||
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(name)}`;
|
||||
await dav('PUT', path, {
|
||||
user,
|
||||
headers: { 'Content-Type': 'text/calendar; charset=utf-8' },
|
||||
body: ics,
|
||||
});
|
||||
return { put: name };
|
||||
}
|
||||
|
||||
export async function radicaleDeleteEvent(calendarId, filename, user) {
|
||||
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(filename)}`;
|
||||
await dav('DELETE', path, { user });
|
||||
return { deleted: filename };
|
||||
}
|
||||
|
||||
export async function radicaleMoveEvent({ fromCalendar, toCalendar, filename, user }) {
|
||||
if (!fromCalendar || !toCalendar || !filename) {
|
||||
throw new Error('fromCalendar, toCalendar, filename required');
|
||||
}
|
||||
const ics = await radicaleGetEvent(fromCalendar, filename, user);
|
||||
await radicalePutEvent(toCalendar, filename, ics, user);
|
||||
await radicaleDeleteEvent(fromCalendar, filename, user);
|
||||
return { moved: filename, from: fromCalendar, to: toCalendar };
|
||||
}
|
||||
|
||||
function escapeXml(s) {
|
||||
return String(s).replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
|
||||
}
|
||||
442
agap-mcp/src/server.js
Normal file
442
agap-mcp/src/server.js
Normal file
@@ -0,0 +1,442 @@
|
||||
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);
|
||||
});
|
||||
102
agap-mcp/src/vaultwarden.js
Normal file
102
agap-mcp/src/vaultwarden.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
const BW = 'bw';
|
||||
const ORG_ID = '4bd75130-b4d3-48d4-a4cb-e52b70295a51';
|
||||
const AI_COLLECTION = '5be27a82-8475-4c38-96b2-fa94ec8c957b';
|
||||
|
||||
let _session = null;
|
||||
|
||||
function bwEnv() {
|
||||
const env = { ...process.env };
|
||||
for (const k of ['HTTPS_PROXY','HTTP_PROXY','ALL_PROXY','https_proxy','http_proxy','all_proxy'])
|
||||
delete env[k];
|
||||
env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
return env;
|
||||
}
|
||||
|
||||
function run(args, input) {
|
||||
return execFileSync(BW, args, {
|
||||
env: bwEnv(),
|
||||
encoding: 'utf8',
|
||||
input,
|
||||
stdio: input ? ['pipe','pipe','pipe'] : ['ignore','pipe','pipe'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export async function initVaultwarden() {
|
||||
const email = process.env.BW_EMAIL || 'allogn@gmail.com';
|
||||
const password = process.env.BW_PASSWORD;
|
||||
|
||||
// Data dir is mounted from host — server already configured, skip bw config server
|
||||
|
||||
let status = 'unauthenticated';
|
||||
try {
|
||||
status = JSON.parse(run(['status'])).status;
|
||||
} catch {}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
run(['login', email, password, '--raw']);
|
||||
}
|
||||
|
||||
_session = run(['unlock', password, '--raw']);
|
||||
run(['sync', '--session', _session]);
|
||||
console.log('Vaultwarden: ready');
|
||||
}
|
||||
|
||||
function session() {
|
||||
if (!_session) throw new Error('Vaultwarden not initialized');
|
||||
return _session;
|
||||
}
|
||||
|
||||
export function vwGetPassword(name) {
|
||||
return run(['get', 'password', name, '--session', session()]);
|
||||
}
|
||||
|
||||
export function vwGetItem(name) {
|
||||
return JSON.parse(run(['get', 'item', name, '--session', session()]));
|
||||
}
|
||||
|
||||
export function vwListItems(search) {
|
||||
const args = ['list', 'items', '--session', session()];
|
||||
if (search) args.push('--search', search);
|
||||
return JSON.parse(run(args));
|
||||
}
|
||||
|
||||
export function vwListOrgItems(search) {
|
||||
const args = ['list', 'items', '--organizationid', ORG_ID, '--session', session()];
|
||||
if (search) args.push('--search', search);
|
||||
return JSON.parse(run(args));
|
||||
}
|
||||
|
||||
export function vwCreateLogin({ name, username, password, url, notes }) {
|
||||
const item = {
|
||||
organizationId: ORG_ID,
|
||||
collectionIds: [AI_COLLECTION],
|
||||
folderId: null,
|
||||
type: 1,
|
||||
name,
|
||||
notes: notes || null,
|
||||
favorite: false,
|
||||
login: {
|
||||
username: username || null,
|
||||
password,
|
||||
uris: url ? [{ match: null, uri: url }] : [],
|
||||
},
|
||||
};
|
||||
const encoded = run(['encode'], JSON.stringify(item));
|
||||
return JSON.parse(run(['create', 'item', encoded, '--session', session()]));
|
||||
}
|
||||
|
||||
export function vwUpdatePassword(nameOrId, newPassword) {
|
||||
let item;
|
||||
try {
|
||||
item = JSON.parse(run(['get', 'item', nameOrId, '--session', session()]));
|
||||
} catch {
|
||||
const items = vwListOrgItems(nameOrId);
|
||||
item = items.find(i => i.name === nameOrId);
|
||||
if (!item) throw new Error(`Item not found: ${nameOrId}`);
|
||||
}
|
||||
item.login.password = newPassword;
|
||||
const encoded = run(['encode'], JSON.stringify(item));
|
||||
return JSON.parse(run(['edit', 'item', item.id, encoded, '--session', session()]));
|
||||
}
|
||||
75
agap-mcp/src/zabbix.js
Normal file
75
agap-mcp/src/zabbix.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const BASE = () => `${process.env.ZABBIX_URL || 'http://localhost:81'}/api_jsonrpc.php`;
|
||||
let _token = null;
|
||||
let _reqId = 1;
|
||||
|
||||
export function initZabbix(token) {
|
||||
_token = token;
|
||||
console.log('Zabbix: ready');
|
||||
}
|
||||
|
||||
async function api(method, params = {}) {
|
||||
const res = await fetch(BASE(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${_token}` },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: _reqId++ }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(`Zabbix ${method}: ${data.error.data}`);
|
||||
return data.result;
|
||||
}
|
||||
|
||||
export async function zabbixGetProblems() {
|
||||
const problems = await api('problem.get', {
|
||||
output: ['eventid', 'name', 'severity', 'clock', 'acknowledged'],
|
||||
selectAcknowledges: 'count',
|
||||
recent: true,
|
||||
sortfield: 'eventid',
|
||||
sortorder: 'DESC',
|
||||
});
|
||||
return problems.map(p => ({
|
||||
id: p.eventid,
|
||||
name: p.name,
|
||||
severity: ['Not classified','Information','Warning','Average','High','Disaster'][+p.severity],
|
||||
time: new Date(+p.clock * 1000).toISOString(),
|
||||
acknowledged: +p.acknowledged > 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function zabbixGetHosts() {
|
||||
return api('host.get', {
|
||||
output: ['hostid', 'host', 'name', 'available', 'status'],
|
||||
selectInterfaces: ['ip'],
|
||||
}).then(hosts => hosts.map(h => ({
|
||||
id: h.hostid,
|
||||
name: h.name || h.host,
|
||||
available: ['Unknown','Available','Unavailable'][+h.available] || 'Unknown',
|
||||
enabled: h.status === '0',
|
||||
ip: h.interfaces?.[0]?.ip,
|
||||
})));
|
||||
}
|
||||
|
||||
export async function zabbixGetItems(hostid) {
|
||||
return api('item.get', {
|
||||
output: ['itemid', 'name', 'key_', 'lastvalue', 'units', 'lastclock'],
|
||||
hostids: hostid,
|
||||
monitored: true,
|
||||
sortfield: 'name',
|
||||
}).then(items => items.map(i => ({
|
||||
name: i.name,
|
||||
key: i.key_,
|
||||
value: i.lastvalue,
|
||||
units: i.units,
|
||||
updated: new Date(+i.lastclock * 1000).toISOString(),
|
||||
})));
|
||||
}
|
||||
|
||||
export async function zabbixGetTriggers(hostid) {
|
||||
const params = { output: ['triggerid','description','priority','value'], active: 1, monitored: 1 };
|
||||
if (hostid) params.hostids = hostid;
|
||||
return api('trigger.get', params).then(triggers => triggers.map(t => ({
|
||||
id: t.triggerid,
|
||||
name: t.description,
|
||||
severity: ['Not classified','Information','Warning','Average','High','Disaster'][+t.priority],
|
||||
problem: t.value === '1',
|
||||
})));
|
||||
}
|
||||
2
agap-mcp/start.sh
Normal file
2
agap-mcp/start.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec node src/server.js
|
||||
Reference in New Issue
Block a user