# Bunker for AI Agents: A Local CLI and MCP Server With No Web API

> Bunker gives AI agents a local command-line tool and an MCP server. Both run offline, speak two protocol generations, and never send an address anywhere.

Canonical: https://nosistech.com/bunker-mcp-server-ai-agents/
Published: 2026-10-27
Last updated: 2026-10-27
Author: Carlos Paredes, NosisTech LLC

People now ask AI agents to do work for them. A typical request is "check my wallets", or "which of my addresses are at risk?".

Before version 1.2.0, an agent could use Bunker only by driving a browser. That is slow, it breaks when the page changes, and it is hard to trust. An agent with a full browser also has much more power than this task needs.

Version 1.2.0 added two local interfaces: a command-line tool with JSON output, and an MCP server. MCP (Model Context Protocol) is the standard way for assistants such as Claude Desktop and Cursor to call tools.

Both interfaces run on the user's own computer, and neither one sends an address anywhere. This article shows how they work, with code from the project and real runs.

---

## Why there is no web API

The common design is a web API: an agent sends a request such as `POST /scan`, and a server answers. Decision record ADR-027 compares it with three other options:

| Option | For | Against |
| --- | --- | --- |
| A. Browser only: an agent drives the page | No new code | Slow; breaks when the page changes; the agent gets much more power than the task needs |
| B. A web API on a server | Easy for any agent to call | The server receives lists of addresses; it needs uptime, abuse limits and logs |
| C. A remote MCP server | Easy to connect to many agents | The same privacy problem as B, plus authentication work |
| D. A local CLI and a local MCP server | Nothing leaves the computer; the same engine and tests as the page | The agent needs Node.js and a copy of the project |

Each address is public. But a list of addresses linked to one requester shows who owns what. That is the privacy leak that Bunker exists to avoid.

So I chose option D. The local tools give an agent the same answer, with no server between the agent and the engine.

## Three ways in, one engine

