Skip to content

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

Use declarative JSONata transforms and auto-generated MCP servers with zero data retention to make your SaaS platform AI-ready and enterprise-compliant.

Riya Sethi Riya Sethi · · 20 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.

Zero Data Retention: Making MCP Servers Enterprise-Compliant

Enterprise procurement kills deals over data retention. When your AI agent connects to a customer's NetSuite, BambooHR, or Salesforce instance through an MCP server, their InfoSec team will ask one question before anything else: do you store our data? If the answer is anything other than "no, the payload never leaves memory," expect a months-long security review that may never close.

The regulatory pressure is accelerating. The EU AI Act's high-risk system requirements become enforceable on August 2, 2026, with penalties reaching €35 million or 7% of global turnover. In February 2026, Spain's data protection authority (AEPD) published an 81-page guidance document mapping GDPR obligations directly to agentic AI architectures - explicitly calling out that AI agents accessing third-party services change the data lifecycle and the attack surface. The Dutch DPA issued similar warnings the same month. SOC 2 Type II auditors now specifically ask about AI agent data flows. The only architecture that answers all of these cleanly is zero data retention (ZDR): your MCP server processes every API payload entirely in memory, transforms it via JSONata, returns the result, and discards it.

The declarative architecture we have been building throughout this guide makes ZDR an inherent structural property rather than a policy you bolt on after the fact. JSONata expressions are pure functions - they transform an input into an output without writing to disk, queuing, or caching. The MCP proxy routes tool calls through the same stateless execution pipeline. There is nothing to retain because nothing is stored. For the full compliance deep-dive, see Zero Data Retention MCP Servers: Building SOC 2 & GDPR Compliant AI Agents.

Architecture: The Stateless Pass-Through MCP Proxy

Here is the end-to-end data flow for a zero-retention MCP tool call. Every step between receiving the agent's request and returning the response operates exclusively in request-scoped memory:

sequenceDiagram
    participant Agent as AI Agent
    participant Proxy as MCP Proxy
    participant Engine as JSONata Engine
    participant API as SaaS API

    Agent->>Proxy: tools/call (JSON-RPC)
    Proxy->>Proxy: Hash token, validate
    Proxy->>Engine: Split flat args into query + body
    Engine->>Engine: Apply query mapping (in memory)
    Engine->>Engine: Apply body mapping (in memory)
    Engine->>API: Forward mapped request
    API-->>Engine: Raw response
    Engine->>Engine: Apply response mapping (in memory)
    Engine-->>Proxy: Normalized result
    Proxy-->>Agent: JSON-RPC response
    Note over Proxy,Engine: All customer data garbage-collected.<br>Zero bytes written to storage.

Every step labeled "in memory" means exactly that: the data exists only in the runtime's request-scoped memory. No write-ahead log, no temporary table, no message queue. When the HTTP response completes, the runtime garbage-collects the entire request context. The only durable artifacts are the MCP token hash (which contains zero customer data) and the integration config (which describes how to call the API, not what data came back).

Code Example: In-Memory Request/Response Handling

Here is how a stateless MCP proxy handler processes a tool call without persisting any payload data:

async function handleToolCall(
  toolName: string,
  args: Record<string, unknown>,
  integration: IntegrationConfig,
  tokenContext: TokenContext
): Promise<McpResult> {
  // 1. Look up tool definition (from config, not from stored data)
  const tool = findTool(toolName, integration, tokenContext.filters)
  if (!tool) throw new McpError('Tool not found')
 
  // 2. Split flat MCP arguments into query + body using schemas
  //    All in local variables — nothing written to storage
  const queryParams = extractBySchema(args, tool.query_schema)
  const bodyParams = extractBySchema(args, tool.body_schema)
 
  // 3. Apply JSONata query mapping (pure function, no side effects)
  const mappedQuery = await jsonata(integration.queryMapping).evaluate({
    query: queryParams,
    context: tokenContext.credentials
  })
 
  const mappedBody = tool.body_schema
    ? await jsonata(integration.bodyMapping).evaluate({ body: bodyParams })
    : undefined
 
  // 4. Call upstream API — response held only in local variable
  const upstreamResponse = await fetch(
    buildUrl(integration.base_url, tool.path, mappedQuery),
    {
      method: tool.httpMethod,
      headers: buildAuthHeaders(tokenContext.credentials),
      body: mappedBody ? JSON.stringify(mappedBody) : undefined
    }
  )
 
  // 5. Check for rate limits — pass through, don't absorb
  if (upstreamResponse.status === 429) {
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          error: 'rate_limited',
          ratelimit_reset: upstreamResponse.headers.get('ratelimit-reset'),
          ratelimit_remaining: '0'
        })
      }],
      isError: true
    }
  }
 
  // 6. Apply JSONata response mapping (pure function)
  const rawData = await upstreamResponse.json()
  const normalizedData = await jsonata(integration.responseMapping).evaluate({
    response: rawData,
    context: tokenContext.credentials
  })
 
  // 7. Return to agent — rawData and normalizedData are garbage-collected
  //    when this function returns. Nothing is persisted.
  return {
    content: [{
      type: 'text',
      text: JSON.stringify({ result: normalizedData })
    }]
  }
}

