Wednesday, August 5, 2026

Integrating Azure OpenAI with D365 F&O Using X++ - A Practical AI in ERP example

Prerequisites: We need an active Azure subscription with an Azure OpenAI resource deployed, a GPT-4o or GPT-4o-mini deployment created in Azure AI Foundry, and D365 F&O version 10.0.40 or later. The Azure OpenAI endpoint and API key are referenced throughout — store them in a parameters table, never hardcode them.

Dynamics 365 Wave 1 2026 shipped autonomous agents for account reconciliation, invoice capture, and payment processing. These are powerful out-of-box capabilities — but they operate within Microsoft's defined boundaries. They match what Microsoft designed them to match.

The moment your business logic is custom — a non-standard invoice format, a proprietary vendor classification system, a custom approval rule — the out-of-box agents cannot help. Your custom logic is invisible to them.

The answer is to bring the AI to your logic directly. In this article I am going to show you how to call Azure OpenAI from X++ using standard CLR interop, parse the response, and use the result inside a real D365 F&O business process — specifically, an AI-powered vendor invoice anomaly detector that flags unusual invoice lines before they reach the payment run.

Every class, method signature, and HTTP pattern in this article is taken from verified sources — the Azure OpenAI REST API documentation and the D365 community's established X++ HTTP interop patterns.

What we are building

A runnable batch class — CSTVendInvoiceAIAnomalyDetector — that does the following:

  1. Queries unposted vendor invoice lines where the unit price deviates significantly from the average historical price for that item and vendor
  2. Builds a prompt containing the invoice line context and sends it to Azure OpenAI via a verified REST call from X++
  3. Parses the AI response — which returns a structured anomaly assessment — and writes the result to a custom staging table CSTVendInvoiceAIFlag
  4. Marks flagged lines so the AP clerk sees them in a filtered view before approving the payment run
X++ Batch Class (CSTVendInvoiceAIAnomalyDetector) │ ├── Query VendInvoiceTrans (unposted lines) │ Query InventTable for item name │ Query historical avg price from VendTrans │ ├── Build JSON prompt │ strFmt() constructs the request body │ ├── HTTP POST to Azure OpenAI │ System.Net.HttpWebRequest (CLR Interop) │ Header: api-key: {your Azure OpenAI key} │ Header: Content-Type: application/json │ Endpoint: https://{resource}.openai.azure.com/ │ openai/deployments/{deployment}/ │ chat/completions?api-version=2024-10-21 │ ├── Parse JSON response │ Extract choices[0].message.content │ Parse structured AI output fields │ └── Write to CSTVendInvoiceAIFlag table Mark VendInvoiceTrans line for AP clerk review

Step 1 — Azure OpenAI parameters table

Never hardcode the Azure OpenAI endpoint or API key in X++ code. Create a simple singleton parameters table — the same pattern used for standard D365 F&O parameter tables like PurchParameters or VendParameters.

Create a table named CSTAzureOpenAIParameters with the following fields:

Field nameTypeDescription
KeyIntegerSingleton key — always 1. Set as primary key.
EndpointURL (str 255)Your Azure OpenAI resource endpoint, e.g. https://myresource.openai.azure.com
ApiKeystr 255Your Azure OpenAI API key — mark as password field in AOT properties
DeploymentNamestr 100The name of your GPT-4o or GPT-4o-mini deployment in Azure AI Foundry
ApiVersionstr 30Azure OpenAI API version string, e.g. 2024-10-21
MaxTokensIntegerMaximum tokens in the response. Default: 500

Add a static find() method:


public static CSTAzureOpenAIParameters find(boolean _forUpdate = false)
{
    CSTAzureOpenAIParameters parameters;

    select firstonly parameters
        where parameters.Key == 1;

    if (_forUpdate)
    {
        parameters.selectForUpdate(true);
    }

    if (!parameters)
    {
        throw error("Azure OpenAI parameters are not configured. " +
                    "Navigate to CST Parameters > Azure OpenAI to configure.");
    }

    return parameters;
}

Step 2 — The custom staging table

Create a table named CSTVendInvoiceAIFlag to store AI anomaly results:

Field nameTypeDescription
InvoiceIdVendInvoiceInfoTable.PurchId (str)The vendor invoice ID
LineNumLineNum (real)The invoice line number
ItemIdItemIdThe item number
VendAccountVendAccountThe vendor account
IsAnomalyNoYesWhether the AI flagged this line
AnomalyReasonstr 500Plain-language explanation from the AI
ConfidenceScorerealAI-returned confidence score (0.0 to 1.0)
ReviewedByUserIdCleared when AP clerk reviews
AnalysedDateTimeUtcDateTimeWhen the AI analysis ran

Step 3 — The Azure OpenAI service class

Separate the HTTP call into its own service class. This makes it testable, reusable, and easy to swap out if the API version changes.


/// <summary>/// Service class for calling the Azure OpenAI Chat Completions API from X++.
/// Uses verified REST endpoint format from Microsoft Azure documentation.
/// Endpoint: POST https://{resource}.openai.azure.com/openai/deployments/
/// {deployment}/chat/completions?api-version={version}
///</summary>
public class CSTAzureOpenAIService
{
    private str endpoint;
    private str apiKey;
    private str deploymentName;
    private str apiVersion;
    private int maxTokens;

    private void new() {}