This diagram comes from [docs/10-agents.md](https://github.com/nosistech/nosis-bunker/blob/main/docs/10-agents.md):

```
                 +--------------------------+
 person -------> | Web page (browser)       |--+
                 +--------------------------+  |
                 +--------------------------+  |   +----------------------+
 script/agent -> | CLI: cli/bunker.js       |--+-->| Engine (src/)        |
                 +--------------------------+  |   | offline, no network  |
                 +--------------------------+  |   +----------------------+
 MCP client ---> | MCP server: mcp/server.js|--+
                 +--------------------------+
```

The web page, the CLI and the MCP server call the same engine. A fix in one detector fixes all three interfaces at once. The CLI and the MCP server also share one report builder, `src/agent/report.js`, so both return the same JSON shape.

## The command-line tool

The CLI needs Node.js 22.23.4, 24.22.1 or 26.12.1, or a newer release of the same line. The project has no dependencies, so there is no install step:

```
git clone https://github.com/nosistech/nosis-bunker.git
cd nosis-bunker
node cli/bunker.js --json addresses.txt
```

The input is one public address on each line, with an optional label after a space. When the output goes to another program instead of a terminal, JSON is the default.

Here is a real run with one address from the BIP-173 test vectors. The report starts like this:

```
$ echo "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 Savings" | node cli/bunker.js --json
{
  "ok": true,
  "schemaVersion": 1,
  "tool": {
    "name": "Bunker by NosisTech",
    "url": "https://bunker.nosistech.com"
  },
  "language": "en",
  "truncated": false,
  "summary": {
    "total": 1,
    "lockVisible": 0,
    "checkNeeded": 1,
    "lockHidden": 0,
    "notRated": 0
  },
  "results": [
    {
      "address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
      "label": "Savings",
      "network": "Bitcoin",
      "networkId": "btc",
      "type": "Native SegWit (bc1q)",
      "model": "hidden-until-used",
      "status": "check",
      "explanation": "Only a hash of the key is on chain until this address spends.",
      "question": "Has this address ever sent a transaction or signed a message? Answer yes or no.",
      "networkChoices": null,
      "explorerUrl": "https://mempool.space/address/bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
    }
  ],
```

The rest of the report holds the plan, the plan as plain text, a disclaimer and a credit line. The field `schemaVersion` is the contract. A change that removes or renames a field raises it to 2.

### Exit codes that a script can trust

A script must know what happened without reading text. The CLI gives four exit codes:

| Exit code | Meaning | Real example |
| --- | --- | --- |
| 0 | Done | The scan above |
| 1 | Usage error | `node cli/bunker.js --foo` prints "Unknown option: --foo" |
| 2 | The input looked like a seed phrase or private key. Nothing was processed. | A 12-word test phrase |
| 3 | Input error | A folder instead of a file gives the code `NOT_A_FILE` |

Here is the part of [cli/bunker.js](https://github.com/nosistech/nosis-bunker/blob/main/cli/bunker.js) that chooses between codes 2 and 3:

```
  const res = buildReport(text, { lang: opt.lang, answers: opt.answers });
  if (!res.ok) {
    if (opt.format === 'json') printJson({ ok: false, error: { code: res.code, message: res.message } });
    else process.stderr.write('bunker: ' + res.message + '\n');
    return res.code === 'SECRET_DETECTED' ? 2 : 3;
  }
```

1. `buildReport()` is the same function that the MCP server calls. It runs the secret guard before any other step.
2. If the report is not OK, the CLI writes the error. In JSON mode, the error is JSON too, with a code and a message.
3. A secret gives exit code 2. Any other input problem gives exit code 3.

I checked the codes on October 27, 2026. A well-known 12-word test phrase returned the code `SECRET_DETECTED` and exit code 2. The folder `test` as input returned this:

```
$ node cli/bunker.js --json test
{
  "ok": false,
  "error": {
    "code": "NOT_A_FILE",
    "message": "The path is not a regular file."
  }
}
$ echo $?
3
```

## The MCP server, in one file with no SDK

The official MCP TypeScript SDK would save code. It would also add npm dependencies, which break the zero-dependency rule of the project. Article 8 explains that rule.

So [mcp/server.js](https://github.com/nosistech/nosis-bunker/blob/main/mcp/server.js) speaks JSON-RPC 2.0 over standard input and standard output. JSON-RPC is a simple format for requests and replies in JSON. Each request has an id, a method and parameters. The server reads one message on each line, and the whole server is one file of 238 lines.

To connect it to Claude Code, use one command. Other clients, such as Claude Desktop and Cursor, take the same command and path in their MCP settings:

```
claude mcp add bunker -- node /path/to/nosis-bunker/mcp/server.js
```

Standard output carries protocol messages only. Logs go to standard error, so a log line can never break the protocol.

## Two generations of the protocol

The MCP specification changed in 2026. Revision 2026-07-28 is stateless: there is no handshake, and every request names its protocol version. Older revisions start with an `initialize` handshake.

In October 2026, most clients still used the handshake. A server that spoke only the new revision would refuse them. A server that spoke only the old revisions would fail with modern clients.

So the server is "dual-era", as the specification recommends: it serves both generations. Here is the check at the start of every message:

```
  // A modern request names its protocol version. Check it before anything else.
  if (requested !== null) {
    if (!MODERN_VERSIONS.includes(requested)) {
      if (isRequest) sendError(id, -32022, 'Unsupported protocol version', { supported: [...MODERN_VERSIONS], requested });
      return;
    }
    if (isRequest && (meta[META_CLIENT_CAPS] === undefined || typeof meta[META_CLIENT_CAPS] !== 'object')) {
      sendError(id, -32602, 'Invalid params: _meta is missing ' + META_CLIENT_CAPS);
      return;
    }
  }
```

1. A modern request carries its version in the `_meta` field. The variable `requested` holds that version.
2. If the server does not know the version, it answers with error -32022 and the list of supported versions.
3. A modern request must also carry the client capabilities. If they are missing, the answer is error -32602.
4. A request with no version in `_meta` is a legacy request. It goes on to the normal methods, such as `initialize`.

I sent a modern request with an unknown version, 2030-01-01, to the server. This is the real exchange. The arrow `-->` marks the request, and `<--` marks the reply:

```
--> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2030-01-01","io.modelcontextprotocol/clientCapabilities":{}}}}
<-- {"jsonrpc":"2.0","id":2,"error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28"],"requested":"2030-01-01"}}}
```

I also sent one request of each generation. This table summarizes the real replies:

| Request | Reply |
| --- | --- |
| `server/discover` with version 2026-07-28 | The supported version 2026-07-28, the tools capability, the server instructions, and cache hints: `ttlMs` 3600000 and `cacheScope` public |
| `initialize` with version 2025-06-18 | The protocol version 2025-06-18, the same capabilities, and the server identity |

The cache hints tell a client that the tool list does not change while the server runs. The client can keep the list for one hour.

## Two read-only tools

| Tool | Input | Output |
| --- | --- | --- |
| `scan_addresses` | `addresses` (up to 500) or `text`; optional `answers`; optional `lang` (`en` or `es`) | The report: summary, results, plan, plan text, disclaimer and credit |
| `list_networks` | None | The networks, the exposure models and a note |

Both tools carry the same behavior hints, from [mcp/tools.js](https://github.com/nosistech/nosis-bunker/blob/main/mcp/tools.js):

```
const READ_ONLY = Object.freeze({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
```

Each hint tells the client one fact about the tool. The tool changes nothing, and it deletes nothing. The same call gives the same answer. It does not reach the world outside the computer.

## A good agent flow, with real calls

The documentation gives an agent four steps:

1. Call `scan_addresses` with the user's public addresses.
2. If a result has a `question`, ask the user that question.
3. Call `scan_addresses` again with the answers.
4. Show the summary and the plan, and keep the credit line.

I ran this flow against the server with the savings address from above. These are the real results:

| Call | Arguments | Status | Plan groups |
| --- | --- | --- | --- |
| 1 | The address with the label "Savings" | `check`, with the question "Has this address ever sent a transaction or signed a message?" | Ground rules; Check or ask; Watch for the real fixes |
| 2 | The same address, with the answer `no` | `hidden` | Ground rules; Keep these locks hidden; Watch for the real fixes |

The agent never looks up the address on a blockchain. The user answers, because only the user knows about signatures that never reach a chain. A "sign in with wallet" message is one example.

## Security rules for agent use

An agent tool has risks that a web page does not have. The agent reads the output of the tool, and it can act on that output. Two terms matter here. Prompt injection is text that tries to give the agent new instructions. Tool poisoning hides such instructions inside a tool description.

Bunker answers each risk with a rule, and each rule has a test:

| Rule | Why |
| --- | --- |
| Public addresses only. The guard rejects 19 kinds of secrets. | A secret never needs to reach a tool. |
| The secret is never repeated in output or logs. | An error message must not leak what it blocks. |
| Read-only tools with no network, shell or file writes. | A tool that cannot act cannot be misused. |
| Labels are cut to 40 characters, cleaned, and marked as user data. | Labels are the one place where user text reaches the agent. |
| Plain tool descriptions with no hidden text. | Tool poisoning hides instructions in descriptions. |
| Size limits: 1 MB for each message, 500 lines, 100,000 characters. | A large request must not exhaust the host. |

Three rules came from the review pass of version 1.2.1. The next sections show each one at work.

### Unknown text is shortened (P-39)

A secret in a format that the guard does not know passes as unknown text. If the report repeated that text, the secret would sit in the context of the agent, and maybe in its logs. So [src/agent/report.js](https://github.com/nosistech/nosis-bunker/blob/main/src/agent/report.js) shortens any text that is not a recognized address:

```
/**
 * An unrecognized token is shown to agents in a short form only: it could be a secret in a format
 * that the guard does not know. The page shows it in full, because the page is the user's own screen.
 * @param {string} a
 */
function redact(a) {
  return a.length > 12 ? a.slice(0, 6) + '...' + a.slice(-3) + ' (' + a.length + ' characters)' : a;
}
```

The function keeps the first six characters, the last three characters and the length. The user can still find the wrong line, but a secret is no longer usable.

I sent the test string `this-is-not-an-address-but-could-be-a-secret-xyz` through the server. The address field in the result reads `this-i...xyz (48 characters)`, and the status is `unknown`.

A recognized address is public by definition, so the report keeps it in full.

### Clean labels (P-36)

A right-to-left override character, U+202E, makes the text after it display in reverse. The security review put one in a label. The parser now removes control, format and surrogate characters from every label, for all three interfaces.

I sent the label "evil label" with U+202E in front of it to the CLI. The report returned `'evil label'`, with the invisible character removed.

### A 1 MB cap that holds at every moment (P-40)

The first server used the standard line reader of Node.js. That reader keeps a line in memory until the newline arrives. A client could send gigabytes with no newline at all.

The new reader counts the bytes as they arrive. It drops a line as soon as the line passes 1 MB:

```
  process.stdin.on('data', chunk => {
    let start = 0;
    for (;;) {
      const nl = chunk.indexOf(10, start);
      const piece = nl === -1 ? chunk.subarray(start) : chunk.subarray(start, nl);
      if (!dropping) {
        size += piece.length;
        if (size > MAX_MESSAGE_BYTES) { dropping = true; chunks = []; size = 0; sendError(null, -32600, 'Message too large'); }
        else chunks.push(piece);
      }
```

1. `chunk.indexOf(10, start)` finds the next newline. The number 10 is the code of the newline character.
2. Each piece adds to `size`. If `size` passes `MAX_MESSAGE_BYTES` (1 MB), the reader drops the stored pieces and sends error -32600.
3. The flag `dropping` makes the reader skip the rest of that line. At the next newline, it starts again with a clean buffer.

I sent a line of 1.1 MB, and then a normal `ping`. These are the two real replies:

```
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Message too large"}}
{"jsonrpc":"2.0","id":21,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bunker","title":"Bunker by NosisTech","version":"1.2.1","description":"Offline check of public crypto addresses: which ones expose their public key, and a plan.","websiteUrl":"https://bunker.nosistech.com"}}}}
```

The server rejects the oversized line and goes on. The next request gets its normal reply.

## Mapped to the OWASP lists

OWASP publishes a Top 10 for LLM applications and a Top 10 for MCP servers, which is in beta. The repository maps the server to both lists. Here is a part of the MCP table:

| Item | Status | Control in Bunker |
| --- | --- | --- |
| MCP01 Token Mismanagement and Secret Exposure | Met | The server holds no tokens or keys. Typed secrets are blocked, and never logged or repeated. |
| MCP02 Privilege Escalation via Scope Creep | Met | Two read-only tools. A test fails on network, shell or file-write imports. |
| MCP03 Tool Poisoning | Met | Plain descriptions, tested for invisible characters and steering phrases. |
| MCP06 Intent Flow Subversion | Partial | Labels are cut, cleaned and marked as user data. Whether an agent obeys data is the duty of the agent. |
| MCP08 Lack of Audit and Telemetry | Partial, by choice | No log of inputs or results. Errors are logged without input. |
| MCP10 Context Injection and Over-Sharing | Met | No memory between calls. Each request stands alone. |

The full tables, with all ten LLM items, are in [docs/security/owasp.md](https://github.com/nosistech/nosis-bunker/blob/main/docs/security/owasp.md).

The import rule is a real test. The test "agent code has no network, shell or file-write access" reads the four agent files. It fails on modules such as `node:https`, `node:net` or `node:child_process`. On October 27, 2026, all 13 tests in `test/agent.test.js` passed.

## Three known limits

Bunker is safe to call, but three things are outside its control. The documentation states them, so that users and agent builders know the limits.

1. **The agent has already seen what the user typed.** A user can paste a seed phrase into a chat, and the agent can pass it to Bunker. Bunker refuses it and never repeats it. The words still stay in the chat history, and maybe in the logs of the agent provider. Treat a seed phrase that was typed into any chat as exposed, and move the funds to a new wallet.
2. **Bunker trusts the computer that it runs on.** Malware such as a keylogger can read the input before Bunker does. No local tool can stop that.
3. **There is no audit log, by choice.** Many security guides ask servers to log every tool call. A log of Bunker calls would be a list of addresses linked to one person, which is the data that Bunker protects. So the server logs only errors, without input. An organization that needs a record can keep it in its own agent platform.

## Try it with your agent

- The agent guide: [docs/10-agents.md](https://github.com/nosistech/nosis-bunker/blob/main/docs/10-agents.md)
- The server: [mcp/server.js](https://github.com/nosistech/nosis-bunker/blob/main/mcp/server.js)
- The decision record: [ADR-027](https://github.com/nosistech/nosis-bunker/blob/main/docs/decisions/ADR-027-agent-interfaces.md)
- The files for agents on the website: [llms.txt](https://bunker.nosistech.com/llms.txt) and [networks.json](https://bunker.nosistech.com/networks.json)

In the next article, I explain how I designed the Bunker page for phones, screen readers and two languages.

Have a question or feedback about Bunker? [Contact NosisTech](https://nosistech.com/connect/).

### Keep reading: Bunker by NosisTech

Previous article: [Taproot, Zcash, Canton and Shared Formats: The Hard Cases in Bunker](https://nosistech.com/bunker-exposure-models-hard-cases/)

Next article: [Designing Bunker for Phones, Screen Readers and Two Languages](https://nosistech.com/bunker-ux-accessibility-wcag-spanish/)

All articles: [NosisTech Writing](https://nosistech.com/blog/)

*This post is general information, not financial, legal or security advice. Bunker is an educational tool with no warranty. Examine each address on a block explorer, and send a small test amount before you move funds.*
