Building custom tools and MCP Apps for NetSuite

The NetSuite AI Connector Service ships a set of standard tools. When those don’t cover it you write your own, and since 2026.1 you can give them an interactive UI that renders inside the AI client.

If you’re trying to connect a client rather than build for one, you want connecting Claude or ChatGPT to NetSuite instead.


If you built a tool before 2026.1, migrate it

Start here, because there’s no symptom to tell you.

Tools written to the pre-2026.1 spec still run in 2026.1. They just have no execution logs, and they’ll stop working in a future release. Nothing warns you and nothing breaks until it does, which is the worst combination.

Migrating is also how you get the logs, and they land in the ordinary place, Customization > Scripting > Script Execution Logs. That’s most of the reason to bother. Debugging a tool an AI invoked, with no logs, is guesswork.

What changes

OldNew
JSDoc tagnone required@NScriptType CustomTool
Entry pointsync or asyncmust be async
SDF object typetooltoolset
Script ID prefixcustomtool_custtoolset_
Exposure attribute<exposeto3rdpartyagents><exposetoaiconnector>

1. The script file

/**
 * @NApiVersion 2.1
 * @NScriptType CustomTool
 */
define(["N/log"], function (log) {
  return {
    helloWorld: async function (args) {
      log.debug("Saying hi to ", args["name"]);
      return `Hello World ${args["name"]}`;
    },
  };
});

2. The SDF XML definition

Rename the file to match the new script ID, so customtool_sample.xml becomes custtoolset_sample.xml. Then change three things inside and leave the rest alone.

<toolset scriptid="custtoolset_sample">
<name>Sample Tool</name>
  <scriptfile>[/SuiteApps/com.netsuite.tools/tools/sampletool.js]</scriptfile>
  <rpcschema>[/SuiteApps/com.netsuite.tools/tools/sampletool_schema.json]</rpcschema>
  <exposetoaiconnector>T</exposetoaiconnector>
  <permissions>
    <permission>
      <permkey>LIST_ITEM</permkey>
      <permlevel>VIEW</permlevel>
    </permission>
  </permissions>
</toolset>
<scriptfile>, <rpcschema> and the whole <permissions> block carry straight over.

3. Redeploy

Deploy the project with the updated script and SDF object, then check the tools still answer from your client.

There’s a working example to copy from in the MCP-Sample-Tools directory of Oracle’s SuiteCloud Project Repository on Oracle Samples GitHub, with the 2026.1 updates already applied.

Source: SuiteAnswers 1024036.


What a custom tool is made of

Three files, every time.

The script file is your SuiteScript. It has to return every method named as a tool in the schema. A schema entry with no matching method behind it is one deploy away from being somebody’s afternoon.

The JSON-RPC schema is a JSON file defining the tools: method name, the description the model reads, and the input and output shapes.

The toolset SDF object is the XML that ties script to schema. It also carries the permissions that decide whether the tool shows up in the client at all, which is easy to overlook when you’re thinking of it as plumbing.

Four modules you can’t use

N/http, N/https, N/llm and N/sftp aren’t supported in custom tool scripts.

Better to know that now than three days into a design. A custom tool can’t call an external API, can’t post to a webhook, can’t reach SFTP, and can’t call an LLM of its own. If yours needs any of that it has to hand off, a scheduled or Map/Reduce script, or a queue record the tool writes and something else picks up.

Every other module you reference has to load at deploy time, too. A reference to something that isn’t there fails the deployment rather than failing at runtime. That’s the better of the two, but it’s still a surprise if you weren’t expecting it.

You can’t edit these in the File Cabinet

Once a SuiteApp or ACP with a custom tool is deployed, the script file, the schema and any HTML UI resource are read-only in the File Cabinet. SDF is the only way to change them.

That’s correct behavior, and it trips up anyone used to opening a script in the File Cabinet for a two-line fix.

Source: SuiteAnswers 1045802.


Three things that cost time

Role permissions still apply, and they fail quietly. An N/record.load(‘customer’, 1234) inside your tool runs under the calling user’s role. If that role can’t read Customer, the call fails inside the tool and the user doesn’t necessarily see an error. Either build the check into the tool and return a clear message, or move the privileged work into a scheduled script the tool triggers.

Return structured errors, not just logs. Before the migration above, N/log output didn’t surface for custom tools the way it does for ordinary scripts. Migrate and you get proper logs. Do both anyway, because the client shows the caller your response and never your log. A tool that fails should fail with a readable message, a JSON object with a status field and an explanation. Otherwise the model makes up a reason for the failure and the person believes it.

Nothing validates your inputs. MCP doesn’t type-check arguments before they reach you. A model that sends “1000.50” as a string where you expected a number will crash the tool. That’s the right design, since you control the shape, but coercing and validating every argument is your job on every tool. On 2026.2, Custom Tool Validation in SuiteCloud project deployment catches some of it earlier.


MCP Apps: giving a tool a user interface

