Monday, July 6, 2026

Model Context Protocol in D365 F&O — Developer Guide with Working Code

Version note: The dynamic Dynamics 365 ERP MCP server reached General Availability in February 2026. It requires version 10.0.47, 10.0.46 PQU-2, or 10.0.45 PQU-7. The static MCP server with 13 predefined tools is retired in 2026 — this article covers the dynamic server only.
If you have been building integrations with D365 F&O for any length of time, you know the pattern: write a custom service class, expose it as an OData endpoint, document it, and then maintain it through every platform update.

The Dynamics 365 ERP MCP Server replaces that entire approach for AI agent scenarios. It exposes your F&O environment — including your custom data entities, forms, buttons, and X++ classes — to any compatible AI agent through a single, standardised protocol called Model Context Protocol (MCP).

In this article I will cover what MCP is, how the three categories of tools work, show you working code and agent call sequences for each one, and walk through everything you need to set it up in your environment.



What is MCP?


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

Think of it as the USB-C standard for AI-to-application connectivity. Before USB-C, every device had its own connector. Before MCP, every AI integration had its own custom API.

In D365 F&O, the MCP server sits between your AI agent and your ERP environment:
Natural language prompt (user / autonomous trigger) │ ▼ AI Agent ←── reads tool descriptions, plans which tools to call (Copilot Studio / VS Code / Azure AI Foundry / custom client) │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Dynamics 365 ERP MCP Server │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Data Tools │ │ Form Tools │ │ Action Tools │ │ │ │ (7 tools) │ │ (13 tools) │ │ (2 tools) │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ └─────────┼─────────────────┼──────────────────┼──────────┘ │ │ │ ▼ ▼ ▼ OData Entities Form View Model ICustomAPI classes (incl. custom) (same as client) (your X++ code) │ │ │ └─────────────────┴──────────────────┘ │ D365 F&O Database & Business Logic (security roles enforced on every call)
⚠️ Static MCP server is retired in 2026. The earlier version had 13 hardcoded tools and was built on the Dataverse connector framework. It is still available on environments running version 10.0.2263.17 and above but will be switched off. Migrate to the dynamic server now to avoid disruption.

Tool Category 1 — Data Tools (CRUD via OData / SQL)


Data tools are the fastest and most efficient way for an agent to create, read, update, or delete records. They work through the existing OData data entity layer — the same entities you expose for standard integrations. Custom data entities you have built are automatically discoverable.
ToolWhat it does
data_find_entity_typeDiscovers which OData entity types match the agent's intent. Returns multiple candidate hits — agent picks the right one.
data_get_entity_metadataReturns full schema for an entity — fields, keys, navigation properties. Must be called before create/update/delete.
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. Deep inserts (parent + child in one call) are not supported.
data_update_entitiesUpdates existing records by key.
data_delete_entitiesDeletes records by key.
Demo 1 — Read vendor open invoices using Data Tools                Working agent sequence
Show me all open vendor invoices for vendor US-001 that are overdue

Here is the exact sequence of tool calls the agent makes:

  • data_find_entity_typeQuery: "vendor invoice". Returns candidates including VendorInvoiceHeaderEntity, VendInvoiceInfoTable. Agent selects VendorInvoiceHeaderEntity.
  • data_get_entity_metadataGets schema for VendorInvoiceHeaderEntity — confirms fields: InvoiceVendorAccountNumber, DueDate, PaymentStatus, InvoiceAmount, CurrencyCode.
  • data_find_entitiesFilter: InvoiceVendorAccountNumber eq 'US-001' and DueDate lt 2026-06-03 and PaymentStatus eq 'None'. Returns matching invoice records as JSON.

The agent then formats the results as a natural language table for the user. Three tool calls, no custom code.

What this looks like in a Copilot Studio agent instruction

You are a finance assistant for Dynamics 365 F&O.

When the user asks about overdue vendor invoices:
1. Use data_find_entity_type to find the correct vendor invoice entity
2. Use data_get_entity_metadata to understand its fields
3. Use data_find_entities with a filter on vendor account, due date less
   than today, and payment status equal to 'None'
4. Present results as a summary with invoice number, amount,
   currency, and days overdue

