agap-mcp: MediaWiki + Todoist tools, vault trust-gate, registry wiring

Commits a cluster of entangled agap-mcp / Adolf-tooling WIP that had accumulated
uncommitted in shared files (server.js, the three MCP-config layers). Bundled as
one commit because server.js interleaves all of it and cannot be cleanly split;
each stream is named here for the record. Authorized by alvis 2026-07-23.

- **kb#95 — family MediaWiki tools:** new src/mediawiki.js (wiki_search / wiki_read
  / wiki_edit, MediaWiki login->CSRF->edit flow, no new deps), registered in
  server.js and fetched from the family.alogins.net Vaultwarden login item.
  Proven standalone against family.alogins.net (search/read/edit, revid 1520 on a
  bot-userspace page). Wired into all three layers: openai/shared-mcp.json,
  adolf/openclaw.json, openai/agent-registry.yaml.

- **kb#147 — vault trust-gate (A2A-15), DORMANT:** new src/trust-gate.js (+ two
  test files), requireVaultAccess() around the vw_* tools, gated by
  AGAP_MCP_ENFORCE_VAULT_TRUST (docker-compose.yml, default 0). OFF by default —
  vw_* behaviour is byte-for-byte unchanged until an operator sets ENFORCE=1 and
  populates AGAP_MCP_AGENT_TOKENS from Vaultwarden. That activation is a separate
  human step; kb#147 remains escalated for human verification and is NOT verified
  by this commit. js-yaml added to read the registry. agent-registry.yaml mounted
  read-only as the trust-class source of truth.

- **Todoist tools:** new src/todoist.js (initTodoist + 6 todoist_* tools),
  registered in server.js, sourced from the TODOIST_TOKEN Vaultwarden item.

- **kanboard cutover cleanup:** removes src/kanboard.js and its imports — the
  kanboard_* slice moved to the standalone kanboard-mcp on 2026-07-06.

- **openai/validate_capability_grants.py:** cross-checks the registry against the
  live openclaw.json + shared-mcp.json layers; passes (exit 0).

No secrets committed: all tokens come from Vaultwarden via env/.env; the trust
gate's AGAP_MCP_AGENT_TOKENS defaults to `{}` (fail-closed). node_modules/ now
gitignored, package-lock.json tracked.

NOT YET ACTIVATED: agap-mcp has not been rebuilt and adolf-llm/adolf not
restarted, so the wiki/todoist tools are wired but not live. That restart is the
outstanding step on kb#95 (and stays a human/orchestrator action).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 06:48:07 +00:00
parent 288cb6b36a
commit b548a8f345
15 changed files with 2876 additions and 365 deletions

1
agap-mcp/.gitignore vendored
View File

@@ -1 +1,2 @@
.env
node_modules/

View File

@@ -21,5 +21,24 @@ services:
- ZABBIX_URL=http://192.168.1.4:81
- RADICALE_URL=http://localhost:5232
- RADICALE_USER=alvis
# kb#147 (A2A-15) — vault trust gate, OFF by default (0). Flipping this
# to 1 requires a container restart AND real values for
# AGENT_REGISTRY_PATH/AGAP_MCP_AGENT_TOKENS below to be populated first
# (see src/trust-gate.js) — that restart is the deliberate handover
# step this task does NOT perform (never restart the live agap-mcp
# service unattended). Until both are set, vw_* tools behave exactly
# as before this change.
- AGAP_MCP_ENFORCE_VAULT_TRUST=0
- AGENT_REGISTRY_PATH=/agent-registry.yaml
# JSON map {"<bearer-token>": "<agent-id>"}. Real per-agent tokens must
# be generated, stored in Vaultwarden (e.g. AGAP_MCP_TOKEN_ADOLF,
# AGAP_MCP_TOKEN_CLAUDE_CODER), and referenced here via .env — never
# inlined in this committed file. Empty object = no caller resolves to
# any agent, i.e. fail-closed once ENFORCE is turned on.
- AGAP_MCP_AGENT_TOKENS=${AGAP_MCP_AGENT_TOKENS:-{}}
volumes:
- /home/alvis/.config/Bitwarden CLI:/bw-data
# Read-only: agent-registry.yaml is the version-controlled source of
# truth for trust classes (kb#134/kb#147) — mounted, never copied, so
# a registry edit takes effect on container restart with no rebuild.
- /home/alvis/agap_git/openai/agent-registry.yaml:/agent-registry.yaml:ro