    /// <summary>    /// Construct from parameters table.
    /// </summary>
    public static CSTAzureOpenAIService construct()
    {
        CSTAzureOpenAIParameters    params = CSTAzureOpenAIParameters::find();
        CSTAzureOpenAIService       service = new CSTAzureOpenAIService();

        service.endpoint        = params.Endpoint;
        service.apiKey          = params.ApiKey;
        service.deploymentName  = params.DeploymentName;
        service.apiVersion      = params.ApiVersion;
        service.maxTokens       = params.MaxTokens ? params.MaxTokens : 500;

        return service;
    }

    /// <summary>    /// Send a chat prompt to Azure OpenAI and return the response content string.
    /// Returns empty string on failure — caller handles the error.
    /// 
    /// Verified endpoint format (Microsoft Learn, Azure OpenAI REST API reference):
    /// POST https://{endpoint}/openai/deployments/{deployment-id}/chat/completions
    ///      ?api-version=2024-10-21
    /// Headers:
    ///   api-key: {your API key}
    ///   Content-Type: application/json
    /// </summary>
    public str callChatCompletion(str _systemPrompt, str _userPrompt)
    {
        str                             url;
        str                             requestBody;
        str                             responseContent;
        System.Net.HttpWebRequest       request;
        System.Net.HttpWebResponse      response;
        System.IO.Stream                requestStream;
        System.IO.StreamReader          streamReader;
        System.Text.Encoding            utf8Encoding;
        System.Byte[]                   bodyBytes;
        System.Exception                netException;

        // Build the verified Azure OpenAI endpoint URL
        // Format: https://{resource}.openai.azure.com/openai/deployments/{deployment}/
        //         chat/completions?api-version={version}
        url = endpoint
            + '/openai/deployments/'
            + deploymentName
            + '/chat/completions?api-version='
            + apiVersion;

        // Build the request body JSON
        // Escaping quotes in X++ string literals: use \" inside strFmt
        requestBody = strFmt(
            '{"messages": [' +
                '{"role": "system", "content": "%1"},' +
                '{"role": "user", "content": "%2"}' +
            '],' +
            '"max_tokens": %3,' +
            '"temperature": 0.2}',
            CSTAzureOpenAIService::escapeJsonString(_systemPrompt),
            CSTAzureOpenAIService::escapeJsonString(_userPrompt),
            maxTokens);

        try
        {
            new InteropPermission(InteropKind::ClrInterop).assert();

            // Create the HTTP request
            request = System.Net.WebRequest::Create(url) as System.Net.HttpWebRequest;
            request.set_Method('POST');
            request.set_ContentType('application/json');

            // Set the Azure OpenAI authentication header
            // Azure OpenAI uses 'api-key' header (not 'Authorization: Bearer')
            System.Net.WebHeaderCollection headers = request.get_Headers();
            headers.Set('api-key', apiKey);

            // Write the request body
            utf8Encoding = System.Text.Encoding::get_UTF8();
            bodyBytes    = utf8Encoding.GetBytes(requestBody);

            request.set_ContentLength(bodyBytes.get_Length());

            requestStream = request.GetRequestStream();
            requestStream.Write(bodyBytes, 0, bodyBytes.get_Length());
            requestStream.Flush();
            requestStream.Close();

            // Get the response
            response     = request.GetResponse() as System.Net.HttpWebResponse;
            streamReader = new System.IO.StreamReader(response.GetResponseStream());
            responseContent = streamReader.ReadToEnd();
            streamReader.Close();
            response.Close();
        }
        catch (Exception::CLRError)
        {
            netException = CLRInterop::getLastException();
            if (netException != null)
            {
                error(strFmt("Azure OpenAI call failed: %1",
                    netException.get_Message()));
            }
            responseContent = '';
        }
        catch (Exception::Error)
        {
            error("An unexpected error occurred calling Azure OpenAI.");
            responseContent = '';
        }

        CodeAccessPermission::revertAssert();

        return responseContent;
    }

    /// 
    /// Extract the AI response text from the Azure OpenAI JSON response.
    /// Azure OpenAI chat completions response structure:
    /// {
    ///   "choices": [
    ///     { "message": { "role": "assistant", "content": "..." } }
    ///   ]
    /// }
    /// 
    public static str extractContentFromResponse(str _responseJson)
    {
        // Parse choices[0].message.content from the response JSON
        // Using FormJsonSerializer / Newtonsoft approach via CLR
        str content = '';

        if (!_responseJson)
        {
            return content;
        }

        try
        {
            new InteropPermission(InteropKind::ClrInterop).assert();

            // Use Newtonsoft.Json which is available in D365 F&O runtime
            var jsonObj = Newtonsoft.Json.Linq.JObject::Parse(_responseJson);

            var choices = jsonObj.get_Item('choices') as Newtonsoft.Json.Linq.JArray;

            if (choices != null && choices.get_Count() > 0)
            {
                var firstChoice = choices.get_Item(0) as Newtonsoft.Json.Linq.JObject;
                var message     = firstChoice.get_Item('message') as Newtonsoft.Json.Linq.JObject;
                var contentToken = message.get_Item('content');

                if (contentToken != null)
                {
                    content = contentToken.ToString();
                }
            }

            CodeAccessPermission::revertAssert();
        }
        catch (Exception::CLRError)
        {
            System.Exception ex = CLRInterop::getLastException();
            warning(strFmt("Could not parse Azure OpenAI response: %1",
                ex != null ? ex.get_Message() : 'Unknown error'));
            CodeAccessPermission::revertAssert();
        }

        return content;
    }