Always use data tools for read operations — they are faster than form tools.
Demo 2 — Create a new vendor record using Data ToolsWorking agent sequence
Create a new vendor called "Contoso Supplies Ltd" with vendor group DOMESTIC and payment terms Net30
  • data_find_entity_typeQuery: "vendor". Selects VendVendorV2Entity as the correct entity for vendor master creation.
  • data_get_entity_metadataGets schema — identifies required fields: VendorAccountNumber (auto-generated), VendorOrganizationName, VendorGroupId, PaymentTermName.
  • data_create_entitiesPosts the new vendor record with the extracted field values.

The body the agent sends to data_create_entities looks like this:

{

     "entityName": "VendVendorV2Entity",

     "records": [

    {

        "VendorOrganizationName": "Contoso Supplies Ltd",

       "VendorGroupId": "DOMESTIC",

       "PaymentTermName": "Net30",

       "CurrencyCode": "USD"

     }
   ]
}

⚠️ Deep inserts are not supported

You cannot create a vendor and its bank accounts in a single data_create_entities call. Create the vendor first, then create bank account records separately using the appropriate entity, referencing the vendor account number returned from the first call.



Tool Category 2 — Form Tools (Button-driven business logic)


Form tools are the most powerful category. They let the agent interact with D365 F&O the same way a human user would — opening forms, setting field values, clicking action buttons, filtering grids, and saving records. This is not screen scraping. The agent works through server APIs that expose the application view model — the same model the client uses. Every form interaction respects the same security roles and runs the same business logic as a human click.

Use form tools when the operation involves button-driven business logic that is not available through a data entity — like releasing a purchase order, approving a workflow, posting a journal from a form, or any custom action button you have built through extensions.
ToolDescription
form_find_menu_itemFind a menu item in the navigation pane (filtered by security role)
form_open_menu_itemOpen a form via a menu item
form_find_controlsFind controls on the form. One search term per call — call multiple times for multiple controls
form_open_or_close_tabOpen or close a FastTab. Tabs are closed by default
form_set_control_valuesSet values on form controls. Do NOT use for lookup fields
form_open_lookupOpen a lookup field. Use this instead of set_control_values for lookup controls
form_filter_formApply a filter at form level
form_filter_gridFilter on a specific grid (supports "matches" operator only)
form_select_grid_rowSelect a row in a grid — required before row-level actions
form_click_controlClick a button or control, including custom extension buttons
form_sort_grid_columnSort a grid by a column
form_save_formSave the current form
form_close_formClose the current form
Demo 3 — Release a purchase requisition using Form ToolsWorking agent sequence — button-driven business logic
Release purchase requisition PR-000042 for approval

This is a classic form tools scenario — "Release" is a button on the Purchase Requisitions form that runs business logic. It is not a simple data entity update.

  • form_find_menu_itemSearch: "purchase requisitions". Returns the menu item for the All Purchase Requisitions form.
  • form_open_menu_itemOpens the All Purchase Requisitions form. View model returned includes grid with all requisitions the agent's security role can see.
  • form_filter_gridFilter: PurchReqId matches PR-000042. Grid narrows to the specific requisition.
  • form_select_grid_rowSelects the PR-000042 row. Required before any row-level button can be clicked.
  • form_find_controlsSearch: "Release". Finds the Release button on the Workflow menu group.
  • form_click_controlClicks the Release button. D365 F&O executes the release logic — same as a human clicking it. Workflow submission triggers.
  • form_close_formCloses the form cleanly.
⚠️ Always select the grid row before clicking a row-level button

Calling form_click_control on a row-level action without first calling form_select_grid_row results in the action either failing or acting on the wrong record. Row selection is a required step.

Demo 4 — Create a sales order with lines using Form ToolsMulti-step form interaction with FastTab handling
Create a sales order for customer US-004 with item D0001, quantity 10, and warehouse 11

This demonstrates the FastTab pattern — the most common form tools pitfall.

  • form_find_menu_itemSearch: "all sales orders". Finds the All Sales Orders menu item.
  • form_open_menu_itemOpens All Sales Orders form.
  • form_find_controlsSearch: "New". Finds the New button in the action pane.
  • form_click_controlClicks New. A new sales order header record is created and the form enters edit mode.
  • form_open_lookupOpens the Customer Account lookup. Searches for and selects US-004. Using form_open_lookup here — not form_set_control_values — because CustAccount is a lookup field.
  • form_open_or_close_tabOpens the "Lines" FastTab. FastTabs are closed by default — the agent cannot see or interact with the lines grid until this tab is explicitly opened.
  • form_find_controlsSearch: "Add line" inside the Lines tab. Finds the Add line button.
  • form_click_controlClicks Add line. A new sales order line row is created in the grid.
  • form_open_lookupOpens the Item Number lookup. Selects D0001.
  • form_set_control_valuesSets Quantity = 10. Quantity is a numeric field — form_set_control_values is correct here (not a lookup).
  • form_open_lookupOpens the Warehouse lookup. Selects warehouse 11.
  • form_save_formSaves the sales order. D365 F&O runs all standard validations — price defaulting, inventory checks, credit limit checks — exactly as it would for a human.
  • form_close_formCloses the form. Agent confirms the sales order number from the saved record.
