> ## Content Index
> Fetch the complete content index at: https://blog.bajonczak.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Building a Real Sync Insights Agent with MCP and Foundry (SAP HR + Entra ID)
- URL: https://blog.bajonczak.com/building-a-real-sync-insights-agent-with-mcp-and-foundry-sap-hr-entra-id/
- Published: 2026-03-24T12:56:53.000Z
- Updated: 2026-08-02T08:32:40.000Z
- Description: A practical Sync Insights agent design for SAP HR and Entra ID using MCP and Foundry: narrow tools, OData, Graph, mismatch logic, security boundaries and tests.
- Author: Sascha Bajonczak
- Tags: AI Agents, Azure, SAP, Identity

An SAP HR to Entra ID agent should not be a chatbot with access to two sensitive systems.

That is the wrong starting point.

The useful version is smaller and more boring: a Sync Insights agent that can compare SAP HR or SuccessFactors with Entra ID, report drift, explain mismatches, and maybe prepare remediation steps without immediately changing identity data.

I would build it around tools, not around prompts.

The prompt can help users ask better questions. The backend should decide what the agent can actually do.

## What the agent should answer

Start with questions, not APIs.

Good first questions:

- Which active SAP users are missing in Entra ID?
- Which Entra users no longer exist as active SAP users?
- Which users have department mismatches?
- Which users have manager mismatches?
- Which mismatches are new since yesterday?
- Which departments have the most identity drift?
- Which changes are safe suggestions and which need human review?

Those are operational questions. They are also testable.

Bad first question:

> Can the agent read SAP and Entra and keep them in sync?

That hides too much. It mixes discovery, comparison, policy and write actions into one vague request.

## Architecture

I would keep the architecture simple:

```text
User
  -> Teams / Copilot / internal web app
  -> Foundry agent
  -> MCP tools
  -> Integration backend
  -> SAP SuccessFactors / SAP HR OData
  -> Microsoft Graph / Entra ID
  -> Sync report + cited source records

```

The important part is the boundary between the agent and the systems.

The agent does not get raw, open-ended access to SAP and Graph. It gets narrow tools. The backend owns credentials, filtering, comparison rules, logging and rate limits.

In other words:

> Let the agent orchestrate. Do not let it become the security model.

## Tool design

I would expose tools that match operational tasks.

### `sap_list_active_users`

Returns a normalized list of active HR users.

```json
{
  "name": "sap_list_active_users",
  "description": "List active employees from SAP HR or SuccessFactors with key identity attributes.",
  "parameters": {
    "type": "object",
    "properties": {
      "limit": {
        "type": "integer",
        "default": 1000,
        "maximum": 5000
      }
    }
  }
}

```

I would not expose every HR field. The tool should return the fields needed for identity drift, not a full employee dossier.

### `entra_list_users`

Returns the Entra ID side of the comparison.

```json
{
  "name": "entra_list_users",
  "description": "List Entra ID users with key attributes used for SAP HR comparison.",
  "parameters": {
    "type": "object",
    "properties": {
      "limit": {
        "type": "integer",
        "default": 2000,
        "maximum": 10000
      }
    }
  }
}

```

Again, keep the result narrow. The agent does not need every Entra property to answer drift questions.

### `report_sync_mismatches`

This is the main tool.

```json
{
  "name": "report_sync_mismatches",
  "description": "Compare SAP HR and Entra ID users and return missing users or attribute mismatches.",
  "parameters": {
    "type": "object",
    "properties": {
      "includeDepartments": { "type": "boolean", "default": true },
      "includeManagers": { "type": "boolean", "default": true },
      "limitSap": { "type": "integer", "default": 1000 },
      "limitEntra": { "type": "integer", "default": 2000 }
    }
  }
}

```

This tool should call SAP and Entra through the backend, run comparison logic in code, and return a structured result.

The model can summarize it. It should not calculate it from raw dumps in the chat context.

## Project shape

For a TypeScript implementation, I would keep the project boring:

```text
mcp-sap-entra-agent/
  src/
    config.ts
    sapClient.ts
    entraClient.ts
    mismatches.ts
    mcpServer.ts
    audit.ts
  .env
  package.json
  tsconfig.json

```

