Skip to content

Transform Code & MCP Examples: A Hands-On Engineering Guide for SaaS APIs

Stop writing brittle API integration scripts. Learn how to use declarative JSONata transform code and auto-generated MCP servers to make your SaaS platform AI-ready.

Yuvraj Muley Yuvraj Muley · · 12 min read
Transform Code & MCP Examples: A Hands-On Engineering Guide for SaaS APIs

If you are an engineering leader or senior PM at a B2B SaaS company, the integration playbook that worked in 2022 is now actively losing you deals. Hand-writing bespoke TypeScript or Python adapters for every CRM, HRIS, and ticketing API your enterprise customers ask for, then re-writing them again as Model Context Protocol (MCP) tool servers for AI agents, is no longer a viable engineering investment.

You are reading this because writing custom API integrations for AI agents is burning your engineering cycles, and you are looking for a scalable exit strategy. This guide provides exactly that: a hands-on architectural blueprint for building production-ready integrations using declarative JSONata transformations stored as data, and exposing them via auto-generated MCP servers derived directly from your API configs. Both concepts are demonstrated below with runnable examples.

The End of Custom Integration Code in 2026

Two massive forces are currently colliding on your product roadmap.

The first is the sheer demand for AI agents. Gartner predicts that 40% of enterprise applications will be integrated with task-specific AI agents by the end of 2026, up from less than 5% today. Every Salesforce, NetSuite, and Zendesk integration your team ships now has to work twice: once as a standard REST/GraphQL connector for your product's internal logic, and again as an agent-callable tool surface.

The second force is the protocol layer underneath those agents. The public MCP server registry expanded from 1,200 servers in Q1 2025 to over 9,400 servers by April 2026, with month-over-month growth tracking at +18%. Furthermore, MCP's TypeScript and Python SDKs reached 97 million monthly downloads in March 2026, up from approximately 2 million at launch. The Model Context Protocol is officially the default contract between AI agents and your APIs.

The engineering implication of these forces is brutal. Most unified API platforms and embedded integration tools attempt to solve the N×M integration problem (connecting N AI models to M SaaS platforms) with brute force. Behind their unified facades, they maintain separate code paths for each integration. If you keep writing imperative code for every integration—a HubSpotAdapter.ts, a SalesforceClient.py, a custom MCP server per provider—your maintenance load grows linearly with every new connector. Add 100 integrations and you have 100 files, 100 sets of pagination bugs, 100 OAuth refresh edge cases, and 100 opaque error codes to keep in sync with upstream API drift.

The modern architectural alternative is the interpreter pattern. Instead of writing code for each integration, the runtime acts as a generic execution engine. New providers become data-only changes. AI tool surfaces fall out of the same metadata. Zero Integration-Specific Code: How to Ship API Connectors as Data-Only Operations is not just an ideal; it is a strict engineering requirement for scaling past 50 integrations.

Why JSONata Is the Universal Language for Transform Code

Declarative API integration is the architectural pattern of storing API transformation logic as data rather than executable code. We use JSONata as the universal transformation engine. JSONata is a declarative, side-effect-free query and transformation language for JSON. Think of it as XSLT for the JSON era, or jq with conditionals, string manipulation, recursion, and custom functions. You write an expression that describes the shape of the output you want; the engine evaluates it against the input.

For integration work, as we explored in our developer tutorial on building JSONata mappings, storing transform code as JSONata strings in a database instead of writing Python or TypeScript matters for five critical reasons:

  1. Hot-Swappable Logic: A JSONata expression is just a string. It lives in a database column, a YAML file, or a config record. You can version it, override it, and hot-swap it without restarting the application or triggering a CI/CD pipeline.
  2. Turing-Complete but Constrained: It is expressive enough to flatten nested arrays, build SOQL WHERE clauses, or unwind semicolon-separated email lists. Yet, because expressions are pure functions, they are side-effect free. They cannot make a network call, scribble to disk, or take down your runtime, making them highly testable and parallelizable.
  3. Composes with Schemas: Because the input and output are both JSON Schema-shaped, you can validate transforms statically and generate documentation directly from them.
  4. Survives API Drift: When HubSpot adds a new field or Salesforce renames LastActivityDate, you change one expression. No deploys, no PRs, no CI run.
  5. Cost Efficiency: Companies utilizing JSONata to evaluate billions of events are saving up to $500K/year in compute costs by replacing heavy Node/Python worker instances with optimized execution pipelines.

The 3-Level Override Hierarchy