✅ Key rule: form_open_lookup vs form_set_control_values

Use form_open_lookup for any field that shows a lookup icon in the UI — Customer Account, Item Number, Warehouse, Vendor, Currency, etc. Use form_set_control_values only for plain text, numeric, or date fields. Mixing these up is the most common form tool error.


Tool Category 3 — Action Tools (Custom X++ business logic)


Action tools are for scenarios where neither data entities nor form navigation can reach the logic you need to expose — for example, a custom calculation engine, a complex validation that spans multiple tables, or a business rule that is not triggered by any existing button.

You write an X++ class that implements the ICustomAPI interface, register it through the Dataverse Custom APIs form, and it becomes discoverable and invocable through api_find_actions and api_invoke_action.

ToolWhat it does
api_find_actionsReturns all ICustomAPI classes the agent's security role has access to
api_invoke_actionInvokes a specific class by its registered name, passing the required input parameters
Demo 5 — Custom X++ Action Tool: Vendor Credit Limit CheckerComplete X++ class + registration steps + agent invocation
Can we issue a new purchase order to vendor US-103? Check if they are within their credit limit.

Vendor credit limit checking requires joining VendTable, VendTrans, and a custom configuration table — logic that is not available through any standard data entity or form button. This is a perfect candidate for an Action Tool.

Step 1 — Write the X++ class

/// <summary>
/// AI Tool — checks whether a vendor is within their approved credit limit
/// based on current open purchase orders and outstanding invoices.
/// </summary>
[CustomAPI(
    'Check vendor credit limit',
    'Checks whether a vendor is within their approved credit limit. ' +
    'Returns the credit limit, current exposure (open POs + outstanding invoices), ' +
    'available credit, and whether a new purchase order can be issued.')]
[AIPluginOperationAttribute]
[DataContract]
public final class VendCreditLimitCheckAPI implements ICustomAPI
{
    // --- Input ---
    private VendAccount vendAccount;

    // --- Output ---
    private boolean     canIssueNewPO;
    private AmountMST   creditLimit;
    private AmountMST   currentExposure;
    private AmountMST   availableCredit;
    private str         statusMessage;

    // -------------------------------------------------------
    // Input Parameters
    // -------------------------------------------------------

    [CustomAPIRequestParameter('The vendor account number to check', true),
     DataMember('vendorAccountNumber')]
    public VendAccount parmVendAccount(VendAccount _vendAccount = vendAccount)
    {
        vendAccount = _vendAccount;
        return vendAccount;
    }

    // -------------------------------------------------------
    // Output Properties
    // -------------------------------------------------------

    [CustomAPIResponseProperty('Whether a new purchase order can be issued to this vendor'),
     DataMember('canIssueNewPO')]
    public boolean parmCanIssueNewPO(boolean _val = canIssueNewPO)
    {
        canIssueNewPO = _val;
        return canIssueNewPO;
    }

    [CustomAPIResponseProperty('Approved credit limit for the vendor in company currency'),
     DataMember('creditLimit')]
    public AmountMST parmCreditLimit(AmountMST _val = creditLimit)
    {
        creditLimit = _val;
        return creditLimit;
    }

    [CustomAPIResponseProperty('Current exposure: sum of open PO values + outstanding invoices'),
     DataMember('currentExposure')]
    public AmountMST parmCurrentExposure(AmountMST _val = currentExposure)
    {
        currentExposure = _val;
        return currentExposure;
    }

    [CustomAPIResponseProperty('Available credit: credit limit minus current exposure'),
     DataMember('availableCredit')]
    public AmountMST parmAvailableCredit(AmountMST _val = availableCredit)
    {
        availableCredit = _val;
        return availableCredit;
    }

    [CustomAPIResponseProperty('Plain language status message explaining the result'),
     DataMember('statusMessage')]
    public str parmStatusMessage(str _val = statusMessage)
    {
        statusMessage = _val;
        return statusMessage;
    }

