@icoretech/warden-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 icoretech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # warden-mcp
2
+
3
+ Programmatic Vaultwarden/Bitwarden vault management over MCP (Model Context Protocol), backed by the official Bitwarden CLI (`bw`).
4
+
5
+ This project exists to let agents and automation **create/search/read/update/move** vault items without re-implementing Bitwarden’s client-side crypto.
6
+
7
+ Published package: `@icoretech/warden-mcp`
8
+
9
+ ## Highlights
10
+
11
+ - MCP Streamable HTTP (SSE) endpoint at `POST /sse` + health check at `GET /healthz`
12
+ - Runtime guardrail metrics at `GET /metricsz`
13
+ - Item types: **login**, **secure note**, **card**, **identity**, plus an **SSH key** convention (secure note + standard fields)
14
+ - Attachments: create/delete/download
15
+ - Organization + collection helpers (list + org-collection CRUD)
16
+ - Safe-by-default: item reads are **redacted** unless explicitly revealed; secret helper tools return `null` unless `reveal: true`
17
+
18
+ ## How It Works
19
+
20
+ The server executes `bw` commands on your behalf:
21
+
22
+ - Bitwarden/Vaultwarden connection + credentials are provided via **HTTP headers** per request.
23
+ - The server maintains per-profile `bw` state under `KEYCHAIN_BW_HOME_ROOT` to avoid session/config clashes.
24
+ - Writes can optionally call `bw sync` (internal; not exposed as an MCP tool).
25
+
26
+ ### Required Headers
27
+
28
+ - `X-BW-Host` (must be an HTTPS origin, for example `https://vaultwarden.example.com`)
29
+ - `X-BW-Password` (master password; required to unlock)
30
+ - Either:
31
+ - `X-BW-ClientId` + `X-BW-ClientSecret` (API key login), or
32
+ - `X-BW-User` (email for user/pass login; still uses `X-BW-Password`)
33
+ - Optional:
34
+ - `X-BW-Unlock-Interval` (seconds)
35
+
36
+ ## Security Model
37
+
38
+ There is **no built-in auth** layer in v1. Run it only on a trusted network boundary (localhost, private subnet, VPN, etc.).
39
+
40
+ Mutation control:
41
+
42
+ - Set `READONLY=true` to block all write operations (create/edit/delete/move/restore/attachments).
43
+ - Session guardrails:
44
+ - `KEYCHAIN_SESSION_MAX_COUNT` (default `32`)
45
+ - `KEYCHAIN_SESSION_TTL_MS` (default `900000`)
46
+ - `KEYCHAIN_SESSION_SWEEP_INTERVAL_MS` (default `60000`)
47
+ - `KEYCHAIN_MAX_HEAP_USED_MB` (default `1536`, set `0` to disable memory fuse)
48
+ - `KEYCHAIN_METRICS_LOG_INTERVAL_MS` (default `0`, disabled)
49
+
50
+ Redaction defaults (item reads):
51
+
52
+ - Login: `password`, `totp`
53
+ - Card: `number`, `code`
54
+ - Identity: `ssn`, `passportNumber`, `licenseNumber`
55
+ - Custom fields: hidden fields (Bitwarden `type: 1`)
56
+ - Attachments: `attachments[].url` (signed download URL token)
57
+ - Password history: `passwordHistory[].password`
58
+
59
+ Reveal rules:
60
+
61
+ - Tools accept `reveal: true` where applicable (default is `false`).
62
+ - Secret helper tools (`get_password`, `get_totp`, `get_notes`, `generate`, `get_password_history`) return `structuredContent.result = { kind, value, revealed }`.
63
+ - When `reveal` is omitted/false, `value` is `null` (or historic passwords are `null`) and `revealed: false`.
64
+
65
+ ## Quick Start
66
+
67
+ ### Docker Compose (recommended)
68
+
69
+ Starts a local Vaultwarden + HTTPS proxy (for `bw`), bootstraps a test user, and runs the MCP server.
70
+
71
+ ```bash
72
+ cp .env.example .env
73
+ make up
74
+ curl -fsS http://localhost:3005/healthz
75
+ ```
76
+
77
+ Run integration tests:
78
+
79
+ ```bash
80
+ make test
81
+ ```
82
+
83
+ Run session flood regression locally (guardrail sanity):
84
+
85
+ ```bash
86
+ npm run test:session-regression
87
+ ```
88
+
89
+ ### Local Dev (host)
90
+
91
+ ```bash
92
+ npm install
93
+ cp .env.example .env
94
+ npm run dev
95
+ ```
96
+
97
+ ### Run via npx
98
+
99
+ ```bash
100
+ npx -y @icoretech/warden-mcp
101
+ npx -y @icoretech/warden-mcp --stdio
102
+ ```
103
+
104
+ ## Tool Reference (v1)
105
+
106
+ Vault/session:
107
+
108
+ - `keychain.status`
109
+ - `keychain.encode` (base64-encode a string via `bw encode`)
110
+ - `keychain.generate` (returns a generated secret only when `reveal: true`)
111
+
112
+ Items:
113
+
114
+ - `keychain.search_items`, `keychain.get_item`, `keychain.update_item`
115
+ - `keychain.create_login`, `keychain.create_note`, `keychain.create_card`, `keychain.create_identity`, `keychain.create_ssh_key`
116
+ - `keychain.delete_item`, `keychain.restore_item`
117
+
118
+ Folders:
119
+
120
+ - `keychain.list_folders`, `keychain.create_folder`, `keychain.edit_folder`, `keychain.delete_folder`
121
+
122
+ Orgs/collections:
123
+
124
+ - `keychain.list_organizations`, `keychain.list_collections`
125
+ - `keychain.list_org_collections`, `keychain.create_org_collection`, `keychain.edit_org_collection`, `keychain.delete_org_collection`
126
+ - `keychain.move_item_to_organization`
127
+
128
+ Attachments:
129
+
130
+ - `keychain.create_attachment`, `keychain.delete_attachment`, `keychain.get_attachment`
131
+
132
+ Sends:
133
+
134
+ - `keychain.send_list`, `keychain.send_template`, `keychain.send_get`
135
+ - `keychain.send_create` (quick create via `bw send`)
136
+ - `keychain.send_create_encoded`, `keychain.send_edit` (advanced create/edit via `bw send create|edit`)
137
+ - `keychain.send_remove_password`, `keychain.send_delete`
138
+ - `keychain.receive`
139
+
140
+ Direct “bw get …” helpers:
141
+
142
+ - `keychain.get_username` (returns `{ kind:"username", value, revealed:true }`)
143
+ - `keychain.get_password` / `keychain.get_totp` / `keychain.get_notes` (only return real values when `reveal: true`)
144
+ - `keychain.get_uri`, `keychain.get_exposed`
145
+ - `keychain.get_folder`, `keychain.get_collection`, `keychain.get_organization`, `keychain.get_org_collection`
146
+ - `keychain.get_password_history` (only returns historic passwords when `reveal: true`)
147
+
148
+ ## Known Limitations
149
+
150
+ - `bw list items --search` (and thus `keychain.search_items`) does not reliably search inside **custom field values**.
151
+ - SSH keys are stored as secure notes in v1 (until `bw` supports native SSH key item creation).
152
+ - High-risk CLI features are intentionally not exposed yet (export/import).
153
+
154
+ ## Contributing
155
+
156
+ See `AGENTS.md` for repo guidelines, dev commands, and testing conventions.
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/warden-mcp.js — CLI entry for @icoretech/warden-mcp
4
+
5
+ import { spawnSync } from 'node:child_process';
6
+ import { accessSync, constants, existsSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ // Resolve bw binary: optional dep → system PATH
14
+ if (!process.env.BW_BIN) {
15
+ try {
16
+ const require = createRequire(import.meta.url);
17
+ // @bitwarden/cli installs the binary at <pkg>/dist/bw (the npm bin shim
18
+ // lives at node_modules/.bin/bw, but we resolve to the actual package).
19
+ const pkgManifest = require.resolve('@bitwarden/cli/package.json');
20
+ const pkgDir = dirname(pkgManifest);
21
+ // The CLI binary is published as `bw` (no extension) inside the package.
22
+ const candidate = join(pkgDir, 'dist', 'bw');
23
+ if (existsSync(candidate)) {
24
+ try {
25
+ accessSync(candidate, constants.X_OK);
26
+ process.env.BW_BIN = candidate;
27
+ } catch {
28
+ // Not executable — fall through to system bw
29
+ }
30
+ }
31
+ } catch {
32
+ // @bitwarden/cli optional dep not installed — fall through to system bw
33
+ }
34
+ }
35
+
36
+ // Verify bw is available (either from optional dep or system PATH)
37
+ if (!process.env.BW_BIN) {
38
+ const probe = spawnSync('bw', ['--version'], { encoding: 'utf8' });
39
+ if (probe.error) {
40
+ console.error(
41
+ '[warden-mcp] ERROR: bw CLI not found.\n' +
42
+ 'Install it with: npm install -g @bitwarden/cli\n' +
43
+ 'Or set the BW_BIN environment variable to the path of the bw binary.',
44
+ );
45
+ process.exit(1);
46
+ }
47
+ // System bw is available — bwCli.ts will find it via PATH
48
+ }
49
+
50
+ // Delegate to the compiled server entry, forwarding all arguments.
51
+ const serverPath = resolve(__dirname, '../dist/server.js');
52
+ if (!existsSync(serverPath)) {
53
+ console.error(
54
+ '[warden-mcp] ERROR: dist/server.js not found. Run `npm run build` first.',
55
+ );
56
+ process.exit(1);
57
+ }
58
+
59
+ // Use dynamic import to run the server module (it has top-level await).
60
+ await import(serverPath);
package/dist/app.js ADDED
@@ -0,0 +1,3 @@
1
+ // src/app.ts
2
+ // Re-export HTTP transport for backward compatibility with existing imports.
3
+ export { createKeychainApp, } from './transports/http.js';
@@ -0,0 +1,106 @@
1
+ // src/bw/bwCli.ts
2
+ import { spawn } from 'node:child_process';
3
+ export class BwCliError extends Error {
4
+ exitCode;
5
+ stdout;
6
+ stderr;
7
+ constructor(message, opts) {
8
+ super(message);
9
+ this.name = 'BwCliError';
10
+ this.exitCode = opts.exitCode;
11
+ this.stdout = opts.stdout;
12
+ this.stderr = opts.stderr;
13
+ }
14
+ }
15
+ export async function runBw(args, opts = {}) {
16
+ const bwBin = process.env.BW_BIN ?? 'bw';
17
+ // Ensure the CLI never blocks waiting for a prompt (e.g. master password).
18
+ // This is critical for running as an MCP server / in test automation.
19
+ const finalArgs = args.includes('--nointeraction')
20
+ ? args
21
+ : ['--nointeraction', ...args];
22
+ const env = { ...process.env, ...(opts.env ?? {}) };
23
+ const debug = (process.env.KEYCHAIN_DEBUG_BW ?? 'false').toLowerCase() === 'true';
24
+ const startedAt = Date.now();
25
+ function safeArg(a) {
26
+ // Avoid logging encoded JSON blobs (may contain secrets).
27
+ if (a.length > 80)
28
+ return '<redacted>';
29
+ return a;
30
+ }
31
+ function safeRenderedArgs(argv) {
32
+ const out = [];
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ if (a === '--session') {
36
+ out.push('--session');
37
+ if (i + 1 < argv.length) {
38
+ out.push('<redacted>');
39
+ i++;
40
+ }
41
+ continue;
42
+ }
43
+ out.push(safeArg(a));
44
+ }
45
+ return out.join(' ');
46
+ }
47
+ if (debug) {
48
+ const rendered = safeRenderedArgs(finalArgs);
49
+ console.log(`[bw] start: ${bwBin} ${rendered}`);
50
+ }
51
+ const child = spawn(bwBin, finalArgs, {
52
+ env,
53
+ stdio: ['pipe', 'pipe', 'pipe'],
54
+ });
55
+ const stdoutChunks = [];
56
+ const stderrChunks = [];
57
+ child.stdout.on('data', (d) => stdoutChunks.push(d));
58
+ child.stderr.on('data', (d) => stderrChunks.push(d));
59
+ if (opts.stdin !== undefined) {
60
+ child.stdin.write(opts.stdin);
61
+ }
62
+ child.stdin.end();
63
+ let timeout;
64
+ const timeoutMs = opts.timeoutMs ?? 60_000;
65
+ const timedOut = new Promise((_resolve, reject) => {
66
+ timeout = setTimeout(() => {
67
+ try {
68
+ child.kill('SIGKILL');
69
+ }
70
+ catch {
71
+ // ignore
72
+ }
73
+ if (debug) {
74
+ console.log(`[bw] timeout after ${timeoutMs}ms`);
75
+ }
76
+ reject(new Error(`bw command timed out after ${timeoutMs}ms`));
77
+ }, timeoutMs);
78
+ });
79
+ const completed = new Promise((resolve, reject) => {
80
+ child.on('error', reject);
81
+ child.on('close', (code) => {
82
+ if (timeout)
83
+ clearTimeout(timeout);
84
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
85
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
86
+ const exitCode = code ?? 1;
87
+ if (exitCode !== 0) {
88
+ if (debug) {
89
+ console.log(`[bw] fail: exit=${exitCode} ms=${Date.now() - startedAt}`);
90
+ }
91
+ const safeCmd = `${bwBin} ${safeRenderedArgs(finalArgs)}`;
92
+ reject(new BwCliError(`${safeCmd} failed with exit code ${exitCode}`, {
93
+ exitCode,
94
+ stdout,
95
+ stderr,
96
+ }));
97
+ return;
98
+ }
99
+ if (debug) {
100
+ console.log(`[bw] ok: ms=${Date.now() - startedAt}`);
101
+ }
102
+ resolve({ stdout, stderr });
103
+ });
104
+ });
105
+ return Promise.race([completed, timedOut]);
106
+ }
@@ -0,0 +1,87 @@
1
+ // src/bw/bwHeaders.ts
2
+ import { readBwEnv } from './bwSession.js';
3
+ function header(req, name) {
4
+ const v = req.header(name);
5
+ if (typeof v !== 'string')
6
+ return undefined;
7
+ const s = v.trim();
8
+ return s.length ? s : undefined;
9
+ }
10
+ function headerPresent(req, name) {
11
+ return Boolean(header(req, name));
12
+ }
13
+ function requireHeader(req, name) {
14
+ const v = header(req, name);
15
+ if (!v)
16
+ throw new Error(`Missing required header: ${name}`);
17
+ return v;
18
+ }
19
+ function parseBwHost(raw) {
20
+ let url;
21
+ try {
22
+ url = new URL(raw);
23
+ }
24
+ catch {
25
+ throw new Error('x-bw-host must be an https url');
26
+ }
27
+ if (url.protocol !== 'https:') {
28
+ throw new Error('x-bw-host must be an https url');
29
+ }
30
+ if (url.username || url.password) {
31
+ throw new Error('x-bw-host must not include credentials');
32
+ }
33
+ if (url.pathname !== '/' || url.search || url.hash) {
34
+ throw new Error('x-bw-host must be an https origin without path, query, or fragment');
35
+ }
36
+ return url.origin;
37
+ }
38
+ export function bwEnvFromExpressHeaders(req) {
39
+ const anyBwHeader = headerPresent(req, 'x-bw-host') ||
40
+ headerPresent(req, 'x-bw-password') ||
41
+ headerPresent(req, 'x-bw-user') ||
42
+ headerPresent(req, 'x-bw-username') ||
43
+ headerPresent(req, 'x-bw-clientid') ||
44
+ headerPresent(req, 'x-bw-clientsecret');
45
+ if (!anyBwHeader)
46
+ return null;
47
+ const host = parseBwHost(requireHeader(req, 'x-bw-host'));
48
+ const password = requireHeader(req, 'x-bw-password');
49
+ const unlockIntervalRaw = header(req, 'x-bw-unlock-interval');
50
+ const unlockIntervalSeconds = unlockIntervalRaw
51
+ ? Number.parseInt(unlockIntervalRaw, 10)
52
+ : 300;
53
+ const clientId = header(req, 'x-bw-clientid');
54
+ const clientSecret = header(req, 'x-bw-clientsecret');
55
+ const user = header(req, 'x-bw-user') ?? header(req, 'x-bw-username');
56
+ const login = clientId && clientSecret
57
+ ? { method: 'apikey', clientId, clientSecret }
58
+ : user
59
+ ? { method: 'userpass', user }
60
+ : (() => {
61
+ throw new Error('Missing Bitwarden login headers: provide x-bw-clientid + x-bw-clientsecret OR x-bw-user');
62
+ })();
63
+ return {
64
+ host,
65
+ password,
66
+ unlockIntervalSeconds: Number.isFinite(unlockIntervalSeconds)
67
+ ? unlockIntervalSeconds
68
+ : 300,
69
+ login,
70
+ };
71
+ }
72
+ /**
73
+ * Resolve BwEnv from Express request headers (X-BW-*) first.
74
+ * Falls back to environment variables (BW_HOST, BW_PASSWORD, etc.) if no
75
+ * BW headers are present. Returns null if neither source provides credentials.
76
+ */
77
+ export function bwEnvFromHeadersOrEnv(req) {
78
+ const fromHeaders = bwEnvFromExpressHeaders(req);
79
+ if (fromHeaders)
80
+ return fromHeaders;
81
+ try {
82
+ return readBwEnv();
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
@@ -0,0 +1,54 @@
1
+ // src/bw/bwPool.ts
2
+ import crypto from 'node:crypto';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { BwSessionManager } from './bwSession.js';
6
+ export class BwSessionPool {
7
+ managers = new Map();
8
+ rootDir;
9
+ constructor(opts) {
10
+ this.rootDir = opts.rootDir;
11
+ }
12
+ keyForEnv(env) {
13
+ const identity = env.login.method === 'apikey'
14
+ ? { method: 'apikey', clientId: env.login.clientId }
15
+ : { method: 'userpass', user: env.login.user };
16
+ const secrets = env.login.method === 'apikey'
17
+ ? {
18
+ clientSecret: this.hashSecret(env.login.clientSecret),
19
+ password: this.hashSecret(env.password),
20
+ }
21
+ : { password: this.hashSecret(env.password) };
22
+ const keyMaterial = JSON.stringify({ host: env.host, identity, secrets });
23
+ return crypto.createHash('sha256').update(keyMaterial).digest('hex');
24
+ }
25
+ hashSecret(value) {
26
+ return crypto.createHash('sha256').update(value).digest('hex');
27
+ }
28
+ async getOrCreate(envLike) {
29
+ // Validate the minimum shape we need at runtime.
30
+ if (!envLike || typeof envLike !== 'object') {
31
+ throw new Error('Invalid Bitwarden config payload (expected object).');
32
+ }
33
+ const env = envLike;
34
+ if (!env.host || typeof env.host !== 'string') {
35
+ throw new Error('Invalid Bitwarden config: missing host.');
36
+ }
37
+ if (!env.password || typeof env.password !== 'string') {
38
+ throw new Error('Invalid Bitwarden config: missing password.');
39
+ }
40
+ if (!env.login || typeof env.login !== 'object') {
41
+ throw new Error('Invalid Bitwarden config: missing login.');
42
+ }
43
+ const key = this.keyForEnv(env);
44
+ const existing = this.managers.get(key);
45
+ if (existing)
46
+ return existing;
47
+ const homeDir = join(this.rootDir, key);
48
+ await mkdir(homeDir, { recursive: true });
49
+ const manager = new BwSessionManager({ ...env, homeDir });
50
+ manager.startKeepUnlocked();
51
+ this.managers.set(key, manager);
52
+ return manager;
53
+ }
54
+ }