Agent & BTP Tutorials 11 min read

Building a Joule Agent + MCP Server for Sales Order Creation on SAP BTP

A step-by-step guide to building an MCP server on BTP Cloud Foundry that lets a SAP Joule Studio agent create, read, update, and delete Sales Orders in S/4HANA via natural language — using API_SALES_ORDER_SRV, header-plus-item deep insert, Node.js and the SAP Cloud SDK.

S
s4ready.ai Team

In our Business Partner guide we let Joule manage master data. This one tackles a transaction: creating Sales Orders in S/4HANA from natural language. A business user types “create an order for customer 1000001: 10 units of TG11,” and a Joule Studio agent calls an MCP server that builds the order — header and line items in one call — through API_SALES_ORDER_SRV.

At a glance

  • API: API_SALES_ORDER_SRV · EntitySets A_SalesOrder (header) + A_SalesOrderItem (items), linked by to_Item
  • Runtime: Node.js 20 + SAP Cloud SDK, deployed on BTP Cloud Foundry
  • Agent: Joule Studio custom agent, registered via BTP Destination
  • Key difference from BP: a sales order is created header + items together (a deep insert), and — unlike Business Partners — it can be deleted, with conditions

1. Prerequisites

SAP System & BTP

  • SAP S/4HANA (Cloud Public/Private or on-premise with REST API access)
  • Communication Arrangement for scenario SAP_COM_0109 (Sales Order Integration) configured and active
  • Master data in place: the Sold-To customer must exist with a customer role and the relevant sales area; the Material must be extended to that sales org and plant
  • BTP Subaccount with Cloud Foundry enabled and a Destination to your S/4HANA system
  • Joule Studio access (SAP Business AI entitlement)

Developer Tools

  • Node.js 20+ and npm (locally or in BAS)
  • SAP Business Application Studio (BAS) or VS Code with SAP extensions
  • Cloud Foundry CLI (cf) installed and logged in
  • @sap-cloud-sdk/api-sales-order and @modelcontextprotocol/sdk packages

2. Architecture overview

Same four-layer flow as the Business Partner agent — only the backend service and tools change. Joule routes the user’s intent to the agent, the agent calls a tool on your MCP Server, and the server uses the SAP Cloud SDK to call API_SALES_ORDER_SRV over a BTP Destination.

Joule agent to MCP server to S/4HANA sales order architectureA vertical flow. A business user talks to SAP Joule chat in natural language. Joule routes the intent to a Joule Studio agent. The agent calls a custom Node.js MCP server on BTP Cloud Foundry, which exposes read, create, update and delete sales order tools. The MCP server uses the SAP Cloud SDK and a BTP Destination to call API_SALES_ORDER_SRV on S/4HANA, entity sets A_SalesOrder and A_SalesOrderItem over OData, communication scenario SAP_COM_0109.Business user → SAP Joule chatNatural language: “Create an order for customer 1000001”Joule Studio AgentRoutes the intent to the right toolMCP Server · Node.js + SAP Cloud SDKTools: read_so · create_so · update_so · delete_so (BTP Cloud Foundry)BTP DestinationOAuth2 / Basic Auth to S/4HANAS/4HANA · API_SALES_ORDER_SRVA_SalesOrder + A_SalesOrderItem · SAP_COM_0109
Same pattern, different backend. The MCP server is the only custom code you write.

💡 Why SAP Cloud SDK instead of raw fetch?

The type-safe client handles CSRF token fetching and ETag management automatically on PATCH/DELETE, and — crucially for orders — it serialises the deep insert (header plus nested to_Item) into one valid OData payload. Hand-rolling that JSON and the x-csrf-token / If-Match dance is where most integrations break.

3. API & entity field verification

Service: API_SALES_ORDER_SRV · EntitySets: A_SalesOrder, A_SalesOrderItem · Protocol: OData v2 Metadata URL: /sap/opu/odata/sap/API_SALES_ORDER_SRV/$metadata

A_SalesOrder — key header fields

