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:
@@ -122,10 +122,41 @@
|
||||
// OpenClaw's documented CLI-native alias for transport: "streamable-http".
|
||||
mcp: {
|
||||
servers: {
|
||||
// kb#144 (A2A-12): toolFilter.include is OpenClaw's OWN per-server
|
||||
// tool-scoping mechanism (mcp.servers.*.toolFilter, zod-validated;
|
||||
// applied in agent-bundle-mcp-materialize.js when THIS container's own
|
||||
// tool bundle is built). CORRECTION (kb#144 second pass, 2026-07-22):
|
||||
// this does NOT reach the model on Adolf's kimi backbone -- Kimi CLI
|
||||
// (inside the separate adolf-llm container) reads its own
|
||||
// project-root .mcp.json, seeded from openai/shared-mcp.json, and
|
||||
// applies ITS OWN enabledTools/disabledTools (McpServerCommonFields,
|
||||
// computeEnabledNames). Live wire.jsonl verification (restart + one
|
||||
// real turn) proved OpenClaw's toolFilter alone left Kimi's actual
|
||||
// tool counts unchanged. This block is still correct for OpenClaw's
|
||||
// own MCP client surface -- see openai/shared-mcp.json for the layer
|
||||
// that actually scopes what the model sees.
|
||||
//
|
||||
// Scoped to Adolf's CORE memory ops: recall/retain/reflect (the
|
||||
// hooks below do IMPLICIT recall/retain automatically; these MCP
|
||||
// tools cover explicit "remember this" / "what do you recall about
|
||||
// X" turns) plus single-memory read/update and directive CRUD
|
||||
// (standing instructions). Cuts 20 rarely-used/admin tools: the
|
||||
// mental-model CRUD (7), document CRUD (3), operation-tracking (3),
|
||||
// tags (1), bank admin (4), sync_retain, invalidate_memory -- none
|
||||
// of which Adolf's Matrix persona drives turn-to-turn; reach the
|
||||
// hindsight MCP directly (unscoped) for that admin work instead of
|
||||
// paying for it on every Adolf turn. 29 tools -> 9.
|
||||
hindsight: {
|
||||
type: "http",
|
||||
url: "http://hindsight:8888/mcp/adolf/",
|
||||
toolFilter: {
|
||||
include: ["recall", "retain", "reflect", "list_memories", "get_memory", "update_memory", "list_directives", "create_directive", "delete_directive"],
|
||||
},
|
||||
},
|
||||
// Already minimal (5 tools -- message_send/cron_create/cron_list/
|
||||
// nodes_invoke/browser_invoke, all core to Adolf's matrix-chat +
|
||||
// cron-scheduling + browser capabilities) -- deliberately NO
|
||||
// toolFilter here, not an oversight.
|
||||
"openclaw-tools": {
|
||||
type: "http",
|
||||
url: "http://openclaw-tools:8020/mcp",
|
||||
@@ -138,9 +169,18 @@
|
||||
// instance on :3103 (app-wide jsonrpc token). Not part of the openai
|
||||
// compose network, so reached via host.docker.internal (already
|
||||
// extra_hosts-mapped for this container) rather than a service name.
|
||||
// kb#144: scoped to task-triage/proactive-monitoring core --
|
||||
// list/read/search/create/update/move/status/assign/comment/
|
||||
// activity. Cuts subtask CRUD, task-link CRUD, tag CRUD, and
|
||||
// destructive remove_task/remove_comment (9 tools) -- deep
|
||||
// task-graph management is claude-coder's job (the actual task
|
||||
// worker), not Adolf's lighter triage/reporting role. 23 -> 14.
|
||||
kanboard: {
|
||||
type: "http",
|
||||
url: "http://host.docker.internal:3104/mcp",
|
||||
toolFilter: {
|
||||
include: ["kanboard_list_projects", "kanboard_get_project", "kanboard_list_tasks", "kanboard_my_tasks", "kanboard_get_task", "kanboard_search_tasks", "kanboard_list_users", "kanboard_project_activity", "kanboard_create_task", "kanboard_update_task", "kanboard_move_task", "kanboard_change_task_status", "kanboard_assign_task", "kanboard_add_comment"],
|
||||
},
|
||||
},
|
||||
// marketplace-mcp (kb task #61) -- the SAME shared marketplace-mcp
|
||||
// instance Claude Code and OpenWebUI use (single service, port 3101,
|
||||
@@ -149,21 +189,48 @@
|
||||
// shared bearer token (MARKETPLACE_MCP_TOKEN in Vaultwarden / this
|
||||
// container's env, injected via openai/.env -> docker-compose.yml).
|
||||
// Reached via host.docker.internal, same reasoning as kanboard above.
|
||||
// kb#144: scoped to READ-ONLY discovery (find_best/search/product/
|
||||
// recommendations/reviews/compare/status). Cuts the checkout
|
||||
// surface -- add_to_cart, get_cart, login, open_vnc, save_session,
|
||||
// submit_sms (6 tools) -- real-money/session-auth actions Adolf
|
||||
// shouldn't take unattended from background Matrix chat. This
|
||||
// whole server is also the FIRST candidate to move OUT to Torgash
|
||||
// (kb#130's marketplace-analyst persona, not yet built) per
|
||||
// design §2/§8; scoping now both cuts tokens and removes purchase
|
||||
// risk in the meantime rather than waiting on Torgash to exist.
|
||||
// 13 -> 7.
|
||||
marketplace: {
|
||||
type: "http",
|
||||
url: "http://host.docker.internal:3101/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer ${MARKETPLACE_MCP_TOKEN}",
|
||||
},
|
||||
toolFilter: {
|
||||
include: ["marketplace_find_best", "marketplace_search", "marketplace_get_product", "marketplace_get_recommendations", "marketplace_get_reviews", "marketplace_compare_prices", "marketplace_status"],
|
||||
},
|
||||
},
|
||||
// agap-mcp (kb#64) -- the SAME shared agap-mcp instance Claude Code uses
|
||||
// (network_mode: host, :3100, unauthenticated on localhost). Grants Adolf
|
||||
// the same access as Claude: vault (vw_* for credential fetching) plus
|
||||
// gitea/ha/zabbix/radicale. Reached via host.docker.internal like
|
||||
// kanboard/marketplace above.
|
||||
// kb#144: scoped to Adolf's proactive-auditor/personal-assistant
|
||||
// core -- vault read/write (trusted, credential help), HA
|
||||
// (smart-home monitoring), Zabbix (the literal "proactive
|
||||
// auditor" job), calendar read/write (minus admin
|
||||
// create/delete_calendar), Todoist. Cuts all 6 gitea_* tools
|
||||
// (repo/wiki/issue management is claude-coder's infra-ops domain,
|
||||
// not Adolf's Matrix persona) plus radicale_create_calendar/
|
||||
// delete_calendar (rare calendar-admin ops) -- 8 tools. 32 -> 24.
|
||||
// kb#95: added wiki_search/wiki_read/wiki_edit (family MediaWiki,
|
||||
// family.alogins.net / РодоВики -- source of truth for relatives,
|
||||
// dates, events) -- squarely Adolf's persona domain. 35 -> 27.
|
||||
agap: {
|
||||
type: "http",
|
||||
url: "http://host.docker.internal:3100/mcp",
|
||||
toolFilter: {
|
||||
include: ["vw_get_password", "vw_get_item", "vw_list_items", "vw_create_login", "vw_update_password", "ha_get_state", "ha_list_entities", "ha_call_service", "ha_get_history", "zabbix_get_problems", "zabbix_get_hosts", "zabbix_get_items", "zabbix_get_triggers", "radicale_list_calendars", "radicale_list_events", "radicale_get_event", "radicale_put_event", "radicale_delete_event", "radicale_move_event", "todoist_list_tasks", "todoist_list_projects", "todoist_create_task", "todoist_update_task", "todoist_complete_task", "wiki_search", "wiki_read", "wiki_edit"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -198,6 +265,21 @@
|
||||
"quota-command": {
|
||||
enabled: true,
|
||||
},
|
||||
// Kimi quota footer (kb #85) — installed external plugin, bind-mounted
|
||||
// read-only from openai/kimi-quota-footer-plugin (see that project's
|
||||
// docker-compose.yml adolf.volumes) onto
|
||||
// .openclaw/extensions/kimi-quota-footer. Appends a compact Kimi
|
||||
// usage line to every outgoing reply via the reply_payload_sending
|
||||
// hook (not a raw conversation hook, so no allowConversationAccess/
|
||||
// allowPromptInjection opt-in needed), reusing the same LLM-free
|
||||
// adolf-llm:8010/usage route as quota-command above. Verified this
|
||||
// hook fires on Adolf's actual send path (sendDurableMessageBatch ->
|
||||
// deliverOutboundPayloadsInternal) as long as channels.matrix.streaming
|
||||
// stays unset/"off" as it is today — see the plugin's index.js header
|
||||
// comment for the streaming caveat if that ever changes.
|
||||
"kimi-quota-footer": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
1
agap-mcp/.gitignore
vendored
1
agap-mcp/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.env
|
||||
node_modules/
|
||||
|
||||
@@ -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
1611
agap-mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"express": "^4.19.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"zod": "^3.23.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,143 +285,66 @@ 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('todoist_list_projects', 'List Todoist projects (id + name).', {}, async () => {
|
||||
try { return ok(await todoistListProjects()); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_list_tasks', 'List tasks in a Kanboard project', {
|
||||
project_id: z.number(),
|
||||
status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'),
|
||||
}, async ({ project_id, status }) => {
|
||||
try { return ok(await kbListTasks(project_id, status)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_my_tasks', "List tasks assigned to the bot user (claude) across all projects", {
|
||||
status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'),
|
||||
}, async ({ status }) => {
|
||||
try { return ok(await kbMyTasks(status)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_get_task', 'Get full detail of a Kanboard task, including its subtasks and comments', {
|
||||
task_id: z.number(),
|
||||
}, async ({ task_id }) => {
|
||||
try { return ok(await kbGetTask(task_id)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_search_tasks', 'Search tasks in a project using Kanboard query syntax (e.g. "assignee:claude status:open", "due:today", "color:red")', {
|
||||
project_id: z.number(),
|
||||
query: z.string(),
|
||||
}, async ({ project_id, query }) => {
|
||||
try { return ok(await kbSearchTasks(project_id, query)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_list_users', 'List all Kanboard users (id, username, name, role)', {},
|
||||
async () => {
|
||||
try { return ok(await kbListUsers()); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_project_activity', 'Get the recent activity stream for a project (who did what)', {
|
||||
project_id: z.number(),
|
||||
}, async ({ project_id }) => {
|
||||
try { return ok(await kbProjectActivity(project_id)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_create_task', 'Create a Kanboard task (authored by claude). At minimum provide title and project_id', {
|
||||
title: z.string(),
|
||||
project_id: z.number(),
|
||||
description: z.string().optional().describe('Markdown'),
|
||||
column_id: z.number().optional(),
|
||||
owner_id: z.number().optional().describe('Assignee user id'),
|
||||
color_id: z.string().optional().describe('e.g. yellow, blue, red, green'),
|
||||
date_due: z.string().optional().describe('YYYY-MM-DD'),
|
||||
priority: z.number().optional(),
|
||||
swimlane_id: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try { return ok(await kbCreateTask(args)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_update_task', 'Update fields of a Kanboard task', {
|
||||
id: z.number().describe('Task id'),
|
||||
title: z.string().optional(),
|
||||
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(),
|
||||
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(),
|
||||
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 kbUpdateTask(args)); } catch (e) { return err(e); }
|
||||
try { return ok(await todoistCreateTask(args)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_move_task', 'Move a task to a column/position (e.g. to "Work in progress" or "Done"). project_id and swimlane_id are auto-resolved if omitted', {
|
||||
task_id: z.number(),
|
||||
column_id: z.number().describe('Target column id (see kanboard_get_project)'),
|
||||
position: z.number().optional().describe('default 1 (top)'),
|
||||
swimlane_id: z.number().optional(),
|
||||
project_id: z.number().optional(),
|
||||
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 kbMoveTask(args)); } catch (e) { return err(e); }
|
||||
try { return ok(await todoistUpdateTask(args)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('kanboard_change_task_status', 'Open or close (mark done/archive) a task', {
|
||||
task_id: z.number(),
|
||||
action: z.enum(['open', 'close']),
|
||||
}, async ({ task_id, action }) => {
|
||||
try { return ok(await kbChangeTaskStatus(task_id, action)); } catch (e) { return err(e); }
|
||||
server.tool('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_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); }
|
||||
// --- 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_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('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_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`);
|
||||
@@ -44,11 +44,18 @@ schema_version: 1
|
||||
# ── trust classes (§5) ──────────────────────────────────────────────────
|
||||
# "human > trusted > sandboxed > untrusted". Numeric rank lets routing/grant
|
||||
# code do `>=` comparisons instead of string-matching an ordered list.
|
||||
#
|
||||
# default_budget_usd/budget_duration (kb#147): defense-in-depth defaults fed
|
||||
# into each agent's LiteLLM virtual-key spec (agent_registry.py:
|
||||
# litellm_key_spec()) when the agent doesn't override them. Moot for money
|
||||
# TODAY (§3a: no metered API by default, only free local-small is reachable
|
||||
# without opt-in) but real once anything metered is opted into a tier pool —
|
||||
# a budget cap should already exist rather than being bolted on later.
|
||||
trust_classes:
|
||||
human: { rank: 3, note: "vault access yes; not MCP-tool-scoped, IS the reasoning" }
|
||||
trusted: { rank: 2, note: "vault access yes (DECIDED, kb#147); outward actions per ask-first rules" }
|
||||
sandboxed: { rank: 1, note: "no vault, no outward sends; scoped MCP allowlist; KB access project-scoped" }
|
||||
untrusted: { rank: 0, note: "anything ingesting the open web; its OUTPUTS are tainted, not just its access restricted" }
|
||||
trusted: { rank: 2, note: "vault access yes (DECIDED, kb#147); outward actions per ask-first rules", default_budget_usd: 50.0, budget_duration: "30d" }
|
||||
sandboxed: { rank: 1, note: "no vault, no outward sends; scoped MCP allowlist; KB access project-scoped", default_budget_usd: 5.0, budget_duration: "30d" }
|
||||
untrusted: { rank: 0, note: "anything ingesting the open web; its OUTPUTS are tainted, not just its access restricted", default_budget_usd: 0.0, budget_duration: "30d" }
|
||||
|
||||
# ── runtimes ─────────────────────────────────────────────────────────────
|
||||
# Backbones deliberately OUTSIDE model-registry.yaml's `models:` list.
|
||||
@@ -88,11 +95,83 @@ agents:
|
||||
mcp_servers: [hindsight, openclaw-tools, kanboard, marketplace, agap]
|
||||
gateway_tools: [cron, nodes, browser] # openclaw.json gateway.tools.allow, live 2026-07-21
|
||||
vault_access: true # trusted-only per §5 DECIDED; reaches vw_* via the `agap` MCP server
|
||||
# kb#144 (A2A-12): PER-TOOL scoping, TWO layers — 2026-07-22, second
|
||||
# pass after a live-verified miss on the first. Each server below
|
||||
# keeps its own justification comment in its config; this is the
|
||||
# registry's copy of the same lists (source of truth this side —
|
||||
# validate_capability_grants.py cross-checks against BOTH).
|
||||
#
|
||||
# Layer 1 — OpenClaw's own mcp.servers.*.toolFilter.include in
|
||||
# adolf/openclaw.json, applied when OpenClaw (the `adolf` container)
|
||||
# builds ITS OWN tool bundle. Landed first pass, verified schema-valid
|
||||
# via `openclaw config validate` / `openclaw mcp probe`. Real, correct
|
||||
# for OpenClaw's own client — but NOT what determines the model's
|
||||
# actual per-turn context on Adolf's kimi backbone.
|
||||
#
|
||||
# Layer 2 — shared-mcp.json's per-server `enabledTools`, which
|
||||
# adolf-llm/server.js's writeMcpConfig() seeds into each Kimi CLI
|
||||
# session's project-root .mcp.json (Gate 1). THIS is the layer that
|
||||
# actually reaches the model: Kimi CLI auto-discovers that file, not
|
||||
# OpenClaw's config, and applies its own McpServerCommonFields.
|
||||
# enabledTools/disabledTools via computeEnabledNames (an allowlist
|
||||
# when only enabledTools is set — confirmed by decompiling the
|
||||
# installed @moonshot-ai/kimi-code package's dist/main.mjs, both
|
||||
# copies of the function, packages/agent-core{,-v2}/src/agent/mcp/
|
||||
# connection-manager.ts). The first kb#144 pass got this backwards —
|
||||
# see the release comment on kb#144 for the exact wire.jsonl proof
|
||||
# (tool counts unchanged post-restart) that caught it: Layer 1 alone
|
||||
# is invisible to Kimi.
|
||||
#
|
||||
# Counts (same tool lists both layers, confirmed identical by
|
||||
# validate_capability_grants.py, exit 0):
|
||||
# agap 32->24, hindsight 29->9, kanboard 23->14,
|
||||
# openclaw-tools 5->5 (already minimal, no filter needed).
|
||||
# marketplace stays UNFILTERED at layer 1 (7/13 kept) but is not in
|
||||
# shared-mcp.json AT ALL — Kimi's session never had it in the first
|
||||
# place (pre-existing gap between what OpenClaw offers Adolf and
|
||||
# what reaches Kimi, out of kb#144's scope to close).
|
||||
# Reachable-by-Kimi total: hindsight+kanboard+openclaw-tools+agap
|
||||
# 102-13(marketplace, never reached Kimi)=89 -> 9+14+5+24=52 tools
|
||||
# (-42%). Byte-measured (chars/4) against each server's real
|
||||
# tools/list JSON schemas: est. ~7K tokens saved/turn — estimate
|
||||
# pending the real wire.jsonl number, which needs the adolf-llm
|
||||
# container restart alvis owns (shared-mcp.json is bind-mounted
|
||||
# read-only but adolf-llm's server.js caches its content at process
|
||||
# start, so editing the file alone does not take effect — see
|
||||
# capability_grant_status below for the confirm-post-restart command).
|
||||
#
|
||||
# kb#95 (2026-07-23): added wiki_search/wiki_read/wiki_edit (family
|
||||
# MediaWiki / РодоВики, family.alogins.net) to agap-mcp and to both
|
||||
# layers' agap allowlist below — Adolf's persona domain (relatives,
|
||||
# dates, events), same reasoning as HA/Zabbix/Todoist above. This ages
|
||||
# the counts comment above (agap 32->24, total 52) by +3/+3; not
|
||||
# recomputed here since it needs the same live wire.jsonl proof kb#144
|
||||
# used and this task does not touch the running containers (see
|
||||
# shared_mcp_kimi_allowlist below for the exact confirm command).
|
||||
mcp_tool_filter:
|
||||
hindsight: [recall, retain, reflect, list_memories, get_memory, update_memory, list_directives, create_directive, delete_directive]
|
||||
kanboard: [kanboard_list_projects, kanboard_get_project, kanboard_list_tasks, kanboard_my_tasks, kanboard_get_task, kanboard_search_tasks, kanboard_list_users, kanboard_project_activity, kanboard_create_task, kanboard_update_task, kanboard_move_task, kanboard_change_task_status, kanboard_assign_task, kanboard_add_comment]
|
||||
marketplace: [marketplace_find_best, marketplace_search, marketplace_get_product, marketplace_get_recommendations, marketplace_get_reviews, marketplace_compare_prices, marketplace_status]
|
||||
agap: [vw_get_password, vw_get_item, vw_list_items, vw_create_login, vw_update_password, ha_get_state, ha_list_entities, ha_call_service, ha_get_history, zabbix_get_problems, zabbix_get_hosts, zabbix_get_items, zabbix_get_triggers, radicale_list_calendars, radicale_list_events, radicale_get_event, radicale_put_event, radicale_delete_event, radicale_move_event, todoist_list_tasks, todoist_list_projects, todoist_create_task, todoist_update_task, todoist_complete_task, wiki_search, wiki_read, wiki_edit]
|
||||
openclaw-tools: null # no filter in openclaw.json — already minimal (5/5 kept)
|
||||
note: >
|
||||
"scoped core tools" per kb#134's brief — this IS openai/openclaw.json's
|
||||
live mcp.servers block, not a narrower aspirational allowlist. Per-tool
|
||||
(not per-server) scoping + LiteLLM virtual-key budgets are kb#147's job;
|
||||
this field is the input #147 consumes.
|
||||
"scoped core tools" per kb#134's brief, now REAL at both levels: this
|
||||
IS openai/openclaw.json's live mcp.servers block (server selection)
|
||||
plus its per-server toolFilter.include (tool selection, kb#144). Not
|
||||
a narrower aspirational allowlist — validate_capability_grants.py
|
||||
cross-checks both against the live config. LiteLLM virtual-key
|
||||
budgets remain kb#147's separate job; this field is the input #147
|
||||
consumes for MCP scope (litellm_key_spec() handles the model side).
|
||||
capability_grant: # kb#147 — the enforcement input for LiteLLM + agap-mcp
|
||||
litellm_key_alias: adolf
|
||||
mcp_auth_token_env: AGAP_MCP_TOKEN_ADOLF # secret lives in Vaultwarden + this container's env, never in git; see agap-mcp/docker-compose.yml AGAP_MCP_AGENT_TOKENS
|
||||
note: >
|
||||
Reachable model tiers derived at read time from preferred_tier via
|
||||
agent_registry.py:litellm_key_spec() — not duplicated here. Actual
|
||||
virtual-key provisioning happens via openai/provision_litellm_keys.py
|
||||
against the live LiteLLM proxy; NOT run automatically by this
|
||||
registry (privileged action, requires the LiteLLM master key —
|
||||
kb#147 handover step, see task comment).
|
||||
memory:
|
||||
banks:
|
||||
- { id: adolf-alvis, role: private, interlocutor: alvis }
|
||||
@@ -127,6 +206,10 @@ agents:
|
||||
Widest-scoped agent by design (this session's own tool surface) —
|
||||
gated by ask-first rules on outward/destructive actions rather than
|
||||
MCP allowlisting. Per kb#147, a real virtual-key budget still applies.
|
||||
capability_grant:
|
||||
litellm_key_alias: claude-coder
|
||||
mcp_auth_token_env: AGAP_MCP_TOKEN_CLAUDE_CODER
|
||||
note: "same mechanism as adolf's capability_grant above; see that note."
|
||||
memory:
|
||||
banks: []
|
||||
model: "session (ephemeral, per invocation) + repo state (git history, CLAUDE.md files, kanboard task/comment history) — no persistent Hindsight bank"
|
||||
@@ -150,6 +233,15 @@ agents:
|
||||
vault_access: false # sandboxed — hard rule §5, no exceptions
|
||||
outward_sends: false
|
||||
note: "scoped to mcp__marketplace__* tools only; no gitea/ha/zabbix/radicale, no kanboard project outside its own."
|
||||
capability_grant:
|
||||
litellm_key_alias: torgash
|
||||
mcp_auth_token_env: AGAP_MCP_TOKEN_TORGASH
|
||||
note: >
|
||||
Not provisioned yet (agent not built — see persona.prompt_source
|
||||
above). litellm_key_spec('torgash') already resolves correctly
|
||||
against the registry today (preferred_tier: small -> models=[local-
|
||||
small's litellm_model_name] only, no large/paid-fallback) — verified
|
||||
by kb#147's provision_litellm_keys.py --dry-run.
|
||||
memory:
|
||||
banks: [{ id: torgash, role: private }]
|
||||
current_state: "bank not yet created — target state, same as the persona itself"
|
||||
@@ -178,6 +270,10 @@ agents:
|
||||
native_tools: [WebSearch, WebFetch]
|
||||
vault_access: false
|
||||
outward_sends: false
|
||||
capability_grant:
|
||||
litellm_key_alias: researcher
|
||||
mcp_auth_token_env: AGAP_MCP_TOKEN_RESEARCHER
|
||||
note: "not provisioned yet (agent not built) — same verification status as torgash's capability_grant above."
|
||||
memory:
|
||||
banks: [{ id: researcher, role: private }]
|
||||
current_state: "bank not yet created — target state"
|
||||
@@ -293,3 +389,84 @@ routing_consumption:
|
||||
>= required, and current a(t) == 1 (resolved via agent_registry.py's
|
||||
effective_card(), which dereferences `backbone` into model-registry.yaml
|
||||
or `runtimes:` above) — no submitter-side hardcoded agent id needed.
|
||||
|
||||
# ── kb#147 (A2A-15) implementation status ────────────────────────────────
|
||||
# What "enforced" means as of this task, per enforcement point (§5 lists
|
||||
# three: agap-mcp vault tools, OpenClaw per-agent MCP scoping, LiteLLM
|
||||
# virtual keys). Recorded here — not just in the KB task comment — because
|
||||
# this file is the grants source of truth the design demands.
|
||||
capability_grant_status:
|
||||
agap_mcp_vault_gate: >
|
||||
IMPLEMENTED, tested, NOT ACTIVATED. agap-mcp/src/trust-gate.js resolves
|
||||
a bearer token -> agent id -> trust rank (reading THIS file, mounted
|
||||
read-only) and agap-mcp/src/server.js gates every vw_* tool behind it.
|
||||
Proven with two real, run-now test suites (no live container touched):
|
||||
src/trust-gate.test.mjs (pure logic, 10/10) and
|
||||
src/trust-gate-http.test.mjs (real HTTP request/response, 4/4) — the
|
||||
latter shows a sandboxed-agent token, no token, and an unknown token all
|
||||
get "vault access denied" while a trusted-agent token passes. OFF by
|
||||
default (AGAP_MCP_ENFORCE_VAULT_TRUST=0 in docker-compose.yml) so this
|
||||
change is zero-impact until an operator: (1) generates real per-agent
|
||||
bearer tokens, stores them in Vaultwarden, wires them into
|
||||
AGAP_MCP_AGENT_TOKENS, (2) sets AGAP_MCP_ENFORCE_VAULT_TRUST=1, (3)
|
||||
restarts the agap-mcp container — deliberately not done by this task
|
||||
(never restart the live agap-mcp service unattended).
|
||||
litellm_virtual_keys: >
|
||||
SPEC'D, NOT PROVISIONED. agent_registry.py:litellm_key_spec() computes
|
||||
each agent's model allow-list (derived from preferred_tier x
|
||||
model-registry.yaml routing.tiers, non-metered only unless opted in)
|
||||
and a default budget from trust_classes[...].default_budget_usd.
|
||||
openai/provision_litellm_keys.py turns that spec into LiteLLM
|
||||
/key/generate calls. Verified with --dry-run (prints the exact payload
|
||||
per agent, no network call) — actually creating keys needs
|
||||
LITELLM_MASTER_KEY against the live proxy, a privileged write this task
|
||||
does not perform unattended; see the kb#147 task comment for the exact
|
||||
command to run once approved.
|
||||
openclaw_mcp_allowlist: >
|
||||
STRUCTURAL at both server AND tool level, cross-checked (kb#144 extended
|
||||
this from server-only). adolf/openclaw.json's mcp.servers block is
|
||||
adolf's real, live MCP surface (git-controlled): its server set matches
|
||||
tool_allowlist.mcp_servers, and each server's toolFilter.include (added
|
||||
kb#144 first pass) matches tool_allowlist.mcp_tool_filter — exactly.
|
||||
openai/validate_capability_grants.py checks this automatically, read-only,
|
||||
no live changes. Real and correct for OpenClaw's OWN MCP client surface —
|
||||
but per the kb#144 first-pass release comment's wire.jsonl proof, this
|
||||
layer alone does NOT reach the model on Adolf's kimi backbone (see
|
||||
shared_mcp_kimi_allowlist below, the layer that does). NOT restarted by
|
||||
this task for this file's change either way (openclaw.json is
|
||||
bind-mounted read-only as the live config; `docker compose restart adolf`
|
||||
is the activation step, alvis's call).
|
||||
shared_mcp_kimi_allowlist: >
|
||||
STRUCTURAL, cross-checked, NOT YET ACTIVATED — kb#144 SECOND pass
|
||||
(2026-07-22), added after live verification (restart + one real turn,
|
||||
wire.jsonl inspection) proved the first pass's openclaw.json-only fix
|
||||
left Kimi's actual tool bundle unchanged (23/32/29/5, not 14/24/9/5).
|
||||
Root cause: Kimi CLI auto-discovers a project-root `.mcp.json` that
|
||||
adolf-llm/server.js's writeMcpConfig() seeds from openai/shared-mcp.json
|
||||
— a completely separate config from openclaw.json, read by a separate
|
||||
MCP client (Kimi CLI inside the adolf-llm container, not OpenClaw inside
|
||||
the adolf container). shared-mcp.json now carries the same per-server
|
||||
tool lists as `enabledTools` (Kimi's own allowlist field —
|
||||
McpServerCommonFields.enabledTools, applied via computeEnabledNames;
|
||||
confirmed by decompiling the installed @moonshot-ai/kimi-code package's
|
||||
dist/main.mjs, both copies of the function/schema, no live container
|
||||
touched). openai/validate_capability_grants.py now cross-checks THIS
|
||||
file too (load_shared_mcp_enabled_tools), same exit-0-or-fail contract
|
||||
as the openclaw.json check. marketplace is absent from shared-mcp.json
|
||||
entirely (pre-existing: Kimi's session never had it) — not asserted by
|
||||
the validator for that server, by design, not a gap this task opened.
|
||||
NOT YET ACTIVATED: shared-mcp.json IS bind-mounted read-only into
|
||||
adolf-llm (`./shared-mcp.json:/shared-mcp.json:ro` in
|
||||
openai/docker-compose.yml) so the file on disk is already what the
|
||||
container would read — but adolf-llm/server.js loads it ONCE into a
|
||||
module-level variable at process start (not per-request), so editing the
|
||||
file alone does not take effect; `docker compose restart adolf-llm` is
|
||||
the only remaining step, deliberately not run by this task (never
|
||||
restart a live service unattended). Confirm the real post-restart
|
||||
per-turn token delta via the adolf-llm container's Kimi session wire
|
||||
log: `docker exec adolf-llm sh -c "tail -1
|
||||
/root/.kimi-code/sessions/*/agents/main/wire.jsonl"` (after one real
|
||||
turn against a NEW session, since existing sessions' .mcp.json is
|
||||
rewritten on their next turn too) and compare per-server tool counts
|
||||
against 9/14/5/24 (hindsight/kanboard/openclaw-tools/agap) — the exact
|
||||
same command the first-pass verification used to catch the miss.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"mcpServers": {
|
||||
"hindsight": {
|
||||
"type": "http",
|
||||
"url": "http://hindsight:8888/mcp/adolf/"
|
||||
"url": "http://hindsight:8888/mcp/adolf/",
|
||||
"enabledTools": ["recall", "retain", "reflect", "list_memories", "get_memory", "update_memory", "list_directives", "create_directive", "delete_directive"]
|
||||
},
|
||||
"openclaw-tools": {
|
||||
"type": "http",
|
||||
@@ -10,11 +11,19 @@
|
||||
},
|
||||
"kanboard": {
|
||||
"type": "http",
|
||||
"url": "http://host.docker.internal:3104/mcp"
|
||||
"url": "http://host.docker.internal:3104/mcp",
|
||||
"enabledTools": ["kanboard_list_projects", "kanboard_get_project", "kanboard_list_tasks", "kanboard_my_tasks", "kanboard_get_task", "kanboard_search_tasks", "kanboard_list_users", "kanboard_project_activity", "kanboard_create_task", "kanboard_update_task", "kanboard_move_task", "kanboard_change_task_status", "kanboard_assign_task", "kanboard_add_comment"]
|
||||
},
|
||||
"agap": {
|
||||
"type": "http",
|
||||
"url": "http://host.docker.internal:3100/mcp"
|
||||
"url": "http://host.docker.internal:3100/mcp",
|
||||
"enabledTools": ["vw_get_password", "vw_get_item", "vw_list_items", "vw_create_login", "vw_update_password", "ha_get_state", "ha_list_entities", "ha_call_service", "ha_get_history", "zabbix_get_problems", "zabbix_get_hosts", "zabbix_get_items", "zabbix_get_triggers", "radicale_list_calendars", "radicale_list_events", "radicale_get_event", "radicale_put_event", "radicale_delete_event", "radicale_move_event", "todoist_list_tasks", "todoist_list_projects", "todoist_create_task", "todoist_update_task", "todoist_complete_task", "wiki_search", "wiki_read", "wiki_edit"]
|
||||
},
|
||||
"marketplace": {
|
||||
"type": "http",
|
||||
"url": "http://host.docker.internal:3101/mcp",
|
||||
"bearerTokenEnvVar": "MARKETPLACE_MCP_TOKEN",
|
||||
"enabledTools": ["marketplace_find_best", "marketplace_search", "marketplace_get_product", "marketplace_get_recommendations", "marketplace_get_reviews", "marketplace_compare_prices", "marketplace_status"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
313
openai/validate_capability_grants.py
Executable file
313
openai/validate_capability_grants.py
Executable file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""validate_capability_grants — kb#147 (A2A-15), extended by kb#144 (A2A-12):
|
||||
cross-check that agent-registry.yaml's declared tool_allowlist.mcp_servers
|
||||
(server-level) AND tool_allowlist.mcp_tool_filter (per-tool level, kb#144)
|
||||
for each agent match what that agent's real, git-controlled config actually
|
||||
grants it -- at BOTH layers that materialize a tool bundle for Adolf:
|
||||
|
||||
1. OpenClaw's own mcp.servers.*.toolFilter.include in adolf/openclaw.json
|
||||
(adolf's own MCP client surface).
|
||||
2. shared-mcp.json's per-server `enabledTools` (kb#144 verification pass,
|
||||
2026-07-22): what adolf-llm/server.js's writeMcpConfig() seeds into
|
||||
each Kimi CLI session's project-root .mcp.json -- the layer that
|
||||
ACTUALLY determines the MODEL's tool bundle for Adolf's kimi backbone.
|
||||
Layer 1 alone shipped a false "Done" once already (kb#144 first pass):
|
||||
wire.jsonl proved Kimi's tool counts were unchanged because OpenClaw's
|
||||
toolFilter never reaches the Kimi CLI, which reads its own
|
||||
enabledTools/disabledTools (McpServerCommonFields, computeEnabledNames
|
||||
-- confirmed by decompiling the installed @moonshot-ai/kimi-code
|
||||
package's dist/main.mjs). Checking only layer 1 would pass this
|
||||
validator while leaving the real per-turn token bloat unfixed again.
|
||||
|
||||
Read-only. Makes no live changes and touches no running service — it just
|
||||
diffs already-committed files so a registry edit that silently drifts from
|
||||
an agent's real config fails loudly (exit 1) instead of rotting quietly,
|
||||
which is exactly the "scattered configs" failure mode kb#147's acceptance
|
||||
bar ("grants live in the agent registry, not scattered configs") exists to
|
||||
prevent. kb#144's acceptance bar ("Adolf's tools are sourced from the
|
||||
registry") means both layers, not just the one OpenClaw itself reads.
|
||||
|
||||
Only agents with a `prompt_source` pointing at a real openclaw.json-shaped
|
||||
config are checked; agents that are registry-only target state (torgash,
|
||||
researcher — no config file exists yet) are reported as skipped, not failed.
|
||||
|
||||
Usage:
|
||||
./validate_capability_grants.py
|
||||
./validate_capability_grants.py --openclaw-json ../adolf/openclaw.json --id adolf
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
import agent_registry as ar
|
||||
|
||||
HERE_ADOLF_OPENCLAW_JSON = "../adolf/openclaw.json"
|
||||
SHARED_MCP_JSON = "shared-mcp.json"
|
||||
|
||||
# id -> path to the git-controlled OpenClaw config that IS this agent's live
|
||||
# MCP surface. Only adolf has one today (claude-coder has no openclaw.json --
|
||||
# it's a CLAUDE.md-driven persona, not an OpenClaw runtime; see its
|
||||
# capability_grant note in agent-registry.yaml).
|
||||
KNOWN_CONFIGS = {
|
||||
"adolf": HERE_ADOLF_OPENCLAW_JSON,
|
||||
}
|
||||
|
||||
# id -> path to the shared-mcp.json this agent's backbone runtime seeds its
|
||||
# session .mcp.json from (kb#144 layer-2 check, see module docstring). Only
|
||||
# agents on a Kimi-CLI-shaped backbone go through this file at all.
|
||||
KNOWN_SHARED_MCP = {
|
||||
"adolf": SHARED_MCP_JSON,
|
||||
}
|
||||
|
||||
|
||||
def _strip_jsonc_comments(text):
|
||||
"""Drop // line comments. Good enough for this read-only check: this
|
||||
file's comments are all on their own line or trail real content with no
|
||||
'//' inside a string value today -- verified by hand."""
|
||||
out = []
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//"):
|
||||
continue
|
||||
m = re.search(r'(?<!:)//', line)
|
||||
if m and line[: m.start()].count('"') % 2 == 0:
|
||||
line = line[: m.start()]
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _find_key_brace(text, key, start=0):
|
||||
"""Find `key: {` (bare or quoted key) at or after `start`; return the
|
||||
index of the matching '{'."""
|
||||
m = re.search(rf'["\']?{re.escape(key)}["\']?\s*:\s*\{{', text[start:])
|
||||
if not m:
|
||||
raise ValueError(f"key {key!r} not found from offset {start}")
|
||||
return start + m.end() - 1 # index of the '{' itself
|
||||
|
||||
|
||||
def _matching_close_brace(text, open_idx):
|
||||
depth = 0
|
||||
for i in range(open_idx, len(text)):
|
||||
if text[i] == '{':
|
||||
depth += 1
|
||||
elif text[i] == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
raise ValueError("unbalanced braces")
|
||||
|
||||
|
||||
def _extract_object_top_level_keys(text, key_path):
|
||||
"""openclaw.json is JS-object-literal JSON5 (bare identifier keys,
|
||||
trailing commas) -- `json.loads` can't touch it and pulling in a JSON5
|
||||
parser is overkill for one narrow read. Instead: locate `key_path`
|
||||
(e.g. ["mcp", "servers"]) by finding each key's opening '{' in turn, then
|
||||
brace-depth-scan that object collecting only its DIRECT child keys
|
||||
(`name: {` at depth 1). Sufficient and honest for this validation
|
||||
script's one job; not a general JSON5 reader."""
|
||||
pos = 0
|
||||
open_idx = 0
|
||||
for key in key_path:
|
||||
open_idx = _find_key_brace(text, key, pos)
|
||||
pos = open_idx + 1
|
||||
close_idx = _matching_close_brace(text, open_idx)
|
||||
body = text[open_idx + 1 : close_idx]
|
||||
|
||||
# Depth-0 identifiers immediately followed by ": {" are this object's
|
||||
# direct child keys (mcp.servers' entries are always objects). Only try
|
||||
# a key match right after a boundary ('{', ',', or start-of-body) so an
|
||||
# identifier can't be "matched" starting mid-token from inside a nested
|
||||
# value (the earlier, buggy version of this scanner did exactly that).
|
||||
keys = []
|
||||
depth = 0
|
||||
i, n = 0, len(body)
|
||||
prev_boundary = True
|
||||
key_re = re.compile(r'["\']?([A-Za-z0-9_-]+)["\']?\s*:\s*\{')
|
||||
while i < n:
|
||||
ch = body[i]
|
||||
if ch in ' \t\r\n':
|
||||
i += 1
|
||||
continue
|
||||
if depth == 0 and prev_boundary:
|
||||
m = key_re.match(body, i)
|
||||
if m:
|
||||
keys.append(m.group(1))
|
||||
i = m.end() - 1 # land on the key's '{' so the normal handling below opens depth 1
|
||||
prev_boundary = False
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
prev_boundary = True
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
prev_boundary = True
|
||||
elif ch == ',':
|
||||
prev_boundary = True
|
||||
else:
|
||||
prev_boundary = False
|
||||
i += 1
|
||||
return sorted(keys)
|
||||
|
||||
|
||||
def load_mcp_servers(path):
|
||||
with open(path) as f:
|
||||
raw = f.read()
|
||||
text = _strip_jsonc_comments(raw)
|
||||
return _extract_object_top_level_keys(text, ["mcp", "servers"])
|
||||
|
||||
|
||||
def _locate_object(text, key_path, start=0):
|
||||
"""Chase `key_path` (e.g. ["mcp", "servers", "hindsight"]) through nested
|
||||
`key: {` objects, same navigation _extract_object_top_level_keys does
|
||||
internally, exposed standalone so other extractors (toolFilter below)
|
||||
can reuse it instead of re-deriving brace offsets."""
|
||||
pos = start
|
||||
open_idx = start
|
||||
for key in key_path:
|
||||
open_idx = _find_key_brace(text, key, pos)
|
||||
pos = open_idx + 1
|
||||
close_idx = _matching_close_brace(text, open_idx)
|
||||
return open_idx, close_idx
|
||||
|
||||
|
||||
def _matching_close_bracket(text, open_idx):
|
||||
"""Same brace-depth-scan as _matching_close_brace, for '[' / ']' — needed
|
||||
to bound a toolFilter.include array (a list, not an object)."""
|
||||
depth = 0
|
||||
for i in range(open_idx, len(text)):
|
||||
if text[i] == '[':
|
||||
depth += 1
|
||||
elif text[i] == ']':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
raise ValueError("unbalanced brackets")
|
||||
|
||||
|
||||
def load_tool_filter(path, server_name):
|
||||
"""kb#144: extract mcp.servers.<server_name>.toolFilter.include as a
|
||||
sorted list of tool names, or None if that server has no toolFilter (or
|
||||
no include list) at all — OpenClaw's own semantics for "no toolFilter":
|
||||
every tool the server offers stays eligible (see schema-BqdpWz19.js:
|
||||
"When omitted, all server tools remain eligible unless excluded.").
|
||||
exclude-only filters are not modeled here (none of Adolf's servers use
|
||||
exclude today) and are reported as None (unrestricted) rather than
|
||||
silently mis-parsed.
|
||||
"""
|
||||
with open(path) as f:
|
||||
raw = f.read()
|
||||
text = _strip_jsonc_comments(raw)
|
||||
try:
|
||||
server_open, server_close = _locate_object(text, ["mcp", "servers", server_name])
|
||||
except ValueError:
|
||||
return None # server not present in this config at all
|
||||
body = text[server_open : server_close + 1]
|
||||
try:
|
||||
tf_open, tf_close = _locate_object(body, ["toolFilter"])
|
||||
except ValueError:
|
||||
return None # no toolFilter -> unrestricted, by OpenClaw's own semantics
|
||||
tf_body = body[tf_open : tf_close + 1]
|
||||
m = re.search(r'["\']?include["\']?\s*:\s*\[', tf_body)
|
||||
if not m:
|
||||
return None # exclude-only or empty toolFilter -- not modeled, treat as unrestricted
|
||||
bracket_open = tf_body.index('[', m.start())
|
||||
bracket_close = _matching_close_bracket(tf_body, bracket_open)
|
||||
arr_body = tf_body[bracket_open + 1 : bracket_close]
|
||||
return sorted(re.findall(r'["\']([A-Za-z0-9_.\-\*]+)["\']', arr_body))
|
||||
|
||||
|
||||
def load_shared_mcp_enabled_tools(path):
|
||||
"""kb#144 layer-2 check (see module docstring): shared-mcp.json is
|
||||
strict JSON (no JSON5 quirks, unlike openclaw.json), so a plain
|
||||
`json.load` is enough -- no brace-scanner needed here. Returns
|
||||
{server_name: sorted-tool-list-or-None}, None meaning no `enabledTools`
|
||||
key on that server (unfiltered -- every tool it offers stays eligible,
|
||||
same "omitted = unrestricted" semantics as OpenClaw's own toolFilter).
|
||||
"""
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
out = {}
|
||||
for name, cfg in (data.get("mcpServers") or {}).items():
|
||||
tools = cfg.get("enabledTools")
|
||||
out[name] = sorted(tools) if tools else None
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--registry", default=None)
|
||||
ap.add_argument("--openclaw-json", default=None, help="override path for --id's config")
|
||||
ap.add_argument("--id", default=None, help="only this agent id (default: every id in KNOWN_CONFIGS)")
|
||||
args = ap.parse_args()
|
||||
|
||||
reg = ar.load_registry(args.registry)
|
||||
ids = [args.id] if args.id else list(KNOWN_CONFIGS)
|
||||
|
||||
failures = 0
|
||||
for agent_id in ids:
|
||||
agent = ar.get_agent(reg, agent_id)
|
||||
declared = sorted((agent.get("tool_allowlist") or {}).get("mcp_servers") or [])
|
||||
config_path = args.openclaw_json or KNOWN_CONFIGS.get(agent_id)
|
||||
if not config_path:
|
||||
print(f"SKIP {agent_id}: no known live config to cross-check (target-state agent)")
|
||||
continue
|
||||
try:
|
||||
live = load_mcp_servers(config_path)
|
||||
except FileNotFoundError:
|
||||
print(f"SKIP {agent_id}: config not found at {config_path}")
|
||||
continue
|
||||
if declared == live:
|
||||
print(f"OK {agent_id}: registry tool_allowlist.mcp_servers == live {config_path} mcp.servers -> {live}")
|
||||
else:
|
||||
failures += 1
|
||||
print(f"FAIL {agent_id}: registry says {declared} but {config_path} actually grants {live}")
|
||||
|
||||
# kb#144: per-tool cross-check, same idea one level down. Only
|
||||
# meaningful for servers the registry actually declares a filter
|
||||
# for (mcp_tool_filter); a server absent from that map is not
|
||||
# asserted either way here (it may be intentionally unfiltered).
|
||||
declared_filters = (agent.get("tool_allowlist") or {}).get("mcp_tool_filter") or {}
|
||||
for server_name, declared_tools in declared_filters.items():
|
||||
live_tools = load_tool_filter(config_path, server_name)
|
||||
declared_sorted = sorted(declared_tools) if declared_tools else None
|
||||
if declared_sorted == live_tools:
|
||||
shown = live_tools if live_tools is not None else "(unfiltered)"
|
||||
print(f"OK {agent_id}/{server_name}: registry mcp_tool_filter == live toolFilter.include -> {shown}")
|
||||
else:
|
||||
failures += 1
|
||||
print(f"FAIL {agent_id}/{server_name}: registry mcp_tool_filter says {declared_sorted} but live toolFilter.include is {live_tools}")
|
||||
|
||||
# kb#144 layer-2: the file that actually reaches the MODEL for a
|
||||
# Kimi-backed agent (see module docstring for why layer 1 alone
|
||||
# missed the real bug once already). Servers the registry declares a
|
||||
# filter for but that don't appear in shared-mcp.json at all (e.g.
|
||||
# marketplace, which OpenClaw carries but Kimi's session never sees)
|
||||
# are not asserted here -- that's a separate, pre-existing gap
|
||||
# between what OpenClaw offers Adolf and what reaches Kimi, not a
|
||||
# drift this validator's job to catch.
|
||||
shared_mcp_path = KNOWN_SHARED_MCP.get(agent_id)
|
||||
if shared_mcp_path:
|
||||
try:
|
||||
live_shared = load_shared_mcp_enabled_tools(shared_mcp_path)
|
||||
except FileNotFoundError:
|
||||
print(f"SKIP {agent_id}: shared-mcp.json not found at {shared_mcp_path}")
|
||||
else:
|
||||
for server_name, declared_tools in declared_filters.items():
|
||||
if server_name not in live_shared:
|
||||
continue
|
||||
declared_sorted = sorted(declared_tools) if declared_tools else None
|
||||
live_tools = live_shared[server_name]
|
||||
if declared_sorted == live_tools:
|
||||
shown = live_tools if live_tools is not None else "(unfiltered)"
|
||||
print(f"OK {agent_id}/{server_name}: registry mcp_tool_filter == live shared-mcp.json enabledTools -> {shown}")
|
||||
else:
|
||||
failures += 1
|
||||
print(f"FAIL {agent_id}/{server_name}: registry mcp_tool_filter says {declared_sorted} but shared-mcp.json enabledTools is {live_tools}")
|
||||
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user