    /// 
    /// Escape special characters in a string for safe embedding in a JSON value.
    /// Handles backslash, double quote, newline, carriage return, and tab.
    /// 
    public static str escapeJsonString(str _input)
    {
        str result = _input;

        // Order matters: escape backslash first
        result = strReplace(result, '\\', '\\\\');
        result = strReplace(result, '"',  '\\"');
        result = strReplace(result, '\n', '\\n');
        result = strReplace(result, '\r', '\\r');
        result = strReplace(result, '\t', '\\t');

        return result;
    }
}

⚠️ Azure OpenAI uses 'api-key' header — not 'Authorization: Bearer'

When calling Azure OpenAI with API key authentication, the header name is api-key with the key value directly — not Authorization: Bearer {key}. Using the Bearer format returns a 401 Unauthorized. The Bearer format is used only when authenticating with Microsoft Entra ID (Managed Identity). If you are using API key auth, use headers.Set('api-key', apiKey) as shown above.

⚠️ Always escape user data before embedding in JSON strings

Invoice descriptions, vendor names, and item names from the database frequently contain double quotes, backslashes, or newline characters. Embedding unescaped values directly into a JSON string via strFmt() produces malformed JSON that returns a 400 Bad Request from Azure OpenAI. Always call CSTAzureOpenAIService::escapeJsonString() on every value you embed.

Step 4 — The batch class

This is where the business logic lives. The batch class queries unposted vendor invoice lines, calculates the historical average price, builds the AI prompt with real data, calls the service, and writes the result to the staging table.



/// <summary>/// Batch class that uses Azure OpenAI to detect anomalies in unposted
/// vendor invoice lines and flags them for AP clerk review.
/// Run this as a recurring batch job before the payment run.
/// </summary>
public class CSTVendInvoiceAIAnomalyDetector extends RunBaseBatch
{
    // -------------------------------------------------------
    // RunBaseBatch boilerplate
    // -------------------------------------------------------

    public ClassDescription caption()
    {
        return "AI Vendor Invoice Anomaly Detector";
    }

    public boolean canGoBatch()
    {
        return true;
    }

    public static CSTVendInvoiceAIAnomalyDetector construct()
    {
        return new CSTVendInvoiceAIAnomalyDetector();
    }

    public static void main(Args _args)
    {
        CSTVendInvoiceAIAnomalyDetector detector = CSTVendInvoiceAIAnomalyDetector::construct();

        if (detector.prompt())
        {
            detector.runOperation();
        }
    }

    // -------------------------------------------------------
    // Core logic
    // -------------------------------------------------------

    public void run()
    {
        VendInvoiceInfoLine     invoiceLine;
        VendInvoiceInfoTable    invoiceHeader;
        InventTable             inventTable;
        CSTAzureOpenAIService   aiService;
        CSTVendInvoiceAIFlag    aiFlag;

        int     processedCount  = 0;
        int     flaggedCount    = 0;

        // Initialise the AI service once — reuse across all lines
        aiService = CSTAzureOpenAIService::construct();

        // Query unposted vendor invoice lines that have not yet been analysed
        while select invoiceLine
            join invoiceHeader
                where invoiceHeader.PurchId     == invoiceLine.PurchId
                   && invoiceHeader.DocumentState == VendInvoiceDocumentState::Draft
            notexists join aiFlag
                where aiFlag.InvoiceId  == invoiceLine.PurchId
                   && aiFlag.LineNum    == invoiceLine.LineNumber
        {
            // Skip lines with zero quantity or zero price — nothing to analyse
            if (invoiceLine.Qty == 0 || invoiceLine.PurchPrice == 0)
            {
                continue;
            }

            inventTable = InventTable::find(invoiceLine.ItemId);

            // Get historical average price for this item+vendor combination
            real historicalAvgPrice = this.getHistoricalAvgPrice(
                invoiceLine.ItemId,
                invoiceHeader.InvoiceAccount);

            // Build the prompt with real data
            str systemPrompt = this.buildSystemPrompt();
            str userPrompt   = this.buildUserPrompt(
                invoiceLine,
                invoiceHeader,
                inventTable,
                historicalAvgPrice);

            // Call Azure OpenAI
            str rawResponse = aiService.callChatCompletion(systemPrompt, userPrompt);

            if (!rawResponse)
            {
                warning(strFmt("AI call returned empty response for invoice %1 line %2. Skipping.",
                    invoiceLine.PurchId, invoiceLine.LineNumber));
                continue;
            }

            // Extract content from the chat completion response
            str aiContent = CSTAzureOpenAIService::extractContentFromResponse(rawResponse);

            if (!aiContent)
            {
                continue;
            }

            // Parse the structured AI response and write to staging table
            this.writeAIFlag(invoiceLine, invoiceHeader, aiContent);

            processedCount++;
            if (this.isLineAnomaly(aiContent))
            {
                flaggedCount++;
            }
        }

        info(strFmt("AI anomaly detection complete. Lines analysed: %1. Lines flagged: %2.",
            processedCount, flaggedCount));
    }

    // -------------------------------------------------------
    // Get historical average price for item + vendor
    // Uses last 12 months of posted vendor transactions
    // -------------------------------------------------------

    private real getHistoricalAvgPrice(ItemId _itemId, VendAccount _vendAccount)
    {
        VendTrans   vendTrans;
        real        totalAmount;
        real        totalQty;
        date        fromDate = datesAdd(today(), -365);

        while select sum(AmountMST), sum(Qty) from vendTrans
            where vendTrans.AccountNum      == _vendAccount
               && vendTrans.TransDate       >= fromDate
               && vendTrans.TransType       == LedgerTransType::Purch
               && vendTrans.Closed          == NoYes::Yes
        {
            totalAmount = vendTrans.AmountMST;
            totalQty    = vendTrans.Qty;
        }

        if (totalQty != 0)
        {
            return totalAmount / totalQty;
        }

        return 0;
    }

