Skip to main content
Glama
Old-Steel-Arsenal

GunStore-POS Admin MCP

README.md
# GunStore-POS Admin MCP

A local [MCP](https://modelcontextprotocol.io) server that wraps the GunStore-POS
Frappe REST API, so you can read and change a store's settings and content from
Claude or Codex — toggle integration config, edit item pricing/listing, fix
records, trigger RSR/FastBound/ATF operations, run the Firearms-In-Stock report.

## How it works

- A small **generic backbone** (`frappe_*` tools) covers every doctype and every
  whitelisted method — present and future. A **curated layer** makes the frequent
  settings/sync/report ops one call.
- Talks to Frappe over HTTPS with token auth (`Authorization: token key:secret`).
- **Safety**: delete/cancel/submit and destructive methods — including this
  domain's own high-consequence verbs (dispose / push / charge / consolidate /
  ship / return / receive / sold / settle / onboard) —
  require `confirm=true`;
  password/credential fields are never transmitted (set those in Desk);
  schema/permission doctypes are read-only.

## Setup

### 1. Generate a Frappe API key
In Desk as the user you want to act as (Administrator): top-right avatar →
**My Settings** → **API Access** → **Generate Keys**. Copy the **API Key** and
**API Secret** (the secret is shown only once).

### 2. Configure credentials
```bash
cp .env.example .env
# edit .env: FRAPPE_BASE_URL, FRAPPE_API_KEY, FRAPPE_API_SECRET
```
`.env` is git-ignored. Point at a dev site first (`http://dev.localhost:8000`) to test,
then switch to your live POS URL (e.g. `https://pos.example.com`).

### 3. Install
```bash
uv sync          # creates .venv and installs deps
```

### 4. Register with your agent

`/path/to/gunstore-pos-mcp` below is wherever you cloned this repo (the package is
at the repo root). The server loads `.env` by its own file path, so credentials
never go in the agent config.

**Claude Code** — add to `.mcp.json` (project) or run `claude mcp add`:
```json
{
  "mcpServers": {
    "gunstore-pos": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/gunstore-pos-mcp", "gunstore-mcp"]
    }
  }
}
```
**Claude Desktop** — the same block in `claude_desktop_config.json`.

**Codex** — add to `~/.codex/config.toml`, then **restart Codex** (it reads MCP
servers at startup):
```toml
[mcp_servers.gunstore-pos]
command = "uv"            # or an absolute path to uv if it isn't on Codex's PATH
args = ["run", "--directory", "/path/to/gunstore-pos-mcp", "gunstore-mcp"]
startup_timeout_sec = 120
```
Verify with a quick stdio handshake (`initialize` + `tools/list`) or just list
tools from within Codex.

Secrets stay in `.env` (loaded by the server), not in the agent config.

## Tools

> **Tool reference, organised by task (safety notes and alternatives included): [TOOLS.md](TOOLS.md)**

115 tools total: 10 generic + 56 curated + 12 distributor + 7 CPA reports + 30 shop-floor; 108 register by default. Two opt-in sets are held back: the 4 distributor queue actions (`GUNSTORE_MCP_DISTRIBUTOR_ACTIONS=1`) and the 3 GunBroker write actions (`GUNSTORE_MCP_GUNBROKER_ACTIONS=1`). Neither is registered otherwise — an absent tool cannot be talked into firing.

**Modes**: `GUNSTORE_MCP_MODE=cpa` starts a read-only accountant surface —
exactly 26 tools (the write surface is never registered), a per-name read-only
method allowlist at the client layer, and the 7 integration Settings doctypes
blocked from reads. Default (`full`) is the whole surface. Register a second
server entry (e.g. `gunstore-pos-cpa`) with the same command plus
`"env": {"GUNSTORE_MCP_MODE": "cpa"}` to run both side by side.

### Standalone CPA install

To install **only** the read-only accountant surface (no full server) — e.g. on
a second machine or for an analyst agent:

```bash
git clone git@github.com:Old-Steel-Arsenal/gunstore-pos-mcp.git ~/gunstore-pos-mcp
claude mcp add gunstore-pos-cpa --scope user \
  --env GUNSTORE_MCP_MODE=cpa \
  --env FRAPPE_BASE_URL=https://pos.example.com \
  --env FRAPPE_API_KEY=<key> \
  --env FRAPPE_API_SECRET=<secret> \
  -- uv run --directory ~/gunstore-pos-mcp gunstore-mcp
```

