SAP BTP · Model Context Protocol

Wiring Microsoft Exchange to a Joule procurement agent over MCP

SAP Joule Agent procurement-mailer-mcp Exchange / MS Graph

Everything needed to take this integration from an empty subaccount to a validated, live tool call — the accounts and tooling, the Microsoft-side identity plumbing, the MCP server on Cloud Foundry, the BTP destination, and the Joule Studio wiring. Steps are numbered in setup order, which runs opposite to the runtime call order shown above.

RuntimeJoule → MCP → Graph
Setup orderGraph → MCP → Joule
Tools exposedping · list_messages · read_message · send_mail
00
Context

Overview & architecture

The agent never talks to Microsoft directly. A custom MCP server sits in the middle: Joule issues a tool call, the server translates it into a Microsoft Graph request against a single scoped mailbox, and hands the result back. Three stages, two directions — invocation flows left-to-right, data flows back right-to-left.

Stage 1 · Joule
Plans the request, selects an MCP tool, and calls the server over Streamable HTTP through a BTP destination.
Stage 2 · MCP
Node.js + TypeScript server on Cloud Foundry. Exposes four tools and maps them onto Graph calls through the graph-mail destination (an Entra app identity).
Stage 3 · Graph
Microsoft Graph over Exchange Online, restricted by an application access policy to just the demo mailbox.

The companion architecture page covers the same three stages visually — the runtime call flow, the component architecture, and the procurement data model — and is worth keeping open alongside this runbook.

01
Before you start

Prerequisites

Access & accounts

SAP BTP
Subaccount in the us10 region with Cloud Foundry enabled, an entitlement for SAP Build Process Automation / Joule Studio, and a HANA Cloud / HDI container for the CAP backend if present.
Roles: Subaccount Administrator (entitlements, service instances) or at minimum Destination Administrator role collection (for Stage 04), plus the ProcessAutomationAdmin and ProcessAutomationDeveloper role collections for Joule Studio work.
Cloud Foundry
Org Fairconsult Corp.-demo, space procurement-demo.
Roles: SpaceDeveloper in the target space (push, bind, restage) and OrgUser in the org; OrgManager / SpaceManager only if you also need to create the space or assign roles to others.
Microsoft Entra
A tenant where you can register an app and grant admin consent to Graph application permissions.
Roles: Application Administrator (or Cloud Application Administrator) to register the app and create the client secret — but tenant-wide admin consent to Graph application permissions (Mail.Read, Mail.Send) requires Global Administrator or Privileged Role Administrator.
Exchange Online
Admin rights to create an application access policy, plus one demo mailbox the app will be scoped to.
Roles: Exchange Administrator (covers New-ApplicationAccessPolicy, Test-ApplicationAccessPolicy, and creating the security group); Recipient Administrator alone can create the group/mailbox but not the access policy.
GitHub
Repo access with SSH configured (personal id_ed25519 vs. work id_github_work / github-work host alias).
Roles: Write on the repo to push branches; Maintain or Admin only if you manage branch protection or repo settings.

Local tooling

Same toolchain on both platforms — Node (via nvm), the Cloud Foundry CLI with the MultiApps plugin, the MTA build tool, and the Microsoft-side config tooling (Azure CLI + PowerShell with the Exchange Online module). Pick the block for your OS.

macOS (Apple Silicon)

bash — macOS (Apple Silicon)
# Homebrew, if you don't have it
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Node via nvm (LTS)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
nvm install --lts && nvm use --lts

# Cloud Foundry CLI v8 + MultiApps (MTA) plugin + MTA build tool
brew install cloudfoundry/tap/cf-cli@8
cf install-plugin multiapps
npm install -g mbt

# Microsoft-side config tooling
brew install azure-cli
brew install --cask powershell
pwsh -c "Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force"
Apple Silicon gotcha (macOS only)Installing multiapps from the plugin repo can fetch the x86_64 build on arm64 Macs, and cf deploy then fails with a bad-binary error. The fix is to download the osxarm64 binary from the plugin's GitHub releases and install it manually:
bash — multiapps arm64 fix
# Remove the broken x86_64 install
cf uninstall-plugin multiapps

# Download the arm64 build from GitHub and install it manually
curl -LO https://github.com/cloudfoundry/multiapps-cli-plugin/releases/latest/download/multiapps-plugin.osxarm64
chmod +x multiapps-plugin.osxarm64
cf install-plugin ./multiapps-plugin.osxarm64 -f

# Verify
cf plugins | grep -i multiapps