The key discipline: every variable holding customer data (rawData, normalizedData, upstreamResponse) is local to this function scope. When the function returns, these are eligible for garbage collection. No global cache, no write to a log sink, no temporary file.

Implementing a Short-Lived In-Memory Token Vault

OAuth tokens are the one piece of credential data your proxy must hold temporarily. The pattern below keeps tokens in memory with automatic TTL-based expiration, avoiding any writes to persistent storage for the access tokens themselves. The platform refreshes OAuth tokens shortly before they expire, keeping valid credentials available without manual intervention.

interface VaultEntry {
  accessToken: string
  expiresAt: number        // Unix timestamp in ms
}
 
class InMemoryTokenVault {
  private cache = new Map<string, VaultEntry>()
  private evictionTimers = new Map<string, ReturnType<typeof setTimeout>>()
 
  async getToken(accountId: string): Promise<string> {
    const entry = this.cache.get(accountId)
 
    if (entry && entry.expiresAt > Date.now() + 60_000) {
      return entry.accessToken  // Valid for at least 60 more seconds
    }
 
    // Token missing or about to expire — refresh it
    return this.refreshAndCache(accountId)
  }
 
  private async refreshAndCache(accountId: string): Promise<string> {
    const credentials = await loadCredentials(accountId) // from encrypted config store
    const newToken = await performOAuthRefresh(credentials)
 
    const entry: VaultEntry = {
      accessToken: newToken.access_token,
      expiresAt: Date.now() + (newToken.expires_in * 1000),
    }
 
    this.cache.set(accountId, entry)
 
    // Schedule eviction slightly after expiry
    this.scheduleEviction(accountId, newToken.expires_in * 1000 + 5_000)
 
    return entry.accessToken
  }
 
  private scheduleEviction(accountId: string, delayMs: number) {
    const existing = this.evictionTimers.get(accountId)
    if (existing) clearTimeout(existing)
 
    this.evictionTimers.set(accountId, setTimeout(() => {
      this.cache.delete(accountId)
      this.evictionTimers.delete(accountId)
    }, delayMs))
  }
 
  // Call on shutdown for clean eviction
  evictAll() {
    this.cache.clear()
    for (const timer of this.evictionTimers.values()) clearTimeout(timer)
    this.evictionTimers.clear()
  }
}

The vault stores only short-lived OAuth access tokens, never API response data. Tokens are evicted automatically when their TTL expires. On process restart, the cache starts empty and tokens are lazily refreshed on the next inbound request. The refresh token itself is read from an encrypted credential store and never held in the vault longer than the duration of the refresh call.

MCP Tool Schemas for Enterprise ERPs

To make the ZDR architecture concrete for complex enterprise integrations, here are auto-generated MCP tool schemas for NetSuite and SAP. These are derived directly from integration config + documentation records, following the generation pattern described earlier in this guide.

NetSuite: List Vendor Bills

NetSuite's multi-currency and multi-subsidiary configuration means the underlying SuiteQL query can have up to four variants depending on the connected account's feature flags. The MCP tool abstracts all of this behind a single, stable schema:

{
  "name": "list_all_netsuite_vendor_bills",
  "description": "List vendor bills from NetSuite. Supports filtering by vendor, subsidiary, date range, and approval status. Results include currency and subsidiary context based on the account's feature configuration.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "vendor_id": {
        "type": "string",
        "description": "Filter by vendor internal ID"
      },
      "subsidiary_id": {
        "type": "string",
        "description": "Filter by subsidiary internal ID (only applies if multi-subsidiary is enabled)"
      },
      "transaction_date_from": {
        "type": "string",
        "description": "Filter bills on or after this date (ISO 8601)"
      },
      "transaction_date_to": {
        "type": "string",
        "description": "Filter bills on or before this date (ISO 8601)"
      },
      "approval_status": {
        "type": "string",
        "enum": ["pending_approval", "approved", "rejected"],
        "description": "Filter by approval status"
      },
      "limit": {
        "type": "string",
        "description": "The number of records to fetch"
      },
      "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."
      }
    }
  },
  "tags": ["accounting", "ap"]
}

SAP: Get Business Partner by ID

{
  "name": "get_single_sap_business_partner_by_id",
  "description": "Retrieve a single SAP Business Partner by ID. Returns partner details including addresses, roles, tax numbers, and bank accounts.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "id": {
        "type": "string",
        "description": "The BusinessPartner ID to retrieve. Required."
      },
      "expand": {
        "type": "string",
        "description": "Comma-separated navigation properties to expand (e.g., 'to_BusinessPartnerAddress,to_BusinessPartnerBank')"
      }
    },
    "required": ["id"]
  },
  "tags": ["erp", "master-data"]
}

In both cases, the agent sees a stable, well-described input schema. The underlying SuiteQL query construction, OData expansion, authentication, and response mapping all happen in the stateless pipeline. No ERP data is cached.

Safe Debugging and Telemetry Without Persisting Payloads

Zero data retention does not mean zero observability. You can maintain full operational visibility using metadata-only logging - capturing everything you need to debug issues without recording a single byte of customer data.

Define an explicit allowlist of safe fields, and reject everything else:

interface SafeLogEntry {
  // What happened
  request_id: string
  timestamp: string
  tool_name: string
  mcp_method: string            // "tools/call", "tools/list", etc.
 
  // Who and where
  integrated_account_id: string
  integration_name: string
  environment_id: string
 
  // Performance
  upstream_status: number
  upstream_latency_ms: number
  total_latency_ms: number
 
  // Errors (category only, never the message body)
  error_type?: string           // "rate_limited" | "auth_expired" | "upstream_5xx" | "transform_error"
  error_code?: number
 
  // Sizing (counts, not content)
  response_record_count?: number
  request_param_count?: number
}
 
function logToolCall(entry: SafeLogEntry) {
  // Allowlist-only serialization prevents accidental payload logging
  const sanitized = {
    request_id: entry.request_id,
    timestamp: entry.timestamp,
    tool_name: entry.tool_name,
    mcp_method: entry.mcp_method,
    integrated_account_id: entry.integrated_account_id,
    integration_name: entry.integration_name,
    environment_id: entry.environment_id,
    upstream_status: entry.upstream_status,
    upstream_latency_ms: entry.upstream_latency_ms,
    total_latency_ms: entry.total_latency_ms,
    error_type: entry.error_type,
    error_code: entry.error_code,
    response_record_count: entry.response_record_count,
    request_param_count: entry.request_param_count,
  }
 
  console.log(JSON.stringify(sanitized))
}

The rules for compliant MCP server telemetry:

  • Log the shape, not the content. Record that a list_all_netsuite_vendor_bills call returned 47 records in 320ms with status 200. Never log what those records contain.
  • Log error categories, not error bodies. An auth_expired error type tells you everything you need to triage. The actual error message from the upstream API might contain account names, email addresses, or other PII.
  • Never log request arguments. The flat MCP input object may contain customer names, email addresses, or query filters that constitute PII under GDPR. Log the count of parameters, not their values.
  • Redact headers before logging. Strip Authorization, Cookie, and any custom auth headers. Log only the header names, never values.

CI/CD Tests to Prove Zero Retention

A ZDR claim is only as strong as the automated tests that enforce it. Here are concrete test patterns you should run in CI on every commit:

describe('Zero Data Retention', () => {
 
  it('tool calls produce no disk writes', async () => {
    const tempDir = await createIsolatedTempDir()
    const fsWriteSpy = spyOn(fs, 'writeFile')
    const fsAppendSpy = spyOn(fs, 'appendFile')
 
    await handleToolCall(
      'list_all_hubspot_contacts',
      { limit: '10' },
      testIntegration,
      testToken
    )
 
    expect(fsWriteSpy).not.toHaveBeenCalled()
    expect(fsAppendSpy).not.toHaveBeenCalled()
    await expectDirectoryEmpty(tempDir)
  })
 
  it('log output contains no payload data', async () => {
    const logCapture: string[] = []
    const originalLog = console.log
    console.log = (...args: unknown[]) =>
      logCapture.push(args.join(' '))
 
    await handleToolCall(
      'get_single_netsuite_vendor_bill_by_id',
      { id: 'FIXTURE_VENDOR_ID_123' },
      testIntegration,
      testToken
    )
 
    console.log = originalLog
    const fullLog = logCapture.join('\n')
 
    // Known fixture values must not appear in logs
    expect(fullLog).not.toContain('FIXTURE_VENDOR_ID_123')
    expect(fullLog).not.toContain('vendor_name_from_fixture')
    expect(fullLog).not.toContain('test@example.com')
 
    // Structured metadata MUST be present
    expect(fullLog).toContain('request_id')
    expect(fullLog).toContain('upstream_status')
    expect(fullLog).toContain('tool_name')
  })
 
  it('in-memory token vault evicts expired tokens', async () => {
    const vault = new InMemoryTokenVault()
 
    // Insert a token that expires in 100ms
    vault['cache'].set('test-account', {
      accessToken: 'short-lived-secret',
      expiresAt: Date.now() + 100
    })
    vault['scheduleEviction']('test-account', 100)
 
    expect(vault['cache'].has('test-account')).toBe(true)
 
    // Wait for eviction
    await sleep(200)
 
    expect(vault['cache'].has('test-account')).toBe(false)
  })
 
  it('no database writes occur during tool execution', async () => {
    const dbSpy = spyOn(database, 'execute')
 
    await handleToolCall(
      'create_a_jira_issue',
      { summary: 'Test issue', project_key: 'TEST' },
      testIntegration,
      testToken
    )
 
    // Only config/token lookups allowed — no INSERT, UPDATE, or DELETE
    const writes = dbSpy.calls.filter(call =>
      /INSERT|UPDATE|DELETE/i.test(call.args[0])
    )
    expect(writes.length).toBe(0)
  })
})

These tests catch the most common ZDR regressions: a new logging library that serializes full request objects, a caching layer added for performance, or a debug flag that dumps payloads to disk. If any of these tests fail, the build breaks before the code reaches production.

Tip

Add a grep-based pipeline step that scans your codebase for dangerous patterns: console.log(.*response, fs.writeFile, cache.set(.*body, or JSON.stringify(.*rawData. This catches accidental payload persistence that unit tests might miss.

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

How do MCP servers handle zero data retention for enterprise compliance?
A compliant MCP server operates as a stateless pass-through proxy. API payloads are transformed in request-scoped memory using JSONata expressions, returned to the AI agent, and immediately garbage-collected. No customer data is written to disk, databases, queues, or log files. Only metadata (request ID, tool name, HTTP status, latency) is logged. This architecture satisfies SOC 2, GDPR, and HIPAA requirements by ensuring there is no data at rest to protect, audit, or breach-notify about.
What is the right MCP server data retention policy for enterprise deployments?
The safest policy is zero retention: process all third-party API data in memory and discard it when the HTTP response completes. Store only integration configuration (how to call the API) and hashed authentication tokens (never raw tokens). Log only operational metadata like timestamps, tool names, status codes, and latency. This keeps your compliance footprint minimal and avoids becoming a sub-processor of your customers' regulated data.
How do you debug MCP servers without storing request or response payloads?
Use metadata-only logging with an explicit field allowlist. Capture the request ID, tool name, integration name, upstream HTTP status, response record count, and latency. Log error categories (rate_limited, auth_expired, transform_error) instead of error message bodies. Never log request arguments, response bodies, or authorization headers. This gives you full operational visibility for triage without persisting any customer data.

More from our Blog