Skip to main content
Glama
aayushsinghm16

harbor-mcp-server

README.md
# harbor-mcp-server

An MCP server that gives an AI agent access to a subscription business's database — without giving it the database.

Reads are parsed and allowlisted before they run. Personal data is masked on the way out. Writes require a preview and a confirmation token. Everything is written to an audit log the agent cannot read.

```bash
npx harbor-mcp-server
```

That is the whole setup — no native addon to compile, no database to provision. It ships with a seeded demo database of 140 customers, ~1,950 invoices, ~260 support tickets and 90 days of usage events, so there is nothing to configure before you can ask it a question.

---

## Why this exists

"Let Claude query our database" is a two-line proof of concept and a genuinely hard production problem. The gap between them is not the SQL — it is everything you have to be sure of before you point a language model at a table containing real customers.

This server is the second thing, built small enough to read in one sitting.

---

## What it refuses to do

Each of these is enforced by parsing the statement into an AST, not by pattern-matching the string. Regex checks for `DROP` are defeated by comments, casing and string literals; they are not used for the decision.

| Attempt | Result |
| --- | --- |
| `DELETE FROM invoices` | Rejected — only SELECT is permitted |
| `SELECT id FROM customers; DROP TABLE customers` | Rejected — statement stacking |
| `SELECT id FROM customers -- x\n; DELETE FROM invoices` | Rejected — stacking hidden behind a comment |
| `SELECT * FROM audit_log` | Rejected — table not on the allowlist |
| `SELECT name FROM sqlite_master` | Rejected — schema introspection is via tools, not SQL |
| `SELECT id FROM customers WHERE id IN (SELECT tool FROM audit_log)` | Rejected — disallowed table in a subquery |
| `SELECT load_extension('evil.so') FROM customers` | Rejected — banned function |
| `SELECT * FROM customers LIMIT 99999` | Allowed, clamped to 500 rows |
| `SELECT * FROM invoices` | Rejected — unbounded scan of a large table |
| `SELECT SUM(amount_cents) FROM invoices GROUP BY ...` | Allowed — aggregates bound their own output |

Every rejection carries a `hint` naming the specific table, column or clause that caused it, so the agent can correct itself rather than retrying blind:

```
Only SELECT is permitted; received "DELETE".

How to fix: This server is read-only. To change data, use issue_refund or
extend_trial, which require explicit confirmation.
```

## What it masks

Email, phone and address columns come back partially redacted:

```
| company_name    | email                            |
| --------------- | -------------------------------- |
| Camden Digital  | ke***********@camdendigital.com  |
| Beacon Robotics | to************@beaconrobotics.com|
```

Masking happens on the way out, keyed on column name, which means it survives `SELECT *`, joins, and aliases. Filtering still works on the real value — searching `priya` finds her, the response just will not hand you her address book entry.

Set `HARBOR_REVEAL_PII=true` to turn it off.

## How writes work

Writes are disabled unless the operator sets `HARBOR_ALLOW_WRITES=true`, and even then they cannot fire in one call.

**Call 1** — no token. Nothing changes; you get a preview:

```
## Refund preview — nothing has changed yet

Invoice **inv_00002** for customer **cus_0001**
- Charged: $49.00
- Already refunded: $0.00
- **This refund: $10.00**
- After: $10.00 refunded of $49.00
- Reason: Service outage goodwill credit

To execute, call again with `confirm_token: "DJ7-O9d14gtk"`. Expires in 300s.
```

**Call 2** — same arguments plus that token. Now it happens.

The preview is the point. It renders as plain text in the transcript, so a human reading along sees the exact amount and the exact invoice before anything is written. And because tokens live in process memory, a model that hallucinates a refund cannot execute one — it cannot invent a token that exists.

Tokens are single-use, expire in five minutes, and are bound to the exact arguments they were issued for. Replaying one with a larger `amount_cents` fails. A rejected attempt burns the token rather than letting an agent grind against it.

## The audit log

Every call is recorded before the caller gets a response — allowed, denied or errored:

```
allowed  harbor_run_query      rows=  1  16ms  customers
denied   harbor_run_query      rows=  0   2ms  Only SELECT is permitted; received "DELETE".
allowed  harbor_issue_refund   rows=  0  66ms  preview
allowed  harbor_issue_refund   rows=  1  71ms  executed
```

The table is deliberately outside the allowlist. The agent writes to it by acting and cannot read, mine or edit it — so after a session you can answer "what did it actually do?" without trusting the agent's own account.

---

## Tools

| Tool | Purpose |
| --- | --- |
| `harbor_list_tables` | Readable tables, row counts, and a note on each |
| `harbor_describe_table` | Columns, types, nullability, which are masked |
| `harbor_run_query` | Guarded SELECT — the general-purpose escape hatch |
| `harbor_find_customer` | Turn "the Kestrel account" into a customer id |
| `harbor_customer_360` | Profile, subscription, billing, tickets, usage in one call |
| `harbor_revenue_summary` | Revenue by month, plan, country or industry |
| `harbor_issue_refund` | Refund an invoice — two-step |
| `harbor_extend_trial` | Extend a trial — two-step |

One flexible query tool plus a few composite ones, rather than thirty narrow endpoints. An agent that can write SQL will out-compose any fixed set of endpoints; the guard is what makes that safe. The composite tools exist because some questions get asked constantly and deserve a single round trip.

