harbor-mcp-server
# 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
Scored across 8 tools
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.
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.
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.
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.