Field NameTypeRequiredNotes
SalesOrderString (key)read, update, delete
SalesOrderTypeStringcreatee.g. OR / TA (standard order)
SalesOrganizationStringcreateSelling org unit
DistributionChannelStringcreatee.g. 10
OrganizationDivisionStringcreatee.g. 00
SoldToPartyStringcreateCustomer BP number (must have sales area)
PurchaseOrderByCustomerStringCustomer PO reference
RequestedDeliveryDateEdm.DateTimeRequested delivery date
TransactionCurrencyStringUsually derived from customer

A_SalesOrderItem — key item fields (via to_Item)

Field NameTypeRequiredNotes
SalesOrderString (key)Set by deep insert
SalesOrderItemString (key)e.g. 10, 20 — auto-numbered if omitted
MaterialStringcreateMust be extended to the sales org/plant
RequestedQuantityDecimalcreateOrder quantity
RequestedQuantityUnitStringe.g. PC, EA
PlantStringSupplying plant

4. Project setup in BAS

Step 1 — Initialise the project

mkdir so-mcp-server && cd so-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk \
  @sap-cloud-sdk/api-sales-order \
  @sap-cloud-sdk/connectivity \
  @sap-cloud-sdk/http-client
npm install -D typescript @types/node ts-node
npx tsc --init

Step 2 — Project structure

so-mcp-server/
├── src/
│   ├── tools/
│   │   ├── read_so.ts
│   │   ├── create_so.ts
│   │   ├── update_so.ts
│   │   └── delete_so.ts
│   └── index.ts          ← MCP server entry point
├── manifest.yml          ← BTP Cloud Foundry config
├── package.json
└── .env                  ← destination name, local dev only

Step 3 — MCP server entry point (index.ts)

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerReadSoTool }   from "./tools/read_so.js";
import { registerCreateSoTool } from "./tools/create_so.js";
import { registerUpdateSoTool } from "./tools/update_so.js";
import { registerDeleteSoTool } from "./tools/delete_so.js";

const server = new McpServer({
  name: "so-mcp-server",
  version: "1.0.0",
  description: "CRUD operations on SAP Sales Orders via API_SALES_ORDER_SRV"
});

registerReadSoTool(server);
registerCreateSoTool(server);
registerUpdateSoTool(server);
registerDeleteSoTool(server);

const transport = new StdioServerTransport();
await server.connect(transport);
console.log("Sales Order MCP Server running");

5. The four MCP tools

Each tool maps to an HTTP method on the A_SalesOrder entity set.

ToolHTTPEndpointJoule example prompt
read_soGET/A_SalesOrder or /A_SalesOrder('{id}')Show me sales order 12345
create_soPOST/A_SalesOrder (deep insert with to_Item)Create an order for customer 1000001, 10 of TG11
update_soPATCH/A_SalesOrder('{SalesOrder}')Set the PO number on order 12345 to 4500099
delete_soDELETE/A_SalesOrder('{SalesOrder}')Delete sales order 12345

Tool 1: read_so — read sales orders

Returns a list (optionally filtered by Sold-To party) or a single order with its items expanded.

// tools/read_so.ts
import { salesOrderService } from "@sap-cloud-sdk/api-sales-order";

export function registerReadSoTool(server: McpServer) {
  server.tool("read_so", {
    salesOrderId: { type: "string", optional: true,
      description: "Sales order number. Omit to list recent orders." },
    soldToParty:  { type: "string", optional: true,
      description: "Filter by Sold-To customer BP number" }
  }, async ({ salesOrderId, soldToParty }) => {
    const { salesOrderApi } = salesOrderService();
    const dest = { destinationName: "S4H_SO_DEST" };

    if (salesOrderId) {
      const so = await salesOrderApi.requestBuilder()
        .getByKey(salesOrderId)
        .expand(salesOrderApi.schema.TO_ITEM)   // pull the line items
        .execute(dest);
      return { content: [{ type: "text", text: JSON.stringify({
        id: so.salesOrder, soldTo: so.soldToParty, type: so.salesOrderType,
        items: so.toItem.map(i => ({ item: i.salesOrderItem,
          material: i.material, qty: i.requestedQuantity })) }) }] };
    }

    const req = salesOrderApi.requestBuilder().getAll().top(20).select(
      salesOrderApi.schema.SALES_ORDER,
      salesOrderApi.schema.SALES_ORDER_TYPE,
      salesOrderApi.schema.SOLD_TO_PARTY,
      salesOrderApi.schema.PURCHASE_ORDER_BY_CUSTOMER
    );
    if (soldToParty) req.filter(salesOrderApi.schema.SOLD_TO_PARTY.equals(soldToParty));
    const result = await req.execute(dest);
    return { content: [{ type: "text", text: JSON.stringify(result.map(so => ({
      id: so.salesOrder, type: so.salesOrderType, soldTo: so.soldToParty
    }))) }] };
  });
}