    // -------------------------------------------------------
    // Business Logic
    // -------------------------------------------------------

    public void run(Args _args)
    {
        VendTable       vendTable;
        PurchTable      purchTable;
        VendTrans       vendTrans;
        AmountMST       openPOValue;
        AmountMST       outstandingInvoices;

        changecompany(curext())
        {
            vendTable = VendTable::find(this.parmVendAccount());

            if (!vendTable)
            {
                this.parmCanIssueNewPO(false);
                this.parmStatusMessage(
                    strFmt('Vendor %1 was not found.', this.parmVendAccount()));
                return;
            }

            // Read credit limit from VendTable extension field
            // Assumes a custom field VendCreditLimit added via table extension
            this.parmCreditLimit(vendTable.VendCreditLimit);

            if (this.parmCreditLimit() == 0)
            {
                // No limit configured — allow by default
                this.parmCanIssueNewPO(true);
                this.parmStatusMessage(
                    strFmt('Vendor %1 has no credit limit configured. New POs can be issued.',
                        this.parmVendAccount()));
                return;
            }

            // Sum open (not invoiced) purchase order values
            while select sum(PurchQty), sum(PurchPrice) from purchTable
                where purchTable.OrderAccount   == this.parmVendAccount()
                   && purchTable.PurchStatus    == PurchStatus::Received
            {
                openPOValue += purchTable.PurchQty * purchTable.PurchPrice;
            }

            // Sum outstanding (unpaid) vendor invoice amounts
            while select sum(AmountMST) from vendTrans
                where vendTrans.AccountNum  == this.parmVendAccount()
                   && vendTrans.TransType   == LedgerTransType::Purch
                   && vendTrans.Closed      == NoYes::No
                   && vendTrans.AmountMST   > 0
            {
                outstandingInvoices += vendTrans.AmountMST;
            }

            AmountMST exposure  = openPOValue + outstandingInvoices;
            AmountMST available = this.parmCreditLimit() - exposure;

            this.parmCurrentExposure(exposure);
            this.parmAvailableCredit(available);

            if (available > 0)
            {
                this.parmCanIssueNewPO(true);
                this.parmStatusMessage(
                    strFmt('Vendor %1 is within credit limit. ' +
                           'Limit: %2, Exposure: %3, Available: %4.',
                        this.parmVendAccount(),
                        this.parmCreditLimit(),
                        exposure,
                        available));
            }
            else
            {
                this.parmCanIssueNewPO(false);
                this.parmStatusMessage(
                    strFmt('Vendor %1 has exceeded their credit limit. ' +
                           'Limit: %2, Current exposure: %3. ' +
                           'New purchase orders cannot be issued.',
                        this.parmVendAccount(),
                        this.parmCreditLimit(),
                        exposure));
            }
        }
    }
}

Step 2 — Create the Action Menu Item

In Visual Studio, add an Action Menu Item to your project:

  • Name: VendCreditLimitCheckAPI
  • Object Type: Class
  • Object: VendCreditLimitCheckAPI

Step 3 — Create the Security Privilege

Add a Security Privilege (e.g. VendCreditLimitCheckAPIPrivilege) with the menu item as an entry point, Access Level = Create. Assign the privilege to a duty/role the agent identity will use (e.g. Accounts Payable role).

Step 4 — Build, flush cache, and synchronise

// After deploying your package, flush the AOD cache:
https://<your-env>.operations.dynamics.com/?cmp=USMF&mi=SysClassRunner&cls=SysFlushAOD

// Then in F&O:
// System Administration → Setup → Synchronize Dataverse Custom APIs → Synchronize

Your class should appear in the grid with status Registered.

Step 5 — Agent invocation sequence

  • api_find_actionsReturns all registered ICustomAPI classes the agent's role can access. Agent reads descriptions and identifies VendCreditLimitCheckAPI as matching the user's intent.
  • api_invoke_actionInvokes VendCreditLimitCheckAPI with vendorAccountNumber = "US-103". X++ run(Args _args) executes against live F&O data.

The agent receives the response JSON and presents it to the user:

{
  "canIssueNewPO": true,
  "creditLimit": 500000.00,
  "currentExposure": 312500.00,
  "availableCredit": 187500.00,
  "statusMessage": "Vendor US-103 is within credit limit. Limit: 500000, Exposure: 312500, Available: 187500."
}