1611
agap-mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"express": "^4.19.0",
"js-yaml": "^4.1.0",
"zod": "^3.23.0"
}
}

View File

@@ -1,215 +0,0 @@
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 };
}

160
agap-mcp/src/mediawiki.js Normal file
View File

@@ -0,0 +1,160 @@
// MediaWiki (РодоВики family wiki) tools for agap-mcp — kb#95.
// Target is family.alogins.net (МediaWiki), NOT Gitea. Follows the exact
// login->edit flow documented in the `rodowiki` skill: login token ->
// action=login -> session cookie -> CSRF token -> read/search/edit. Read
// operations also require login ($wgGroupPermissions['*']['read'] = false).
//
// Credentials: Vaultwarden org item "family.alogins.net" (username field
// holds the wiki account name "Claude", password field holds its password),
// fetched once at init like every other agap-mcp integration.
//
// Session handling: Node's fetch does not auto-manage cookies like curl's
// --cookie-jar, so a tiny module-level cookie jar (Map) is kept here and
// reused across calls -- this module is a singleton within the process, so
// one login session serves every MCP request for the process lifetime.
// withAuth() retries once on an auth-shaped failure (expired session) by
// forcing a fresh login, mirroring how a human re-running the skill's shell
// snippet would just log in again.
const BASE = () => (process.env.MEDIAWIKI_URL || 'http://localhost:8099').replace(/\/$/, '');
let _username = null;
let _password = null;
let _cookies = new Map();
let _loggedIn = false;
export function initMediaWiki(username, password) {
_username = username;
_password = password;
console.log('MediaWiki: ready');
}
function cookieHeader() {
return [..._cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
function storeCookies(res) {
const setCookies =
typeof res.headers.getSetCookie === 'function'
? res.headers.getSetCookie()
: res.headers.get('set-cookie')
? [res.headers.get('set-cookie')]
: [];
for (const sc of setCookies) {
const pair = sc.split(';')[0];
const idx = pair.indexOf('=');
if (idx > -1) _cookies.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
}
}
async function api(params, { method = 'GET' } = {}) {
const url = new URL(`${BASE()}/api.php`);
const headers = { Cookie: cookieHeader() };
let body;
if (method === 'GET') {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
} else {
body = new URLSearchParams(params);
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
const res = await fetch(url, { method, headers, body });
storeCookies(res);
const data = await res.json();
if (data.error) throw new Error(`MediaWiki API error: ${data.error.info || JSON.stringify(data.error)}`);
return data;
}
async function login() {
if (!_username || !_password) throw new Error('MediaWiki not initialized');
const tokenData = await api({ action: 'query', meta: 'tokens', type: 'login', format: 'json' });
const logintoken = tokenData.query.tokens.logintoken;
const loginData = await api(
{ action: 'login', lgname: _username, lgpassword: _password, lgtoken: logintoken, format: 'json' },
{ method: 'POST' }
);
if (loginData.login?.result !== 'Success') {
throw new Error(`MediaWiki login failed: ${loginData.login?.result || JSON.stringify(loginData)}`);
}
}
async function withAuth(fn) {
if (!_loggedIn) {
await login();
_loggedIn = true;
}
try {
return await fn();
} catch (e) {
// Session likely expired mid-process: force a fresh login and retry once.
if (/notloggedin|readapidenied|permissiondenied|mustbeloggedin/i.test(e.message)) {
_loggedIn = false;
await login();
_loggedIn = true;
return await fn();
}
throw e;
}
}
function stripHtml(s) {
return (s || '').replace(/<[^>]+>/g, '');
}
export async function wikiSearch(query, limit = 20) {
if (!query || !query.trim()) throw new Error('query is required');
return withAuth(async () => {
const data = await api({
action: 'query',
list: 'search',
srsearch: query,
srlimit: String(limit),
format: 'json',
});
return data.query.search.map((p) => ({
title: p.title,
snippet: stripHtml(p.snippet),
wordcount: p.wordcount,
}));
});
}
export async function wikiRead(title) {
if (!title || !title.trim()) throw new Error('title is required');
return withAuth(async () => {
const data = await api({
action: 'query',
titles: title,
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
format: 'json',
});
const page = Object.values(data.query.pages)[0];
if (page.missing !== undefined) throw new Error(`Page not found: ${title}`);
return page.revisions?.[0]?.slots?.main?.['*'] ?? '';
});
}
export async function wikiEdit(title, text, summary) {
if (!title || !title.trim()) throw new Error('title is required');
if (text === undefined || text === null) throw new Error('text is required');
return withAuth(async () => {
const tokenData = await api({ action: 'query', meta: 'tokens', format: 'json' });
const csrftoken = tokenData.query.tokens.csrftoken;
const editData = await api(
{
action: 'edit',
title,
text,
summary: summary || `Update ${title}`,
token: csrftoken,
format: 'json',
},
{ method: 'POST' }
);
if (editData.edit?.result !== 'Success') {
throw new Error(`MediaWiki edit failed: ${JSON.stringify(editData.edit || editData)}`);
}
return { title, result: editData.edit.result, newrevid: editData.edit.newrevid, oldrevid: editData.edit.oldrevid };
});
}

View File

@@ -11,10 +11,34 @@ import { initGitea, giteaListRepos, giteaReadFile, giteaWikiList, giteaWikiRead,
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';
import { initTodoist, todoistListTasks, todoistListProjects, todoistCreateTask, todoistUpdateTask, todoistCompleteTask } from './todoist.js';
import { initMediaWiki, wikiSearch, wikiRead, wikiEdit } from './mediawiki.js';
import { loadTokenMap, resolveCallerAgent, vaultAllowed, authHeaderToken } from './trust-gate.js';
const PORT = parseInt(process.env.PORT || '3100');
// --- kb#147: vault trust gate (A2A-15) ---------------------------------
// OFF by default (ENFORCE_VAULT_TRUST=false) so merging this file changes
// NOTHING about the live service's behavior until an operator deliberately
// sets AGAP_MCP_ENFORCE_VAULT_TRUST=1 AND populates AGAP_MCP_AGENT_TOKENS
// with real per-agent bearer tokens (stored in Vaultwarden, injected via
// this container's .env — never committed to git). That two-step
// activation is the kb#147 handover: it requires a docker-compose env
// change + container restart, which this task deliberately does not do
// (see DESIGN-a2a-agents.md v2.1 §5 — vault access = trusted only).
const ENFORCE_VAULT_TRUST = process.env.AGAP_MCP_ENFORCE_VAULT_TRUST === '1';
const AGENT_TOKENS = loadTokenMap();
function requireVaultAccess(callerAgentId) {
if (!ENFORCE_VAULT_TRUST) return; // legacy behavior: unchanged until activated
if (!vaultAllowed(callerAgentId)) {
throw new Error(
`vault access denied: caller ${callerAgentId ? `'${callerAgentId}'` : '(unauthenticated)'} ` +
`is not trust_class >= trusted (kb#147, DESIGN-a2a-agents.md §5)`
);
}
}
// --- Init services ---
async function init() {
await initVaultwarden();
@@ -31,13 +55,19 @@ async function init() {
const ha_token = orgToken('HA_TOKEN');
const zabbix_token = orgToken('ZABBIX_TOKEN');
const radicale_password = orgToken('RADICALE_PASSWORD');
const kanboard_token = orgToken('KANBOARD_TOKEN');
const todoist_token = orgToken('TODOIST_TOKEN');
// family.alogins.net (РодоВики MediaWiki) — a login item, not a bare
// token: username + password both needed for the login flow.
const wikiItem = orgItems.find(i => i.name === 'family.alogins.net');
if (!wikiItem) throw new Error('Token not found in org: family.alogins.net');
initGitea(gitea_token);
initHA(ha_token);
initZabbix(zabbix_token);
initRadicale(radicale_password);
initKanboard(kanboard_token);
initTodoist(todoist_token);
initMediaWiki(wikiItem.login?.username, wikiItem.login?.password);
}
@@ -50,18 +80,19 @@ function err(e) {
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
}
function createServer() {
function createServer(callerAgentId = null) {
const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' });
// --- Vaultwarden tools ---
// --- Vaultwarden tools (kb#147: gated to trust_class >= trusted) ---
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); }
try { requireVaultAccess(callerAgentId); 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 {
requireVaultAccess(callerAgentId);
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); }
@@ -72,6 +103,7 @@ function createServer() {
org: z.boolean().optional(),
}, async ({ search, org }) => {
try {
requireVaultAccess(callerAgentId);
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); }
@@ -84,14 +116,14 @@ function createServer() {
url: z.string().optional(),
notes: z.string().optional(),
}, async (args) => {
try { return ok(vwCreateLogin(args)); } catch (e) { return err(e); }
try { requireVaultAccess(callerAgentId); 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); }
try { requireVaultAccess(callerAgentId); return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); }
});
// --- Gitea tools ---
@@ -253,144 +285,67 @@ function createServer() {
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); }
// --- Todoist tools (Todoist API v1) ---
server.tool('todoist_list_tasks', 'List active Todoist tasks. Use `query` for a Todoist filter (e.g. "today", "overdue", "#Work & p1"), or `project_id` to scope to one project. Returns compact tasks.', {
query: z.string().optional().describe('Todoist filter query, e.g. "today", "overdue", "#Project & p1". Omit for all active tasks.'),
project_id: z.string().optional().describe('Restrict to a project id (from todoist_list_projects). Ignored when query is set.'),
}, async ({ query, project_id }) => {
try { return ok(await todoistListTasks({ query, project_id })); } 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('todoist_list_projects', 'List Todoist projects (id + name).', {}, async () => {
try { return ok(await todoistListProjects()); } 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('todoist_create_task', 'Create a Todoist task. `due_string` is natural language ("tomorrow 9am", "every monday"). `priority`: API 1=normal..4=urgent (note: Todoist UI p1 = API 4). Defaults to the Inbox project.', {
content: z.string().describe('Task title/content (required).'),
description: z.string().optional(),
due_string: z.string().optional().describe('Natural-language due date, e.g. "today", "tomorrow 9am", "next monday".'),
priority: z.number().int().min(1).max(4).optional().describe('API priority 1=normal .. 4=urgent.'),
project_id: z.string().optional().describe('Target project id (default Inbox).'),
labels: z.array(z.string()).optional(),
}, async (args) => {
try { return ok(await todoistCreateTask(args)); } 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('todoist_update_task', 'Update a Todoist task (reschedule/edit). Give the task id plus the fields to change.', {
id: z.string(),
content: z.string().optional(),
description: z.string().optional(),
due_string: z.string().optional(),
priority: z.number().int().min(1).max(4).optional(),
labels: z.array(z.string()).optional(),
}, async (args) => {
try { return ok(await todoistUpdateTask(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('todoist_complete_task', 'Complete (close) a Todoist task by id.', {
id: z.string().describe('Task id from todoist_list_tasks.'),
}, async ({ id }) => {
try { return ok(await todoistCompleteTask({ id })); } 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); }
});
// --- MediaWiki (family wiki / РодоВики) tools ---
server.tool('wiki_search', 'Search the family wiki (РодоВики) for pages matching a query. Returns title + snippet.', {
query: z.string().describe('Search text, e.g. a person\'s name or event.'),
limit: z.number().int().min(1).max(50).optional().describe('Max results, default 20.'),
}, async ({ query, limit }) => {
try { return ok(await wikiSearch(query, limit)); } 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('wiki_read', 'Read the raw wikitext content of a family wiki page by exact title.', {
title: z.string().describe('Exact page title, e.g. "Антон Логинс".'),
}, async ({ title }) => {
try { return ok(await wikiRead(title)); } 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); }
});
server.tool('wiki_edit', 'Create or update a family wiki page. Overwrites the page with the given wikitext.', {
title: z.string().describe('Exact page title to create/update.'),
text: z.string().describe('Full wikitext content of the page.'),
summary: z.string().optional().describe('Edit summary, default "Update <title>".'),
}, async ({ title, text, summary }) => {
try { return ok(await wikiEdit(title, text, summary)); } catch (e) { return err(e); }
});
return server;
}
@@ -404,9 +359,10 @@ const sseTransports = new Map();
// Streamable HTTP — stateless: fresh server per request, survives container restarts
app.all('/mcp', async (req, res) => {
try {
const callerAgentId = resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => transport.close());
await createServer().connect(transport);
await createServer(callerAgentId).connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (e) {
console.error('MCP request error:', e.message);
@@ -418,10 +374,11 @@ app.all('/mcp', async (req, res) => {
// Legacy SSE — kept for backward compatibility
app.get('/sse', async (req, res) => {
const callerAgentId = resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS);
const transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => sseTransports.delete(transport.sessionId));
await createServer().connect(transport);
await createServer(callerAgentId).connect(transport);
});
app.post('/messages', async (req, res) => {
@@ -430,7 +387,7 @@ app.post('/messages', async (req, res) => {
await transport.handlePostMessage(req, res);
});
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 45 }));
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 30, vaultTrustEnforced: ENFORCE_VAULT_TRUST }));
init()
.then(() => {

101
agap-mcp/src/todoist.js Normal file
View File

@@ -0,0 +1,101 @@
// Todoist tools for agap-mcp (Adolf + Claude). Uses the unified Todoist API v1
// (https://api.todoist.com/api/v1) — REST v2 / Sync v9 are deprecated (410).
// Bearer auth with a personal API token (Vaultwarden: TODOIST_TOKEN), injected
// at init like the other services. Deliberately a SMALL, curated tool surface:
// every tool schema here is re-sent to Kimi on every Adolf turn (kb#101), so we
// expose only the essentials and return compact task objects.
const BASE = 'https://api.todoist.com/api/v1';
let _token = null;
export function initTodoist(token) {
_token = token;
console.log('Todoist: ready');
}
async function api(method, path, body) {
if (!_token) throw new Error('Todoist not initialized');
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${_token}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) throw new Error(`Todoist ${method} ${path}: ${res.status} ${text.slice(0, 300)}`);
return text ? JSON.parse(text) : null;
}
// Keep tool output (and thus Kimi context) small — return only fields an agent
// needs to reason about or act on, not the full Todoist task object.
function slimTask(t) {
if (!t || typeof t !== 'object') return t;
return {
id: t.id,
content: t.content,
project_id: t.project_id,
priority: t.priority, // API 1=normal .. 4=urgent (inverse of the Todoist UI's p1..p4)
due: t.due?.string || t.due?.date || null,
labels: t.labels && t.labels.length ? t.labels : undefined,
};
}
function results(data) {
if (Array.isArray(data?.results)) return data.results;
return Array.isArray(data) ? data : [];
}
// List tasks — either a Todoist filter query (e.g. "today", "overdue",
// "#Work & p1") via the /tasks/filter endpoint, or all active tasks optionally
// scoped to one project.
export async function todoistListTasks({ query, project_id } = {}) {
let path;
if (query && query.trim()) {
path = `/tasks/filter?query=${encodeURIComponent(query.trim())}`;
} else if (project_id) {
path = `/tasks?project_id=${encodeURIComponent(project_id)}`;
} else {
path = '/tasks';
}
return results(await api('GET', path)).map(slimTask);
}
export async function todoistListProjects() {
return results(await api('GET', '/projects')).map((p) => ({
id: p.id,
name: p.name,
is_favorite: p.is_favorite || undefined,
is_inbox: p.inbox_project || p.is_inbox_project || undefined,
}));
}
export async function todoistCreateTask({ content, description, due_string, priority, project_id, labels } = {}) {
if (!content || !content.trim()) throw new Error('content is required');
const body = { content: content.trim() };
if (description) body.description = description;
if (due_string) body.due_string = due_string;
if (priority) body.priority = priority;
if (project_id) body.project_id = project_id;
if (Array.isArray(labels) && labels.length) body.labels = labels;
return slimTask(await api('POST', '/tasks', body));
}
export async function todoistUpdateTask({ id, content, description, due_string, priority, labels } = {}) {
if (!id) throw new Error('id is required');
const body = {};
if (content !== undefined) body.content = content;
if (description !== undefined) body.description = description;
if (due_string !== undefined) body.due_string = due_string;
if (priority !== undefined) body.priority = priority;
if (labels !== undefined) body.labels = labels;
return slimTask(await api('POST', `/tasks/${encodeURIComponent(id)}`, body));
}
export async function todoistCompleteTask({ id } = {}) {
if (!id) throw new Error('id is required');
await api('POST', `/tasks/${encodeURIComponent(id)}/close`);
return { id, completed: true };
}

View File

@@ -0,0 +1,103 @@
// kb#147 HTTP-layer proof, run with: node src/trust-gate-http.test.mjs
//
// Proves the Authorization-header -> agent-id -> trust-rank path end to end
// over real HTTP, WITHOUT touching the live agap-mcp container (:3100),
// LiteLLM, or Vaultwarden: this spins up a throwaway express app on an
// ephemeral local port using the exact same trust-gate.js functions
// server.js imports, with a synthetic registry + token map (no real bw
// session, no real credentials). It exercises authHeaderToken() (the bit
// trust-gate.test.mjs's pure unit tests can't reach, since it needs a real
// `req` object) on top of the already-unit-tested trustRankOf/vaultAllowed.
import assert from 'node:assert/strict';
import express from 'express';
import {
loadTokenMap,
resolveCallerAgent,
vaultAllowed,
authHeaderToken,
isVaultTool,
_resetRegistryCacheForTests,
} from './trust-gate.js';
const registry = {
trust_classes: {
trusted: { rank: 2 },
sandboxed: { rank: 1 },
untrusted: { rank: 0 },
},
agents: [
{ id: 'adolf', trust_class: 'trusted' },
{ id: 'torgash', trust_class: 'sandboxed' },
],
};
_resetRegistryCacheForTests(registry);
const tokenMap = loadTokenMap(JSON.stringify({
'tok-adolf-e2e-test': 'adolf',
'tok-torgash-e2e-test': 'torgash',
}));
// A minimal stand-in for server.js's app.all('/mcp', ...) handler: resolve
// the caller from the Authorization header, then simulate a vw_get_password
// tool call gated the same way requireVaultAccess() gates it in server.js.
const app = express();
app.post('/mcp', (req, res) => {
const callerAgentId = resolveCallerAgent(authHeaderToken(req), tokenMap);
const toolName = req.body?.tool || 'vw_get_password';
if (isVaultTool(toolName) && !vaultAllowed(callerAgentId, registry)) {
return res.status(200).json({ isError: true, error: `vault access denied for caller=${callerAgentId || '(none)'}` });
}
return res.status(200).json({ isError: false, caller: callerAgentId });
});
const server = app.listen(0);
const port = server.address().port;
async function post(token) {
const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ tool: 'vw_get_password' }),
});
return res.json();
}
let passed = 0;
async function check(label, fn) {
await fn();
passed++;
console.log(`ok - ${label}`);
}
try {
await check('trusted agent (adolf) bearer token -> vw_get_password allowed over real HTTP', async () => {
const body = await post('tok-adolf-e2e-test');
assert.equal(body.isError, false);
assert.equal(body.caller, 'adolf');
});
await check('sandboxed agent (torgash) bearer token -> vw_get_password DENIED over real HTTP', async () => {
const body = await post('tok-torgash-e2e-test');
assert.equal(body.isError, true);
assert.match(body.error, /vault access denied/);
});
await check('no Authorization header at all -> vw_get_password DENIED over real HTTP', async () => {
const body = await post(null);
assert.equal(body.isError, true);
});
await check('garbage/unknown token -> vw_get_password DENIED over real HTTP', async () => {
const body = await post('this-token-was-never-issued');
assert.equal(body.isError, true);
});
console.log(`\n${passed} passed`);
} finally {
server.close();
_resetRegistryCacheForTests(null);
}

View File

@@ -0,0 +1,91 @@
// trust-gate — kb#147 (A2A-15): vault access = trusted-only, enforced here.
//
// agap-mcp has no per-caller identity today (every MCP client hits the same
// unauthenticated /mcp endpoint on :3100) — that's the gap DESIGN-a2a-agents.md
// v2.1 §5 flags as the crux of "vault access = trusted only" (DECIDED, alvis).
// This module is the enforcement point: it maps a bearer token off the request
// to an agent id (via AGAP_MCP_AGENT_TOKENS, a secret never committed to git),
// then looks up that agent's trust class in agent-registry.yaml (the
// version-controlled source of truth for grants, kb#134) to decide whether
// vault tools (vw_*) may run.
//
// FAIL-CLOSED PRINCIPLE (once enforcement is turned on): no token, an
// unrecognized token, or an agent below `trusted` rank all resolve to "no
// vault access" — there is no default-allow path once AGAP_MCP_ENFORCE_VAULT_TRUST
// is on. See server.js for the off-by-default activation gate: merging this
// module changes zero live behavior until an operator deliberately flips that
// flag AND supplies real per-agent tokens (kb#147 handover step).
import { readFileSync } from 'fs';
import yaml from 'js-yaml';
const VAULT_TOOL_PREFIX = 'vw_';
const DEFAULT_REGISTRY_PATH = '/agent-registry.yaml';
const DEFAULT_TRUSTED_RANK = 2; // matches agent-registry.yaml trust_classes.trusted.rank; used only if the registry can't be read
let _registryCache = null;
export function loadRegistry(path = process.env.AGENT_REGISTRY_PATH || DEFAULT_REGISTRY_PATH) {
if (_registryCache) return _registryCache;
try {
_registryCache = yaml.load(readFileSync(path, 'utf8'));
} catch (e) {
// Fail closed, not fail crash: no registry readable means no agent can be
// proven trusted, so vaultAllowed() below returns false for everyone
// rather than the process refusing to start (agap-mcp serves gitea/ha/
// zabbix/radicale/todoist tools too, which don't depend on this file).
console.error(`trust-gate: could not load agent registry from ${path}: ${e.message}`);
_registryCache = { agents: [], trust_classes: {} };
}
return _registryCache;
}
// Test-only: let unit tests inject a registry object instead of touching the
// filesystem, and let the CLI/tests reset the module-level cache between runs.
export function _resetRegistryCacheForTests(registry = null) {
_registryCache = registry;
}
export function trustedRankThreshold(registry = loadRegistry()) {
return registry.trust_classes?.trusted?.rank ?? DEFAULT_TRUSTED_RANK;
}
export function trustRankOf(agentId, registry = loadRegistry()) {
if (!agentId) return -1; // unauthenticated caller: rank below every real trust class
const agent = (registry.agents || []).find(a => a.id === agentId);
if (!agent) return -1; // unknown agent id: fail closed, not "assume trusted"
const cls = registry.trust_classes?.[agent.trust_class];
return cls ? cls.rank : -1;
}
// tokenMap: { "<bearer-token>": "<agent-id>" } — parsed once at startup from
// AGAP_MCP_AGENT_TOKENS (JSON), itself sourced from per-agent tokens stored in
// Vaultwarden and injected via this container's .env, never inlined in git.
export function loadTokenMap(raw = process.env.AGAP_MCP_AGENT_TOKENS) {
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object') ? parsed : {};
} catch (e) {
console.error(`trust-gate: AGAP_MCP_AGENT_TOKENS is not valid JSON: ${e.message}`);
return {};
}
}
export function resolveCallerAgent(bearerToken, tokenMap) {
if (!bearerToken) return null;
return tokenMap[bearerToken] || null;
}
export function isVaultTool(toolName) {
return toolName.startsWith(VAULT_TOOL_PREFIX);
}
export function vaultAllowed(agentId, registry = loadRegistry()) {
return trustRankOf(agentId, registry) >= trustedRankThreshold(registry);
}
export function authHeaderToken(req) {
const header = req.headers?.['authorization'] || req.headers?.['Authorization'] || '';
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
}

View File

@@ -0,0 +1,101 @@
// Proof-of-enforcement for kb#147, run with: node src/trust-gate.test.mjs
//
// Deliberately does NOT touch the live agap-mcp container, LiteLLM, or
// Vaultwarden — it exercises the exact exported functions server.js calls
// (trustRankOf/vaultAllowed/resolveCallerAgent/isVaultTool) against a
// synthetic registry + token map, so this is a real test of the enforcement
// logic itself, not a mock of it.
import assert from 'node:assert/strict';
import {
trustRankOf,
vaultAllowed,
resolveCallerAgent,
isVaultTool,
trustedRankThreshold,
_resetRegistryCacheForTests,
} from './trust-gate.js';
const registry = {
trust_classes: {
human: { rank: 3 },
trusted: { rank: 2 },
sandboxed: { rank: 1 },
untrusted: { rank: 0 },
},
agents: [
{ id: 'adolf', trust_class: 'trusted' },
{ id: 'claude-coder', trust_class: 'trusted' },
{ id: 'torgash', trust_class: 'sandboxed' },
{ id: 'researcher', trust_class: 'sandboxed' },
{ id: 'kimi-endpoint', trust_class: 'untrusted' },
],
};
const tokenMap = {
'tok-adolf': 'adolf',
'tok-claude-coder': 'claude-coder',
'tok-torgash': 'torgash',
'tok-researcher': 'researcher',
};
let passed = 0;
function check(label, fn) {
fn();
passed++;
console.log(`ok - ${label}`);
}
check('trusted rank threshold resolves from registry', () => {
assert.equal(trustedRankThreshold(registry), 2);
});
check('trusted agents (adolf, claude-coder) can reach vault', () => {
assert.equal(vaultAllowed('adolf', registry), true);
assert.equal(vaultAllowed('claude-coder', registry), true);
});
check('sandboxed agents (torgash, researcher) CANNOT reach vault', () => {
assert.equal(vaultAllowed('torgash', registry), false);
assert.equal(vaultAllowed('researcher', registry), false);
});
check('untrusted agent cannot reach vault', () => {
assert.equal(vaultAllowed('kimi-endpoint', registry), false);
});
check('unauthenticated caller (no token resolved) cannot reach vault', () => {
assert.equal(vaultAllowed(null, registry), false);
assert.equal(trustRankOf(null, registry), -1);
});
check('unknown/unregistered agent id fails closed, not open', () => {
assert.equal(vaultAllowed('some-new-agent-nobody-declared', registry), false);
});
check('resolveCallerAgent maps bearer token -> agent id, else null', () => {
assert.equal(resolveCallerAgent('tok-torgash', tokenMap), 'torgash');
assert.equal(resolveCallerAgent('tok-adolf', tokenMap), 'adolf');
assert.equal(resolveCallerAgent('not-a-real-token', tokenMap), null);
assert.equal(resolveCallerAgent(null, tokenMap), null);
});
check('end-to-end: a sandboxed agent\'s token provably cannot reach vw_* tools', () => {
const callerAgentId = resolveCallerAgent('tok-torgash', tokenMap);
assert.equal(callerAgentId, 'torgash');
assert.equal(isVaultTool('vw_get_password'), true);
assert.equal(vaultAllowed(callerAgentId, registry), false); // <- the acceptance bar
});
check('end-to-end: a trusted agent\'s token can reach vw_* tools', () => {
const callerAgentId = resolveCallerAgent('tok-adolf', tokenMap);
assert.equal(vaultAllowed(callerAgentId, registry), true);
});
check('non-vault tool name is unaffected by the gate', () => {
assert.equal(isVaultTool('gitea_read_file'), false);
assert.equal(isVaultTool('zabbix_get_problems'), false);
});
_resetRegistryCacheForTests(null);
console.log(`\n${passed} passed`);