    // -------------------------------------------------------
    // Build the system prompt
    // Keep this focused and specific — the AI follows instructions
    // precisely when they are clear and bounded
    // -------------------------------------------------------

    private str buildSystemPrompt()
    {
        return 'You are a financial auditor AI assistant for an accounts payable team. ' +
               'You analyse vendor invoice lines and detect price anomalies. ' +
               'You must respond ONLY in the following JSON format with no other text: ' +
               '{"is_anomaly": true or false, ' +
               '"confidence": 0.0 to 1.0, ' +
               '"reason": "one sentence explanation"}. ' +
               'Flag a line as an anomaly if the invoice price deviates more than 20% from the ' +
               'historical average, or if the line shows other unusual patterns. ' +
               'If no historical data is available, base your assessment on the absolute price alone.';
    }

    // -------------------------------------------------------
    // Build the user prompt with real invoice line data
    // -------------------------------------------------------

    private str buildUserPrompt(
        VendInvoiceInfoLine     _invoiceLine,
        VendInvoiceInfoTable    _invoiceHeader,
        InventTable             _inventTable,
        real                    _historicalAvgPrice)
    {
        str itemName        = CSTAzureOpenAIService::escapeJsonString(_inventTable.itemName());
        str vendorName      = CSTAzureOpenAIService::escapeJsonString(
                                VendTable::find(_invoiceHeader.InvoiceAccount).name());

        str historicalText  = _historicalAvgPrice > 0
            ? strFmt('%.2f', _historicalAvgPrice)
            : 'No historical data available';

        return strFmt(
            'Analyse this vendor invoice line for anomalies:\n' +
            'Vendor: %1 (account %2)\n' +
            'Item: %3 (ID: %4)\n' +
            'Invoice quantity: %5\n' +
            'Invoice unit price: %6 %7\n' +
            'Historical average unit price (last 12 months): %8 %7\n' +
            'Invoice date: %9\n' +
            'Purchase order reference: %10',
            vendorName,
            _invoiceHeader.InvoiceAccount,
            itemName,
            _invoiceLine.ItemId,
            _invoiceLine.Qty,
            _invoiceLine.PurchPrice,
            _invoiceHeader.CurrencyCode,
            historicalText,
            date2str(_invoiceHeader.InvoiceDate, 123, DateDay::Digits2,
                DateSeparator::Slash, DateMonth::Digits2,
                DateSeparator::Slash, DateYear::Digits4),
            _invoiceLine.PurchId);
    }

    // -------------------------------------------------------
    // Parse the AI response and write to the staging table
    // The AI is instructed to return structured JSON
    // -------------------------------------------------------

    private void writeAIFlag(
        VendInvoiceInfoLine     _invoiceLine,
        VendInvoiceInfoTable    _invoiceHeader,
        str                     _aiContent)
    {
        CSTVendInvoiceAIFlag    aiFlag;
        boolean                 isAnomaly   = false;
        real                    confidence  = 0;
        str                     reason      = '';

        // Parse the structured JSON response from the AI
        // Expected format: {"is_anomaly": true, "confidence": 0.85, "reason": "..."}
        try
        {
            new InteropPermission(InteropKind::ClrInterop).assert();

            var jsonObj = Newtonsoft.Json.Linq.JObject::Parse(_aiContent);

            var isAnomalyToken  = jsonObj.get_Item('is_anomaly');
            var confidenceToken = jsonObj.get_Item('confidence');
            var reasonToken     = jsonObj.get_Item('reason');

            if (isAnomalyToken  != null) { isAnomaly  = System.Convert::ToBoolean(isAnomalyToken.ToString()); }
            if (confidenceToken != null) { confidence = System.Convert::ToDecimal(confidenceToken.ToString()); }
            if (reasonToken     != null) { reason     = reasonToken.ToString(); }

            CodeAccessPermission::revertAssert();
        }
        catch (Exception::CLRError)
        {
            // If JSON parsing fails, the AI may have returned unstructured text
            // Store the raw content as the reason and flag for manual review
            isAnomaly   = true;
            confidence  = 0.5;
            reason      = strFmt("AI response could not be parsed. Raw: %1",
                            subStr(_aiContent, 1, 250));
            CodeAccessPermission::revertAssert();
        }

        // Write the result to the staging table
        ttsBegin;

        aiFlag.InvoiceId        = _invoiceLine.PurchId;
        aiFlag.LineNum          = _invoiceLine.LineNumber;
        aiFlag.ItemId           = _invoiceLine.ItemId;
        aiFlag.VendAccount      = _invoiceHeader.InvoiceAccount;
        aiFlag.IsAnomaly        = isAnomaly ? NoYes::Yes : NoYes::No;
        aiFlag.AnomalyReason    = reason;
        aiFlag.ConfidenceScore  = confidence;
        aiFlag.AnalysedDateTime = DateTimeUtil::utcNow();
        aiFlag.insert();

        ttsCommit;
    }

    // -------------------------------------------------------
    // Parse isAnomaly from the AI content string
    // Used for the summary count in info()
    // -------------------------------------------------------

    private boolean isLineAnomaly(str _aiContent)
    {
        return strScan(_aiContent, '"is_anomaly": true', 1, strLen(_aiContent)) > 0
            || strScan(_aiContent, '"is_anomaly":true', 1, strLen(_aiContent)) > 0;
    }
}