Copilot responds to the user: "Yes, vendor US-103 is within their credit limit. They have $187,500 in available credit remaining from a $500,000 limit. You can proceed with the new purchase order."

✅ Write the [CustomAPI] description as if explaining to a non-developer

The agent orchestrator reads the description in the [CustomAPI] attribute to decide when to call this action. The more specific and plain-language the description, the more accurately the agent will route to it. Include what it checks, what inputs it needs, and what it returns — in plain English.


Configuration — setting up MCP in your environment

Prerequisites checklist

  • D365 F&O version 10.0.47, or 10.0.46 PQU-2, or 10.0.45 PQU-7
  • Tier 2+ environment or Unified Developer Environment — CHE (Cloud Hosted Environments) are not supported
  • Feature "Dynamics 365 ERP Model Context Protocol server" enabled in Feature Management (on by default)
  • Agent platform registered in Allowed MCP Clients
  • Agent identity assigned the System agent security role + task-appropriate roles

Allowed MCP Clients

By default only two clients can connect to your MCP server:

PlatformClient ID
Microsoft Copilot Studioa1bcd34-xyz-abcd1-abcde1-abcdefghiklj1
Visual Studio Codexyz6443-996ab-45xy2-91jfs-388abc96xyz56

To add another platform (Azure AI Foundry, Claude Desktop, a custom agent host):

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

Agent security — the System agent role

Agent identities assigned the System agent role are exempt from D365 F&O user licensing. The role has no permissions — it is solely for license exemption. Assign additional roles to the agent identity that grant only the permissions needed for its tasks.

⚠️ Do not assign System Administrator to your agent identity

The MCP server excludes security management and user management forms, but assigning System Admin to an agent identity still violates least-privilege principles and creates an audit risk. Always scope agent roles to the minimum required for its tasks.

MCP server URL format

When connecting a compatible agent client (e.g. VS Code, Azure AI Foundry) to your environment, the MCP server URL follows this format:

https://<your-environment>.operations.dynamics.com/mcp

In VS Code, add this to your .vscode/mcp.json:

{
  "servers": {
    "d365fo": {
      "type": "http",
      "url": "https://<your-environment>.operations.dynamics.com/mcp",
      "gallery": false
    }
  }
}

Known limitations

1.English only. MCP responses and metadata always return in US English (en-us), even if the user's locale is different. Form labels may appear in the user's locale but MCP guidance is always English.
2.ISO date/time format. Dates, times, and numerics use ISO format and do not respect user locale.
3.Unsupported controls. 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.FastTabs are closed by default. The agent must call form_open_or_close_tab before accessing any controls inside a closed FastTab.
5.Grid filter operator limited to "matches" only. Date range operators (before, after, between) are not supported in form_filter_grid.
6.No attachments via standard controls. DocuUpload, FileUpload, and document viewer controls are not supported. A separate attachments API exists — see Microsoft Learn for MCP attachments.
7.System admin forms excluded. Feature Management, user management, security configuration, and Entra ID application management are excluded from MCP scope.
8.Not supported for F&O sidecar Copilot agent. Adding the ERP MCP server as a tool in the built-in Copilot for Finance and Operations sidecar agent is not yet officially supported and may produce errors.
9.Unavailable during environment servicing windows. All MCP tool calls fail during scheduled downtime. Design agents with retry logic for these periods.
10.Deep inserts not supported. data_create_entities cannot create parent and child records in a single call. Create them sequentially.

Conclusion

The Dynamics 365 ERP MCP Server is the most significant extensibility change to D365 F&O since the introduction of the Extension model. It shifts the integration surface from custom APIs you build and maintain to a universal protocol that any agent can use — without you writing a single connector.

The three tool categories each serve a clear purpose: Data Tools for efficient CRUD, Form Tools for button-driven business logic that mirrors exactly what a human would do, and Action Tools for custom X++ logic that neither of the first two can reach.

As an F&O developer, your role in this new architecture is to ensure your custom data entities are well-structured, your custom forms and buttons are logically named (because the agent navigates by name), and your custom X++ classes implement the ICustomAPI framework correctly so they are discoverable as Action Tools. That is the foundation of Agentic ERP — and the developers who understand how to build it are the ones who will shape the next generation of ERP implementations. 


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

Model Context Protocol in D365 F&O — Developer Guide with Working Code

Version note: The dynamic Dynamics 365 ERP MCP server reached General Availability in February 2026. It requires version 10.0.47, 10.0.46 P...