`harbor_revenue_summary` also encodes a trap worth knowing about: trialing and canceled subscriptions carry `mrr_cents = 0`, so a naive `AVG(mrr_cents)` across all rows understates ARPA. The tool uses the right denominator so the agent does not have to know that.

---

## Install

### Claude Desktop

`claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "harbor": {
      "command": "npx",
      "args": ["-y", "harbor-mcp-server"]
    }
  }
}
```

To allow refunds and trial extensions:

```json
{
  "mcpServers": {
    "harbor": {
      "command": "npx",
      "args": ["-y", "harbor-mcp-server"],
      "env": { "HARBOR_ALLOW_WRITES": "true" }
    }
  }
}
```

### Claude Code

```bash
claude mcp add harbor -- npx -y harbor-mcp-server
```

### From source

```bash
git clone https://github.com/aayushsinghm16/harbor-mcp-server
cd harbor-mcp-server
npm install
npm run build
npm test
node dist/index.js
```

## Configuration

| Variable | Default | Effect |
| --- | --- | --- |
| `HARBOR_DB_PATH` | `./harbor.db` | Database location. `:memory:` for a throwaway. |
| `HARBOR_ALLOW_WRITES` | `false` | Enables `issue_refund` and `extend_trial`. |
| `HARBOR_REVEAL_PII` | `false` | Returns email, phone and address unmasked. |

Requires Node 22.5+. Hard limits live in `src/constants.ts`: 500 rows maximum, 50 by default, 25,000 characters per response, $500 maximum single refund, 30 days maximum trial extension.

---

## Things to try

Point Claude at it and ask:

- *"Which customers churned, and what reasons did they give?"*
- *"Show me revenue by month for the first half of 2026."*
- *"Which plan carries the most MRR, and what's the average per account?"*
- *"Tell me everything about the Kestrel account."* — one `customer_360` call
- *"Which accounts have both an open ticket and a failed payment?"* — the churn-risk query
- *"Delete all the invoices."* — watch it get refused, with a reason

The last one is the interesting one.

---

## What this does not do

Being straight about the edges, because a security README that claims everything is a security README you should not trust.

**Queries cannot be interrupted mid-flight.** `node:sqlite` is synchronous and exposes no binding for `sqlite3_interrupt`, so the time budget is enforced by refusing expensive plans up front and by capping rows — not by killing a running query. Slow queries are logged, not stopped. If hard interruption is a requirement, execution needs to move to a worker thread that can be terminated. That is a deliberate trade, not an oversight.

**Nothing is read from disk at runtime.** The schema is a TypeScript module,
not a `.sql` file, and there is no native addon. Both are the same lesson: a
bundler tracing a serverless build follows import statements, not paths computed
at runtime, so anything loaded by path is silently dropped and fails on the first
request. Imports cannot go missing.

**It needs Node 22.5 or newer.** The database driver is `node:sqlite`, built into the runtime, so there is no native addon to compile and nothing for a bundler to lose while tracing a serverless build. The cost is a version floor and an `ExperimentalWarning` on stderr.

**Masking is not anonymisation.** Partial masks preserve enough structure to correlate rows. That is intentional — it is what makes the data still analytically useful — but it means the masking defends against casual exfiltration, not against a determined re-identification attack.

**The allowlist is a table allowlist, not a row-level one.** There is no per-tenant or per-user scoping. A real deployment against multi-tenant data needs row-level filtering injected into every query, which is a different and larger piece of work.

**Confirmation tokens live in process memory.** They do not survive a restart and are not shared across replicas. For a single stdio server that is correct; a horizontally scaled HTTP deployment would need shared storage.

---

## Layout

```
src/
├── constants.ts          every safety boundary, in one file
├── db/
│   ├── schema.ts         six business tables plus the audit log, as a string
│   ├── connection.ts
│   └── seed.ts           deterministic — the same numbers on every machine
├── security/
│   ├── sql-guard.ts      AST parsing, allowlist, limit injection
│   ├── pii.ts            column-name-keyed masking
│   ├── confirm.ts        single-use, argument-bound tokens
│   └── audit.ts
├── services/
│   ├── query.ts          plan inspection and execution
│   └── format.ts         markdown/JSON rendering, truncation
└── tools/                one file per domain
```

51 tests cover the guard against statement stacking, comment-hidden injection, subquery smuggling, alias confusion, banned functions and limit evasion; the masking against `SELECT *` and joins; and the confirmation flow against replay, tampering and cross-tool reuse.

```bash
npm test
```

## Licence

MIT.

TDQS

A4.6/5.0

Scored across 8 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: schema discovery, query execution, customer lookup, customer snapshot, revenue reporting, and write actions. Even the two write tools are unambiguous—one targets invoices, the other targets subscriptions.

Naming Consistency4/5

All tools share the consistent 'harbor_' prefix and snake_case convention, with most following verb_noun naming (list_tables, describe_table, run_query, find_customer, issue_refund, extend_trial). 'customer_360' and 'revenue_summary' deviate slightly from the verb-first pattern but remain clear and readable.

Tool Count5/5

Eight tools is well within the ideal range and each earns its place. The set covers schema discovery, querying, customer-specific lookups, a 360 view, revenue reporting, and two common business actions without excess.

Completeness4/5

The tool surface covers the core read/query workflow plus the most needed write operations (refund and trial extension). Minor gaps exist—there are no direct ticket management or subscription modification tools—but the run_query tool can access underlying tables and the 360 view provides recent support/invoice context.

Maintenance

ActivitySlowing
ResponsivenessNo issues