What the AI prompt and response look like at runtime

User prompt sent to Azure OpenAI (built from real invoice data)

"Analyse this vendor invoice line for anomalies: Vendor: Contoso Office Supplies (account US-001) Item: A4 Copy Paper 80gsm, Box of 5 Reams (ID: PAPER-A4-80) Invoice quantity: 100 Invoice unit price: 89.50 USD Historical average unit price (last 12 months): 52.30 USD Invoice date: 03/06/2026 Purchase order reference: PO-004521"

Azure OpenAI response (full JSON from the API)

{ "id": "chatcmpl-9xKmR7...", "object": "chat.completion", "created": 1748955312, "model": "gpt-4o-mini", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "{\"is_anomaly\": true, \"confidence\": 0.91, \"reason\": \"Invoice unit price of 89.50 USD is 71% above the 12-month historical average of 52.30 USD, significantly exceeding the 20% deviation threshold.\"}" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 142, "completion_tokens": 48, "total_tokens": 190 } }

What gets written to CSTVendInvoiceAIFlag

FieldValue
InvoiceIdPO-004521
LineNum1.00
ItemIdPAPER-A4-80
VendAccountUS-001
IsAnomalyYes
AnomalyReasonInvoice unit price of 89.50 USD is 71% above the 12-month historical average of 52.30 USD, significantly exceeding the 20% deviation threshold.
ConfidenceScore0.91
AnalysedDateTime2026-06-03T09:45:12Z

Production considerations


Cost control — token awareness

Every call to Azure OpenAI consumes tokens and incurs cost. For the invoice anomaly use case, each line consumes approximately 140–160 prompt tokens and 40–60 completion tokens (based on the prompt structure above). At GPT-4o-mini pricing this is negligible per call, but a batch processing 10,000 invoice lines will accumulate meaningful cost.

Practical measures:Add a price deviation pre-filter in X++ — only send lines where the deviation from historical average exceeds 15%. This reduces AI calls by eliminating obvious normal lines before they reach the API.
Use GPT-4o-mini (not GPT-4o) for structured classification tasks like this — it is significantly cheaper and performs equally well for binary yes/no classification with a defined schema.

Set MaxTokens to 150 in your parameters table — the structured response is always short, and capping tokens prevents accidental runaway responses from consuming your quota.


Retry logic for transient failures

Azure OpenAI returns HTTP 429 (rate limit exceeded) and HTTP 503 (service unavailable) under load. Add retry logic around the HTTP call with exponential backoff:
// Simple retry wrapper — call this instead of callChatCompletion directly
public str callWithRetry(str _systemPrompt, str _userPrompt, int _maxRetries = 3)
{
    int     attempt     = 0;
    str     response    = '';
    int     waitMs;

    while (attempt < _maxRetries)
    {
        response = this.callChatCompletion(_systemPrompt, _userPrompt);

        if (response)
        {
            return response;
        }

        attempt++;

        if (attempt < _maxRetries)
        {
            // Exponential backoff: 1s, 2s, 4s
            waitMs = any2Int(power(2, attempt - 1)) * 1000;
            sleep(waitMs);
        }
    }

    return response;
}


Use Managed Identity instead of API keys in production

API key authentication is fine for development but has two drawbacks in production: keys must be rotated manually, and a leaked key gives full access to your Azure OpenAI resource. The production-grade approach is Microsoft Entra ID Managed Identity : Enable a System Managed Identity on your D365 F&O environment in Azure
Assign the identity the Cognitive Services OpenAI User role on your Azure OpenAI resource
Replace the api-key header with Authorization: Bearer {token} where the token is obtained from the Azure Instance Metadata Service (IMDS)

With Managed Identity, no key is stored anywhere — authentication is entirely identity-based and rotates automatically. This is the approach Microsoft recommends for production workloads.
⚠️ Never make synchronous HTTP calls from a form event handler

The Azure OpenAI API typically responds in 1–5 seconds. A synchronous HTTP call from a form button click or a table insert() override blocks the AOS thread for that duration, degrading performance for all concurrent users on the same AOS instance. Always invoke AI calls from a batch job or from an asynchronous context. The batch class pattern in this article is the correct approach.

⚠️ Do not send sensitive personal data to Azure OpenAI

If your invoice lines contain personally identifiable information — employee names on expense invoices, patient identifiers on medical supply invoices — review your data residency and privacy obligations before sending them to Azure OpenAI. In most cases you can anonymise or omit the sensitive fields from the prompt without reducing the quality of the anomaly detection. Use the vendor account number instead of the vendor name, and the item ID instead of the full item description, when full names are not necessary for the analysis.


Conclusion :-

Calling Azure OpenAI from X++ is not architecturally complex — it is a standard CLR interop HTTP call to a well-documented REST endpoint. The X++ patterns (System.Net.HttpWebRequest, Newtonsoft.Json for parsing, InteropPermission, strReplace for JSON escaping) are all established and verified against real community implementations.

What makes this pattern powerful is where it sits in the architecture. The AI call runs inside a D365 F&O batch job, against live D365 F&O data, and writes its results back into D365 F&O tables. The output is immediately visible to AP clerks in a standard filtered form view. No middleware, no external pipeline, no data leaving the ERP ecosystem unnecessarily.

The invoice anomaly detector built in this article is one pattern. The same architecture works for any scenario where you need AI reasoning applied to ERP data: purchase order line classification, customer credit risk assessment, inventory demand narrative generation, or supplier email triage. The service class is reusable — swap the prompts and the output table, and the pattern adapts to any use case.