Tool 2: create_so — create a sales order (header + items)

This is the heart of the agent. The order is built as a deep insert: the header carries an array of items via toItem(). SalesOrderType, the sales-area fields, and SoldToParty are mandatory; each item needs at least Material and RequestedQuantity.

// tools/create_so.ts
import { salesOrderService } from "@sap-cloud-sdk/api-sales-order";

export function registerCreateSoTool(server: McpServer) {
  server.tool("create_so", {
    soldToParty:         { type: "string", description: "Customer BP number" },
    salesOrderType:      { type: "string", description: "Order type e.g. OR" },
    salesOrganization:   { type: "string", description: "Sales org e.g. 1010" },
    distributionChannel: { type: "string", description: "Distribution channel e.g. 10" },
    division:            { type: "string", description: "Division e.g. 00" },
    purchaseOrder:       { type: "string", optional: true, description: "Customer PO reference" },
    items: { type: "array", description:
      "Line items: [{ material, quantity, unit? }]" },
  }, async ({ soldToParty, salesOrderType, salesOrganization,
              distributionChannel, division, purchaseOrder, items }) => {
    const { salesOrderApi, salesOrderItemApi } = salesOrderService();

    // Build each line item
    const lineItems = items.map((i: any) => {
      const b = salesOrderItemApi.entityBuilder()
        .material(i.material)
        .requestedQuantity(i.quantity);
      if (i.unit) b.requestedQuantityUnit(i.unit);
      return b.build();
    });

    // Build the header and attach items via the to_Item navigation (deep insert)
    const order = salesOrderApi.entityBuilder()
      .salesOrderType(salesOrderType)
      .salesOrganization(salesOrganization)
      .distributionChannel(distributionChannel)
      .organizationDivision(division)
      .soldToParty(soldToParty);
    if (purchaseOrder) order.purchaseOrderByCustomer(purchaseOrder);
    order.toItem(lineItems);

    const result = await salesOrderApi.requestBuilder()
      .create(order.build())
      .execute({ destinationName: "S4H_SO_DEST" });

    return { content: [{ type: "text",
      text: `Sales order created successfully. Order Number: ${result.salesOrder}` }] };
  });
}

Tool 3: update_so — update header fields

PATCH needs a CSRF token and the current ETag. Fetch with .getByKey() first so the SDK has the ETag, change the field, then .update().

// tools/update_so.ts
import { salesOrderService } from "@sap-cloud-sdk/api-sales-order";

export function registerUpdateSoTool(server: McpServer) {
  server.tool("update_so", {
    salesOrderId:  { type: "string", description: "Sales order number to update" },
    purchaseOrder: { type: "string", optional: true, description: "New customer PO reference" },
  }, async ({ salesOrderId, purchaseOrder }) => {
    const { salesOrderApi } = salesOrderService();
    const dest = { destinationName: "S4H_SO_DEST" };

    // 1. Fetch existing order (SDK stores the ETag)
    const so = await salesOrderApi.requestBuilder().getByKey(salesOrderId).execute(dest);

    // 2. Apply changes
    if (purchaseOrder) so.purchaseOrderByCustomer = purchaseOrder;

    // 3. PATCH — SDK sends If-Match + CSRF token automatically
    await salesOrderApi.requestBuilder().update(so).execute(dest);
    return { content: [{ type: "text",
      text: `Sales order ${salesOrderId} updated successfully.` }] };
  });
}

Tool 4: delete_so — delete a sales order

⚠️ Sales orders can be deleted — Business Partners cannot.

Unlike API_BUSINESS_PARTNER (where you block rather than delete), API_SALES_ORDER_SRV supports DELETE — but only for orders not yet referenced by a follow-on document (delivery, billing). If subsequent documents exist, S/4HANA rejects the delete; the business-correct action there is to reject the line items, not force a delete.

