inventory-doctor
Reads Amazon-style seller-sku/quantity TSV inventory exports for cross-source sync diagnostics, including SKU mismatches and oversell risk.
Compares inventory from Shopify product/inventory CSV exports or live Shopify Admin API stores, detecting SKU mismatches, oversell risk, blank-vs-zero quantities, barcode conflicts, and location-level drift.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@inventory-doctordiff these two CSV exports and flag any oversell risks"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
inventory-doctor
Multi-source inventory sync diagnostics — separate observed inventory differences from conditional selling risk.
Compare two inventory snapshots (CSV exports or live Shopify stores) and get a report of everything that doesn't line up: SKU mismatches, oversell risk, blank cells masquerading as zeros, barcode conflicts, and more.
Your data never leaves your machine. CSV files are parsed locally; Shopify API calls go directly from your computer to your own stores over HTTPS. There is no server, no telemetry, no upload.
Not a sync tool — an auditor for sync tools. Trunk, Syncio, Synkro and friends write to your inventory (and their own reviews show they sometimes get it wrong). inventory-doctor never writes inventory to a commerce API (it can save local snapshots/proposals and send explicitly configured webhooks): it is the independent, read-only reconciliation layer you run alongside whatever sync app you use. See docs/competitive-analysis.md for the full comparison.
Built for agentic commerce. AI-buyer traffic is growing fast — agent-initiated store visits are up ~393% year over year — and AI buyers are cancellation-intolerant: one oversell is a failed order and a merchant record that buying agents learn to avoid, not a disappointed human who might come back. Meanwhile every sync engine in your stack is writing to your inventory around the clock. Every sync-app user needs an independent audit layer, and inventory-doctor is the read-only reconciliation layer built to be exactly that. See docs/competitive-analysis.md for the full landscape.
Who this is for
Developers and agencies run the engine directly: the CLI and the MCP server are the whole diagnostic core, MIT, no account, no service in the middle. Wire
snapshot save+snapshot checkinto your own cron or CI and you own the schedule.Merchants and teams who would rather not run infrastructure get the same engine behind a hosted app (in development): it schedules the daily check, keeps the history, compares the store against a 3PL/ERP export, and alerts when something breaks.
The split is deliberate and one-way: the engine stays open so you can audit exactly what is being judged and run it yourself; the hosted app adds the boring parts (scheduling, storage, alerting, multi-source plumbing).
Related MCP server: DataClaw MCP Server
Install & run
npm install # or: pnpm install
npm run build # produces dist/cli.js (with shebang)
# zero-credential path: two CSV exports
npx tsx src/cli.ts diff fixtures/shopify-store-a.csv fixtures/shopify-store-b.csv
# or after build / npm link:
inventory-doctor diff a.csv b.csvZero-credential demo
npx -y inventory-doctor demo # published package; no checkout or credentials
npm run demo -- --format json # this checkout
npx tsx src/cli.ts demo --format markdownThe bundled fixtures are synthetic demonstration data, not verified incidents.
demo uses fixtures/demo-config.json to declare the example's shared-stock
assumption and exits 0 even with deliberately seeded critical findings. Ordinary
diff exits 1 for critical findings, 2 for errors, otherwise 0.
Facts, risks and quantity prerequisites
Findings separate evidence (observations in supplied records), risk
(conditional consequences), assumptions, and verification steps. A critical
finding means urgent verification, not proof of an accepted oversell, lost stock,
a failed sync app, or an incorrect source. Health score measures agreement in the
compared data, not physical availability or order fulfillment accuracy.
Before comparing quantities, establish the same stock pool, quantity state (available, not on-hand/reserved/committed), capture time, SKU identity, location and unit of measure. Shopify adapters prefer explicit available columns; unknown/generic quantity semantics must be checked by the operator. CSV blank cells, absent/untracked API quantities, and numeric zero remain distinct. A missing quantity is never evidence of zero stock.
By default rules.inventoryRelationship is "unknown". Set it to "shared"
only when the sources really represent the same stock pool; use "independent"
for separate pools. rules.allowBackorders: true records intentional backorders
and reduces urgency without changing observations. Example config:
{ "rules": { "inventoryRelationship": "shared", "allowBackorders": false } }Fix export — an audit review proposal, not an import
--fix-export writes a non-importable review proposal CSV, including baseline
and target observed values, proposed available quantity when reviewable, raw
quantity evidence, quantity basis, review status, and blocked reasons. The first
source is a human-selected baseline, never verified truth. Nothing is applied
to inventory; even an unblocked proposal requires human verification.
inventory-doctor diff a.csv b.csv --fix-export review.csv
# stderr: review direction, baseline assumption, review-required/blocked counts
# review.csv is NOT a Shopify import file
# existing files are protected; --force explicitly permits replacementWatch source order. CLI precedence is positional files, then --csv, then
--store, then --woo, then --baseline — not the textual order of mixed flags.
The printed direction names the resolved baseline and target. Ambiguous matches,
missing SKUs, unknown quantities, location aggregates/mismatches and unsupported
quantity bases may block a proposal. Consult each proposal's blocked reasons;
there is no promise that every finding can produce a quantity suggestion.
Do not diff the proposal CSV as if it were a verified inventory snapshot, or import it as a repair. Check original records, reservations, orders, transfers, location mapping and a physical count, then use a platform-specific approved workflow if a change is warranted. Re-read actual inventory afterward.
Snapshots — time-series diagnosis
A single diff compares two snapshots taken now. Saving snapshots over time unlocks the time-series rule (nightly-zero): "this SKU was in stock every day and suddenly reads 0" — a suspicious observation a one-off diff cannot see. It does not prove a wipe or its cause.
inventory-doctor snapshot save store-a.csv # or: --store store-a via snapshot save store:store-a
inventory-doctor snapshot list shopify-store-a
inventory-doctor snapshot check shopify-store-a # needs ≥3 snapshots; exit 1 on critical
inventory-doctor diff now.csv --baseline shopify-store-a # latest snapshot
inventory-doctor diff now.csv --baseline shopify-store-a@2026-09-01 # a specific oneAlert when the check catches something critical — the check POSTs a JSON summary and still exits 1, whether or not the alert went through:
inventory-doctor snapshot check shopify-store-a --webhook https://hooks.slack.com/your-endpoint{ "name": "shopify-store-a", "critical": 1, "warning": 0,
"findings": [{ "severity": "critical", "message": "\"SKU-1\" was stocked across 2 snapshots … but reads 0 in the latest" }] }Only critical findings alert — a warning-only history stays silent, so the
endpoint doesn't fatigue you into ignoring it. A failed send (network error,
non-2xx) is a stderr warning and never changes the exit code. Set a default in
inventory-doctor.json and the flag wins per-run:
{ "stores": [ … ], "notify": { "webhookUrl": "https://hooks.slack.com/your-endpoint" } }Snapshots live as local JSONL files under ~/.local/share/inventory-doctor/snapshots/<name>/ (override
with INVENTORY_DOCTOR_SNAPSHOT_DIR). Put snapshot save + snapshot check on a cron and
you have daily reconciliation with no service in the middle and no account.
Keep as much history as you like, but give the check a window — it only needs the recent past, and every loaded record costs memory (~300 bytes, measured):
inventory-doctor snapshot check shopify-store-a --window 30--window for both snapshot check and snapshot history must be a positive
safe integer (decimal digits only); fractions, exponents, zero and invalid text
are errors, not silently rounded or interpreted as “all”. Detection requires at
least three loaded snapshots; a smaller window is allowed but cannot diagnose
the time-series pattern.
inventory-doctor snapshot history shopify-store-a SKU-1 --window 30
inventory-doctor snapshot prune shopify-store-a --keep 30 # preview
inventory-doctor snapshot prune shopify-store-a --keep 30 --dry-run # preview
inventory-doctor snapshot prune shopify-store-a --keep 30 --yes # delete candidates--keep must be a safe integer >=3. Prune is preview-only unless --yes is
explicitly supplied; --dry-run and --yes cannot be combined. The plan lists
candidate IDs/paths before deletion. Retention removes local history permanently;
back it up first. It does not change store inventory.
A year of daily snapshots of a 5,000-SKU shop is ~0.5 GB on disk; loading all
of it would need ~510 MB of RAM. With --window 30 the run stays around 45 MB
however long the history grows. The listing is cheap either way — only the
snapshots inside the window are read.
GitHub Action
Run a diff in CI with the composite action from this repo (published to the marketplace):
- uses: shidesheng0218/inventory-doctor@v0.3.0
with:
source-a: exports/store-a.csv # or store:<name> / snapshot:<name>
source-b: exports/store-b.csv
# config: path/to/inventory-doctor.json # only if not in a default location
# format: terminal # terminal | markdown | html
# extra-args: --drift-abs 5 # any extra CLI flagssource-a and source-b are required; everything else is optional.
Credentials referenced as "env:VAR_NAME" in inventory-doctor.json are read
from the step environment — set them as env vars in your calling workflow
(composite actions cannot reference the secrets context themselves), e.g.
at job level:
env:
STORE_A_TOKEN: ${{ secrets.STORE_A_TOKEN }} # also STORE_B_CLIENT_ID,
STORE_B_SECRET: ${{ secrets.STORE_B_SECRET }} # STORE_B_SECRET, WOO_CK,
WOO_CK: ${{ secrets.WOO_CK }} # WOO_CS, SHOPIFY_CLIENT_SECRET
WOO_CS: ${{ secrets.WOO_CS }}
SHOPIFY_CLIENT_SECRET: ${{ secrets.SHOPIFY_CLIENT_SECRET }}The report is written to inventory-doctor-report.txt and uploaded as an
artifact; the file path is also exposed as the report output. When critical
findings exist the CLI exits 1 and the step — and therefore the job — goes
red. Set fail-on-critical: 'false' to keep the workflow green and only
collect the report. See .github/workflows/example-inventory-check.yml for a
self-contained run (it diffs this repo's fixtures nightly, with
fail-on-critical: false since the fixtures intentionally contain criticals).
The nine diagnostic rules (observations and conditional risks)
Rule | What it catches | Severity |
| Case-only / whitespace / prefix-suffix SKU variants, orphan SKUs, one-to-many duplicates within one source | info → critical |
| Quantity ≤ 0 on one side but > 0 on the other; "continue selling when out of stock" with empty stock; drift beyond threshold | warning → critical |
| Negative reported available quantity — investigate reservations, backorders and reconciliation; not proof of accepted orders or nonexistent physical stock. Needs no second source, so a single store gets this on day one | critical |
| A blank quantity cell vs an explicit | critical |
| Same barcode, different SKUs across sources — possible mapping conflict requiring verification | critical |
| Overall sync health: % exact / minor drift / severe drift / unmatched → health score 0–100 | info |
| Inventory tracking disabled in one source while another manages stock | info |
| Time-series across saved snapshots: a SKU with a stable positive history suddenly reading 0 ("silently zeroed overnight"), vanishing, or dropping suspiciously fast | warning → critical |
| A bundle (kit) listed sellable while its components can no longer support even one assembly; listed bundle qty drifting from what component stock supports; component missing from a source | warning → critical |
Blank vs "0" is a first-class distinction. CSV parsers love turning empty cells into 0; this tool keeps quantity: null strictly separate from quantity: 0 all the way through.
Supported inputs
Shopify product CSV — both header generations are recognized (
Variant SKUand the currentSKU,Variant Inventory QtyandInventory quantity, etc.)Shopify inventory CSV — both layouts: the long "All states" table (one row per variant × location) and the wide "Available" table (location names as column headers, inferred automatically)
Anything else — alias-based column probing (Amazon-style
seller-sku/quantityTSVs work out of the box), plus explicit mapping when guessing fails:
inventory-doctor diff shopify.csv erp.csv --map sku="Item Code" --map quantity="Stock Count"SKU normalization (trim, case folding, full-width → half-width, zero-width character removal) is used only for comparison — reports always show your raw SKU values.
Multi-location aware. When both sources carry a location dimension (inventory CSV long/wide, or the API), quantities are compared per (SKU, location): a location-level stockout or drift is reported at that location and never hidden by summing across locations, and a SKU stocked at two locations is not mistaken for a duplicate. When one side has no location dimension (product CSV), its per-SKU quantity is compared against the other side's cross-location sum. See fixtures/shopify-inventory-c.csv / -d.csv (long format) and fixtures/shopify-inventory-wide-e.csv / -f.csv (wide format) for worked examples.
Shopify Admin API (optional)
Pull live snapshots instead of exporting CSVs. Create inventory-doctor.json in the project directory (or ~/.config/inventory-doctor/config.json):
{
"stores": [
// Mode 1: existing static token (shpat_... — still works if you already have one)
{ "name": "store-a", "domain": "a.myshopify.com", "accessToken": "env:STORE_A_TOKEN" },
// Mode 2: client credentials grant (the current way to create app credentials)
{ "name": "store-b", "domain": "b.myshopify.com",
"clientId": "env:STORE_B_CLIENT_ID", "clientSecret": "env:STORE_B_SECRET" }
]
}Credentials support "env:VAR_NAME" references so secrets stay out of files. Then:
inventory-doctor diff --store store-a --store store-b # store vs store
inventory-doctor diff --store store-a --csv b.csv # mixed modeThe same file can tune the diagnostic rules (all optional; CLI flags win over these):
{
"rules": {
"disable": ["untracked"], // turn a rule off entirely
"ignoreSkus": ["GIFT-*", "TEST-?"], // glob vs canonical SKU — findings dropped
"severityOverrides": { "blank-vs-zero": "warning" },
"driftAbsThreshold": 10, // like --drift-abs
"driftPctThreshold": 0.3 // like --drift-pct
},
// Bundle kits for the bundle-availability rule (top level, not under "rules").
// computedMax = min over components of floor(componentQty / required), per source/location.
"bundles": [
{ "sku": "GIFT-KIT", "components": [{ "sku": "KIT-A", "quantity": 1 }, { "sku": "KIT-B", "quantity": 2 }] }
]
}Details that matter:
API version pinned to 2026-07 (
/admin/api/2026-07/graphql.json).Inventory is read via
quantities(names: [...])— the oldInventoryLevel.availablefield no longer exists.Tokens from client credentials live 24h; they're cached and refreshed 60s early, not re-requested per call.
Every HTTP request has a 30s timeout, and transient network failures (reset, DNS, timeout) are retried with the same 1s backoff as rate limits — a hung store cannot hang the run.
Rate limiting is adaptive: every response's
extensions.cost.throttleStatus.currentlyAvailabledrives a slow-down before the bucket empties; HTTP 429 /THROTTLEDbacks off 1s and retries. No plan-specific rate numbers are hardcoded.Read-only scopes only:
read_inventory,read_products,read_locations.
Client credentials limitation: the app and the store must belong to the same Shopify org. That covers "a merchant building a tool for their own store". Agencies managing client stores get shop_not_permitted from client credentials — use the OAuth flow below instead.
Shopify OAuth (agency / cross-org)
One-time browser flow per store. Prerequisite: a Dev Dashboard app with a loopback redirect URL (http://127.0.0.1) registered — see Shopify's OAuth docs.
inventory-doctor auth client-store.myshopify.com --client-id ... --client-secret ...This opens the consent screen, exchanges the code for an offline token (never expires), and saves it to ~/.config/inventory-doctor/oauth-tokens.json (mode 0600) — not to your project config. Then reference it with "oauth": true:
{ "name": "client-store", "domain": "client-store.myshopify.com", "oauth": true }WooCommerce (REST API)
Add a woocommerce section to inventory-doctor.json (consumer key/secret from WooCommerce → Settings → Advanced → REST API):
{
"woocommerce": [
{ "name": "woo-shop", "baseUrl": "https://shop.example.com",
"consumerKey": "env:WOO_CK", "consumerSecret": "env:WOO_CS" }
]
}Then woo:<name> works everywhere a source is accepted:
inventory-doctor diff --store store-a --woo woo-shop # Shopify vs WooCommerce
inventory-doctor diff woo-shop-export.csv --woo woo-shopNotes: HTTPS is enforced (Basic Auth over plain HTTP would leak credentials). manage_stock: false maps to "tracking off" (the untracked rule fires as usual), and backorders: yes/notify maps to "continue selling when out of stock". WooCommerce core has no multi-location inventory, so Woo sources have no location dimension.
MCP server (use it from Claude Code and other agents)
Add to your .mcp.json:
{
"mcpServers": {
"inventory-doctor": {
"type": "stdio",
"command": "npx",
"args": ["-y", "inventory-doctor", "mcp"],
"env": { "SHOPIFY_CLIENT_SECRET": "${SHOPIFY_CLIENT_SECRET}" }
}
}
}Six tools are registered:
diff_inventory(sourceA, sourceB, configPath?, maxFindings?)— full diagnosis, returns the JSON report. Findings are capped atmaxFindings(default 100) so a large catalog cannot flood the agent's context; on truncation the payload carriestotalFindingsand points atexplain_skufor the remainder.explain_sku(sku, sources, configPath?)— one SKU's raw values and findings across all sources (for follow-up questions)inventory_health(sources, configPath?)— lightweight agreement-score summary, not proof of stock correctnesssku_history(sku, snapshotName, maxSnapshots?)— canonical SKU matching; per-snapshot/location observed quantity points.nullis unknown, not zero; absent records produce no point.found=falserefers only to the loaded window.snapshot_check(snapshotName, maxSnapshots?)— observed time-series changes with evidence/risk/assumptions/verification, loaded snapshot metadata,totalSnapshotsandwindowed. Needs >=3 loaded snapshots for findings.fix_export(sourceA, sourceB, configPath?)— review proposal only, includingdirection,reviewRequired: true,importable: false,proposals(including blocked reasons and baseline/target evidence),rows,csv,basis, risk/assumptions/verification. Returns text; writes no file or inventory.
maxSnapshots is an optional positive safe integer; omitted means all saved
snapshots. Both source arrays must be nonempty. Findings returned by
diff_inventory, explain_sku, and snapshot_check retain the diagnostic
evidence, risk, assumptions, and verification fields; read those before
recommending action.
Every source argument accepts a CSV file path, woo:<name> (configured WooCommerce), store:<name> (a store from inventory-doctor.json, credentials resolved from env vars), or snapshot:<name>[@<id>] (a saved snapshot) — so an agent can diff two live stores, or a store against a CSV, in one call. configPath is only needed when the config file is not in a default location.
stdout is reserved for JSON-RPC; all logging goes to stderr.
Honest limitations
Time-series needs saved snapshots.
snapshot checkdetects silent zeroing across history, but only over snapshots you actually saved — it cannot reconstruct the past before the first save.WooCommerce has no multi-location inventory in core; per-location diagnosis on that side needs a CSV export from a multi-location plugin instead.
Amazon report headers vary by marketplace and report options; detection is best-effort via column aliases, and
--mapis the escape hatch.Product CSVs carry no per-location inventory; multi-location diagnosis needs the inventory CSV export or the API.
Development
npm test # vitest — rules, CSV adapters, token cache, throttle logic
npm run demo -- --format json
npm run build # tsc + shebang
npm run check:stdout # guards the MCP stdout disciplineArchitecture: src/core/ is a pure-function diagnostic kernel ((records: InventoryRecord[]) => Finding[], zero I/O); src/adapters/ turn CSVs and the Shopify API into that intermediate representation; CLI and MCP layers only parse arguments and format output. Adding a new source (WooCommerce, BigCommerce, …) means adding one adapter, no refactor.
This server cannot be deployed
Maintenance
Related MCP Connectors
Safety-stock & reorder planning, FBA-vs-FBM decision, and inventory health audit for sellers
Checks AI support agent refunds against Shopify and Stripe. Read-only pricing, fit and estimates.
Inventory, restock planning, and sales analytics for your Amazon FBA business.
Reconcile business documents, inspect exact differences and evidence, and export reports.
Related MCP Servers
- AlicenseBqualityBmaintenanceMulti-channel inventory intelligence for Shopify and Amazon sellers. 28 tools for stockout risk, demand forecasts, purchase order management, and sales analytics — with human-in-the-loop safeguards.5056 npm2MIT
- FlicenseNot gradedqualityCmaintenanceAI-first CSV analysis tool that enables AI agents to analyze, query, and audit large CSV files directly within conversations, turning raw data into actionable insights.2-
- FlicenseNot gradedqualityCmaintenanceEnables agents to query and manage inventory data from Feishu source tables, providing tools for stock checks, distribution analysis, alternatives, and weekly ledger updates.-
- AlicenseNot gradedqualityBmaintenanceAnalyzes user-authorized CSV, JSON, or XLSX comment exports locally to generate risk signals, prioritized comments, evidence-backed actions, and reports while keeping data private.MIT