// 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); });