Setup:

```bash
mkdir mcp-sap-entra-agent
cd mcp-sap-entra-agent
npm init -y
npm install typescript ts-node @types/node axios dotenv
npm install @modelcontextprotocol/sdk
npx tsc --init

```

Environment:

```env
SAP_BASE_URL="https://api.successfactors.eu/odata/v2"
SAP_USERNAME="..."
SAP_PASSWORD="***"

ENTRA_TENANT_ID="..."
ENTRA_CLIENT_ID="..."
ENTRA_CLIENT_SECRET="***"

```

For production, I would avoid long-lived secrets where possible and use managed identity, workload identity or a secret store. The `.env` pattern is fine for a local PoC, not for a serious identity integration.

## SAP client

The SAP side should return a normalized shape.

```ts
// src/sapClient.ts
import axios from 'axios';
import { SAP_BASE_URL, SAP_USERNAME, SAP_PASSWORD } from './config';

export type SapUser = {
  userId: string;
  email: string | null;
  firstName: string | null;
  lastName: string | null;
  department: string | null;
  managerId: string | null;
  status: 'Active' | 'Inactive';
};

export async function listSapUsers(limit = 500): Promise<SapUser[]> {
  const url = `${SAP_BASE_URL}/User?$format=json&$top=${limit}&$select=userId,email,firstName,lastName,department,managerId,status`;

  const resp = await axios.get(url, {
    auth: {
      username: SAP_USERNAME,
      password: SAP_PASSWORD,
    },
  });

  const results = resp.data?.d?.results ?? [];

  return results.map((r: any) => ({
    userId: r.userId,
    email: r.email || null,
    firstName: r.firstName || null,
    lastName: r.lastName || null,
    department: r.department || null,
    managerId: r.managerId || null,
    status: r.status === 'Inactive' ? 'Inactive' : 'Active',
  }));
}

```

Real SuccessFactors environments vary. Field names, authentication and pagination are the parts I would expect to adjust first.

## Entra client

The Entra side uses Microsoft Graph.

```ts
// src/entraClient.ts
import axios from 'axios';
import {
  ENTRA_TENANT_ID,
  ENTRA_CLIENT_ID,
  ENTRA_CLIENT_SECRET,
} from './config';

export type EntraUser = {
  id: string;
  userPrincipalName: string;
  mail: string | null;
  displayName: string | null;
  department: string | null;
  managerId: string | null;
};

async function getAccessToken(): Promise<string> {
  const url = `https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token`;
  const params = new URLSearchParams();
  params.append('client_id', ENTRA_CLIENT_ID);
  params.append('client_secret', ENTRA_CLIENT_SECRET);
  params.append('scope', 'https://graph.microsoft.com/.default');
  params.append('grant_type', 'client_credentials');

  const resp = await axios.post(url, params);
  return resp.data.access_token as string;
}

export async function listEntraUsers(limit = 500): Promise<EntraUser[]> {
  const token = await getAccessToken();
  const url = `https://graph.microsoft.com/v1.0/users?$top=${limit}&$select=id,userPrincipalName,mail,displayName,department`;

  const resp = await axios.get(url, {
    headers: { Authorization: `Bearer ${token}` },
  });

  const value = resp.data?.value ?? [];

  return value.map((u: any) => ({
    id: u.id,
    userPrincipalName: u.userPrincipalName,
    mail: u.mail || null,
    displayName: u.displayName || null,
    department: u.department || null,
    managerId: null,
  }));
}

```

Manager lookup may need an additional Graph call. I would not hide that cost. If manager comparison matters, model it explicitly and cache carefully.

## Mismatch calculation

Comparison logic belongs in code.

```ts
// src/mismatches.ts
import { SapUser } from './sapClient';
import { EntraUser } from './entraClient';

export type SyncMismatch = {
  type: 'MissingInEntra' | 'MissingInSap' | 'AttributeMismatch';
  sapUser?: SapUser;
  entraUser?: EntraUser;
  details: string;
};