When you hardcode transformations, customizing an integration for a specific enterprise customer requires branching logic (if tenant_id === '123'). When you use declarative JSONata, you can implement a deep-merge override hierarchy:

  • Level 1 (Platform Base): The default mapping that works for 95% of customers.
  • Level 2 (Environment Override): A specific tenant can override the mapping (e.g., pulling a different set of fields) without affecting other environments.
  • Level 3 (Account Override): An individual connected account can override the mapping to handle their highly specific, legacy custom fields.

The runtime engine simply merges these JSON strings at request time and evaluates the final expression. This three-tier model is described in more detail in our write-up on per-customer data model customization without code.

Hands-On: Writing Transform Code for Complex API Payloads

Let's make this concrete by looking at transform code examples. As detailed in our end-to-end API schema normalization tutorial, suppose you want a unified contacts resource that works identically against HubSpot and Salesforce. Both return wildly different shapes.

HubSpot nests fields inside properties and packs additional emails into a semicolon-separated string. Here is a JSONata transform for HubSpot:

(
  $defaultProps := ["firstname", "lastname", "jobtitle", "email",
    "phone", "mobilephone", "hs_additional_emails"];
  $diff := $difference($keys(response.properties), $defaultProps);
  {
    "id": response.id.$string(),
    "first_name": response.properties.firstname,
    "last_name": response.properties.lastname,
    "title": response.properties.jobtitle,
    "email_addresses": [
      response.properties.email
        ? { "email": response.properties.email, "is_primary": true },
      response.properties.hs_additional_emails
        ? response.properties.hs_additional_emails.$split(";").{ "email": $ }
    ],
    "phone_numbers": [
      response.properties.phone
        ? { "number": response.properties.phone, "type": "phone" },
      response.properties.mobilephone
        ? { "number": response.properties.mobilephone, "type": "mobile" }
    ],
    "custom_fields": response.properties.$sift(
      function($v, $k) { $k in $diff }
    ),
    "created_at": response.createdAt,
    "updated_at": response.updatedAt
  }
)

Salesforce, on the other hand, returns flat, PascalCase fields, dynamic custom fields ending in __c, and a half-dozen distinct phone columns. Here is the Salesforce equivalent, hitting the exact same unified schema:

(
  $convertPhoneNumbers := function($phone, $mobile, $fax) {
    $filter([
      { "number": $phone, "type": "phone" },
      { "number": $mobile, "type": "mobile" },
      { "number": $fax, "type": "fax" }
    ], function($v) { $v.number != null })
  };
 
  response.{
    "id": Id,
    "first_name": FirstName,
    "last_name": LastName,
    "name": $join($removeEmptyItems([FirstName, LastName]), " "),
    "title": Title,
    "account": { "id": AccountId },
    "email_addresses": [{ "email": Email, "is_primary": true }],
    "phone_numbers": $convertPhoneNumbers(Phone, MobilePhone, Fax),
    "last_activity_at": LastActivityDate,
    "custom_fields": $sift($, function($v, $k) { $k ~> /__c$/i and $boolean($v) }),
    "created_at": CreatedDate,
    "updated_at": LastModifiedDate,
    "urls": [{ 
      "url": "https://" & %.context.subdomain & ".lightning.force.com/lightning/r/Contact/" & Id & "/view", 
      "type": "self" 
    }]
  }
)

Breaking Down the Transform Logic

If you want to build JSONata mappings for API integrations, you must understand these specific extraction strategies:

  • Variable Assignment & Custom Functions: We define $convertPhoneNumbers at the top of the Salesforce block using standard JSONata syntax. This keeps the main object mapping clean.
  • Array Filtering ($filter): The $filter function removes empty phone number objects. If a Salesforce contact has no fax number, the fax object is dropped entirely rather than returning {"number": null, "type": "fax"}.
  • Dynamic Key Extraction ($sift): This is the most powerful line in both scripts. The HubSpot version uses $sift to pluck non-default properties as custom fields. The Salesforce version uses a regex match (~> /__c$/i) to isolate Salesforce custom fields dynamically without you needing to know the field names in advance.
  • Context Injection (%.context.subdomain): The Salesforce transform accesses the parent execution context (%) to retrieve the specific tenant's subdomain, dynamically constructing a valid URL to the resource.

The inbound direction works the same way. A unified ?email_addresses.email=foo@bar.com filter becomes HubSpot's filterGroups search syntax, or a Salesforce SOQL WHERE Email = 'foo@bar.com' clause, all driven by a separate query-mapping expression. The engine that evaluates these has no idea which provider it is running against.

The Model Context Protocol (MCP) Architecture

MCP solves a different problem at a different layer. Once your unified API exists, AI agents need a standardized way to discover what tools are available and call them with structured arguments.