Linux alternative (Debian / Ubuntu)

bash — Linux (Debian / Ubuntu)×
# Node via nvm (LTS)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
nvm install --lts && nvm use --lts

# Cloud Foundry CLI v8 (official apt repo) + MultiApps plugin + MTA build tool
wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cf-cli.gpg
echo "deb [signed-by=/usr/share/keyrings/cf-cli.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list
sudo apt update && sudo apt install -y cf8-cli
cf install-plugin multiapps
npm install -g mbt

# Microsoft-side config tooling
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

# PowerShell (Microsoft apt repo — adjust the Ubuntu version to match `lsb_release -rs`)
wget -q "https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb"
sudo dpkg -i packages-microsoft-prod.deb && rm packages-microsoft-prod.deb
sudo apt update && sudo apt install -y powershell
pwsh -c "Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force"
02
Microsoft side · data source

Microsoft / Entra setup

This stage produces the three values the integration authenticates with — tenant ID, client ID, client secret — which land in the graph-mail destination in Stage 04, and locks the app down to a single mailbox. Do it first: nothing downstream works without these values.

Required permissions
  • Application Administrator or Cloud Application Administrator (Entra) — register the app, create the client secret.
  • Global Administrator or Privileged Role Administrator (Entra) — grant tenant-wide admin consent to Graph application permissions; App Admin alone cannot do this step.
  • Exchange Administrator (M365) — create the shared demo mailbox, the security group, and the application access policy in step 2.
  • User Administrator (M365) — only if the demo mailbox is a licensed user account rather than a shared mailbox.