- **Process env beats `.env`**: the server loads `.env` with `override=False`,
  so env vars set in the registration win. A standalone install needs no
  `.env` file at all — and the same checkout can serve several entries with
  different `FRAPPE_BASE_URL` / mode combinations (e.g. a dev-site instance).
- **Verify**: after connecting, `tools/list` must show exactly **26** tools and
  the server name `gunstore-pos-cpa`. A misspelled/unknown mode value refuses
  to start (fail-closed) rather than silently degrading to the writable surface.
- **Security boundary — read before handing this to a third party**: the
  three-layer gate restricts the *MCP call layer* only. The API key/secret in
  the registration is whatever that Frappe user can do — with a System Manager
  key, anyone who extracts it can write via plain REST, MCP gate or not. For a
  third-party (e.g. an outside accountant's machine), create a dedicated
  Frappe API user with read-only roles and register with *that* key pair.

### Generic backbone
| Tool | Purpose |
|---|---|
| `frappe_list_documents` | list any doctype (filters / fields / limit; `limit=0` = all) |
| `frappe_get_document` | fetch one doc (Single: `name == doctype`) |
| `frappe_describe_doctype` | fields incl. custom fields; flags password fields |
| `frappe_create_document` | create (credential fields stripped) |
| `frappe_update_document` | update (credential fields stripped) |
| `frappe_delete_document` | delete — needs `confirm=true` |
| `frappe_submit_document` | submit a draft — needs `confirm=true` |
| `frappe_cancel_document` | cancel a submitted doc — needs `confirm=true` |
| `frappe_run_method` | call any whitelisted method by dotted path |
| `frappe_run_report` | run a Script/Query report |

### Curated
| Tool | Purpose |
|---|---|
| `get_settings` / `update_settings` | `ffl` \| `fastbound` \| `rsr` \| `payroc` \| `woocommerce` \| `shipstation` \| `gunbroker` \| `sports_south` \| `data_service` |
| `find_item` / `item_stock` / `available_serials` | typeahead item search / stock per item / in-stock serials + per-gun prices |
| `firearms_in_stock` | the Firearms In Stock report |
| `receive_goods` | Purchase Receipt + FFL acquisitions + FastBound push (`confirm`); a paid purchase from a private seller (`acquisition_source` "Individual", type blank / Purchase / Individual) needs `seller_payment_method` (Cash / Zelle / Check / ACH) + `seller_payment_reference` unless Cash (gunstore-pos #705 on) |
| `add_stock` / `set_stock` | non-serialized stock add / absolute set (`confirm`) |
| `toggle_service_need` | gunsmith flag on a Serial No (`confirm`) |
| `rsr_catalog_search` | RSR-only catalog search (`distributor_catalog_search` spans every enabled house) |
| `promote_to_item` / `backfill_from_rsr` | RSR catalog row → sellable Item / backfill Item fields (`confirm`) |
| `fastbound_test_connection` / `push_serial_to_fastbound` / `boundbook_reconcile` | FB probe / per-gun correction push (`confirm`) / bound-book reconcile (apply needs `confirm`) |
| `atf_verify_ffl` / `verify_supplier_ffl` / `reverify_all_ffls` | ATF eZ-Check verifies (`confirm`) |
| `woo_test_connection` / `woo_push_item` / `woo_delist_item` / `woo_reconcile` | store probe / list / delist / reconcile an Item — all take `site: retail` (the only store; writes need `confirm`) |
| `woo_push_serial` / `woo_delist_serial` | list / delist ONE gun (SKU `item_code::serial`; `site`; `confirm`) |
| `set_serial_title` | per-gun Woo listing title (writes `Serial No.item_name`; takes effect on next push) |
| **GunBroker channel** | *the environment, credential and money fields of `GunBroker Settings` are refused on every write path — `update_settings`, `frappe_update_document`, `frappe_create_document` and `frappe_run_method`'s field setters (method names matching `set_value`/`db_set`/`save_doc`-alike, `safety._SETTER_METHOD`), enforced by a test over the whole registered surface — all four tool modules the server registers. Sandbox vs live is a Desk change, not a tool call. The 3 writes below need `GUNSTORE_MCP_GUNBROKER_ACTIONS=1` to be registered at all* |
| `gb_test_connection` / `gb_listing_status` | GunBroker probe — the reply's `sandbox` flag says which marketplace answered (needs SYSTEM_ROLES, as does `gb_pull_orders`; the other three need STOCK_ROLES) / POS-vs-GunBroker view of ONE gun's listing (read-only) |
| `gb_push_serial` / `gb_end_listing` | list ONE gun as a fixed-price Buy Now / end its listing (opt-in via `GUNSTORE_MCP_GUNBROKER_ACTIONS=1`, then `confirm`; both reach GunBroker only via the POS) |
| `gb_pull_orders` | run the order poll now (opt-in + `confirm`; takes no arguments). Importing an order creates POS documents and reserves the gun, which is why it is gated with the writes. The reply `{"queued": true}` is a receipt — the poll runs in the background and reports no counts |
| `pending_orders` / `pending_web_orders` | the Pending Order queue: counter/dealer rows + paid web orders (read-only; consignments live in `consignment_queue`) |
| `dispose_order` / `dispose_web_order` | book the FFL transfer dispositions — stock-out + FastBound push (`confirm`) |
| `record_payment` | Payment Entry against an unpaid submitted invoice; Zelle needs `transaction_number` (`confirm`) |
| `cancel_order` | cancel a submitted counter/dealer order — cascades disposition/stock/FastBound/ShipStation reversal + refund (`confirm` + `reason`) |
| `push_shipment` / `mark_shipped_manually` / `shipstation_test_connection` | ShipStation label push (`confirm`) / no-push escape hatch (`confirm`) / probe |
| `consignment_queue` / `consignment_dealers` / `consignment_serials` / `consignment_dealer_orders` | outbound-consignment reads: At-Dealer queue / shippable FFL dealers / pickable serials / settlement queue |
| `create_consignment_out` / `ship_consignment_out` / `push_consignment_shipment` / `mark_consignment_shipped` | build / dispose+ship / ShipStation push / manual-ship w/ tracking (`confirm`) |
| `retry_consignment_invoice` / `return_consignment_lines` / `cancel_consignment` | settlement-invoice retry / take unsold guns back / cancel line-or-draft (`confirm`; cancel needs `reason`) |
| `start_4473` / `manager_override_4473` / `start_transfer_4473` | 4473 kickoff / stuck-sale manager override / customer transfer (`confirm`) |
| `upload_attachment` | multipart file upload → File doc, optionally attached to a doctype+name / Attach field |

### Shop floor: stocktake, cash drawer, storage locations (POS 1.5.0-beta.15)
Every write needs `confirm=true`; there is no registration gate (they are the same counter actions the POS pages offer, role-checked per call and audited on the remote connector). Firearms are never adjusted by a stocktake and storage moves touch no stock or books.

| Tool | Purpose |
|---|---|
| `inventory_counts` / `inventory_count_state` / `inventory_count_variance` | list stocktakes / one count's scans + expected rows / counted-vs-system variance (read-only; counts + variance are also on the cpa surface) |
| `inventory_count_create` / `inventory_count_cancel` | start (whole store or scoped) / cancel an open count (`confirm`) |
| `inventory_count_scan` / `inventory_count_set_qty` / `inventory_count_toggle_serial` / `inventory_count_undo` | record a serial or UPC scan / type a quantity / tick a gun by hand / remove an entry (`confirm`) |
| `inventory_count_finalize` | posts ONE Stock Reconciliation for the non-serialized differences, saves the firearm report (`confirm`; firearms never adjusted) |
| `cash_drawer_today` / `cash_drawer_preview_close` | the drawer page (expected cash + lines) / what a close WOULD do (read-only) |
| `cash_drawer_closes` / `cash_drawer_entries` / `cash_drawer_log` / `cash_drawer_weekly` | daily counts / deposits, cash from the bank, expenses, seller payouts / every cash movement with who and the running balance / the Cash Drawer Weekly report (read-only; also on the cpa surface) |
| `cash_drawer_close_day` | count the drawer, close POS shifts, book the over/short or first-count entry (`confirm`) |
| `cash_drawer_record_entry` | `kind` = deposit \| from_bank \| expense (`confirm`); an expense needs `expense_account` + `memo`; a receipt is optional (POS 1.8.2+), and when given must be a file the same POS user uploaded within a day — remote has no upload tool, so the user uploads it in the POS and passes the `file_url` |
| `cash_drawer_undo` / `cash_drawer_record_payout` | cancel an entry or the latest close / change the method of / re-book an old-way seller payment (first payments go through the receipt) (`confirm`; managers) |
| `storage_map` / `storage_location` / `storage_where` / `storage_unassigned` | the store map (`zones_only` = zone list) / one location's contents / where a serial or item is / unassigned and to-confirm lists (read-only) |
| `storage_create_zone` / `storage_add_positions` / `storage_set_disabled` | zone management (`confirm`; Stock Manager) |
| `storage_scan_move` / `storage_undo_move` / `storage_confirm_taken` | assign a gun / put units into a location / undo it / say where sold units came from (`confirm`) |

### CPA reports (read-only; registered in both modes)
| Tool | Purpose |
|---|---|
| `sales_report` | the POS Sales Report, payload passed through unchanged (views Order / Order Detail / Product; channel POS/Web/Manual) |
| `inventory_receipts` | the POS Inventory Receipts report — everything that entered stock in a period (per unit / per serial, or a category × receipt-type summary), straight from the stock ledger; classes Purchase / Trade-in / Consignment / Intake / Return / Adjustment / Revaluation (cost correction, 0 units), with No-cost / No-A&D flags |
| `gl_entries` | GL rows for a date range (`is_cancelled=0` always; explicit `truncated:true`) |
| `financial_statement` | P&L / Balance Sheet (Date Range) / Trial Balance (fiscal-year auto-resolved) |
| `tax_liability` | sales-tax liability roll-forward from the GL — accounts resolved from the default sales-tax template, vouchers bucketed fail-closed, cent-exact identity asserted |
| `payroc_transactions` | every Payroc card transaction for a date range (≤31 days), read live from the gateway — counter and Woo web orders, sales / refunds / declines, portal refunds and voids included — each matched to the POS with disagreement flags; card type + last 4 only, plus the cardholder name. Needs a gunstore-pos release carrying `payroc/ledger.py`, and an API user with System Manager / Accounts Manager / Accounts User |
| `ar_ap_summary` | aged AR / AP as of a date (Posting Date basis, 30/60/90/120). AP is only meaningful when purchases are recorded as Purchase Invoices in ERPNext — reference only |

## Remote connector (OAuth, no API key)

The same server runs as a remote MCP endpoint with `GUNSTORE_MCP_TRANSPORT=http`.
It is an OAuth **resource server**; the **POS itself is the authorization server**
(Frappe v16's built-in OAuth: dynamic client registration + PKCE). A user adds the
connector URL in claude.ai (Settings → Connectors) or Claude Code
(`claude mcp add --transport http <name> <url>`), signs in to the POS in the browser
and approves. No key is typed anywhere, and the server holds none.

- Every request carries that user's bearer token. The server checks it against the
  POS (`ffl_core.api.connector.connector_identity`, cached 60 s; the token is never
  logged or cached in the clear) and forwards it on every call, so **each call runs
  with the signed-in user's own POS roles**.
- **Works with any MCP client that supports OAuth** — Claude (claude.ai
  Connectors, Claude Code `claude mcp add --transport http`) and ChatGPT (custom
  connectors / developer mode, or the Responses API `mcp` tool with the user's
  token). Each client registers itself with the POS (dynamic client
  registration), and the audit shows which one made each call.
- **Switched in the POS**: Desk → MCP Settings turns the read-only and full
  servers on or off separately (and owns the OAuth settings they sign in
  through); a switched-off server tells the user so on every call.
- **Only connector tokens are accepted**: a token the POS issued to a client created
  by dynamic registration. Tokens of OAuth apps made in Desk are refused.
- **Every call is audited in the POS** (Desk → Connector Audit Log, permanent, rows
  cannot be deleted; reads included): who, through which connector, which tool,
  which arguments (secrets masked by key, in filters and inside JSON strings),
  Started → Success / Failed. The row is written before the tool runs — if it
  cannot be, the tool does not run. Needs a gunstore-pos release carrying
  `ffl_core/api/connector.py` and the Connector Audit Log doctype.
- A user with **Restrict IP** set cannot use the connector: calls reach the POS from
  the connector (and ultimately from Claude's servers), never from the user's IP.
- `cpa` mode keeps all three read-only layers. The full surface is **107 tools**
  remotely: `upload_attachment` reads a path on the *server* and is never registered
  there. The distributor actions never open remotely (the server refuses to start);
  the 3 GunBroker writes open on the **full** connector only, where the POS deploy
  sets `GUNSTORE_MCP_GUNBROKER_ACTIONS` from that store's own GunBroker switch — a
  store that lists on GunBroker must be able to end a listing.
- Env: `GUNSTORE_MCP_TRANSPORT=http`, `FRAPPE_BASE_URL` (the store's public POS URL,
  also the OAuth issuer), `GUNSTORE_MCP_PUBLIC_URL`
  (`https://pos.<domain>/connector/<mode>/mcp`, the URL users are given),
  `GUNSTORE_MCP_PORT`, `GUNSTORE_MCP_MODE`; optional `GUNSTORE_MCP_HOST` (listen
  address, default 127.0.0.1) and `FRAPPE_INTERNAL_URL` (loopback URL of the local
  POS frontend — calls then go there directly with the site's Host header). It runs
  on the POS host behind that host's reverse proxy, which maps the public path to
  `/mcp` and forwards `/.well-known/oauth-protected-resource/<public path>` unchanged.
- Deployment: a container per surface on each store's POS host, behind that host's
  Caddy — image `ghcr.io/old-steel-arsenal/gunstore-pos-mcp` (built on every push to main),
  deployed by each POS release (gunstore-pos `deploy/prod/mcp`, pinned
  `MCP_TAG`) — see `deploy/README.md`.
- POS side, once per site (OAuth Settings): *Show Auth Server Metadata* and
  *Enable Dynamic Client Registration* on; *Skip Authorization* off (every user
  approves). Revoke a user's access in Desk under OAuth Bearer Token.

## Security notes

- Local (stdio) server: uses the key's user (Administrator) = full access. Run it
  **locally only**; keep `.env` out of git (it is, by default). The remote connector
  holds no key — see "Remote connector" above.
- Credentials never transit the MCP: password-type and credential-named fields are
  stripped from every write. Set secrets in Desk directly.
- All writes are logged by Frappe under the key's user (audit trail).
- To revoke access, regenerate that user's keys in Desk.

TDQS

B3.4/5.0

Scored across 77 tools

Disambiguation4/5

Most tools target clearly separated resources and workflows, and descriptions explicitly disambiguate near-twins like woo_push_item vs woo_push_serial and mark_shipped_manually vs mark_consignment_shipped. The sheer number of tools creates some selection noise, but an agent can usually determine the correct tool from the name and description.

Naming Consistency3/5

Names are consistently snake_case and often use domain prefixes like woo_, frappe_, consignment_, and distributor_, which is helpful. However, the set mixes verb_noun actions (dispose_order, create_consignment_out), noun-phrase queue names (pending_orders, consignment_queue), and service-style names (test_connection, sync_catalog), so there is no single predictable pattern.

Tool Count2/5

At 77 tools, this is far beyond the typical well-scoped MCP surface and exceeds the 25+ too-many threshold. While the domain is broad, packing generic CRUD wrappers, integration actions, queues, and reports into one server creates a heavy surface that will strain agent context and tool-selection accuracy.

Completeness4/5

The tool set covers nearly the full gun-store administration lifecycle: inventory, FFL/FastBound compliance, orders, consignments, distributor operations, e-commerce integrations, and financial reporting. A few workflow actions are only reachable via generic frappe_run_method or are missing dedicated tools—e.g., GunBroker push/end and explicit distributor-order confirmation—but these are minor gaps.

Maintenance

ActivityActive
ResponsivenessNo issues