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:
@@ -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
160
agap-mcp/src/mediawiki.js
Normal 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 };
|
||||
});
|
||||
}
|
||||
@@ -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
101
agap-mcp/src/todoist.js
Normal 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 };
|
||||
}
|
||||
103
agap-mcp/src/trust-gate-http.test.mjs
Normal file
103
agap-mcp/src/trust-gate-http.test.mjs
Normal 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);
|
||||
}
|
||||
91
agap-mcp/src/trust-gate.js
Normal file
91
agap-mcp/src/trust-gate.js
Normal 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;
|
||||
}
|
||||
101
agap-mcp/src/trust-gate.test.mjs
Normal file
101
agap-mcp/src/trust-gate.test.mjs
Normal 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`);
|
||||
Reference in New Issue
Block a user