Read freely, confirm every change: MCP tools for golf cart fleets
Split a golf cart fleet's MCP tools into two groups. Read tools (battery, location, faults, zone breaches, trips) answer questions and can run freely within a person's permissions. Action tools (create a work order, lock
Split a golf cart fleet's MCP tools into two groups. Read tools (battery, location, faults, zone breaches, trips) answer questions and can run freely within a person's permissions. Action tools (create a work order, lock, unlock, restart) never act on their own; they return a proposal, and a person confirms it in the AI app. Scope every call to the signed-in person, refuse vehicle commands during an active trip, mask rider personal data by role, and log everything.
That is the whole design. The rest is how to build it: two illustrative JSON shapes, a guardrail table and a pre-launch checklist.
Why split fleet MCP tools into read and action tools?
Because the failure modes differ. A bad read gives a wrong answer a person can check against the console. A bad action locks a cart with a guest on it, restarts a device mid-ride, or opens a work order on someone else's vehicle.
The MCP specification puts it plainly: "For trust & safety and security, there SHOULD always be a human in the loop with the ability to deny tool invocations." It also says applications SHOULD "Present confirmation prompts to the user for operations". Source: MCP specification, Tools.
Treat every tool the same and you either prompt on every "Which vehicles are low on battery before Saturday's rentals?" or you let a model lock carts. Two classes, two rule sets.
What does a read tool look like?
A read tool takes a scope plus filters, returns facts, and is annotated read-only. Discovery happens through tools/list, calls through tools/call. An illustrative shape, not any product's real tool:
{
"name": "list_low_battery_vehicles",
"title": "List low-battery vehicles",
"description": "Returns vehicles in one fleet whose latest battery reading is at or below a threshold. Read-only.",
"inputSchema": {
"type": "object",
"properties": {
"fleet_id": {
"type": "string",
"description": "A fleet the signed-in person can access"
},
"battery_percent_max": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"default": 30
},
"booked_before": {
"type": "string",
"format": "date-time",
"description": "Optional. Only vehicles with a rental starting before this time"
}
},
"required": ["fleet_id"]
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false
}
}
Three things to notice. fleet_id is required, and the server checks the signed-in person can access that fleet instead of trusting the argument. The threshold has bounds in the schema, and the server validates again on every call, because the spec says servers MUST validate all tool inputs. The annotation tells the client this tool is safe to run without a prompt, but clients MUST treat annotations as untrusted unless they come from a trusted server, so it is a UI hint, not a security boundary.
Other reads worth building: vehicles that left a zone or entered an off-limits area in a date range (zones warn the rider and record the breach; they do not touch the cart), carts whose health score dropped this week, open faults, trips per cart, carts that moved while locked, vehicles due for service.
Why should action tools return a proposal instead of acting?
Because the model is drafting, not deciding. An action tool validates the request, runs the same checks the console would run, and returns a proposal the client can show the person. Nothing changes until a second call carries that person's confirmation. Again an illustrative shape, not any product's real tool:
{
"status": "proposed",
"proposal_id": "prop_8f3a21",
"action": "lock_vehicle",
"vehicle_id": "veh_0142",
"requested_by": "user_77",
"checks": {
"role_may_lock": true,
"vehicle_in_workspace": true,
"active_trip": false
},
"expires_at": "2026-10-29T14:35:00Z",
"message": "Lock cart 142? Nothing happens until you confirm."
}
The AI app renders this as a confirmation prompt. On yes, the client calls a separate confirm_proposal tool with the proposal_id. If the proposal has expired, or the checks no longer pass because a trip started in the meantime, the confirmation fails and the person is told why. There is no path from "lock cart 142" to a locked cart without a human decision, and the propose and confirm steps are the only two places state changes, so they are the only two places you have to harden.
How do you scope permissions per person and role?
Give the AI app the same view the person has in the console, nothing more, and no shared service account. Each session is tied to one human login, so tools/list returns only the tools that role may use and every tools/call is filtered to that person's workspace. Three scopes matter:
- Workspace. A dealer supporting several customer fleets sees only the fleet they are signed into.
- Role. A mechanic can propose work orders; a front desk role may read battery and location but cannot propose a restart.
- Money. If a role cannot see revenue in the console, money tools return nothing in the AI app. Not zeros, not a "restricted" placeholder, nothing, because a model reasons over whatever it is handed.
When should the server refuse a command outright?
Some refusals belong above the confirmation step, because no one should be able to confirm them from a chat window. The clearest for carts: refuse lock, unlock and restart while a rider is on an active trip. A locked cart on a fairway is a stranded guest. Add refusals for a vehicle outside the person's workspace and for bulk commands when the role may act on one vehicle at a time.
How do you mask rider data by role?
Trips and riders are the most useful reads and the most sensitive. Mask at the server, per field, per role. A role that cannot see rider names, emails and phones in the console gets the same fields masked in every response: the trip, cart, times and route stay visible; identity fields come back as masked tokens. Do it before serialization so no tool can leak a raw field by accident, and sanitize free text (support notes, damage descriptions) the same way, since the spec requires servers to sanitize tool outputs.
What should the audit log record?
The spec says clients SHOULD log tool usage. Log on the server too, since you hold the facts. For each call, record the human account (not the AI app), the tool, the validated inputs, the resolved workspace and the result size. For actions, add the proposal id, the checks run, who confirmed, when, and the command outcome. Rate limit per person and per tool so a looping client cannot hammer reads or flood the console with proposals.
The whole model in one table:
| Tool type | Examples | What the AI app may do | Guardrail |
|---|---|---|---|
| Read: vehicle state | Battery, location, health score, open faults | Query and summarize freely | Scoped to the person's workspace and role |
| Read: activity | Trips, zone breaches, locked-cart movement, ride replays | Query and summarize freely | Rider identity fields masked by role |
| Read: money | Revenue, receipts, pass sales | Query only if the role sees revenue in the console | Nothing returned to other roles |
| Action: maintenance | Create or close a work order | Propose only | Person confirms; proposal id logged |
| Action: vehicle command | Lock, unlock, restart | Propose only | Refused during an active trip; person confirms |
What should you check before you expose a fleet to an AI app?
- List every tool and label it read or action. Nothing in between.
- Check each action tool returns a proposal and has no code path that executes on its own.
- Tie each session to one human account. No shared tokens for AI apps.
- Mirror console permissions exactly: workspace, role, revenue visibility.
- Mask rider names, emails and phones for roles that cannot see them in the console.
- Add hard refusals no confirmation can override, starting with commands during an active trip.
- Validate every input on the server and drop fields the model invents.
- Rate limit per person and per tool.
- Log every
tools/call, proposal and confirmation with who, what, when and result. - Test with the least privileged role first, then with prompts designed to talk the model into escalating.
What does this look like in practice?
At Axons Mobility we built this pattern into the Axons Mobility MCP server. It exposes 40+ tools across vehicles and devices, trips and riders, money, repairs and support, pricing and zones, and insights and reports. Each person gets only their own permissions, matching the console: their workspace, what their role allows, masked rider names, emails and phones for roles that cannot see them, and no revenue in the AI app for anyone who cannot see it in the console.
The AI app can propose a work order or a lock, unlock or restart command, but nothing happens until a person confirms, and vehicle commands are refused while a rider is on a trip. To try the pattern on a fleet: copy your MCP address from the console, add it as a connector in Claude, ChatGPT or another MCP app, sign in with your own Axons Mobility account, approve the connection, and ask which vehicles are due for service.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.