Building MCP for ERP comes down to four decisions. Expose transactions, not tables: a tool should be something a person could do in the system, such as create an invoice or record a payment, with the business rules inside it. Name and describe each tool for the model that will read the list, with the constraints it must respect in the description rather than in documentation it will never see. Let the OAuth identity on each request decide both which tools are listed and whether each call runs. And design every write so that a retry, a refusal or a question is safe, because a model will produce all three.
The transport, discovery and sign-in are specified and any SDK handles them. The value of the server is in those four decisions, and a business system that gets them right is operable by Claude, ChatGPT or any other client without that client knowing anything about it.
Start from the transaction, not the table
The first instinct when exposing an ERP is to generate a tool per table with create, read, update and delete on each. It produces a large, uniform list that a model handles badly, because the business rule that an invoice needs a tax rate on each line, or that stock cannot be dispatched before it is reserved, lives nowhere the model can see. Expose the actions instead. A useful test is whether a person could describe the tool as something they did today: raised an invoice, recorded a payment, moved a deal, snoozed a chase. Each of those carries its rules, validates its inputs, and returns a readable result.
Alongside the actions, add a small number of summary tools that answer the questions a model asks before acting. One call that returns an account's profile, health, open items and recent history saves the model four calls and several thousand tokens of context, and it makes the next action better informed. Sois calls these examiner tools; examineContact and getAccountingSummary are two. Keep the total surface in view too: Claude Code caps a server's output per call by default, and both Claude and OpenAI offer deferred loading or tool search for large lists, so a server with several hundred tools should return them in a deterministic order (the specification asks for this so clients can cache) and should filter by role before listing.
Naming tools so a model picks the right one
The specification constrains names lightly: one to 128 characters, letters, digits, underscore, hyphen and dot, case-sensitive, unique within the server. Everything else is convention, and the convention that works is a verb followed by the business noun in a consistent case, with the same verbs meaning the same things everywhere. A model choosing between searchInvoices, getInvoice and createInvoice is choosing between a list, one record and a write, and it learns that pattern once for the whole server.
| Weak | Better | Why |
|---|---|---|
invoice | createInvoice | A noun alone does not say whether it reads or writes; a client cannot annotate it and a model cannot rank it against its siblings. |
invoiceCreateV2Final | createInvoice | Version and status belong in the server, not the name. Names that change break cached tool lists and prompt caches. |
doAccounting | recordPayment, sendInvoiceReminders | A catch-all with a mode argument hides the transaction. One name per transaction lets the client apply confirmation per tool. |
get_invoice and getContact mixed | One case throughout | Aggregating clients prefix names by server; consistency inside a server is what the model relies on. |
The description carries the rest: when to use the tool, when not to, and any rule the model must respect before calling it.
Descriptions are read by a model under pressure to act, so write them as instructions. State what the tool does in the first sentence, then the conditions. If a sibling tool is the right choice for a nearby request, say so by name. If a field must be set for the result to be correct, say that in capitals if you have to; Sois's invoice tool tells the model that the tax rate must be set on each line and that the exact rates come from listTaxTypes, because an invoice with no VAT is a worse failure than a refused call. Include one example call. Everything the model needs to call the tool correctly should be in the tool, because it will never open your documentation.
An example tool definition
This is a Sois invoice tool as a client receives it from tools/list, trimmed to the fields that matter, with annotations and an output schema added in the form the current specification defines. It shows the pattern: a verb-noun name, an instructional description, a schema whose property descriptions do the model's error-prevention, and hints a client can use to decide whether to confirm.
{
"name": "createInvoice",
"title": "Create invoice",
"description": "Create a new invoice of any type and return the draft with its auto-generated number. TAX: set tax_rate on each line (for example 20 for 20% VAT); call listTaxTypes for this workspace's exact rates. If the user says 'plus VAT' you MUST set tax_rate or the invoice goes out with no VAT. To email the result use sendInvoice. Example: createInvoice({ type: \"sales_invoice\", contact_id: \"uuid\", currency: \"GBP\", lines: [{ description: \"Consulting\", quantity: 10, unit_price: 150 }] })",
"inputSchema": {
"type": "object",
"properties": {
"type": { "type": "string", "description": "sales_invoice, purchase_invoice, sales_credit_note or purchase_credit_note" },
"contact_id": { "type": "string", "description": "Contact UUID (bill-to for sales, bill-from for purchases)" },
"currency": { "type": "string", "description": "ISO code, for example GBP. Uses the workspace default if omitted" },
"invoice_date": { "type": "string", "description": "ISO date. Defaults to today" },
"reference": { "type": "string" },
"lines": {
"type": "array",
"description": "Line items. Every line MUST carry the numeric unit_price the user asked for",
"items": {
"type": "object",
"properties": {
"description": { "type": "string" },
"quantity": { "type": "number", "description": "Defaults to 1" },
"unit_price": { "type": "number", "description": "NUMBER only: no currency symbols, no thousands separators. Use the exact amount stated; never guess or round" },
"tax_rate": { "type": "number" },
"discount_percent": { "type": "number" }
},
"required": ["description", "unit_price"]
}
}
},
"required": ["type"]
},
"outputSchema": {
"type": "object",
"properties": {
"invoice_id": { "type": "string" },
"number": { "type": "string" },
"status": { "type": "string" },
"total": { "type": "number" }
},
"required": ["invoice_id", "number", "status"]
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false
}
}The annotations say: this writes, it only adds (a draft), calling it twice makes two drafts, and it touches nothing outside the system. Clients must treat annotations as untrusted unless the server is trusted, so they are hints for confirmation behaviour, not a substitute for the server's own checks.
Three choices in that definition are deliberate. The result returns an identifier the model must carry into sendInvoice and recordPayment, which is the specification's recommended way to relate calls now that servers hold no session state. The tool creates a draft, not a posted invoice, so the write is additive and a person or a separate approval tool finalises it. And the output schema means an integration can read the number and total as data while the model reads the same result as text.
Scoping tools to the user
Over HTTP the caller arrives with an OAuth access token bound to your server, and that token identifies a person. The specification allows the result of tools/list to vary by the credentials on the request, so the first scoping decision is to filter the list by that person's role before returning it: a warehouse user does not receive approveInvoice. The list must not vary per connection or as a side effect of other calls, only by authorisation, which is what makes it cacheable.
The second decision is to check again at execution. A client can send any call it wants, and a model can be manipulated by text in a tool result into trying one. Resolve the user from the token on every call, check the permission the tool requires, and refuse with a tool execution error the model can read. Keep OAuth scopes coarse (Sois issues read, write and offline scopes) and let the ERP's own roles be the fine-grained boundary, because those roles already exist, are already maintained, and already mean something to the business. Attribute every call to the person in the log with its arguments and result, so an agent's work is reviewable exactly as a person's is.
- Token arrivesValidate the signature and that the audience is this server, as RFC 8707 requires; reject anything else with 401.
- Resolve the personMap the token to a user in the workspace and load their role and installed apps.
- Filter the listReturn only the tools that role may use, in a stable order, from tools/list.
- Check the callOn tools/call, check the permission again and refuse with isError if it is missing; nothing runs.
- Run and logExecute the transaction, meter it if your own agent did the reasoning, and write the call to the audit log under that person.
Handling writes
A model that receives an ambiguous result will call again, and one that receives an error will try a corrected input. Design for that. Writes that create should return a handle and, where possible, accept an idempotency key or a natural key so a repeat is detected. Writes that change state should be explicit about the transition they perform and refuse impossible ones with a readable reason: recording a payment against a void invoice is an isError result saying so, not a silent no-op and not a stack trace. Never leave a partial write; if a multi-step tool cannot complete, roll back and report.
- Prefer drafts and approvals. Make creation additive (a draft) and give finalisation its own tool with its own permission, so the destructive step is the one a client confirms and a role controls.
- Mark destructive tools. Set
destructiveHinton voids and deletions and say so in the description; Claude and ChatGPT both use such signals when deciding to ask before calling. - Ask rather than guess. When a call needs a decision the tool cannot make, return an input-required result with an elicitation request; the client puts the question to the person and retries the call with the answer.
- Bound the blast radius. Rate-limit per connection, cap spend per integration where your own agent does reasoning, and validate every input server-side regardless of the schema, because the schema is advice to the model, not enforcement.
Testing the surface with a real client
The MCP inspector will exercise tools/list and tools/call and walk the OAuth flow. The real test is a model. Connect Claude as a custom connector, or ChatGPT in developer mode, sign in as a user with a narrow role, and ask for one routine outcome that needs three or four tools. Watch which tools it chooses and why; a wrong choice is almost always a description problem. Then sign in as a user without one of the permissions and confirm the run stops at the right call with a reason the model repeats back.
This is how the Sois workspace server is built and checked: transactions as tools, instructional descriptions, a role-filtered list, a second check on every call, drafts before approvals, and a log a person can read. Developers building apps for the marketplace publish tools into the same list under the same rules, so an app is operable by any agent the moment it is installed. The pattern is not specific to one product; any ERP that adopts it becomes something an agent can run.
Questions people ask
How many tools should an ERP MCP server expose?
As many as there are transactions worth automating, filtered per user so each caller sees a working set. Several hundred is normal for a full system; what matters is that the list is stable, filtered by role, and organised by consistent verbs so the model can rank candidates.
Should I use OAuth scopes for fine-grained permissions?
Use coarse scopes for the connection and the ERP's own roles for the fine-grained boundary, checked on every call. Roles already exist and are maintained by the business; a parallel scope scheme would drift from them.
How should a write behave if the model calls it twice?
Either detect the repeat through an idempotency or natural key and return the existing record, or make the write additive and clearly reported so the duplicate is visible. Never fail silently, and never leave a partial write.
Are tool annotations enforced by the client?
No. They are hints, and the specification tells clients to treat them as untrusted unless the server is trusted. Clients use them to choose confirmation behaviour; the server's own permission and validation checks are what prevent harm.
- Model Context Protocol specification (2026-07-28): tools tool names, schemas, annotations, structured results, error handling and the stateful-handle guidance
- Model Context Protocol specification: authorization token audience validation, scope challenges and the per-request authorisation model
- OpenAI Apps SDK: build an MCP server how ChatGPT uses readOnlyHint, destructiveHint and openWorldHint for confirmation behaviour
- Sois documentation: the workspace MCP server the tool reference the example is drawn from, role filtering, limits and error codes
This article is reviewed when the products it describes change. Next scheduled review: 4 December 2026.