An MCP server is an open JSON-RPC 2.0 endpoint that exposes tools an AI agent can list and invoke. It eliminates the need to write custom LangChain or LlamaIndex wrappers for every API. The protocol has a tiny surface:

  • initialize - Handshake; client and server agree on protocol version and capabilities.
  • tools/list - The server returns its tool catalog with JSON Schemas for inputs.
  • tools/call - The client invokes a named tool with arguments; the server returns content.

A typical agent flow looks like this:

sequenceDiagram
    participant Agent as AI Agent (Claude/ChatGPT)
    participant MCP as MCP Server (/mcp/:token)
    participant Pipeline as Transform Pipeline
    participant API as SaaS API

    Agent->>MCP: POST /mcp/:token<br>{"method": "initialize"}
    MCP-->>Agent: Server capabilities & version
    
    Agent->>MCP: POST /mcp/:token<br>{"method": "tools/list"}
    MCP-->>Agent: Array of available tools & JSON Schemas
    
    Agent->>MCP: POST /mcp/:token<br>{"method": "tools/call", "params": {"name": "list_contacts", "arguments": {"limit": 10}}}
    MCP->>Pipeline: Extract query/body params
    Pipeline->>API: GET /crm/v3/objects/contacts?limit=10
    API-->>Pipeline: Raw vendor response
    Pipeline->>MCP: Apply JSONata response mapping
    MCP-->>Agent: Normalized JSON string

The agent does not care which CRM is on the other end. It sees a stable tool catalog with stable schemas. If you have not read the foundational architecture piece, our Hands-On Guide to Building MCP Servers for AI Agents covers transports, auth, and deployment topology in depth.

Hands-On: Auto-Generating MCP Tools from API Configs

Here is where the two ideas converge. Writing manual MCP tool definitions in TypeScript is redundant if you already have a declarative integration configuration. Your MCP tool catalog should be generated dynamically from that description.

The pattern works like this. Your integration config defines what endpoints exist:

{
  "base_url": "https://api.hubspot.com",
  "resources": {
    "contacts": {
      "list":   { "method": "get",  "path": "/crm/v3/objects/contacts" },
      "get":    { "method": "get",  "path": "/crm/v3/objects/contacts/{{id}}" },
      "create": { "method": "post", "path": "/crm/v3/objects/contacts" },
      "update": { "method": "patch","path": "/crm/v3/objects/contacts/{{id}}" }
    }
  },
  "tool_tags": {
    "contacts": ["crm", "sales"]
  }
}

The Documentation Gate

A separate documentation record provides the description and JSON Schema for each (resource, method) pair. At request time, a tool generator iterates every resource and method, looks up the documentation, and assembles a tool definition.

Documentation acts as a strict quality gate. If a resource has no documentation record, no tool gets generated. This forces the discipline of describing every endpoint before it is exposed to an LLM, preventing the common failure mode where agents call undocumented endpoints and hallucinate schemas.

function generateTools(integration, docs, filters) {
  const tools = []
  for (const [resource, methods] of Object.entries(integration.resources)) {
    for (const method of Object.keys(methods)) {
      const doc = docs.find(d =>
        d.resource === resource && d.method === method
      )
      if (!doc) continue                       // doc gate = quality gate
      if (!matchesFilters(method, filters)) continue
 
      tools.push({
        name: toolName(integration.label, resource, method),
        description: doc.description,
        inputSchema: buildInputSchema(doc, method),
        tags: integration.tool_tags[resource] || []
      })
    }
  }
  return tools
}

Schema Injection for Agents

LLMs need explicit instructions on how to handle pagination. During the auto-generation process, the server automatically injects pagination controls into the generated JSON Schema for list methods.

if (method === 'list' && type === 'query_schema') {
  parsedContent.properties.limit = {
    type: 'string',
    description: 'The number of records to fetch',
  }
  parsedContent.properties.next_cursor = {
    type: 'string',
    description:
      'The cursor to fetch the next set of records. Always send back exactly the cursor value you received (nextCursor) without decoding, modifying, or parsing it. This can be found in the response of the previous tool invocation as nextCursor.',
  }
}

By explicitly telling the agent to pass the cursor back without decoding or parsing it, you prevent the LLM from hallucinating modifications to base64-encoded cursor strings.

The Flat Input Namespace

MCP agents pass all arguments as a single, flat JSON object. However, REST APIs require arguments to be split between query parameters, path variables, and the request body. When the MCP router receives a tools/call request, it cross-references the incoming flat arguments against the generated query and body schemas, routing each argument to its correct destination before passing it to the JSONata transform pipeline. Auto-Generated MCP Tools for AI Agents details exactly how this routing logic operates at scale.

Handling Edge Cases: Rate Limits and Custom Objects

Declarative architectures are not a free lunch. Two edge cases will bite you in production, and you should design for them up front.

The Reality of Rate Limits