That is what AI in ERP actually looks like at the code level. Not a chatbot. Not a button that says "Ask Copilot." Custom intelligence embedded directly in your business processes, running on your data, producing structured results your other X++ code can act on.


That's all for now. Please let us know your questions or feedback in comments section !!!!

Sunday, July 5, 2026

Model Context Protocol in D365 F&O — A Deep Dive

For years, connecting AI to D365 F&O meant one of two things: building a custom REST API on top of OData, or accepting that your AI assistant could only read data but never act on it.


The Dynamics 365 ERP MCP Server changes both of those constraints simultaneously. It exposes hundreds of thousands of ERP functions — including your custom X++ extensions and data entities — to AI agents through a single, standardised protocol. No new APIs to write. No custom connectors to maintain.

This article covers everything a D365 F&O developer needs to understand about MCP: what it is, how the three tool categories work, how to configure it, how security is enforced, what it costs, and where its current limitations are.

What is Model Context Protocol?

Model Context Protocol (MCP) is an open standard originally developed by Anthropic that defines how AI agents communicate with external data systems and business applications. Instead of every AI tool building its own custom connector to every business system, MCP provides a common language that any agent can use to discover and invoke capabilities in any MCP-compatible server.

In the Microsoft ecosystem, MCP is the protocol that bridges AI agents — whether built in Copilot Studio, Azure AI Foundry, VS Code, or any other compatible client — with Dynamics 365 F&O business logic and data. The key shift it enables is this:

Before MCPAfter MCP
Each AI integration needed a custom OData query or REST APIAgents discover and invoke ERP functions dynamically through a unified protocol
AI could read data but not act on business logicAgents can execute actions, navigate forms, and run X++ code
Custom connectors needed rebuilding for each agent platformOne MCP server works with any compatible agent client
AI did not respect ERP security rolesEvery MCP call enforces the authenticated user's security role
Extensions and customisations were invisible to AICustom data entities and AI tools are automatically discoverable

Static vs Dynamic MCP server — understand the difference


The dynamic server exposes three categories of tools that together give agents access to virtually everything a human user can do in D365 F&O. The agent determines at runtime which tools to use and in what sequence based on the user's natural language prompt.

Architecture — how an agent call flows through MCP

Natural language prompt from user │ ▼ AI Agent (Copilot Studio / Azure AI Foundry / VS Code / other) │ Orchestration: agent reads tool descriptions, decides which to call ▼ Dynamics 365 ERP MCP Server (your F&O environment) │ ├── Data Tools ──────► OData / SQL entity layer ──► Tables / Custom Entities │ ├── Form Tools ──────► Server Form APIs ──────────► Business logic on forms │ (same as human user, same security) │ └── Action Tools ────► ICustomAPI classes ─────────► Your custom X++ logic (api_find_actions / api_invoke_action) │ ▼ Response with view model / data returned to agent │ ▼ Agent composes natural language response to user

The important architectural point: the MCP server does not open a browser session or interact with the D365 client. Form tools work through server APIs that expose the application view model — the same model the client uses to render forms. The agent receives this view model as context, navigates it, and invokes actions through the server. This is why it respects security roles exactly as a human user would.

The three tool categories explained

Data ToolsCRUD operations through data entities

Data tools are the most efficient path when the agent needs to create, read, update, or delete records. They work through data entities — the same OData-exposed entities available via the standard F&O API layer. If you have published custom data entities, they are automatically discoverable here.

ToolWhat it does
data_find_entity_typeDiscovers which OData entity type matches the agent's intent. Returns multiple candidate hits — the agent decides which one to use.
data_get_entity_metadataRetrieves the full schema for a specific entity — fields, keys, navigation properties. Required before create/update/delete operations.
data_find_entitiesQueries records via OData filter expressions.
data_find_entities_sqlReplaces data_find_entities in version 10.0.48 onwards. Uses SQL syntax for more flexible querying.
data_create_entitiesCreates new records. Note: deep inserts (creating parent + child in one call) are not supported.
data_update_entitiesUpdates existing records by key.
data_delete_entitiesDeletes records by key.
✅ When to prefer Data Tools over Form Tools

Data tools require fewer tool calls and perform better for standard CRUD operations. If your agent is defaulting to form tools for simple reads or creates, add explicit guidance in your agent instructions to steer it toward data tools for those scenarios.

Form ToolsNavigate forms and execute button-driven business logic

Form tools are the most powerful category. They let the agent interact with D365 F&O exactly as a human would — opening forms, setting field values, clicking buttons, applying filters, and saving records. Any action available to a human user through the application interface is available to the agent through form tools, including custom forms and buttons you have added through extensions.

This is not Computer Use (screen scraping). The agent works through server-side view model APIs — it receives structured data about what is on the form and invokes server-side methods directly. This is both faster and more reliable than UI-based automation.

ToolWhat it does
form_find_menu_itemLocates a menu item by name. Returns only items the security role has access to.
form_open_menu_itemOpens a form via a menu item.
form_find_controlsFinds controls on the open form. Call multiple times with different search terms — only one term per call.
form_open_or_close_tabOpens or closes a FastTab. Tabs are closed by default — the agent must open them before accessing fields inside.
form_set_control_valuesSets values on one or more form controls. Do not use for lookup fields — use form_open_lookup instead.
form_open_lookupOpens a lookup control. Required for fields that require a lookup selection rather than a direct value set.
form_filter_formApplies a filter at the form level.
form_filter_gridApplies a filter on a specific grid.
form_select_grid_rowSelects a row in a grid — required before performing row-level actions.
form_click_controlClicks a button or control. Used to execute any button-driven business logic, including custom buttons added via extensions.
form_sort_grid_columnSorts a grid by a column.
form_save_formSaves the current form.
form_close_formCloses the current form.
⚠️ Form tabs are closed by default

