OpsBridge MCP
by ivantagesam
README.md
# OpsBridge MCP
A Model Context Protocol (MCP) server that gives an AI client controlled, auditable access to a
business's customer and support-ticket data — including one real write action, gated by a
server-enforced approval check rather than a prompt instruction.
**This is a focused technical demonstration, not a product.** It's a portfolio piece built to show
one thing well: a correctly-implemented MCP server in TypeScript, with the specific engineering
discipline that separates a demo that merely *works* from one that's actually *safe to point an LLM
at* — schema validation, parameterized SQL, an approval gate enforced in application code, and an
audit trail, all verified against the real SDK and the real protocol rather than assumed. It is
**not** deployed anywhere, has **no real customers**, and is **not** claiming production readiness
— see [Limitations](#limitations) and [What I'd change for production](#what-id-change-for-production)
for exactly where that line is.
## What problem this solves
AI clients are increasingly expected to take real actions on real systems, not just answer
questions. That creates a specific engineering problem: how do you let a model read live business
data and perform a consequential action, without either (a) giving it unrestricted database access,
or (b) trusting the prompt to be the only thing standing between "the model suggested this" and
"this actually happened"?
OpsBridge is a small, complete answer to that problem for one concrete case: a support-ticket
system. It exposes exactly the data an AI assistant needs (customers, tickets), and exactly one way
to change anything (create a ticket) — and that one write path cannot execute unless the caller
explicitly supplies `approved: true`, checked in server code that runs regardless of what the model
"decides." Everything else in the project — schemas, error handling, audit logging — exists to make
that one guarantee actually trustworthy.
## What MCP is doing in this architecture
The Model Context Protocol is the layer that lets an AI client (Claude Code, Claude Desktop, the
MCP Inspector, or anything else that speaks MCP) discover what this server can do and call it, without
any custom integration code per client. Concretely, in this project MCP is responsible for:
- **Tool discovery** — the server advertises `search_customers`, `get_customer`,
`list_customer_tickets`, and `create_support_ticket`, each with a JSON-Schema-described input and
output, generated automatically from this project's Zod schemas.
- **A structured request/response contract** — every tool call is validated against its schema
before this project's code ever runs, and every response is either a normal result or a
well-formed `isError: true` result — never a raw exception or a malformed reply.
- **Transport** — JSON-RPC 2.0 over stdio. The client spawns `node dist/index.js` as a subprocess
and talks to it over stdin/stdout; there's no network port.
MCP does not do any of the actual work — it's the reason a generic AI client can use this server at
all without bespoke glue code. The business logic, validation, and safety guarantees are this
project's own.
## Architecture
```mermaid
flowchart TD
Client["Claude Code / MCP Client"]
Protocol["MCP Protocol<br/>(JSON-RPC over stdio)"]
Server["OpsBridge MCP Server<br/>src/server.ts · src/index.ts"]
Tools["Tool Layer<br/>src/tools/*.ts"]
Approval["Approval / Validation<br/>src/domain/*.ts"]
DB[("SQLite Database<br/>src/db/*.ts")]
Audit["Audit Log (stderr)<br/>src/lib/audit.ts"]
Client --> Protocol --> Server --> Tools --> Approval --> DB
Tools -.->|every call, success or failure| Audit
```
```
src/
db/ SQLite schema, synthetic seed data, idempotent seeding
domain/ Repository functions (customers, tickets) — plain TS, no MCP knowledge
tools/ One file per MCP tool: Zod schema, audit-log wrapper, thin handler
lib/ Audit logging (lib/audit.ts) and typed error classes (lib/errors.ts)
server.ts Builds the McpServer and registers all tools
index.ts Entrypoint — opens/seeds the DB, connects stdio transport
```
The layering is deliberate and one-directional: each layer only knows about the one below it, and
`domain/` has no import of anything from `@modelcontextprotocol/sdk` — it's plain TypeScript
operating on a `better-sqlite3` database. That's what lets the test suite exercise the real,
end-to-end tool-call path (a real MCP `Client` talking to a real `McpServer`) instead of mocking
the layer boundaries. Full write-up, including exact code paths: [`docs/architecture.md`](docs/architecture.md).
## Tools exposed
| Tool | Type | Purpose |
|---|---|---|
| `search_customers` | read | Find customers by name or email (partial, case-insensitive) |
| `get_customer` | read | Fetch one customer's details by id |
| `list_customer_tickets` | read | List a customer's tickets, optionally filtered by status |
| `create_support_ticket` | **write** | Create a new ticket — requires explicit `approved: true` |
Backed by SQLite with synthetic, fictional data: 10 customers, 18 seeded support tickets.
## Technology stack
| Layer | Choice | Why |
|---|---|---|
| Language | TypeScript, strict mode + `noUncheckedIndexedAccess` / `exactOptionalPropertyTypes` | Catches real bugs at the layer boundaries this project cares about (optional fields, indexed access) |
| MCP SDK | `@modelcontextprotocol/sdk` `1.30.0` | Current published major version — there is no v2 as of this writing; verified against the installed package's own `.d.ts` files rather than tutorials |
| Schema validation | `zod` `^4` | Single source of truth for both runtime validation and the JSON Schema sent to clients |
| Database | `better-sqlite3` `^12` (synchronous) | No async driver/pool complexity for a single-process local server; `^12`, not the newer `13.x`, because `13.x` requires Node 22+ and this project targets Node 20+ |
| Runtime | Node.js 20+ | Stated project baseline |
| Tests | `vitest` `^4` | Connects a real MCP `Client` to a real `McpServer` over `InMemoryTransport` — see [Testing](#testing) |
| Lint | `eslint` `^10` + `typescript-eslint` `^8` | `typescript-eslint` doesn't yet support TypeScript 7 (the new Go-based compiler), so TypeScript is pinned to the `5.9.x` line — a deliberate compatibility choice, not an oversight |
| Dev runner | `tsx` | Runs `src/index.ts` directly without a build step during development |
## Approval mechanism
`create_support_ticket` is the one consequential action in the system, so it's the one place this
project adds a hard gate:
```ts
// src/domain/tickets.ts
export function createSupportTicket(db, input: CreateTicketInput): Ticket {
if (input.approved !== true) {
throw new ApprovalRequiredError(
"Ticket creation was not approved. Set approved=true to confirm this action before it is created.",
);
}
// ... only reaches the INSERT after this point
}
```
Two things make this an actual enforcement mechanism rather than a suggestion:
1. It runs in the domain layer, below the MCP tool layer, before any SQL executes — there is no
code path from the tool handler to the database `INSERT` that skips it.
2. `approved` is a **required** boolean in the tool's input schema, not optional. Omit it and the
call fails schema validation before this code even runs; pass `false` and it's rejected here.
The tool description also asks the model to confirm with the user first — but that's advisory text
for the model's behavior, not what makes the system safe. The guarantee holds even if a model
ignores the description and calls the tool directly; the *server*, not the *prompt*, is the last
line of defense.
**What this does not guarantee:** that a human actually set the flag — `approved: true` is just
another argument a model could supply on its own initiative, with no human ever seeing the request.
Closing that gap fully would require the server to force an interactive confirmation round-trip
back to a human (MCP elicitation); this project deliberately doesn't add that, since it's a real
interaction-model change for a guarantee this project doesn't claim to provide. See
[Limitations](#limitations).
## Security considerations
- **Approval is enforced in application code, not the prompt** — see above.
- **Every tool call is audit-logged** to stderr (`src/lib/audit.ts`, applied at the tool layer via
a `withAudit()` wrapper around all four tools): tool name, timestamp, success/failure, and a
non-sensitive identifier (`customer_id` where applicable); `create_support_ticket` lines also
record whether the call was approved. Never the sensitive content of a call — no ticket
subjects/descriptions, no raw search query text, no email/phone/name.
- **All SQL is parameterized** via `better-sqlite3` prepared statements — no string concatenation,
so there's no SQL injection surface even though input ultimately originates from an LLM.
`search_customers`' `LIKE` pattern also escapes `%`/`_` so search text is matched literally, not
as a wildcard (otherwise a query of just `"%"` would return every row).
- **Input is validated with Zod** before it reaches any business logic — length limits, enum
constraints on `priority`/`status` — rejecting malformed input with a clear error instead of
passing it through.
- **Stored ticket text is framed as data, not instructions.** `subject`/`description` are
free-text, and a ticket created now is read back verbatim by a later `list_customer_tickets`
call — a second-order prompt-injection vector. Response text explicitly notes that this content
is stored customer input, not directives. This is a mitigation, not a guarantee.
- **No authentication or authorization.** This is a local, single-user demo — anyone who can spawn
the process has full access to every tool, including full customer PII. Explicitly out of scope
here; would have to change before this pattern touched real, multi-tenant data.
- **No secrets anywhere in the project.** No API keys, tokens, or credentials; the only external
dependency is the local SQLite file, which is gitignored.
## Example Claude interactions
Read-path prompts, once connected:
- *"Search for a customer named Chen."*
- *"Get full details for customer cust_004."*
- *"What open tickets does cust_005 have?"*
The interesting one is the write path:
> **You:** "Create a high-priority support ticket for cust_002 about their tracking numbers not
> syncing — but check with me before you actually create it."
>
> **Expected behavior:** the model calls `search_customers`/`get_customer` as needed, then either
> asks you to confirm before calling `create_support_ticket`, or calls it once with `approved`
> false/omitted, gets rejected, and surfaces the proposed ticket back to you. Either way, nothing
> is written until you've actually agreed and the model calls it again with `approved: true`.
More scripted walkthroughs, including forcing the rejection path directly to see the raw
enforcement message: [`docs/demo-script.md`](docs/demo-script.md).
## Local setup
Requires **Node.js 20+**.
```bash
npm install
npm run db:seed # creates and seeds data/opsbridge.db (10 customers, 18 tickets)
npm run build # compiles TypeScript to dist/
```
```bash
npm run dev # runs src/index.ts directly with tsx (auto-seeds on first run)
# or, after `npm run build`:
npm start # runs dist/index.js
```
The server communicates over **stdio** — no HTTP port, nothing to browse to directly.
**Connecting to Claude Code:** this repo includes a project-scoped `.mcp.json` (generated via
`claude mcp add opsbridge --scope project -- node dist/index.js`, so it's exactly what the CLI
itself produces, not hand-written). Build first, then approve it once:
```bash
npm run build
claude # prompts to trust this project's .mcp.json server on first run — approve it
claude mcp list # should show: opsbridge: node dist/index.js - ✔ Connected
```
**Connecting any other MCP client** (Claude Desktop, etc.) — most read a JSON config with a
`command`/`args` pair:
```json
{
"mcpServers": {
"opsbridge": {
"command": "node",
"args": ["/absolute/path/to/opsbridge-mcp/dist/index.js"]
}
}
}
```
**Poking at it manually without a full client** — the MCP Inspector, version pinned deliberately
(an unversioned `npx @modelcontextprotocol/inspector` can resolve to a stale cached build instead
of the current release):
```bash
npx @modelcontextprotocol/inspector@2.3.0 node dist/index.js # web UI
npx @modelcontextprotocol/inspector@2.3.0 --cli node dist/index.js -- --method tools/list # headless
```
## Testing
```bash
npm test # vitest — 33 tests across 6 files
npm run typecheck
npm run lint
```
Tests connect a real MCP `Client` to a real `McpServer` over the SDK's `InMemoryTransport`, backed
by a fresh in-memory SQLite database per test (`tests/helpers.ts`) — exercising the actual
request → Zod validation → tool handler → response path a real client goes through, not just the
domain functions in isolation. Coverage includes: successful and empty-result search, customer not
found, ticket listing with/without a status filter, invalid input across every tool, ticket
creation rejected both with `approved: false` and with `approved` omitted entirely, successful
creation, duplicate-submission safety, `LIKE`-wildcard escaping, prompt-injection framing text, and
audit-log content (including that PII never appears in a log line) for every tool.
## Limitations
Deliberate scope cuts for a focused demo, not oversights:
- **No authentication, authorization, or per-user data scoping** — see Security considerations.
- **The approval flag isn't a verified human signal** — it's a boolean a model could set on its own
initiative; see Approval mechanism.
- **No pagination** — search is capped at 10 results; ticket lists are unbounded but the dataset is
tiny.
- **No update or delete tools** — only ticket *creation* is a write action.
- **stdio transport only** — no HTTP/SSE, no remote deployment story.
- **No rate limiting or idempotency key** on `create_support_ticket` — a retried call creates a
second, independent ticket rather than being deduplicated.
- **SQLite, single process** — no connection pooling, no migration tooling beyond
`CREATE TABLE IF NOT EXISTS`.
- **Audit log is a local stderr stream** — not shipped anywhere, not queryable, no retention policy.
## What I'd change for production
If this pattern were ever pointed at real customers instead of synthetic demo data:
- **Move off stdio to Streamable HTTP with OAuth bearer auth**, scoped per tenant/customer — the
SDK already supports this transport; today's stdio model implicitly trusts whoever can spawn the
process, which is fine for a local demo and nowhere else.
- **Add real authorization** mapping the authenticated caller to which customers/tickets they may
touch — every tool is currently unscoped.
- **Make approval verifiable, not just present** — use MCP elicitation to force a real round-trip
confirmation back to a human, or require a short-lived token minted by a separate confirmation
step outside the model's control.
- **Swap SQLite for Postgres** with pooled connections and a real migration tool.
- **Ship the audit log somewhere durable and queryable** (not stderr) with retention and access
controls appropriate for what it's auditing.
- **Add rate limiting and an idempotency key** on the write path.
- **Add pagination** to `search_customers` and `list_customer_tickets`.
- **Add observability** — latency, error rate, and call volume per tool.
- **Run typecheck/test/lint in CI** on every change, not just locally on demand.
None of this is implemented here — the point of this project is to demonstrate the pattern
correctly at small scale, not to pre-build infrastructure a real deployment would need but a demo
doesn't.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues