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>
138 lines
5.7 KiB
JavaScript
138 lines
5.7 KiB
JavaScript
// 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);
|
|
});
|