This is one of the most common reasons an agent fails to find a field. FastTabs in D365 F&O are collapsed by default. The agent must call form_open_or_close_tab to expand the tab before it can read or set values on controls inside it. Build this awareness into your agent instructions for forms with multiple FastTabs.

⚠️ Use form_open_lookup for lookup fields, not form_set_control_values

Calling form_set_control_values on a lookup field (like Vendor Account or Item Number) does not trigger the lookup validation and will result in an unresolved or incorrect value. Always use form_open_lookup for fields that require a lookup selection.

Action ToolsInvoke custom X++ business logic directly

Action tools bridge the gap between the standard data/form layer and your custom X++ code. Any class you write that implements ICustomAPI and is correctly secured becomes automatically discoverable and invocable through MCP — without any additional connector or API work.

ToolWhat it does
api_find_actionsDiscovers available ICustomAPI classes that the agent's security role has access to.
api_invoke_actionInvokes a specific ICustomAPI class by name, passing the required input parameters.

For a class to appear in api_find_actions, it must:

  • Implement the ICustomAPI interface
  • Be decorated with the [CustomAPI] and [AIPluginOperationAttribute] attributes
  • Have an associated Action Menu Item in a deployed security privilege assigned to the agent's role
  • Be registered via System Administration → Setup → Synchronize Dataverse Custom APIs

Once registered, the same class is also accessible through Copilot Studio as a tool and through the Dataverse Custom API layer — three surfaces from one X++ class.

Analytics MCP ServerNatural language queries on Business Performance Analytics (Preview)

Alongside the operational MCP server, Microsoft has released a separate Dynamics 365 ERP Analytics MCP Server (currently in preview). This server connects agents to the Business Performance Analytics layer — the pre-aggregated dimensional model built on top of F&O transactional data.

It exposes three analytical value chains:

  • Record-to-Report — financial data, P&L, budgets
  • Procure-to-Pay — purchase orders, vendor management
  • Order-to-Cash — sales orders, invoicing, receivables

An agent can ask: "Show me budget variance for this fiscal year" or "Which vendors have the highest return rates?" — and the Analytics MCP server translates the question into a DAX query against the BPA model and returns structured JSON data.

✅ Combine both MCP servers for insight-to-action workflows

The real power comes from combining both servers in one agent: use the Analytics MCP server to identify an issue (e.g. "Which purchase orders have been outstanding for more than 60 days?") and then use the operational MCP server to act on the result (e.g. send a reminder, update a status field, trigger an approval). This pattern — insight to action — is what Microsoft means by Agentic ERP.

Prerequisites and setup

Environment requirements

  • D365 F&O version 10.0.47 or later (also available on 10.0.46 PQU-2 and 10.0.45 PQU-7)
  • Tier 2 or above environment, or a Unified Developer Environment (UDE). The MCP server is not supported on Cloud Hosted Environments (CHE).
  • The Dynamics 365 ERP Model Context Protocol server feature must be enabled in Feature Management — it is on by default in supported versions

Allowed MCP Clients

Before any agent platform can connect to your MCP server, it must be explicitly allowed. By default, only two platforms are permitted:

PlatformClient ID
Microsoft Copilot Studio7ab7862c-4c57-491e-8a45-d52a7e023983
Visual Studio Codeaebc6443-996d-45c2-90f0-388ff96faa56

To allow additional agent platforms (e.g. Azure AI Foundry, a custom agent host, Claude Desktop):

  1. Register your agent application in Microsoft Entra ID and note the Application (Client) ID
  2. In D365 F&O, navigate to System Administration → Setup → Allowed MCP Clients
  3. Add a new row with the Client ID and set Allowed to true

Agent security setup

The MCP server enforces D365 F&O security roles on every call. There is no elevated or bypass mode. The agent operates with exactly the same permissions as the user identity it is authenticated as. This means:

  • Create a dedicated service account or Entra ID application for your agent in F&O
  • Assign it the System agent security role (required to exempt it from user licensing — this role has no permissions of its own)
  • Assign additional roles that grant only the permissions the agent needs for its tasks
  • Do not assign System Administrator to your agent identity — the MCP server excludes security management forms, but least-privilege is still best practice
✅ The System agent role exempts agent identities from F&O user licensing

Agent identities assigned to the System agent role do not require a Dynamics 365 F&O user license. This applies to both interactive agents (where a human talks to the agent) and autonomous agents. The human users who interact with a chat-based agent still need their own F&O user license to access the underlying data.

Real-world use case scenarios

๐Ÿงพ Scenario 1 — Vendor invoice processing agent

An AP clerk asks: "Create a vendor invoice for vendor US-001 for $5,000 against PO PO-00123, and submit it for approval." The agent uses data_find_entity_type to locate the VendorInvoiceHeaderEntity, data_get_entity_metadata to understand required fields, data_create_entities to create the invoice header and lines, then form_open_menu_itemform_click_control to submit it for workflow approval — all in one conversational turn.

๐Ÿ“ฆ Scenario 2 — Purchase order status agent in Teams

A procurement manager in Microsoft Teams asks: "What is the status of all purchase orders from vendor GB-001 that are past their delivery date?" The agent uses data_find_entities_sql to query PurchTable with a date filter, aggregates the result, and returns a summary — without the manager opening D365 F&O at all. The same agent can then be asked to send reminders or escalate lines.

