adolf: bearer-authenticate the agap MCP server, fix audio config schema
openclaw.json now sends `Authorization: Bearer ${AGAP_MCP_TOKEN}` to the agap
MCP server, which requires it as of kb#180. The token is injected from
openai/.env via docker-compose.yml and only substituted here, never inlined.
It maps to agent id `adolf`, which is also what the kb#147 vault gate reads.
Fixes tools.media.audio, which had been added but never restart-validated:
the per-entry `apiKey: "not-needed"` is rejected by the schema
("tools.media.audio.models.0: Invalid input"), and an invalid config makes the
gateway refuse to start outright -- adolf crash-looped on the first restart
after the block landed. The old comment claimed the schema requires a
non-empty apiKey; it is the opposite, apiKey is not a valid per-entry key at
all. Isolated with `openclaw config validate` against the running image
(2026.6.11): {provider, model} and {provider, model, baseUrl} validate, and
adding apiKey alone reproduces the failure. baseUrl is kept -- that is the
per-entry override pointing the openai-shaped provider at the local
faster-whisper server. Provider auth follows the normal model auth order per
docs/nodes/audio.md, and faster-whisper-server has no auth to satisfy anyway.
Two lessons encoded in the comments: `enabled: false` does NOT exempt an entry
from schema validation, and a config edit is not done until a restart boots
healthy -- this sat invalid but latent because the running gateway still held
an older loaded config. The block stays enabled: false; turning STT on is
still a kb#175/#191 decision (GTX 1070 co-residency).
Also adds the proactive-prioritization and todoist-capture design notes and
the vw-mcp prototype.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
28
adolf/vw-mcp/.env.example
Normal file
28
adolf/vw-mcp/.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
# Copy to .env (git-ignored) and fill in real values before `docker compose up`.
|
||||
# This server is intentionally narrow: read-only vw_* tools, a dedicated bot
|
||||
# vault identity, and its own bearer token. See CLAUDE.md / kb task #64 for
|
||||
# the full architecture and the sensitive setup steps (creating the bot user
|
||||
# and the "Adolf" collection) that are NOT done by this scaffolding.
|
||||
|
||||
# Port this server listens on. 3100=agap-mcp, 3101=marketplace-mcp,
|
||||
# 3103=kanboard-mcp, 3104=kanboard-mcp-adolf — 3105 verified free at write time.
|
||||
PORT=3105
|
||||
|
||||
# Local Vaultwarden instance (NOT bitwarden.com). Unlike agap-mcp/
|
||||
# marketplace-mcp, this service owns a fresh BITWARDENCLI_APPDATA_DIR volume
|
||||
# with no pre-existing `bw config server`, so vaultwarden.js sets it on every
|
||||
# boot from this var.
|
||||
VW_URL=http://localhost:8041
|
||||
|
||||
# Dedicated bot identity — NEVER the master allogn@gmail.com account.
|
||||
# Create this user in Vaultwarden first (sensitive step, reserved for the
|
||||
# orchestrator — see report). Password: generate one and store it in
|
||||
# Vaultwarden as item "ADOLF_VW_PASSWORD" (also a sensitive step).
|
||||
BW_EMAIL=adolf-vault@auth.local
|
||||
BW_PASSWORD=
|
||||
|
||||
# Bearer token gating /mcp, /sse, /messages (same pattern as
|
||||
# marketplace-mcp). Generate with e.g. `openssl rand -hex 32`, store it in
|
||||
# Vaultwarden as its own item (e.g. "VW_MCP_ADOLF_TOKEN"), and put the real
|
||||
# value here — the line below is a PLACEHOLDER, not a usable secret.
|
||||
VW_MCP_TOKEN=replace-with-output-of-openssl-rand--hex-32
|
||||
2
adolf/vw-mcp/.gitignore
vendored
Normal file
2
adolf/vw-mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
node_modules/
|
||||
10
adolf/vw-mcp/Dockerfile
Normal file
10
adolf/vw-mcp/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
RUN npm install -g @bitwarden/cli
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
COPY vaultwarden.js server.js ./
|
||||
COPY start.sh ./
|
||||
RUN chmod +x start.sh
|
||||
CMD ["./start.sh"]
|
||||
27
adolf/vw-mcp/docker-compose.yml
Normal file
27
adolf/vw-mcp/docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: vw-mcp-adolf
|
||||
services:
|
||||
vw-mcp-adolf:
|
||||
build: .
|
||||
container_name: vw-mcp-adolf
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BITWARDENCLI_APPDATA_DIR=/bw-data
|
||||
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
- HTTPS_PROXY=
|
||||
- HTTP_PROXY=
|
||||
- ALL_PROXY=
|
||||
- https_proxy=
|
||||
- http_proxy=
|
||||
- all_proxy=
|
||||
volumes:
|
||||
# Dedicated, OWN volume — deliberately NOT the host bind mount
|
||||
# (`/home/alvis/.config/Bitwarden CLI`) that agap-mcp/marketplace-mcp
|
||||
# share, and NOT any other bw data dir. This bot's login/session state
|
||||
# must never mix with the master account's or any other bot's.
|
||||
- vw-mcp-adolf_bw-data:/bw-data
|
||||
|
||||
volumes:
|
||||
vw-mcp-adolf_bw-data:
|
||||
11
adolf/vw-mcp/package.json
Normal file
11
adolf/vw-mcp/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "vw-mcp-adolf",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Standalone, read-only MCP server giving Adolf a narrow slice of Vaultwarden (vw_get_password, vw_get_item, vw_list_items only), split out of agap-mcp per kb task #64",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"express": "^4.19.0",
|
||||
"zod": "^3.23.0"
|
||||
}
|
||||
}
|
||||
137
adolf/vw-mcp/server.js
Normal file
137
adolf/vw-mcp/server.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// vw-mcp-adolf — standalone, read-only Vaultwarden MCP server for Adolf (kb
|
||||
// task #64).
|
||||
//
|
||||
// Gives Adolf a narrow, scoped slice of Vaultwarden WITHOUT exposing the
|
||||
// master vault:
|
||||
// - Only 3 read-only tools: vw_get_password, vw_get_item, vw_list_items.
|
||||
// No write tools (vw_create_login / vw_update_password) exist here at
|
||||
// all — omitted, not just unregistered, so there is no code path that
|
||||
// could ever write to the vault.
|
||||
// - Authenticates to Vaultwarden as a DEDICATED bot user
|
||||
// (adolf-vault@auth.local), never the master account.
|
||||
// - Server-side scoping is the real fence: that bot user is granted
|
||||
// read-only access to a narrow "Adolf" collection only.
|
||||
// - Every MCP transport requires `Authorization: Bearer $VW_MCP_TOKEN`,
|
||||
// same pattern as marketplace-mcp (src/server.js) — refuses to start if
|
||||
// VW_MCP_TOKEN is unset, so it can never silently run open. /health stays
|
||||
// unauthenticated (no sensitive data, used for liveness checks).
|
||||
import express from 'express';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { initVaultwarden, vwGetPassword, vwGetItem, vwListItems, vwListOrgItems } from './vaultwarden.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3105');
|
||||
|
||||
// --- Init ---
|
||||
async function init() {
|
||||
await initVaultwarden();
|
||||
}
|
||||
|
||||
// --- MCP server factory (one per session — McpServer can't share transports) ---
|
||||
function ok(text) {
|
||||
return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] };
|
||||
}
|
||||
|
||||
function err(e) {
|
||||
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
||||
}
|
||||
|
||||
function createServer() {
|
||||
const server = new McpServer({ name: 'vw-mcp-adolf', version: '1.0.0' });
|
||||
|
||||
server.tool('vw_get_password', 'Get password for a Vaultwarden item by name (read-only; scoped to the Adolf collection)', { name: z.string() },
|
||||
async ({ name }) => {
|
||||
try { return ok(vwGetPassword(name)); } catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes; read-only; scoped to the Adolf collection)', { name: z.string() },
|
||||
async ({ name }) => {
|
||||
try {
|
||||
const item = vwGetItem(name);
|
||||
return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes });
|
||||
} catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
server.tool('vw_list_items', 'List Vaultwarden items visible to the Adolf bot user (read-only). Searches its personal vault by default; set org=true to search the org (only the Adolf collection is actually visible)', {
|
||||
search: z.string().optional(),
|
||||
org: z.boolean().optional(),
|
||||
}, async ({ search, org }) => {
|
||||
try {
|
||||
const items = org ? vwListOrgItems(search) : vwListItems(search);
|
||||
return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri })));
|
||||
} catch (e) { return err(e); }
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// --- Auth gate ---
|
||||
// This server holds real credentials (a narrow slice, but real), so every
|
||||
// MCP transport requires a bearer token. VW_MCP_TOKEN lives in Vaultwarden
|
||||
// (create it as its own item once the server is live) and is injected via
|
||||
// docker-compose env — never hardcode it here. /health stays open (no
|
||||
// sensitive data, used for liveness checks). If VW_MCP_TOKEN is unset the
|
||||
// server refuses to start, so this can never silently run open.
|
||||
const AUTH_TOKEN = process.env.VW_MCP_TOKEN;
|
||||
if (!AUTH_TOKEN) {
|
||||
console.error('VW_MCP_TOKEN env var is required (see docker-compose.yml / .env)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const header = req.get('authorization') || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (token !== AUTH_TOKEN) {
|
||||
return res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// --- HTTP server (Streamable HTTP + legacy SSE) ---
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const sseTransports = new Map();
|
||||
|
||||
// Streamable HTTP — stateless: fresh server per request, survives container restarts
|
||||
app.all('/mcp', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
res.on('close', () => transport.close());
|
||||
await createServer().connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (e) {
|
||||
console.error('MCP request error:', e.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy SSE — kept for backward compatibility, same pattern as agap-mcp/kanboard-mcp
|
||||
app.get('/sse', requireAuth, async (req, res) => {
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
sseTransports.set(transport.sessionId, transport);
|
||||
res.on('close', () => sseTransports.delete(transport.sessionId));
|
||||
await createServer().connect(transport);
|
||||
});
|
||||
|
||||
app.post('/messages', requireAuth, async (req, res) => {
|
||||
const transport = sseTransports.get(req.query.sessionId);
|
||||
if (!transport) return res.status(400).send('Unknown session');
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 3 }));
|
||||
|
||||
init()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => console.log(`vw-mcp-adolf listening on :${PORT}`));
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('Init failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
2
adolf/vw-mcp/start.sh
Executable file
2
adolf/vw-mcp/start.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec node server.js
|
||||
100
adolf/vw-mcp/vaultwarden.js
Normal file
100
adolf/vw-mcp/vaultwarden.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// Trimmed, read-only copy of agap-mcp/src/vaultwarden.js (kb task #64).
|
||||
//
|
||||
// Deliberate differences from the master copy:
|
||||
// - Only the 3 read tools are implemented: get password, get item, list
|
||||
// items. vwCreateLogin / vwUpdatePassword (and AI_COLLECTION, which only
|
||||
// those write paths needed) are NOT here — this server must never be able
|
||||
// to write to the vault, even in principle.
|
||||
// - Authenticates as a DEDICATED bot user (BW_EMAIL=adolf-vault@auth.local),
|
||||
// never the master allogn@gmail.com account. No default email/password —
|
||||
// both must be explicit in .env so this can never silently fall back to
|
||||
// the master identity.
|
||||
// - BITWARDENCLI_APPDATA_DIR (see docker-compose.yml) points at this
|
||||
// service's OWN volume, separate from the agap-mcp/marketplace-mcp host
|
||||
// bind mount (`/home/alvis/.config/Bitwarden CLI`) — the bot's bw
|
||||
// login/session state must never share a directory with the master's.
|
||||
//
|
||||
// The real fence is server-side: the bot user is granted read-only access to
|
||||
// a narrow "Adolf" collection only (not the whole "AI" collection). This
|
||||
// client code does not filter by collection — Vaultwarden itself only
|
||||
// returns items the bot user's permissions allow, whatever org-wide ORG_ID
|
||||
// is passed.
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
const BW = 'bw';
|
||||
const ORG_ID = '4bd75130-b4d3-48d4-a4cb-e52b70295a51';
|
||||
|
||||
let _session = null;
|
||||
|
||||
function bwEnv() {
|
||||
const env = { ...process.env };
|
||||
for (const k of ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'https_proxy', 'http_proxy', 'all_proxy'])
|
||||
delete env[k];
|
||||
env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
return env;
|
||||
}
|
||||
|
||||
function run(args, input) {
|
||||
return execFileSync(BW, args, {
|
||||
env: bwEnv(),
|
||||
encoding: 'utf8',
|
||||
input,
|
||||
stdio: input ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export async function initVaultwarden() {
|
||||
const email = process.env.BW_EMAIL;
|
||||
const password = process.env.BW_PASSWORD;
|
||||
const server = process.env.VW_URL;
|
||||
if (!email || !password) {
|
||||
throw new Error('BW_EMAIL and BW_PASSWORD env vars are required — the dedicated adolf-vault@auth.local bot credentials, never the master account (see .env.example)');
|
||||
}
|
||||
if (!server) {
|
||||
throw new Error('VW_URL env var is required — this service owns a FRESH BITWARDENCLI_APPDATA_DIR volume (unlike agap-mcp/marketplace-mcp, which reuse the host dir that already has `bw config server` set), so it must configure the server itself on every boot (see .env.example)');
|
||||
}
|
||||
|
||||
// Idempotent — safe to call on every boot, including against an
|
||||
// already-configured appdata dir.
|
||||
run(['config', 'server', server]);
|
||||
|
||||
let status = 'unauthenticated';
|
||||
try {
|
||||
status = JSON.parse(run(['status'])).status;
|
||||
} catch {}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
run(['login', email, password, '--raw']);
|
||||
}
|
||||
|
||||
_session = run(['unlock', password, '--raw']);
|
||||
run(['sync', '--session', _session]);
|
||||
console.log('Vaultwarden: ready (adolf-vault bot identity)');
|
||||
}
|
||||
|
||||
function session() {
|
||||
if (!_session) throw new Error('Vaultwarden not initialized');
|
||||
return _session;
|
||||
}
|
||||
|
||||
export function vwGetPassword(name) {
|
||||
return run(['get', 'password', name, '--session', session()]);
|
||||
}
|
||||
|
||||
export function vwGetItem(name) {
|
||||
return JSON.parse(run(['get', 'item', name, '--session', session()]));
|
||||
}
|
||||
|
||||
export function vwListItems(search) {
|
||||
const args = ['list', 'items', '--session', session()];
|
||||
if (search) args.push('--search', search);
|
||||
return JSON.parse(run(args));
|
||||
}
|
||||
|
||||
export function vwListOrgItems(search) {
|
||||
// Server-side collection permissions (not this code) decide what actually
|
||||
// comes back — the bot user only sees the narrow "Adolf" collection.
|
||||
const args = ['list', 'items', '--organizationid', ORG_ID, '--session', session()];
|
||||
if (search) args.push('--search', search);
|
||||
return JSON.parse(run(args));
|
||||
}
|
||||
Reference in New Issue
Block a user