1 · Register the app & grant Graph permissions

  1. In the Entra admin center → App registrations → New registration. Single tenant is fine for a demo.
  2. On the new app's Overview page, copy the Application (client) ID and the Directory (tenant) ID — these are the CLIENT_ID and TENANT_ID used in the access policy below and in the graph-mail destination (Stage 04).
  3. Under API permissions, add Microsoft Graph → Application permissionsMail.Read and Mail.Send.
  4. Click Grant admin consent — the status column must read Granted for both.
  5. Under Certificates & secrets, create a client secret and copy the value immediately (it's shown once).
Application, not DelegatedThe server runs headless with client-credentials, so both permissions must be Application type. Delegated permissions will authenticate but return 403 on mailbox reads.

2 · Scope the app to one mailbox (Exchange Online)

By default a Graph app with Mail.Read can read every mailbox in the tenant. An application access policy restricts it to the demo mailbox only.

If the demo mailbox doesn't exist yet, create it first: Exchange admin center → Recipients → Mailboxes → Add a shared mailbox (no license needed, covered by Exchange Administrator), or a licensed user in the Microsoft 365 admin center (needs User Administrator + a mailbox license). Its primary address — here procurement-agent@FairConsultCorp.onmicrosoft.com — is the <MAILBOX_UPN> below and the MAILBOX value in manifest.yml (Stage 03).

Everything below runs in PowerShell, not bash. PowerShell itself was installed in Stage 01 (brew install --cask powershell on macOS, apt install powershell on Linux) — drop into a session from your terminal first:

bash → pwsh
# Launch a PowerShell session — your prompt changes to `PS >`
pwsh

# First time only: install and load the Exchange Online module
Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force
Import-Module ExchangeOnlineManagement

# Sanity check — should print the module with its version
Get-Module ExchangeOnlineManagement

# (When you're done later, `exit` drops you back to bash)

With the module loaded, connect and create the policy:

pwsh — Exchange Online
Connect-ExchangeOnline

# A security group holding the mailbox(es) the app may touch
New-DistributionGroup -Name "MCP-Scoped-Mailboxes" -Type Security
Add-DistributionGroupMember -Identity "MCP-Scoped-Mailboxes" -Member <MAILBOX_UPN>

# Restrict the app to that group only
New-ApplicationAccessPolicy `
  -AppId <CLIENT_ID> `
  -PolicyScopeGroupId "MCP-Scoped-Mailboxes" `
  -AccessRight RestrictAccess `
  -Description "Restrict procurement-mailer-mcp to the demo mailbox"

# Confirm: Accessible = should be True for the scoped mailbox, denied elsewhere
Test-ApplicationAccessPolicy -Identity <MAILBOX_UPN> -AppId <CLIENT_ID>
Propagation delayNew access policies can take up to ~30 minutes to take effect. A fresh policy returning 403 right after creation usually just needs time — re-run Test-ApplicationAccessPolicy before assuming it's misconfigured.
03
The bridge

MCP server build & deploy

Runtime & dependencies

Runtime
Node.js 22.x + TypeScript — tsx watch for dev, tscdist/; npm start runs node dist/index.js
MCP
@modelcontextprotocol/sdkMcpServer + StreamableHTTPServerTransport in stateless mode
SAP
@sap-cloud-sdk/connectivity + @sap-cloud-sdk/http-client — every Graph call resolves the graph-mail destination
Schemas
zod — input validation on every tool
Layout
src/index.ts express + transport · src/server.ts tool registry + ping · src/tools/mail.ts the three mail tools · src/graph/client.ts Graph access
Tools
ping · list_messages · read_message · send_mail

Configuration

The Entra credentials from Stage 02 are not environment variables. The server reaches Graph through the graph-mail BTP destination (Stage 04), resolved at runtime via the bound destination service. The app's own config is just the mailbox — PORT is injected by Cloud Foundry — and lives in manifest.yml:

manifest.ymlprocurement-mailer-mcp/manifest.yml
applications:
  - name: procurement-mailer-mcp
    memory: 256M
    disk_quota: 512M
    buildpacks:
      - nodejs_buildpack
    command: npm start
    services:
      - procurement-mailer-dest
      - procurement-mailer-conn
      - procurement-mailer-uaa
    env:
      MAILBOX: procurement-agent@FairConsultCorp.onmicrosoft.com

Transport shape

The server is stateless: every POST /mcp builds a fresh server + transport pair (sessionIdGenerator: undefined), and GET/DELETE — the session channels of Streamable HTTP — answer 405. Keep the route exactly /mcp; the Joule-side destination is configured without it (see Stage 04).

index.tssrc/index.ts
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./server.js";

const PORT = Number(process.env.PORT ?? 3000);

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const server = buildServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless
  });
  res.on("close", () => {
    transport.close();
    server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

// Stateless mode: no GET/DELETE session handling.
app.get("/mcp", (_req, res) => res.status(405).send("Method Not Allowed"));
app.delete("/mcp", (_req, res) => res.status(405).send("Method Not Allowed"));

app.listen(PORT, () => {
  console.log(`MCP server on http://localhost:${PORT}/mcp`);
});

Graph access

No MSAL and no token juggling in code — executeHttpRequest resolves the graph-mail destination, handles the OAuth client-credentials exchange, and prefixes /v1.0:

client.tssrc/graph/client.ts
import { executeHttpRequest } from "@sap-cloud-sdk/http-client";

const DESTINATION = { destinationName: "graph-mail" };

// The shared mailbox every call targets. Overridable via env for other demos.
export const MAILBOX =
  process.env.MAILBOX ?? "procurement-agent@FairConsultCorp.onmicrosoft.com";

export async function graphGet(path: string): Promise<any> {
  const res = await executeHttpRequest(DESTINATION, {
    method: "get",
    url: `/v1.0${path}`,
  });
  return res.data;
}

export async function graphPost(path: string, body: unknown): Promise<number> {
  const res = await executeHttpRequest(DESTINATION, {
    method: "post",
    url: `/v1.0${path}`,
    data: body,
  });
  return res.status; // sendMail returns 202 with no body
}

Deploy to Cloud Foundry

Required permissions
  • SpaceDeveloper (Cloud Foundry, space procurement-demo) — cf push, create/bind service instances, restage.
  • Subaccount Administrator (BTP) — only if the destination, connectivity, or xsuaa entitlements aren't yet assigned to the subaccount.
bash — cf deploy
# Copy the exact API endpoint from BTP cockpit → Cloud Foundry Environment
cf api <CF_API_ENDPOINT>   # e.g. https://api.cf.us10-001.hana.ondemand.com
cf login --sso
cf target -o "Fairconsult Corp.-demo" -s procurement-demo

# One-time: create the three backing services manifest.yml expects
cf create-service destination lite procurement-mailer-dest
cf create-service connectivity lite procurement-mailer-conn
cf create-service xsuaa application procurement-mailer-uaa

# Build, then push — manifest.yml binds the services and sets MAILBOX
npm run build
cf push

# If you re-bind or change a service later: RESTAGE, never restart
cf restage procurement-mailer-mcp
Restage, never restartAll three bindings must be re-bound and restaged for the credentials to land in VCAP_SERVICES. cf restart reuses the old droplet and the env stays empty — only cf restage re-injects the service bindings.
04
BTP side · caller

BTP destination config

Two destinations live in the BTP subaccount → Connectivity → Destinations: one the MCP server uses to call Microsoft Graph, and one Joule Studio uses to reach the MCP server.

Required permissions
  • Destination Administrator role collection (BTP subaccount) — create and edit destinations; Subaccount Administrator also covers it.

1 · graph-mail — MCP server → Microsoft Graph

This is where the three values from Stage 02 live — src/graph/client.ts resolves this destination by name through the bound destination service, and the SAP Cloud SDK handles the token exchange.

Name
graph-mail — must match destinationName in src/graph/client.ts.
URL
https://graph.microsoft.com
Proxy Type
Internet
Authentication
OAuth2ClientCredentials, with the Client ID and Client Secret from Stage 02.
Token Service URL
https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/tokenTENANT_ID is the Directory (tenant) ID from the app's Overview page (Stage 02, step 2).
Additional property
scope = https://graph.microsoft.com/.default

2 · Joule Studio → MCP server

URL
https://procurement-mailer-mcp.cfapps.us10-001.hana.ondemand.com — the app route without a trailing /mcp; Joule appends it. Get the route from cf app procurement-mailer-mcp or the cockpit (Stage 06 shows both).
Proxy Type
Internet
Authentication
As required by the app (e.g. NoAuthentication for a demo, or OAuth if you fronted it).
Additional property
sap-joule-studio-mcp-server = true
The trailing-slash trapIf the URL ends in /mcp, Joule builds /mcp/mcp and every call 404s. Set the URL to the bare route; the two required signals are no trailing /mcp and the sap-joule-studio-mcp-server: true property.
Not visible until registered in Control TowerThe subaccount destination alone doesn't surface the MCP server. It must also be added under SAP Build Control Tower → Destinations (requires the ProcessAutomationAdmin role collection) — only then does the server become available in Joule Studio. That registration is Stage 05, step 1.
05
BTP side · caller

Joule Studio wiring

Required permissions
  • ProcessAutomationDeveloper role collection (SBPA) — build and version the capability in Joule Studio.
  • ProcessAutomationAdmin role collection (SBPA) — register the destination in Control Tower → Destinations, add the MCP tool source, and deploy the capability to ProcurementServiceEnv.
  • Member access to the shared Control Tower environment — the per-member deploy in step 5 requires it for each member.
  1. In SAP Build Control Tower → Destinations, register the Joule-side destination from Stage 04. The subaccount destination alone is not enough — the MCP server only becomes available in Joule Studio after this step.
  2. In Joule Studio, add the MCP server as a tool source, pointing at that destination.
  3. Create the agent (expertise + instructions below) and attach the MCP tool source to it — without the attachment the agent never sees the four tools.
  4. Build the capability (e.g. Procurement-Replica) that surfaces the tools, and version it.
  5. Deploy per member in the shared Control Tower environment (ProcurementServiceEnv) — only needed when publishing; see the note below.
No deploy needed to testThe capability does not have to be deployed for the agent to work. An agent configured with the expertise and instructions below — with the MCP tool source attached — can read and send mail directly from Joule Studio. The per-member deploy in step 5 only matters when you publish to the shared environment.

Agent configuration (example)

The wording is up to you — this is the configuration used for this demo. The one hard requirement: attach the MCP tool source to the agent, or none of the four tools are callable.

Expertise
Email operations and technical documentation for SAP BTP integrations. This agent reads and sends email from a shared procurement mailbox and can clearly explain the setup of MCP servers, Cloud Foundry destinations, and Joule agent configuration.
Instructions
You are a test agent that reads and sends email through a connected Outlook mailbox (procurement-agent@FairConsultCorp.onmicrosoft.com) using the available email tools.

When the user asks you to send an email, use the send_mail tool. Compose a clear, professional subject and body from the user's request, and confirm the recipient before sending. When asked to read or check email, use list_messages and read_message.

Example — if the user says "email my boss confirming the integration works," you would call send_mail with a professional subject like "MCP Email Integration — Working" and a body summarizing what was accomplished, then report back that the email was sent.
Tools
The MCP tool source from step 2, attached to the agent — it then sees ping · list_messages · read_message · send_mail.

That's the whole setup. From here you just test the agent: open it in Joule Studio and prompt it in plain language — "check the procurement inbox" or "email finance confirming the integration works." The agent picks the right MCP tool on its own and calls this server, which runs the Graph request against the mailbox and hands the result back. No manual tool wiring per request — the agent calls the MCP automatically whenever you ask it to send or read email.

Per-member deploymentIn a shared Control Tower env, a capability only shows Deployed after it's explicitly deployed for each member. A capability that looks published but isn't callable is almost always missing its per-member deploy.
SAP_IPA_13366 at deploy timeThis is an SBPA → Joule auth failure during deployment. Re-check the destination authentication and re-consent the connection, then redeploy the capability. It's an auth handshake problem, not a problem with the capability contents.
06
Prove it works

Validation

Two rounds: first locally with npm run dev and the MCP Inspector, then against the deployed route. In both cases validate with a real MCP client — not raw curl, which can't complete the Streamable HTTP handshake and will mislead you.

Required permissions
  • SpaceDeveloper or SpaceAuditor (Cloud Foundry) — read the app route via cf app in Round 2.
  • No role is needed to call the endpoint itself — the demo route is NoAuthentication (Stage 04); anyone with the URL can invoke the tools, which is exactly what Round 2 exploits.

Round 1 · Local — npm run dev + MCP Inspector

bash — two terminals
# Terminal 1 — run the server locally (tsx watch, port 3000)
npm run dev

# Terminal 2 — MCP Inspector, a point-and-click MCP client (opens in the browser)
npx @modelcontextprotocol/inspector

# In the Inspector UI:
#   Transport: Streamable HTTP  ·  URL: http://localhost:3000/mcp
#   Connect → List Tools (expect 4) → call ping → expect "pong @ <timestamp>"
Mail tools need the destinationping works locally with no extra setup. The three mail tools resolve the graph-mail destination, which only exists on BTP — locally, either test them in Round 2 against the deployed route, or feed the SAP Cloud SDK the destination via its destinations env var when starting the server: destinations='[{"name":"graph-mail","url":"https://graph.microsoft.com",...}]' npm run dev.

Round 2 · Deployed — same Inspector, deployed route

No script needed — in the Inspector from Round 1, swap the localhost URL for the deployed route https://procurement-mailer-mcp.cfapps.us10-001.hana.ondemand.com/mcp. Hit Connect, then List Tools: all four methods from src/server.ts / src/tools/mail.ts should appear, which proves the deployed version is connected to the MCP endpoint. Calling ping and list_messages from here also exercises the full Graph chain against the live mailbox.

bash — get the deployed URL
# The deployed URL is the app's route on Cloud Foundry
cf app procurement-mailer-mcp
#   ...
#   routes:   procurement-mailer-mcp.cfapps.us10-001.hana.ondemand.com

# Prefix https:// and append /mcp for the Inspector's URL field:
#   https://procurement-mailer-mcp.cfapps.us10-001.hana.ondemand.com/mcp

# No CLI handy? BTP cockpit → subaccount → Cloud Foundry → Spaces →
#   procurement-demo → procurement-mailer-mcp → Application Routes
MCP Inspector connected to the deployed procurement-mailer server, with List Tools showing Ping, List messages, Read message, and Send mail
MCP Inspector on the deployed route: Streamable HTTP transport, status Connected, server procurement-mailer v0.1.0, and List Tools showing all four methods — the deployed version is wired to the MCP endpoint.
Green pathList Tools shows all four tools, ping returns pong @ <timestamp>, and list_messages returns items from the scoped mailbox. If all three pass against the deployed route, the full chain — destination, bindings, Entra auth, Graph, mailbox scope — is sound.
07
When it breaks

Troubleshooting

SymptomCauseFix
SAP_IPA_13366SBPA → Joule auth failure at capability deploy time.Re-check destination auth, re-consent the connection, redeploy the capability.
Capability not callableMissing per-member deployment in the shared Control Tower env.Deploy the capability explicitly for each member; confirm it reads Deployed.
Tools missing / empty VCAPService bindings not injected — app was restarted, not restaged.Re-bind all three services and cf restage (not restart).
404 on every callDestination URL ends in /mcp, producing /mcp/mcp.Set the destination URL to the bare route with no trailing /mcp.
403 on mailboxDelegated (not Application) permission, or access policy not yet propagated.Confirm Application permissions + admin consent; run Test-ApplicationAccessPolicy; allow ~30 min.
curl "works" but Joule failsRaw curl doesn't complete the Streamable HTTP handshake.Validate with the MCP Inspector (npx @modelcontextprotocol/inspector), not curl.
cf deploy binary errorarm64 / MultiApps plugin architecture mismatch on Apple Silicon.Download multiapps-plugin.osxarm64 from the plugin's GitHub releases and cf install-plugin it manually (Stage 01 has the commands).

end of runbook · setup order Graph → MCP → Joule · back to top ↑