๐Ÿ“Š Scenario 3 — Insight-to-action with Analytics MCP

A finance controller asks: "Show me vendors where our payment cycle time exceeds 45 days, then update their payment terms to Net 30." The agent queries the Analytics MCP server for payment cycle metrics, identifies the qualifying vendors, then uses the operational MCP server's data tools to update VendPaymTermId on each vendor record — an insight-to-action workflow completed in one agent conversation.

⚙️ Scenario 4 — Custom X++ logic via Action Tools

From a previous article on this blog, the CustomAPICalculateCustomerBalance class is registered as an AI tool. A credit controller asks: "What is the current balance for customer US-001?" The agent calls api_find_actions, identifies the registered class, invokes it via api_invoke_action, and returns the live calculated balance — the same value computed by custTable.balanceAllCurrency().

Licensing and cost model

MCP usage incurs cost at two levels: LLM orchestration (the AI thinking about what to call) and MCP tool execution (the actual calls to your F&O environment). The model differs depending on whether you use Copilot Studio or another agent client.

Copilot StudioOther agent client (AI Foundry, custom, etc.)
Orchestration costBilled as an Agent Action at the Copilot Studio fixed rate per tool callBilled by the agent client at its own token consumption rates
MCP tool execution costIncluded in the fixed Agent Action rate — no extra charge0.1 Copilot Credits per tool call (= 1 credit per 10 calls)

Premium license exemption: Users with Dynamics 365 Finance Premium or Dynamics 365 Supply Chain Management Premium licenses are exempt from the 0.1 credit per tool call charge when using agents built outside Copilot Studio. Copilot Studio agents still bill at the fixed Agent Action rate regardless of premium license.

Microsoft 365 Copilot users: If the agent is built in Copilot Studio and the user is licensed with Microsoft 365 Copilot, tool calls to the D365 ERP MCP server do not incur additional credit consumption — the cost is covered by the M365 Copilot license.

Current limitations you need to know

1. English only. The MCP server responds with metadata and guidance in US English (en-us) only. Form labels may appear in the user's locale but MCP responses are always English.
2. ISO date/time format. Dates, times, and numbers use ISO format and do not respect user locale settings.
3. Some controls not supported. Calendar controls, organisation chart controls, list view, availability view, HTML editor, image controls, radio buttons, time edit, and custom controls cannot be interacted with through form tools.
4. Advanced grid filters not supported. The form_filter_grid tool supports only the "matches" operator. Date range operators (before, after, between) are not supported.
5. FastTabs closed by default. The agent must explicitly open each FastTab before it can access controls inside it. Build this into agent instructions for complex forms.
6. No attachments via standard controls. DocuUpload, FileUpload, and document viewer controls are not supported. A separate attachments API is available — see the Microsoft Learn documentation on MCP attachments.
7. System admin forms excluded. Feature Management, user management, security configuration, and Entra ID application management forms are intentionally excluded from the MCP server's scope.
8. Cannot be added to Copilot F&O sidecar agent yet. Adding the ERP MCP server as a tool inside the built-in Copilot for Finance and Operations sidecar is not yet officially supported and may produce errors.
9. Unavailable during servicing windows. MCP requests fail during environment downtime, including scheduled servicing windows. Design your agents with retry logic for these periods.
10. Deep inserts not supported in data_create_entities. Creating a parent and related child records in a single create call is not supported. Create the parent first, then create child records separately.

What this means for D365 developers

The MCP server changes the calculus on two things that developers previously had to build manually.

First, custom integrations. The traditional pattern for exposing F&O business logic to external systems was: write a service class, expose it as a REST endpoint via the custom service framework, document it, and maintain it through upgrades. For scenarios that fit within the MCP server's tool categories, that entire layer is now unnecessary. The agent discovers and invokes the logic directly.

Second, the value of your existing customisations. Custom data entities you have built are automatically discoverable through Data Tools. Custom forms and buttons are automatically accessible through Form Tools. Custom X++ classes registered as AI tools are invocable through Action Tools. Every customisation you have built becomes part of the AI surface of the ERP without any rework.

The one area where custom development still adds value is the ICustomAPI / Action Tools layer — when you need to expose business logic that is not reachable through data entities or form navigation. That is where the X++ AI tool framework covered in the previous article on this blog fits.


Conclusion :-

The Dynamics 365 ERP MCP Server is not a copilot feature. It is a new extensibility surface for the entire platform — one that makes D365 F&O a first-class participant in the AI agent ecosystem rather than a passive data store that agents query around.

The three tool categories each serve a distinct purpose: Data Tools for efficient CRUD, Form Tools for complex business logic that lives in button-driven processes, and Action Tools for X++ code that needs to be AI-callable on demand. Understanding which tool category fits which scenario is the core skill for building effective agents on this platform.

The limitations are real and worth planning around — especially the FastTab behaviour, the lack of advanced grid filters, and the exclusion of system admin forms. But the capability floor is high enough today to automate a significant portion of routine finance and supply chain workflows without any custom integration code.

The question is no longer whether AI can interact with D365 F&O. It can. The question is which of your business processes should be next.


That's all for now. Please let us know your questions or feedback in comments section !!!!

Integrating Azure OpenAI with D365 F&O Using X++ - A Practical AI in ERP example

Prerequisites: We need an active Azure subscription with an Azure OpenAI resource deployed, a GPT-4o or GPT-4o-mini deployment created in Az...