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.
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· EntitySetsA_SalesOrder(header) +A_SalesOrderItem(items), linked byto_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-orderand@modelcontextprotocol/sdkpackages
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.
💡 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 thex-csrf-token/If-Matchdance 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 Name | Type | Required | Notes |
|---|---|---|---|
| SalesOrder | String (key) | — | read, update, delete |
| SalesOrderType | String | create | e.g. OR / TA (standard order) |
| SalesOrganization | String | create | Selling org unit |
| DistributionChannel | String | create | e.g. 10 |
| OrganizationDivision | String | create | e.g. 00 |
| SoldToParty | String | create | Customer BP number (must have sales area) |
| PurchaseOrderByCustomer | String | — | Customer PO reference |
| RequestedDeliveryDate | Edm.DateTime | — | Requested delivery date |
| TransactionCurrency | String | — | Usually derived from customer |
A_SalesOrderItem — key item fields (via to_Item)
| Field Name | Type | Required | Notes |
|---|---|---|---|
| SalesOrder | String (key) | — | Set by deep insert |
| SalesOrderItem | String (key) | — | e.g. 10, 20 — auto-numbered if omitted |
| Material | String | create | Must be extended to the sales org/plant |
| RequestedQuantity | Decimal | create | Order quantity |
| RequestedQuantityUnit | String | — | e.g. PC, EA |
| Plant | String | — | Supplying 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.
| Tool | HTTP | Endpoint | Joule example prompt |
|---|---|---|---|
| read_so | GET | /A_SalesOrder or /A_SalesOrder('{id}') | Show me sales order 12345 |
| create_so | POST | /A_SalesOrder (deep insert with to_Item) | Create an order for customer 1000001, 10 of TG11 |
| update_so | PATCH | /A_SalesOrder('{SalesOrder}') | Set the PO number on order 12345 to 4500099 |
| delete_so | DELETE | /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_SRVsupports 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' }.
| Field | Value |
|---|---|
| Name | S4H_SO_DEST |
| Type | HTTP |
| URL | https://{your-s4hana-host}/sap/opu/odata/sap/API_SALES_ORDER_SRV |
| Authentication | BasicAuthentication or OAuth2ClientCredentials |
| User | Communication 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
- Open Joule Studio → AI Services → Joule Studio.
- Create a new Agent → e.g. Sales Order Agent.
- Add Tool Source → ‘MCP Server’ → enter
https://so-mcp-server.cfapps.{region}.hana.ondemand.com→ the four tools auto-discover. - 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.
- Save and activate the agent.
9. Test with natural language
| Intent | Sample prompt | Expected tool |
|---|---|---|
| Create order | Create a standard order for customer 1000001: 10 units of TG11 | create_so |
| Multi-line | Order for 1000001: 5 of TG11 and 3 of TG12, PO 4500099 | create_so |
| Read | Show me sales order 12345 with its items | read_so |
| List | List recent orders for customer 1000001 | read_so |
| Update | Set the PO number on order 12345 to 4500100 | update_so |
| Delete | Delete sales order 12345 | delete_so |
10. Common errors & fixes
| Error | Cause | Fix |
|---|---|---|
| 400 on create | Missing sales area (org / channel / division) | All three are mandatory alongside SalesOrderType |
| Sold-To not valid | Customer has no sales area / customer role | Extend the BP to the sales area before ordering |
| Material not found | Material not extended to sales org / plant | Extend the material; check Plant in the item |
| 403 on update / delete | Missing CSRF token or wrong ETag | Use .getByKey() before .update() / .delete() |
| Delete rejected | Order already has delivery/billing | Reject the line items instead of deleting |
| Wrong currency / pricing | Currency derived from customer master | Leave TransactionCurrency to default unless required |
11. References & further reading
- API_SALES_ORDER_SRV reference — SAP Business Accelerator Hub
- SAP Cloud SDK — Sales Order Service (VDM) docs
- SAP Cloud SDK — OData v2 type-safe client
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
Building a Joule Agent + MCP Server for Business Partner CRUD on SAP BTP
A step-by-step guide to building a production MCP server on BTP Cloud Foundry that exposes four Business Partner tools to a SAP Joule Studio agent — create, read, update, and block Business Partners in S/4HANA via natural language, using API_BUSINESS_PARTNER, Node.js and the SAP Cloud SDK.
Read more →SAP BTP Architecture for S/4HANA Migration Projects
A technical walkthrough of SAP BTP's four pillars, how BTP connects to S/4HANA via destinations and APIs, CAP vs RAP, Cloud Foundry vs Kyma, and where migration teams should actually start.
Read more →Building AI Agents for SAP: Architecture, Tool Use, and Practical Pitfalls
How to wire AI agents — including LLM-based orchestrators — into SAP systems via BTP destinations, function calling for ABAP analysis, and multi-agent workflows for assessment, remediation, and testing.
Read more →