If you’ve tried to wire Claude or another MCP-aware client into NetSuite, you’ve probably already hit the wall. The public MCP servers I’ve seen for NetSuite are demos. They authenticate with personal OAuth tokens hardcoded into env vars, ship a list of record types that match exactly one account, and expose raw SuiteQL as a single tool with no validation. That’s a great way to get Claude to invent a customrecord_revenue_2024 table that doesn’t exist, get back a 400, freelance an explanation, and confidently tell the user something wrong.
I’ve built production MCP servers for NetSuite. This post is the pattern that actually ships. None of it is exotic, but every piece matters, and every piece is the thing that off-the-shelf tools either skip or get wrong.
Why this is harder than it looks
NetSuite is not Postgres. Four things make a generic "expose-the-database" MCP server fail in NetSuite specifically:
- Auth is its own animal. NetSuite supports OAuth 2.0 now, but the access tokens are short-lived and the refresh dance has account-specific gotchas (sometimes refresh tokens rotate, sometimes they don’t). The right answer is OAuth 2.0 with PKCE for per-user flows and Client Credentials for service-to-service, not the legacy TBA over OAuth 1.0a path you’ll see in older tutorials.
- Every account’s schema is different. Custom record types, custom body/entity/item/column fields, sandbox vs production divergence, even standard records the customer has hidden or renamed. A hardcoded field list is wrong inside a week.
- Schema discovery is not a single endpoint. Standard records come from the REST metadata-catalog. Custom records and custom fields come from SuiteQL. Form-level metadata (which fields are mandatory on a given transaction form) needs a custom RESTlet. Pick one source and you’re missing half the picture.
- Governance is real. NetSuite charges you usage units per API call. A poorly-shaped tool that fans out into 200 record loads will run you out of budget, fail mid-stream, and leave the agent confused.
Get any of these wrong and you ship something that demos well and falls over the first time a real customer asks an off-script question.
Auth: OAuth 2.0 + PKCE, not TBA
Older NetSuite integration guides default to Token-Based Authentication over OAuth 1.0a with HMAC-SHA256 signing. It works, but it’s the long way around in 2026. OAuth 2.0 has been GA on NetSuite for years now and it’s where Oracle is putting the investment.
For an MCP server, you almost always want the Authorization Code grant with PKCE for per-user access (so each end user is acting under their own NetSuite role and permissions), and Client Credentials with M2M tokens for service-to-service work (cron jobs, schema sync, the things that run as the application itself). Same OAuth Integration record on the NetSuite side, two flows.
Authorize endpoint, exchange endpoint, and the URL gotcha:
// Build the authorize URL. PKCE challenge + state go in the query string.
const url = new URL(
'https://' + toUrlAccountId(accountId) +
'.app.netsuite.com/app/login/oauth2/authorize.nl'
);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', 'restlets rest_webservices');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
// Token exchange. Basic auth with client_id:client_secret in the header.
const res = await request(
'https://' + toUrlAccountId(accountId) +
'.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token',
{
method: 'POST',
headers: {
Authorization: 'Basic ' + Buffer.from(clientId + ':' + clientSecret).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: verifier,
}).toString(),
},
);
Three details that bite people:
- Account ID has two forms. The canonical form uses underscores:
1234567for production,1234567_SB1for sandbox,1234567_TSTDRV1234for a test drive. The URL form uses dashes and lowercase:1234567-sb1.app.netsuite.com. Storage keeps the canonical form to match what users see in the NetSuite UI; URLs require the dash form. Have one normalisation helper and use it everywhere. Mixing them up is the single most common cause of silent 403s. - Refresh tokens may or may not rotate. Most NS accounts return a new
refresh_tokenon every refresh call. Some don’t. Treatrefresh_tokenas optional in the response and only overwrite stored state if the new value is non-empty. Otherwise you’ll occasionally null out a working token. - Don’t rely on the stock OIDC userinfo endpoint for identity. NetSuite’s
/services/rest/auth/oauth2/v1/userinfoonly works if the account has flipped on the "NetSuite as OIDC Provider" feature. Most don’t. To reliably get role + employee for the calling user, deploy a tiny SuiteScript RESTlet that returnsnlapiGetContext()identity. Use the OIDC endpoint as the fallback when the RESTlet isn’t deployed.
Schema discovery: three sources, one cache
This is where most public MCP servers stop short. They hardcode customer, salesorder, invoice. They miss customrecord_* entirely. They have no idea which fields are mandatory on which forms. That works on a vanilla sandbox for a demo. It falls over in any real account.
Three NetSuite surfaces are needed to build a complete schema picture:
- REST metadata-catalog endpoint (
/services/rest/record/v1/metadata-catalog). The canonical source for standard records and most of their body fields. JSON-Schema shaped. You walk it record-type by record-type. NetSuite does not expose a single "list all record types" endpoint, so you’ll need to ship a curated list of standard record types and union it with the customs you find via SuiteQL. - SuiteQL against the system tables (
customrecordtype,customfield, etc). The way you actually findcustomrecord_*tables that exist on this account, plus thecustbody_*,custentity_*,custcol_*, andcustitem_*fields layered on top of the standards. - A small custom RESTlet for form-level metadata. Things like "is this field mandatory on the Standard Sales Order form" only live in the form definition, not in the record metadata. The RESTlet runs SuiteScript that reads
record.create.promise({ type, isDynamic: true })and pulls out the field metadata. This call is heavier (2-3 seconds each), so design for parallelism with a sane concurrency cap. M2M tokens allow around 50 concurrent calls per integration record; per-user OAuth tops out around 10. Pick the smaller ceiling so a fallback doesn’t throttle you.
That schema sync is not a 15-minute TTL. A complete walk runs hundreds of metadata calls, plus the custom-field SuiteQL pass, plus form-metadata RESTlet calls. I run it as a weekly cron and on demand. NetSuite schema doesn’t change often; when it does (someone adds a field), the affected tool either gets the new field on the next sync or the validator surfaces a friendly "this field isn’t in the schema cache yet, ask the admin to trigger a sync" message.
Cache everything in your own Postgres (or whatever you’re using). Tables like ns_record_types, ns_fields, ns_custom_lists with account_id as part of every primary key. The tool layer reads from your cache, not from NS, on every call. The cache is the source of truth for "what tools are valid right now."
Tool design: structured tools and a guarded SuiteQL escape hatch
Earlier versions of this post argued for structured tools only, no raw SuiteQL. I’ve changed my mind. Structured tools are still the primary surface for 90% of questions. But there’s a class of analytic queries (joins across transactionline, group-bys over revenue, line-level COGS) that structured tools can’t express cleanly. Refusing those questions makes the agent worse than NetSuite itself. So the right shape is: a curated set of structured tools, plus ONE raw-SuiteQL tool with serious guardrails.
Structured tools cover:
ns_get_schema,ns_find_fields,ns_describe_record,ns_describe_form: discovery. The agent calls these first to learn what fields exist on this account.ns_get_record: read one record by internalId with the requested fields.search_netsuite: structured search with filters and field selection. The dependent input schema only accepts record types and fields the cache knows about, so Claude can’t invent identifiers.list_records,list_list_values: list scoping and code-list resolution (the "Status: Open vs SalesOrd:A" problem).ns_validate_record,ns_create_record,ns_update_record,ns_record_action: writes with the guard protocol below.
The escape hatch is ns_suiteql. It’s a raw SuiteQL tool that goes through three layers of validation BEFORE it ever touches NetSuite:
// 1. Parse the query. Reject multi-statement / DDL / DML up front.
const inspection = inspectSuiteql(query);
if (inspection.hasMultipleStatements) {
throw new ToolError(
ToolErrorCode.INVALID_INPUT,
'Multi-statement queries are not supported. Submit one SELECT at a time.',
);
}
// 2. Validate every referenced record type against the schema cache.
// Tables outside the discovered set (e.g. transactionline, entity, bom,
// accountingbookmap) pass through with a soft warning. Anything that
// looks like a hallucinated customrecord_* hard-rejects.
for (const t of inspection.tables) {
if (t.startsWith('customrecord_') && !schema.recordTypeIds.has(t)) {
throw new ToolError(
ToolErrorCode.HALLUCINATED_RECORD_TYPE,
'Custom record \`' + t + '\` does not exist on this account.',
{ closest: closestMatch(t, [...schema.recordTypeIds]) },
);
}
}
// 3. Validate referenced custom fields the same way.
for (const f of inspection.fields) {
if (/^cust[a-z]+_/.test(f) && !schema.customFieldIds.has(f)) {
throw new ToolError(
ToolErrorCode.HALLUCINATED_FIELD,
'Custom field \`' + f + '\` does not exist on this account.',
{ closest: closestMatch(f, [...schema.customFieldIds]) },
);
}
}
For the parser, node-sql-parser with the Postgres dialect works as a primary. SuiteQL has its own functions (BUILTIN.DF, BUILTIN.CURRENT_DATE, etc.) that the parser doesn’t recognise, so you need a regex fallback to extract table and column identifiers. Either path returns the same shape downstream.
The closest-match suggestion is the bit that earns its keep. When Claude hallucinates customrecord_revenue_2024 and the cache only has customrecord_revenue_snapshot, you don’t just refuse. You return a structured error pointing at the closest candidate. The model retries with the correct identifier and the user never sees the failure.
Responses: the envelope pattern
Every tool returns { data, _meta }. The meta carries source (NetSuite is authoritative; RAG is probabilistic), confidence, freshness (when the schema cache was last synced), citations (internal IDs and record types so the model quotes them verbatim), and refusal for structured "I can’t answer" responses with both user-safe text and model-only retry hints.
I wrote a separate post on the envelope shape: Designing MCP Tool Responses That Don’t Make Claude Lie. The whole point is making trust legible to the model so it stops freelancing on failed calls.
Writes: four layers of safety, not one boolean
Early versions of this post suggested a single confirm_write: true boolean on write tools. That’s not enough. Production write tools need four overlapping protections:
- Two-call acknowledgement protocol. The first call to a write tool always refuses with code
REVIEW_DOCS_FIRSTand returns a payload of relevant policy/process documents pulled from RAG. The agent reads them, judges whether the proposed change is consistent, and re-calls withacknowledged: true. This is the single biggest improvement I’ve made to write safety: instead of "are you sure", the agent has to consider the actual policy docs before the write goes through. - Idempotency keys. Every write tool requires an
idempotency_keyin the call. The server stores a hash of (tool, key, request body) and replays the cached response on duplicate calls. This protects against the model retrying after a transient network failure and creating two records by accident. - Optimistic concurrency. Updates take an optional
expected_lastmodifiedISO timestamp (pulled from a priorns_get_recordcall). If NetSuite’s current value doesn’t match, the update aborts with a clear refusal. Without this, an agent and a human can stomp each other’s edits. - External audit log. Every write call is logged to your own Postgres with timestamp, user, record type, internal ID, before/after field values, the prompt that triggered it, and the model’s reasoning. The log lives OUTSIDE NetSuite so it survives an NS outage and can’t be deleted by the agent. This is also what you hand to compliance when they ask.
Schema validation happens before any of these. The write tools call into the same field-validation layer the SuiteQL guard uses, so a write to a field that doesn’t exist gets refused at the same place as a read.
If you’re building your first one, do it in this order
- Auth first. Until you can hit
/services/rest/record/v1/customer/{id}from your code and get a 200 back, nothing else matters. Get OAuth 2.0 + PKCE working with a single hardcoded record fetch before you touch anything else. - One read tool. Build
ns_get_recordonly. Wire it into Claude Desktop or the MCP inspector. See what real answers look like and where Claude struggles. - Schema discovery. Add the metadata-catalog walk for standard records first; cache the result in Postgres. The custom-fields SuiteQL pass can wait until you actually need it.
- Structured search. Build
search_netsuitewith the schema-driven dependent input schema. This is where the system starts to feel like an analyst. - The SuiteQL escape hatch. Add
ns_suiteqlwith the validation guards above. Don’t ship it without the guards. You’ll regret it. - Writes last, and only if you actually need them. Read-only is a lot of value on its own, and it dramatically simplifies your security review with whoever owns NetSuite at the customer.
Where this is going
Once Claude can read NetSuite, query Drive, and act through the same MCP protocol against Ads and Stripe, you have an analyst that can answer cross-system questions in seconds. Not the chatbot-over-FAQ that most "AI integrations" amount to.
That’s the bet I’m making with this work. Custom MCP servers (not generic ones) are going to be the standard way of plugging real business systems into AI for at least the next several years, and the patterns above are what make them production-grade instead of demo-grade.
Building one of these?
I build custom MCP servers for NetSuite, Google Drive, Google Ads, and the rest of the business stack. If you’re trying to get Claude or another agent reliably wired into your systems and the off-the-shelf options aren’t working, let’s talk.