function normalizeEmail(email: string | null): string | null {
  return email ? email.trim().toLowerCase() : null;
}

export function computeMismatches(
  sapUsers: SapUser[],
  entraUsers: EntraUser[]
): SyncMismatch[] {
  const mismatches: SyncMismatch[] = [];
  const activeSapUsers = sapUsers.filter(s => s.status === 'Active');

  const entraByEmail = new Map<string, EntraUser>();
  const entraByUpn = new Map<string, EntraUser>();

  for (const e of entraUsers) {
    const emailNorm = normalizeEmail(e.mail);
    if (emailNorm) entraByEmail.set(emailNorm, e);
    entraByUpn.set(e.userPrincipalName.toLowerCase(), e);
  }

  for (const s of activeSapUsers) {
    const emailNorm = normalizeEmail(s.email);
    let match: EntraUser | undefined;

    if (emailNorm) match = entraByEmail.get(emailNorm);
    if (!match) match = entraByUpn.get(s.userId.toLowerCase());

    if (!match) {
      mismatches.push({
        type: 'MissingInEntra',
        sapUser: s,
        details: `No Entra user matching SAP userId=${s.userId}, email=${s.email}`,
      });
      continue;
    }

    const sapDept = (s.department || '').trim();
    const entraDept = (match.department || '').trim();

    if (sapDept && entraDept && sapDept !== entraDept) {
      mismatches.push({
        type: 'AttributeMismatch',
        sapUser: s,
        entraUser: match,
        details: `Department mismatch: SAP="${sapDept}" vs ENTRA="${entraDept}"`,
      });
    }
  }

  const sapEmails = new Set(
    activeSapUsers.map(s => normalizeEmail(s.email)).filter((e): e is string => !!e)
  );
  const sapUserIds = new Set(activeSapUsers.map(s => s.userId.toLowerCase()));

  for (const e of entraUsers) {
    const emailNorm = normalizeEmail(e.mail);
    const upn = e.userPrincipalName.toLowerCase();

    const emailInSap = emailNorm && sapEmails.has(emailNorm);
    const idInSap = sapUserIds.has(upn);

    if (!emailInSap && !idInSap) {
      mismatches.push({
        type: 'MissingInSap',
        entraUser: e,
        details: `No SAP user for Entra UPN=${e.userPrincipalName}, mail=${e.mail}`,
      });
    }
  }

  return mismatches;
}

```

I would add unit tests around this before connecting real systems. The comparison rules are where small mistakes become access problems.

## MCP server

The MCP server should expose narrow tools and keep credentials inside the backend.

```ts
// src/mcpServer.ts
import { createMcpServer, ToolDefinition } from '@modelcontextprotocol/sdk';
import { listSapUsers } from './sapClient';
import { listEntraUsers } from './entraClient';
import { computeMismatches } from './mismatches';

const tools: ToolDefinition[] = [
  {
    name: 'sap_list_active_users',
    description: 'List active users from SAP HR or SuccessFactors.',
    inputSchema: {
      type: 'object',
      properties: {
        limit: { type: 'number', minimum: 1, maximum: 5000 },
      },
      required: [],
    },
    handler: async (input) => {
      const users = await listSapUsers(input.limit ?? 1000);
      return { users: users.filter(u => u.status === 'Active') };
    },
  },
  {
    name: 'entra_list_users',
    description: 'List users from Entra ID with identity comparison fields.',
    inputSchema: {
      type: 'object',
      properties: {
        limit: { type: 'number', minimum: 1, maximum: 10000 },
      },
      required: [],
    },
    handler: async (input) => {
      const users = await listEntraUsers(input.limit ?? 2000);
      return { users };
    },
  },
  {
    name: 'report_sync_mismatches',
    description: 'Compare SAP HR and Entra ID users and return missing users or attribute mismatches.',
    inputSchema: {
      type: 'object',
      properties: {
        limitSap: { type: 'number', minimum: 1, maximum: 5000 },
        limitEntra: { type: 'number', minimum: 1, maximum: 10000 },
      },
      required: [],
    },
    handler: async (input) => {
      const sapUsers = await listSapUsers(input.limitSap ?? 1000);
      const entraUsers = await listEntraUsers(input.limitEntra ?? 2000);
      const mismatches = computeMismatches(sapUsers, entraUsers);

      return {
        totalSap: sapUsers.length,
        totalEntra: entraUsers.length,
        mismatchCount: mismatches.length,
        mismatches,
      };
    },
  },
];

