Dentolize · Claude MCP Server (Phase 1) Walkthrough
On this pageBusiness viewTechnical view

The inventory tools

Business view

Once connected, a doctor can ask Claude three kinds of question about their clinic's inventory, and Claude answers by calling one of three tools:

  1. "What's our current stock look like?" — every item on hand, its

quantity, its value, and whether it's fallen to or below its reorder point. Can be filtered to just the items running low, or to items whose name matches a search.

  1. "What have we used up recently?" — consumption over a time window (3

months by default, up to 24), with quantity, cost, and when each item was last used. Netted against any returned stock, so it reflects what was actually consumed.

  1. "What are we spending with suppliers?" — spend by supplier over a time

window (12 months by default, up to 60), including a separate count of orders that don't have a supplier recorded, so sloppy record-keeping shows up as a visible number rather than silently disappearing from the total.

There's also a fourth, invisible-to-the-question tool — clinic context — that just reports which clinic and user a connection belongs to. It exists so a doctor can ask "who am I connected as?" and get a straight answer before trusting anything else.

The tools show exactly what the doctor could already see in clinic-web — never more. If a doctor's role hides stock values or quantities in clinic-web, the same fields come back hidden here, with a plain-English note explaining which permission would unlock them (withheld, on every response). If a doctor can only see inventory items they created themselves, the tools apply that same narrowing. This is not a best-effort translation of permissions — every tool loads the same permission group clinic-web reads and runs the same checks clinic-web's own resolvers run.

Technical view

Where they live

packages/claude-mcp/src/tools/inventory/{stockLevels,consumption,supplierSpend}.ts, registered onto the MCP SDK's server object in packages/claude-mcp/src/mcp/server.ts:123-197. Every tool is tagged readOnlyHint: true, destructiveHint: false (server.ts:27-32) so an MCP client can show the user these are safe, non-mutating calls.

The shared shape every tool follows

Each tool (e.g. stockLevels.ts:70-193) does the same five things, in order:

  1. requireScope(caller, 'inventory:read') — refuses before touching the

database if the token wasn't granted this scope.

  1. withTenant(pool, caller.companyId, ...) — opens a transaction scoped to

exactly one clinic (see Tenant isolation and the read-only role).

  1. loadPermissions(client, { userId, companyId }) — reads the caller's

permission group, the same one clinic-web reads.

  1. Runs the SQL, shaping the WHERE clause and hiding fields based on those

permissions.

  1. Returns a JSON object naming the clinic, the filters actually applied

(echoed back, not just what was asked for), the totals over every matching row (not only the ones listed), what was withheld and why, and a truncated flag so a capped list is never mistaken for the complete picture.

No tool accepts a clinic as an argument — ever

This is stated as an invariant in three places independently: the type definition of AuthenticatedCaller (auth/types.ts:23-27: "no tool may have [a companyId parameter]"), the doc comment on withTenant (db/tenant.ts:82-83), and every individual tool's argument interface, none of which includes a company field. The clinic a tool answers for comes from the caller's token, set once by createMcpServer (mcp/server.ts:108-116) and closed over for the lifetime of that one request — never passed in, never reachable by argument injection.

Numbers come back as strings, not floats

stockLevels.ts:9-13: quantities are Decimal(15,6) and money is money; routing either through a JavaScript number introduces floating-point drift that would make the tool's answer disagree with clinic-web's own display of the same figure. Every quantity and every money value is computed in Postgres and returned as text.

Matching the API's own filtering rules exactly, not approximately

Each tool's comments explicitly cross-reference the API helper it has to agree with:

  • stockLevels.ts mirrors getWhereForInventoryItems's permission check

(VIEW_INVENTORY or VIEW_CREATED_INVENTORY) and its boundary condition — amount < "minAmount", strictly less than. An earlier draft used <=, which reported an item sitting exactly on its reorder point as low, when clinic-web does not consider it low (BUGS.md §3). No seeded test data sat on that exact boundary, so the bug shipped invisibly until a dedicated fixture (sql/004_boundary_fixtures.sql) was added specifically to sit on it.

  • consumption.ts mirrors getWhereForInventoryTransactions: DO_ALL sees

every transaction type; anyone else needs VIEW_INVENTORY_USAGE specifically, checked with a raw .includes() rather than the wildcard-aware can() helper, because that's what the API does too (consumption.ts:82-91).

  • supplierSpend.ts mirrors getWhereForInventoryOrders: DO_ALL or

VIEW_INVENTORY_PURCHASE, and narrows by receivedById OR createdById when the caller only has VIEW_CREATED_INVENTORY — a different pair of columns than the narrowing rule used by the other two tools, because orders and stock movements record "who did this" differently.

Timezone-aware date windows

consumption.ts and supplierSpend.ts are the two tools with a date range, and "the last three months" is defined in the clinic's own timezone, not the server's. The window is computed in Postgres — date_trunc('day', now() at time zone $1) - interval '...' — and then converted back to UTC for comparison, because every timestamp column in this schema is stored as naive UTC (see Security hardening and bugs fixed for what goes wrong when that rule is violated). A clinic with no timezone configured falls back to UTC rather than inheriting wherever the Node process happens to be running.

Unattributed data is counted, never dropped

supplierSpend.ts counts orders with no supplier recorded (ordersWithoutSupplier) in the same window and under the same filters as the attributed totals, specifically so the two numbers can be read against each other. The code comment is direct about why: "a supplier report that silently omitted them would answer 'what did we spend' with a number below the truth while looking complete" — and that gap would grow precisely with the sloppy record-keeping a doctor most needs to be told about.