Designing 110 MCP Tools to Let AI Safely Operate Production Warehouse Data
Flash Warehouse WMS exposes 110 MCP tools to AI agents across 12 business domains. This article details how we organized tools by domain, enforced read/write separation, used WARNING prefixes, locked tenant scope via an unexported Go struct field, and added a panic-on-startup schema validator.
Background: AI Agents Need to Touch Real Warehouse Data
The Flash Warehouse WMS CLI tool fwh includes a built-in MCP (Model Context Protocol) Server that lets AI coding assistants such as Claude Code, Cursor, and Windsurf call warehouse management APIs directly. This means an AI agent can query inventory, approve bills, create products -- all against production data.
This raises a fundamental question: how do you give AI enough capability to be useful while ensuring it cannot cross tenant boundaries, accidentally delete data, or access sensitive fields?
This article describes the safety architecture behind the 110 MCP tools we built for Flash Warehouse WMS.
Domain Organization: 12 Registration Functions, One File Per Domain
The 110 tools are split across 12 registration functions, each in its own Go source file:
| Domain | File | Tools | Examples |
|---|---|---|---|
| Goods management | tools_goods.go | 11 | goods_list, goods_create, goods_delete |
| Bill operations | tools_bill.go | 27 | bill_list, bill_audit, purchase_input_create |
| Warehouse management | tools_warehouse.go | 4 | warehouse_list, warehouse_create |
| Inventory alerts | tools_alert.go | 6 | alert_overview, alert_set_config |
| Pending stock approval | tools_stock.go | 4 | stock_pending, stock_approve_all |
| Stocktake tasks | tools_check.go | 9 | check_task_list, check_task_finish |
| People and RBAC | tools_people.go | 20 | staff_list, role_create, partner_add |
| BI dashboard | tools_dashboard.go | 9 | bi_total_value, bi_line_chart, pos_sell |
| System config | tools_config.go | 17 | config_read, print_template_create, account_update |
| Other in/out bills | tools_other_bill.go | 2 | other_input_bill_create |
| Identity | tools_whoami.go | 1 | whoami |
In server.go, the Serve function calls all 12 registration functions in sequence, passing the enableWrites flag to each.
Read/Write Separation: 46 Read-Only + 64 Write Tools
Every registration function follows the same pattern: register read tools first, then return early if writes are disabled.
func registerGoodsTools(s *mcpsdk.Server, svc *service.Service, enableWrites bool) {
// Read tools -- always registered
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "goods_list",
Description: "List goods...",
}, handler)
if !enableWrites {
return // Stop here -- no write tools
}
// Write tools -- only when enableWrites is true
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "goods_create",
Description: "WARNING: this modifies production data...",
}, handler)
}
The CLI defaults EnableWrites to false. Users must explicitly pass --enable-writes to unlock mutating operations. By default, an AI agent can only read.
The WARNING Prefix: Semantic-Level Defense for LLMs
All 64 write tools have descriptions that begin with WARNING:. This is not decoration -- it leverages the instruction-following behavior of LLMs to reduce accidental mutations. When an agent reads "WARNING: this permanently deletes a goods record," it is more likely to confirm with the user before proceeding.
For irreversible operations (deletions, approvals, bulk actions), we add a second layer: a confirm field that the agent must set to the exact string "YES":
func RequireConfirm(confirm string) error {
if confirm != "YES" {
return ErrNotConfirmed
}
return nil
}
This dual protection (WARNING description + confirm field) ensures destructive operations require an explicit, auditable decision from the agent.
Tenant Lock: The Unexported Go Struct Field
Multi-tenant isolation is the security foundation of WMS. The backend uses binding_user_id to scope every query to a single tenant. In the MCP Server, we enforce this constraint using Go's visibility rules:
type Session struct {
userID int // lowercase = unexported, invisible outside the package
UserName string `json:"user_name"`
BaseURL string `json:"base_url"`
LoggedAt time.Time `json:"logged_at"`
}
The userID field is lowercase, which means:
- No setter method -- once created via
NewSession(), the value cannot be changed - Invisible outside the package -- MCP tool code and CLI command code cannot read or write it directly
- Only the
UserID()method exposes it, and onlyinternal/apicalls that method
This creates a compiler-enforced guarantee: binding_user_id can only come from the login response. It cannot be overridden by CLI flags, environment variables, or MCP tool arguments.
validateInputShape: Panic-on-Startup Schema Check
Even with the above mechanism, if a developer accidentally adds a binding_user_id or user_id field to a tool input struct, an AI agent could bypass tenant isolation. To catch this, we introduced a startup-time validator:
var forbiddenInputFields = map[string]struct{}{
"binding_user_id": {},
"user_id": {},
"tenant_id": {},
// ... and their variants
}
func validateInputShape[T any]() {
// Reflects over every struct field
// If any json tag matches forbiddenInputFields -> panic
}
Every tool calls validateInputShape at registration time. If any input type exposes a forbidden field, the program panics at startup rather than failing silently at runtime. This shifts a potential security vulnerability from "discovered in production" to "caught before the server even starts."
Per-Call Session Validation
Beyond static startup checks, the first line of every tool handler calls ensureSessionAlive:
func ensureSessionAlive(svc *service.Service) error {
fresh, err := config.LoadSession()
// Session file deleted (user ran `fwh logout`) -> error
// Session file user_id differs from in-memory -> error
}
This catches an edge case: the MCP Server is running, but the user logs out or switches accounts in another terminal.
Response Sanitization: AI Should Not See Passwords
Raw database rows from the backend may contain fields like loginPassword and bankAccount. The SanitizeResponse function automatically scrubs every tool response before it reaches the agent -- stripping passwords, masking financial PII, and removing MyBatis Plus internal pagination fields.
Summary
The 110 MCP tools in Flash Warehouse WMS embody a "secure by default, escalate explicitly" design:
- Domain organization -- 12 files with clear boundaries, easy to audit
- Read-only by default -- write tools require explicit opt-in
- Semantic warnings -- WARNING prefix + confirm field for dual protection
- Compiler-enforced isolation -- unexported field + no setter = tenant lock cannot be bypassed
- Startup validation --
validateInputShapecatches tenant field leaks before the server starts - Runtime validation -- every call re-verifies session validity
- Response sanitization -- sensitive data never reaches the AI agent
If you are building a tool layer for AI agents that operate on production data, we hope this architecture provides useful patterns. The Flash Warehouse WMS MCP Server source lives in flash_warehouse_cli/internal/mcp/.