AI agents are incredibly aggressive. A single LangGraph workflow executing a data enrichment loop can fan out 300 tool calls in a few seconds, and the third-party API will start returning HTTP 429 Too Many Requests.

Many integration platforms attempt to mask rate limits by silently retrying failed requests. Do not do this.

Silent retries hide cost, exhaust worker threads, create massive queue backlogs, and make agent behavior non-deterministic. The agent assumes the tool is broken because it is not receiving a response, and the LLM has no signal to back off.

The right pattern is to pass 429 errors directly through to the caller and surface upstream rate limit metadata as standardized headers. Normalize whatever the upstream API's proprietary headers are into the IETF draft specification:

  • ratelimit-limit
  • ratelimit-remaining
  • ratelimit-reset

By passing the 429 and the exact reset window back to the agent, the LLM (or its host runtime) can intelligently pause its execution loop and resume exactly when the window clears. For a deeper treatment, see How to Handle Third-Party API Rate Limits When AI Agents Scrape Data.

Handling Custom Objects and Custom Fields

Every CRM, ticketing system, and HRIS lets customers define their own fields. A rigid unified schema breaks the moment a customer adds Customer_Tier__c to Salesforce or creates an entirely custom Property_Inspection__c object.

The fix is to (a) preserve the raw upstream response as a remote_data field on every unified record, and (b) allow per-account JSONata overrides that map specific custom fields into the unified schema for that customer. Because the MCP server derives its tools dynamically from documentation records, exposing a custom object to an agent is simply a data-entry task. You add the custom object to the integration's resource array, write a brief YAML documentation record describing its schema, and the MCP server instantly picks it up on the next tools/list request. More patterns for this are covered in How Do Unified APIs Handle Custom Fields?.

Warning

If your unified API platform silently retries 429s, force-normalizes custom fields into rigid columns, or makes you redeploy to add an account-specific mapping, you are going to spend more on engineering than you saved. Test these three things on day one of any POC.

Deploying the Integration: From Config to Live MCP Server

The last mile is short, which is the point of the entire architecture. Deploying this requires no code compilation. Once your JSONata manifests and documentation records are saved in the database, exposing it as a live MCP server is a single API call:

curl -X POST https://api.truto.one/integrated-account/$ACCOUNT_ID/mcp \
  -H "Authorization: Bearer $TRUTO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Salesforce CRM tools",
    "config": {
      "methods": ["read", "create"],
      "tags": ["crm"],
      "require_api_token_auth": true
    },
    "expires_at": "2026-12-31T23:59:59Z"
  }'

The response includes a self-contained URL like https://api.truto.one/mcp/<token>. Notice the require_api_token_auth flag. By default, an MCP URL acts as a bearer token. Enabling this flag adds a strict middleware layer, requiring the connecting client to pass a valid API token in the Authorization header, securing the endpoint for enterprise deployments.

You can now paste this URL directly into the Claude Desktop configuration, Cursor, or ChatGPT's developer settings. The agent will immediately discover the generated tools via tools/list and start calling them via tools/call. Token refresh, pagination, error normalization, and rate limit passthrough all happen inside the generic engine—none of it is specific to Salesforce, HubSpot, or whichever integration you connected.

Where to Go From Here

If you take one thing from this guide, let it be this: the unit of work for shipping a new integration in 2026 should be a config record and a JSONata expression, not a TypeScript file and a deploy.

That shift makes the difference between an integrations team that ships one connector a sprint and one that ships ten. It also makes the difference between an AI strategy that wraps your existing APIs in brittle adapters, and one that exposes every integration as an MCP tool surface from day one with zero additional engineering.

FAQ

What is JSONata and why use it for API transformations?
JSONata is a declarative, side-effect-free query language for JSON. It lets you store transformation logic as a string in a database, version it, override it per customer, and hot-swap it without redeploying. For multi-integration platforms, it dramatically reduces the maintenance burden compared to writing imperative adapters per provider.
How do MCP servers get generated automatically from API configs?
A generator iterates over every resource and method defined in your integration config and looks up a matching documentation record containing a description and JSON Schema. Each match becomes an MCP tool with a dynamically generated name, input schema, and tags, reflecting changes instantly without code deploys.
How should an integration platform handle API rate limits with AI agents?
Never mask rate limits with silent retries, as this hides costs and prevents the agent from backing off intelligently. Pass HTTP 429 errors directly to the caller and normalize upstream rate limit information into standard IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset).
How do you handle custom fields and objects in a unified schema?
Preserve the raw upstream response as a remote_data field on every unified record, then use a three-level JSONata override stack (platform base, environment override, per-account override) to map specific custom fields dynamically. For custom objects, simply add a new documentation record to auto-generate the corresponding MCP tools.

More from our Blog