An MCP App is a custom tool that also renders a sandboxed HTML interface inside the client’s chat. The UI exchanges messages with the host and can ask for more tool execution, so you get an actual interactive workflow instead of a wall of prose.

Same framework, same deployment model, same requirements as any custom tool, plus two things.

1. Declare the UI in the JSON-RPC schema

"_meta": {
  "ui": {
    "resourceUri": "ui://SuiteApps/<SuiteAppId>/<folder>/mcp-app.html"
   }  
}

That ui:// URI tells the client the tool has an interface, and where to load it.

2. Bundle the UI as one self-contained HTML file

All CSS and JavaScript inline. It can’t fetch external resources or load dependencies at runtime.

The @modelcontextprotocol/ext-apps SDK gives you an App class that handles the conversation between your UI and the client.

Only the bundled output goes into the SuiteCloud project. Your source UI project stays wherever you keep it. Put the bundle at the path resourceUri names, the SuiteApps folder for SuiteApps, the SuiteScripts folder for ACPs.

Then deploy, connect your client, and test, including any logic tools the UI calls.

Keep the logic out of the UI

This is the part I’d pay attention to. Implement the real action as a standard tool that runs fine headless, then write a separate MCP App tool that puts an interface over it.

The point is that the client gets to choose. When nothing needs a person, it runs the action directly and no panel appears. When something actually needs input or confirmation, it loads the UI. Bury the logic inside the UI tool and you’ve forced a human into the loop forever, including on every run that didn’t need one.

What the split looks like

Oracle’s sample implements exactly two methods, one logic, one UI.

/**
 * customersearchtools.js
 * @NApiVersion 2.1
 * @NModuleScope Public
 * @NScriptType CustomTool
 */
define(['N/search'], function (search) {
  return {
    // Logic tool. Runs with or without a UI.
    listCustomersByStatus: async function (args) {
      try {
        var status = args.status || "CUSTOMER_ACTIVE";
        var results = [];
        var customerSearch = search.create({
          type: search.Type.CUSTOMER,
          filters: [['entitystatus', 'is', status]],
          columns: ['entityid']
        });
        customerSearch.run().each(function (result) {
          results.push(result.getValue('entityid'));
          return true;
        });
        return { customers: results, error: "" };
      } catch (e) {
        return { customers: [], error: e.message || String(e) };
      }
    },
    // MCP App tool. Only supplies the options the UI displays.
    customerStatusPickerApp: async function () {
      return {
        availableStatuses: JSON.stringify([
          { id: "CUSTOMER_ACTIVE", label: "Active" },
          { id: "CUSTOMER_INACTIVE", label: "Inactive" }
        ]),
        error: ""
      };
    }
  };
});

The UI tool doesn’t search. It hands the interface a list of statuses, the user picks one, the UI calls listCustomersByStatus, and the logic tool does the work. Either one can be called on its own.

Both methods return an error string rather than throwing, which is the structured-error habit from earlier actually applied.

In the schema the logic tool carries “annotations”: { “readOnlyHint”: true }, telling the client this one doesn’t change anything. Set that honestly on every read-only tool you write, it’s how a client decides what it can run without stopping to ask.

The complete working example, UI included, is MCP-Sample-Tools/Sample-App in oracle-samples/netsuite-suitecloud-samples on GitHub.

Two security warnings

Treat the UI file as published. The bundled HTML gets delivered to the client. No secrets, no keys, no internal data, and no comments you wouldn’t want read.

The second one is sharper. Hidden is not a security boundary. Marking an MCP App HTML file hidden in the File Cabinet does not hide it from AI clients requesting it through the connector. And more broadly, files in your project, including ones in SuiteScripts, can be referenced as UI resources and served out.

If you’ve been treating hidden as access control anywhere in your account, that’s worth going and looking at today. The real controls are toolset permissions and being deliberate about what the schema exposes.

Source: SuiteAnswers 1046334 and 1045802. The MCP spec’s own documentation covers the UI side.


Oracle ships agent skills for this, and most people don’t know

Separately from the connector, Oracle publishes SuiteCloud Agent Skills. They’re built to the agentskills.io spec, so they work in Claude Code, Codex, Cline, or anything else that reads that format. Free, on GitHub, and barely mentioned anywhere.

For developers:

For finance and business users, leaning on the AI Connector Service: netsuite-ai-connector-instructions (tool selection order, safe SuiteQL, output formatting, multi-subsidiary and currency handling) and netsuite-finance-analyst.

Download SuiteCloud Agent Skills 1.0 from the releases of oracle/netsuite-suitecloud-sdk on GitHub.

Each skill is a Markdown file setting out its purpose, workflow, references and guardrails. Oracle’s own examples of those guardrails are least privilege, don’t guess IDs, and validate against a source of truth, which is more or less the SuiteQL gotchas page written as a constraint on a machine.

Their caveat is worth repeating too. Review the output, and test in a non-production account before anything ships.

Source: SuiteAnswers 1046142 and 1046153.


Building something and stuck? The first hour is free. Bring us the tool that won’t deploy and we’ll work it in your account.