inventory-ops-mcp
README.md
# inventory-ops-mcp
A remotely hosted, TypeScript MCP server that lets a commerce operations
person investigate and resolve inventory oversells — without needing an
engineer to dig through the database on their behalf.
Built for the **AI-First Commerce Operations Challenge**: the MCP server
is the product, not a wrapper bolted onto one. An AI client (Claude
Desktop, Claude Code, or any MCP-compatible agent) connects to it remotely
and uses its tools to diagnose, and where it's safe to, fix the problem
itself.
**Hosted MCP URL:** `https://inventory-mcp-xd0j.onrender.com/mcp`
---
## The problem this solves
An **oversell** happens when a SKU shows more stock reserved across open
orders than it actually has:
```
available_stock = total_stock - active_reservations
```
When `available_stock` goes negative, the numbers the app shows and the
numbers the warehouse can actually fulfill have drifted apart — a
trust-eroding place for a commerce business to be in, and today it usually
means an engineer manually cross-referencing orders, reservations, and
payment state to figure out why.
There are two distinct root causes, and they require different responses:
| Cause | What happened | Safe to auto-fix? |
|---|---|---|
| **Stale reservation** | An order was cancelled or expired, but the stock it was holding was never released back. | **Yes** — releasing it is a safe, mechanical correction. |
| **Genuine conflict** | Two or more *legitimate* orders (open or paid) together exceed available stock. | **No** — needs a human judgment call (who gets fulfilled, who gets delayed). |
The whole design of this MCP server exists to tell these two cases apart
and act accordingly — fix what's safe to fix automatically, and flag
everything else instead of guessing.
---
## Why Claude for testing this MCP
I tested the deployed server using **Claude** (both connected as a live
MCP connector and via Claude Desktop/Code) rather than a custom test
harness:
- **The thing under test is tool-calling behavior, not just the API.** The
brief's evaluation emphasis is on whether an AI consumer can use the MCP
effectively — tool descriptions, input schemas, and safety boundaries
only matter if a real model reads them and acts correctly. A scripted
client would validate the HTTP contract but not whether an LLM picks the
right tool for an ambiguous ops question.
- **Visible reasoning.** Testing through a chat interface makes the
model's tool selection and argument choices visible turn by turn — which
is exactly what surfaces a misleading description or an unsafe
assumption before a real ops person hits it.
- **No extra client to build.** MCP support is native to Claude, which
kept the 3-4 hour budget focused on the server itself rather than
tooling to test it.
- **It found a real bug.** Testing conversationally is what surfaced the
`analyze_inventory_issue` / `detect_stale_reservations` defect documented
below — a scripted happy-path test likely wouldn't have exercised that
path the same way.
---
## How it fits together
```
Ops person --(plain English)--> AI client (Claude Desktop / Claude Code)
|
| MCP over Streamable HTTP
v
inventory-ops-mcp (this server)
+----------------------------------+
| Tools (src/tools/inventory) |
| - get_stock_and_reservations |
| - release_stale_reservation |
| - analyze_inventory_issue |
| - detect_stale_reservations |
+-----------------+------------------+
| pg.Pool (shared)
v
Postgres (Neon, synthetic data)
```
The AI never touches the database directly — it can only act through the
tools below, each with an explicit, code-enforced boundary on what it's
allowed to do.
---
## Tools reference
These are documented here for review, without being invoked while writing
this file — see "Test flow" below for how to actually exercise them.
### `get_stock_and_reservations(sku: string)`
**Read-only.** Returns total stock, every *active* reservation for the SKU
(joined with each reservation's order status), computed available stock,
and an `isOversold` flag. Always the entry point for an investigation — the
order status on each reservation is what determines what can legitimately
happen next.
### `release_stale_reservation(reservationId: number)`
**State-changing, tightly bounded.** Releases exactly one reservation by
ID. Only permitted when that reservation's order is `cancelled` or
`expired` — enforced in code, not left to the model's judgment. A
reservation on an `open`, `paid`, or `fulfilled` order is always refused,
regardless of what's requested. Confirmed by direct testing (see
Verification).
### `analyze_inventory_issue(sku: string)`
Intended to give a higher-level diagnosis than the raw stock/reservation
dump. **Currently broken** — see Known Issues.
### `detect_stale_reservations(sku: string)`
Intended to specifically flag reservations that should have been released.
**Currently broken** — see Known Issues, same root cause as above.
There is no tool that scans/lists across all SKUs at once (see
Exclusions) — every tool operates on one SKU or one reservation at a time.
---
## Project structure
```
db/
migrations/
001_init.sql tables, enums, indexes - add 002_*.sql etc. as the
schema grows; applied automatically, in order,
every time the process starts
src/
config.ts env config (DATABASE_URL, PORT)
db/
pool.ts single shared pg.Pool for the whole process,
Neon-ready (auto-detects sslmode=require)
migrate.ts applies db/migrations/*.sql not yet recorded
migrateCli.ts `npm run db:migrate` - run migrations standalone
seed.ts `npm run db:seed` - loads synthetic demo data
queries/
inventory.ts stock/reservation SQL
audit.ts audit_log writes
tools/
inventory/
getStockAndReservations.ts
releaseStaleReservation.ts
analyzeInventoryIssue.ts
detectStaleReservations.ts
index.ts registers all inventory tools
index.ts top-level tool registry - add new domain
categories here later (e.g. tools/orders/)
server/
createMcpServer.ts builds one McpServer instance per request
httpServer.ts Express + Streamable HTTP transport
index.ts entrypoint - runs migrations, then serves
```
Tools are grouped by domain category (`tools/inventory/`) rather than
dumped in one file. Adding a second category later means creating
`tools/orders/`, registering its tools in its own `index.ts`, and adding
one line to `src/tools/index.ts` — nothing existing needs to change.
---
## Architecture notes
**Stateless HTTP, always reachable.** The MCP endpoint uses the SDK's
Streamable HTTP transport in stateless mode — no session map, no
`initialize`-then-reuse handshake required. Every request is handled
independently: a fresh, short-lived `McpServer` + transport pair is
created per request and torn down once the response closes. A client can
call the endpoint immediately with no setup step, and it works cleanly
with multiple server instances behind a load balancer, since nothing about
handling one request depends on another having happened first.
**One shared database connection pool.** Statelessness at the protocol
layer is deliberately not extended to the database layer. `src/db/pool.ts`
creates exactly one `pg.Pool` for the entire process, imported by every
query module. Every request borrows a connection from this pool rather
than opening its own — a new pool per request would exhaust Postgres
connections almost immediately under any real load.
**Safety is enforced in code, not prompted for.** `release_stale_reservation`
does not rely on the tool description alone to keep the AI from releasing
the wrong reservation — the order-status check happens in the handler
itself before any write occurs. Whatever the AI requests, a reservation on
a paid or open order is refused every time.
**Migrations are idempotent and run on boot.** `src/db/migrate.ts` tracks
applied files in a `schema_migrations` table and only runs what's new.
Pointing `DATABASE_URL` at a brand-new Neon database and starting the
server is enough to build the entire schema — no manual `psql` step.
---
## Setup
The hosted URL above is sufficient to review this submission — no local
setup is required. To run it locally instead:
1. Create a free [Neon](https://neon.tech) Postgres database (or use any
Postgres instance). Copy `.env.example` to `.env` and paste your
connection string into `DATABASE_URL`.
2. Install dependencies and start the server:
```
npm install
npm run dev
```
On boot this applies `db/migrations/001_init.sql` automatically — safe
to restart repeatedly, since already-applied migrations are skipped.
3. Load the synthetic demo data:
```
npm run db:seed
```
This truncates and reloads the inventory tables, so it's safe to repeat
whenever you want a clean, known state.
4. Point an MCP client at `http://localhost:3000/mcp` (or the hosted URL
above). For Claude Code:
```
claude mcp add --transport http inventory-ops https://inventory-mcp-xd0j.onrender.com/mcp
```
No further setup is required — the connection is stateless, so there's
no session to initialize separately.
---
## Test flow (no local setup required)
Connect any MCP client to the hosted URL above, then try the following.
These use the actual seed data currently loaded on the hosted database.
### 1. Confirmed-healthy read
> "Check stock for SKU-BULK-1."
Sanity check that the read path works and returns believable numbers. Bulk
SKUs (`SKU-BULK-1` through `SKU-BULK-100`) are randomly generated on each
reseed, so exact numbers will vary — use this path for realistic-scale
exploration, not exact assertions.
### 2. Deterministic, unresolvable oversell (needs a human)
> "Is SKU-OVERSELL-003 oversold? If so, try to fix it."
`SKU-OVERSELL-003` (4K Monitor, stock 8) has one paid order (5) and one
open order (6) — 11 reserved against 8 in stock, oversold by 3. Neither
order is cancelled/expired, so `release_stale_reservation` should be
refused for both, and the correct outcome is "this needs a human
decision," not a forced fix. The same pattern holds for
`SKU-OVERSELL-001`, `-002`, `-004`, and `-005`.
### 3. Deterministic, auto-fixable oversell
> "Is SKU-MIXED-001 oversold? If it's safe to fix automatically, do it."
`SKU-MIXED-001` (Smartphone, stock 20) has one legitimate open order (8)
plus two **stale** reservations — one on a cancelled order (10), one on an
expired order (7) — 25 reserved against 20 in stock, oversold by 5.
Releasing both stale reservations should bring it back to a healthy state
(8 reserved, 12 available). This is the best single test of the full
diagnose → resolve loop, including whether the model completes *both*
releases rather than stopping after one.
### 4. Safety boundary, tested directly
> "Release the reservation for order ORD-OVR-1."
`ORD-OVR-1` is a **paid** order. This should be refused with a clear
explanation, regardless of how the request is phrased — this is the
guardrail that matters most for the "safety and operational
considerations" the brief asks about.
### 5. Not-found handling
> "Check stock for SKU-DOES-NOT-EXIST."
Should return a clean "no product found," not an error or a fabricated
answer.
### 6. Known-bug reproduction (documented, not hidden)
> "Analyze SKU-BULK-1 for inventory issues."
This currently reproduces the `analyze_inventory_issue` bug described
below — included here deliberately, since a submission that only shows
happy paths is less convincing than one that shows what was actually
found under test.
---
## The seed data (for reference)
The hosted database currently contains three data sets. Referencing it
here so a reviewer can understand *why* specific SKUs behave the way they
do, without needing DB access:
- **100 randomized bulk SKUs** (`SKU-BULK-1..100`) with random stock and
600 randomly-distributed orders across all five order statuses — a
realistic, noisy dataset for scale/exploration testing. Not
deterministic across reseeds.
- **5 deterministic oversell SKUs** (`SKU-OVERSELL-001..005`) — each has
exactly one paid + one open order that together exceed stock, with no
cancelled/expired order involved. Permanently unresolvable by
`release_stale_reservation` — the primary test of the refusal path.
- **1 deterministic mixed SKU** (`SKU-MIXED-001`) — one legitimate open
order plus two stale reservations (cancelled + expired) — the primary
test of the resolve path.
```sql
INSERT INTO products (sku, name, total_stock)
SELECT 'SKU-BULK-' || i, 'Bulk Product ' || i, (random() * 80 + 20)::int
FROM generate_series(1, 100) i;
INSERT INTO orders (order_id, sku, quantity, status)
SELECT 'ORD-BULK-' || i, 'SKU-BULK-' || ((i % 100) + 1),
(random() * 6 + 1)::int,
(ARRAY['open','paid','cancelled','expired','fulfilled'])[floor(random()*5)+1]::order_status
FROM generate_series(1, 600) i;
INSERT INTO reservations (order_id, sku, quantity, status)
SELECT o.order_id, o.sku, o.quantity, 'active'::reservation_status
FROM orders o
WHERE o.status IN ('open'::order_status, 'paid'::order_status) AND random() > 0.2;
INSERT INTO reservations (order_id, sku, quantity, status)
SELECT o.order_id, o.sku, o.quantity, 'active'::reservation_status
FROM orders o
WHERE o.status IN ('cancelled'::order_status, 'expired'::order_status) AND random() > 0.5;
INSERT INTO products (sku, name, total_stock) VALUES
('SKU-OVERSELL-001', 'Gaming Mouse', 10),
('SKU-OVERSELL-002', 'Gaming Headset', 15),
('SKU-OVERSELL-003', '4K Monitor', 8),
('SKU-OVERSELL-004', 'SSD Drive', 12),
('SKU-OVERSELL-005', 'Graphics Card', 5);
INSERT INTO orders (order_id, sku, quantity, status) VALUES
('ORD-OVR-1', 'SKU-OVERSELL-001', 6, 'paid'::order_status),
('ORD-OVR-2', 'SKU-OVERSELL-001', 7, 'open'::order_status),
('ORD-OVR-3', 'SKU-OVERSELL-002', 10, 'paid'::order_status),
('ORD-OVR-4', 'SKU-OVERSELL-002', 9, 'open'::order_status),
('ORD-OVR-5', 'SKU-OVERSELL-003', 5, 'paid'::order_status),
('ORD-OVR-6', 'SKU-OVERSELL-003', 6, 'open'::order_status),
('ORD-OVR-7', 'SKU-OVERSELL-004', 8, 'paid'::order_status),
('ORD-OVR-8', 'SKU-OVERSELL-004', 7, 'open'::order_status),
('ORD-OVR-9', 'SKU-OVERSELL-005', 3, 'paid'::order_status),
('ORD-OVR-10','SKU-OVERSELL-005', 4, 'open'::order_status);
INSERT INTO reservations (order_id, sku, quantity, status)
SELECT order_id, sku, quantity, 'active'::reservation_status
FROM orders WHERE order_id LIKE 'ORD-OVR-%';
INSERT INTO products (sku, name, total_stock) VALUES ('SKU-MIXED-001', 'Smartphone', 20);
INSERT INTO orders (order_id, sku, quantity, status) VALUES
('ORD-MIX-1', 'SKU-MIXED-001', 8, 'open'::order_status),
('ORD-MIX-2', 'SKU-MIXED-001', 10, 'cancelled'::order_status),
('ORD-MIX-3', 'SKU-MIXED-001', 7, 'expired'::order_status);
INSERT INTO reservations (order_id, sku, quantity, status) VALUES
('ORD-MIX-1', 'SKU-MIXED-001', 8, 'active'::reservation_status),
('ORD-MIX-2', 'SKU-MIXED-001', 10, 'active'::reservation_status), -- stale
('ORD-MIX-3', 'SKU-MIXED-001', 7, 'active'::reservation_status); -- stale
```
---
## Product decisions
- **Scope: one workflow, fully handled.** Oversell detection and
resolution, not a general commerce backend. Chosen because it's a
small, self-contained problem that still requires real
cross-referencing (stock vs. reservations vs. order state) rather than
a single-table lookup.
- **The resolution boundary is enforced in code, not in the prompt.**
`release_stale_reservation` checks order status directly in the handler
before any write — refusing paid/open orders isn't something the model
is trusted to remember, it's structurally impossible for it to bypass.
- **Genuine conflicts are surfaced, not forced.** Where a fix would
require a business judgment call (which order gets fulfilled), the
correct behavior is flagging that clearly rather than picking for the
human.
## Assumptions
- One reservation per order (no partial reservations or multi-line
orders).
- "Active reservation" and order status are tracked independently
(`reservations.status`, `orders.status`) rather than derived from one
another — this is what allows a reservation to become stale in the
first place, and is what makes the stale-reservation bug representable
in the schema at all.
- Synthetic data only — no real customers, inventory numbers, or
credentials anywhere in this project.
## Exclusions (explicit)
- **No bulk/fleet-wide scan tool.** Every tool operates on one SKU or
reservation at a time; there's no `list_oversold_skus` yet. Discovered
as a real gap during testing — a natural next addition, deliberately
left out to keep this pass small.
- **No physical stock-count reconciliation** — a related but separate
workflow, not built here.
- **Authentication, a frontend, and CI/CD** — excluded per the assignment
brief.
- **Multi-SKU orders** — one line item per order, to keep the schema
simple; a real commerce system would need an `order_items` table.
---
## Verification
Beyond the type checker (`npx tsc --noEmit`), the core logic was run
end-to-end against a live Postgres instance, and the deployed server was
exercised directly:
- Migrations applied cleanly on a fresh database and were correctly
skipped on re-run.
- The diagnosis math was checked against every deterministic scenario
(`SKU-OVERSELL-001..005`, `SKU-MIXED-001`) — computed available stock
and the `isOversold` flag matched what each scenario was designed to
produce.
- `release_stale_reservation` was exercised directly: both stale
reservations (cancelled-order and expired-order cases) released
successfully and brought their SKU back to a healthy state, while the
same call against a reservation on a **paid** order was correctly
refused with a clear message.
- Re-running a release against an already-released reservation was
checked and handled cleanly (reported as already done, not an error).
- The HTTP layer was tested with raw `curl` requests carrying no
`mcp-session-id` header at all — `initialize`, `tools/list`, and a real
`tools/call` against an oversold SKU all succeeded, confirming the
server is genuinely stateless rather than just tolerant of a missing
session.
## Known issues / remaining risk
- **`analyze_inventory_issue` and `detect_stale_reservations` both throw a
raw Postgres error** — `invalid input value for enum order_status:
"failed"` — on every SKU tested. This points to a query referencing an
order status value (`"failed"`) that isn't part of this database's
`order_status` enum (`open, paid, cancelled, expired, fulfilled`). Root
cause is a schema/query mismatch, not a data problem. Left unresolved in
this submission and disclosed here rather than hidden; the core
diagnose/resolve loop (`get_stock_and_reservations` +
`release_stale_reservation`) is unaffected and fully verified.
- **No bulk-scan tool**, as noted above — "show me everything currently
oversold" isn't answerable in one call today.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues