ivantagesam

OpsBridge MCP

Community ivantagesam
Updated

MCP server demo: TypeScript, SQLite, approval-gated business actions for AI clients

OpsBridge MCP

A Model Context Protocol (MCP) server that gives an AI client controlled, auditable access to abusiness's customer and support-ticket data — including one real write action, gated by aserver-enforced approval check rather than a prompt instruction.

This is a focused technical demonstration, not a product. It's a portfolio piece built to showone thing well: a correctly-implemented MCP server in TypeScript, with the specific engineeringdiscipline that separates a demo that merely works from one that's actually safe to point an LLMat — schema validation, parameterized SQL, an approval gate enforced in application code, and anaudit trail, all verified against the real SDK and the real protocol rather than assumed. It isnot deployed anywhere, has no real customers, and is not claiming production readiness— see Limitations and What I'd change for productionfor exactly where that line is.

What problem this solves

AI clients are increasingly expected to take real actions on real systems, not just answerquestions. That creates a specific engineering problem: how do you let a model read live businessdata 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-ticketsystem. It exposes exactly the data an AI assistant needs (customers, tickets), and exactly one wayto change anything (create a ticket) — and that one write path cannot execute unless the callerexplicitly 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 makethat 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, theMCP Inspector, or anything else that speaks MCP) discover what this server can do and call it, withoutany 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 andoutput, generated automatically from this project's Zod schemas.
  • A structured request/response contract — every tool call is validated against its schemabefore this project's code ever runs, and every response is either a normal result or awell-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 subprocessand 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 atall without bespoke glue code. The business logic, validation, and safety guarantees are thisproject's own.

Architecture

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, anddomain/ has no import of anything from @modelcontextprotocol/sdk — it's plain TypeScriptoperating 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 mockingthe layer boundaries. Full write-up, including exact code paths: 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
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 thisproject adds a hard gate:

// 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 nocode 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 thecall 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 textfor the model's behavior, not what makes the system safe. The guarantee holds even if a modelignores the description and calls the tool directly; the server, not the prompt, is the lastline of defense.

What this does not guarantee: that a human actually set the flag — approved: true is justanother 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-tripback to a human (MCP elicitation); this project deliberately doesn't add that, since it's a realinteraction-model change for a guarantee this project doesn't claim to provide. SeeLimitations.

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 viaa withAudit() wrapper around all four tools): tool name, timestamp, success/failure, and anon-sensitive identifier (customer_id where applicable); create_support_ticket lines alsorecord whether the call was approved. Never the sensitive content of a call — no ticketsubjects/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, notas a wildcard (otherwise a query of just "%" would return every row).
  • Input is validated with Zod before it reaches any business logic — length limits, enumconstraints on priority/status — rejecting malformed input with a clear error instead ofpassing it through.
  • Stored ticket text is framed as data, not instructions. subject/description arefree-text, and a ticket created now is read back verbatim by a later list_customer_ticketscall — a second-order prompt-injection vector. Response text explicitly notes that this contentis 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 spawnthe process has full access to every tool, including full customer PII. Explicitly out of scopehere; 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 externaldependency 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 notsyncing — but check with me before you actually create it."

Expected behavior: the model calls search_customers/get_customer as needed, then eitherasks you to confirm before calling create_support_ticket, or calls it once with approvedfalse/omitted, gets rejected, and surfaces the proposed ticket back to you. Either way, nothingis 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 rawenforcement message: docs/demo-script.md.

Local setup

Requires Node.js 20+.

npm install
npm run db:seed     # creates and seeds data/opsbridge.db (10 customers, 18 tickets)
npm run build        # compiles TypeScript to dist/
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 viaclaude mcp add opsbridge --scope project -- node dist/index.js, so it's exactly what the CLIitself produces, not hand-written). Build first, then approve it once:

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 acommand/args pair:

{
  "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 insteadof the current release):

npx @modelcontextprotocol/[email protected] node dist/index.js       # web UI
npx @modelcontextprotocol/[email protected] --cli node dist/index.js -- --method tools/list   # headless

Testing

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, backedby a fresh in-memory SQLite database per test (tests/helpers.ts) — exercising the actualrequest → Zod validation → tool handler → response path a real client goes through, not just thedomain functions in isolation. Coverage includes: successful and empty-result search, customer notfound, ticket listing with/without a status filter, invalid input across every tool, ticketcreation rejected both with approved: false and with approved omitted entirely, successfulcreation, duplicate-submission safety, LIKE-wildcard escaping, prompt-injection framing text, andaudit-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 owninitiative; see Approval mechanism.
  • No pagination — search is capped at 10 results; ticket lists are unbounded but the dataset istiny.
  • 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 asecond, independent ticket rather than being deduplicated.
  • SQLite, single process — no connection pooling, no migration tooling beyondCREATE 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 — theSDK already supports this transport; today's stdio model implicitly trusts whoever can spawn theprocess, which is fine for a local demo and nowhere else.
  • Add real authorization mapping the authenticated caller to which customers/tickets they maytouch — every tool is currently unscoped.
  • Make approval verifiable, not just present — use MCP elicitation to force a real round-tripconfirmation back to a human, or require a short-lived token minted by a separate confirmationstep 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 accesscontrols 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 patterncorrectly at small scale, not to pre-build infrastructure a real deployment would need but a demodoesn't.

MCP Server · Populars

MCP Server · New

    weed33834

    🛡️ AgentSeed

    AgentSeed - anti-hallucination guardrails for AI coding agents: hybrid Skill + MCP plugin (Agent Plugins 1.0.0) that forces spec-driven development and verifies code before it is marked done.

    Community weed33834
    geolens-io

    GeoLens

    Self-hosted geospatial data catalog with semantic search (pgvector), OGC/STAC APIs, and map builder. Built on FastAPI, PostGIS, React, and MapLibre.

    Community geolens-io
    leonardosepulvedat

    MCP n8n Server

    Complete n8n API integration for Claude Desktop and Cursor - 100 workflow templates with intelligent matching

    Community leonardosepulvedat
    maximhq

    Bifrost AI Gateway

    The Fastest LLM Gateway with built in OTel observability and MCP gateway

    Community maximhq
    crisnahine

    rails-ai-context

    45 MCP tools that give AI coding agents ground truth about your Rails app: schema, models, routes, controllers, views, jobs, conventions. Works with Claude Code, Cursor, GitHub Copilot, OpenCode and Codex CLI. MCP or CLI, in-Gemfile or standalone, and it still answers when the app can't boot.

    Community crisnahine