async function startServer() {
  const server = createMcpServer({ tools });
  await server.listen(3000);
  console.log('[MCP] SAP/Entra Sync Insights agent listening on :3000');
}

startServer().catch((err) => {
  console.error('[MCP] Failed to start server', err);
  process.exit(1);
});

```

The exact MCP SDK API may change. I would treat this as the shape of the implementation, not a copy-paste guarantee for every SDK version.

## Security trimming and blast radius

This agent should start read-only.

The first version should not disable users, update departments or write manager relationships. It should report drift.

Controls I would add early:

- least-privilege Graph application permissions
- narrow SAP API user or destination
- no salary or private HR fields in tool results
- max result limits
- correlation IDs for every tool call
- logs that record decisions, not full HR payloads
- role-based access to the agent
- no write tools until drift reporting is trusted

The agent should be allowed to say:

> I can show the mismatch report, but I cannot change Entra ID from this chat.

That is not a limitation. That is good design.

## Where Foundry helps

Foundry is useful around the agent lifecycle.

I would use it for:

- registering the agent and tools
- controlling who can access the agent
- evaluating expected answers
- testing tool-call behavior
- deploying the agent in a managed way
- monitoring usage

But Foundry does not replace the integration backend. The backend is still where SAP calls, Graph calls, comparison logic and policy enforcement should live.

If the backend is a mess, Foundry only gives the mess a nicer front door.

## Example user interactions

A good Sync Insights agent should answer like an operations tool.

User:

> Show me active SAP users missing in Entra.

Agent:

> I found 14 active SAP users without a matching Entra account. 9 are new hires from the last 48 hours, 3 are contractors without a mapped mail address, and 2 look like data issues because the SAP userId does not match the Entra UPN pattern. I created no changes. Here is the report grouped by likely cause.

User:

> Which department mismatches should IAM review first?

Agent:

> I would start with 6 users where SAP shows Finance but Entra still shows Sales, because those department values are used in access packages. The other 11 mismatches look like display/reporting drift only.

That is the useful level. Not “here is a giant JSON dump”. Not “I fixed everything for you”.

## Tests I would run first

Before this goes near production, I would test:

| Test                                    | Expected result                       |
| --------------------------------------- | ------------------------------------- |
| SAP active user missing in Entra        | reported as MissingInEntra            |
| Entra user with no active SAP match     | reported as MissingInSap              |
| same user with different department     | reported as AttributeMismatch         |
| inactive SAP user                       | not treated like a missing Entra user |
| user without permission asks for report | denied                                |
| huge tenant query                       | result limited and paginated          |
| SAP unavailable                         | clear error, no invented answer       |
| Graph throttling                        | retry or fail cleanly                 |
| sensitive HR field requested            | refused or omitted                    |

The important test is the negative one: the agent must not answer reports for users who are not allowed to see them.

## My take

A real Sync Insights agent is not magic. It is an operational layer over a boring integration backend.

That is exactly why I like the pattern.

SAP and Entra stay behind narrow tools. Comparison logic lives in code. Foundry hosts and governs the agent. MCP makes the tool boundary explicit. The user gets a conversational way to inspect identity drift without giving the model uncontrolled access to HR and identity systems.

I would start read-only, make the reports trustworthy, then add assisted remediation later.

If the reports are not boring yet, the automation should not be powerful yet.

## Related articles

- [SAP vs. Entra ID: Why Your User Sync Should Be a Product, Not a Script](https://blog.bajonczak.com/sap-vs-entra-id-why-your-user-sync-should-be-a-product-not-a-script/)
- [A practical SAP agent in Azure AI Foundry: OData in, governed answer out](https://blog.bajonczak.com/a-practical-sap-agent-in-azure-ai-foundry-odata-in-governed-answer-out/)