// tools/delete_so.ts
import { salesOrderService } from "@sap-cloud-sdk/api-sales-order";

export function registerDeleteSoTool(server: McpServer) {
  server.tool("delete_so", {
    salesOrderId: { type: "string", description: "Sales order number to delete" },
  }, async ({ salesOrderId }) => {
    const { salesOrderApi } = salesOrderService();
    const dest = { destinationName: "S4H_SO_DEST" };

    // Fetch first so the SDK has the ETag for the DELETE
    const so = await salesOrderApi.requestBuilder().getByKey(salesOrderId).execute(dest);
    await salesOrderApi.requestBuilder().delete(so).execute(dest);
    return { content: [{ type: "text",
      text: `Sales order ${salesOrderId} has been deleted.` }] };
  });
}

6. Configure the BTP Destination

Point a Destination at your S/4HANA system; the SAP Cloud SDK uses it when you pass { destinationName: 'S4H_SO_DEST' }.

FieldValue
NameS4H_SO_DEST
TypeHTTP
URLhttps://{your-s4hana-host}/sap/opu/odata/sap/API_SALES_ORDER_SRV
AuthenticationBasicAuthentication or OAuth2ClientCredentials
UserCommunication user from SAP_COM_0109 arrangement
sap-client (property)Your SAP client number e.g. 100

SAP_COM_0109 — Communication Arrangement. Ensure a Communication Arrangement for scenario SAP_COM_0109 (Sales Order Integration) is active. It creates the communication user and activates the API_SALES_ORDER_SRV endpoint.

7. Deploy to BTP Cloud Foundry

manifest.yml

applications:
- name: so-mcp-server
  memory: 256M
  buildpacks:
  - nodejs_buildpack
  command: node dist/index.js
  env:
    NODE_ENV: production
  services:
  - destination-service-instance   # binds BTP Destination service
  - xsuaa-service-instance         # binds XSUAA for OAuth2

Build and push

npx tsc
cf login -a https://api.cf.{region}.hana.ondemand.com
cf push
# routes: so-mcp-server.cfapps.{region}.hana.ondemand.com

8. Register the MCP server in Joule Studio

  1. Open Joule Studio → AI Services → Joule Studio.
  2. Create a new Agent → e.g. Sales Order Agent.
  3. Add Tool Source → ‘MCP Server’ → enter https://so-mcp-server.cfapps.{region}.hana.ondemand.com → the four tools auto-discover.
  4. Configure the system prompt:
You are an SAP Sales Order assistant. You can:
- Read and search sales orders
- Create a sales order (header plus one or more line items)
- Update a sales order's PO reference
- Delete a sales order (only if no delivery or billing exists yet)
For a standard order, salesOrderType is OR.
Always confirm the order number returned after a successful create.
If the user gives a customer and materials, ask for quantities you don't have.
  1. Save and activate the agent.

9. Test with natural language

IntentSample promptExpected tool
Create orderCreate a standard order for customer 1000001: 10 units of TG11create_so
Multi-lineOrder for 1000001: 5 of TG11 and 3 of TG12, PO 4500099create_so
ReadShow me sales order 12345 with its itemsread_so
ListList recent orders for customer 1000001read_so
UpdateSet the PO number on order 12345 to 4500100update_so
DeleteDelete sales order 12345delete_so

10. Common errors & fixes

ErrorCauseFix
400 on createMissing sales area (org / channel / division)All three are mandatory alongside SalesOrderType
Sold-To not validCustomer has no sales area / customer roleExtend the BP to the sales area before ordering
Material not foundMaterial not extended to sales org / plantExtend the material; check Plant in the item
403 on update / deleteMissing CSRF token or wrong ETagUse .getByKey() before .update() / .delete()
Delete rejectedOrder already has delivery/billingReject the line items instead of deleting
Wrong currency / pricingCurrency derived from customer masterLeave TransactionCurrency to default unless required

11. References & further reading


This is part two of a series. See part one — Business Partner CRUD with Joule + MCP — and the plain-English map of SAP’s AI stack. Planning an S/4HANA move? Start with a free readiness scan.

Keep reading