Model Context Protocol (Part 2)

Neil HaddleySeptember 21, 2025

Business Central

Business CentralAImcpmodel-context-protocolbusiness-centralclaude

I created a Model Context Protocol (MCP) server for Microsoft Business Central

I created a business-central-mcp folder. I ran npm init -y. I updated the generated package.json file adding type: "module".

I created a business-central-mcp folder. I ran npm init -y. I updated the generated package.json file adding type: "module".

I ran npm install @modelcontextprotocol/sdk and npm install zod.

I ran npm install @modelcontextprotocol/sdk and npm install zod.

I ran npx -y @modelcontextprotocol/inspector npx -y tsx main.ts.

I ran npx -y @modelcontextprotocol/inspector npx -y tsx main.ts.

I confirmed the hello tool returned success

I confirmed the hello tool returned success

I replaced the hello tool with a get-vendors tool

I replaced the hello tool with a get-vendors tool

I ran npx -y @modelcontextprotocol/inspector npm run dev. The get_vendors tool returned success. Later I ran npm run build and then npx -y @modelcontextprotocol/inspector npm run start

I ran npx -y @modelcontextprotocol/inspector npm run dev. The get_vendors tool returned success. Later I ran npm run build and then npx -y @modelcontextprotocol/inspector npm run start

For remote servers, I set up a Streamable HTTP transport that handles both client requests and server-to-client notifications.

I ran docker build -t business-central-mcp .then npm run docker:run

I ran docker build -t business-central-mcp .then npm run docker:run

I ran npx -y @modelcontextprotocol/inspector

I ran npx -y @modelcontextprotocol/inspector

I ran the get_vendors tool

I ran the get_vendors tool

I used the Azure Container Apps extension for Visual Studio Code to choose existing Container Apps resources and deploy my application.

https://learn.microsoft.com/en-us/azure/container-apps/deploy-visual-studio-code

I entered the name of new container apps environment

I entered the name of new container apps environment

I selected a location

I selected a location

I selected the Deploy Project from Workspace... menu item

I selected the Deploy Project from Workspace... menu item

I accepted the generated container app name

I accepted the generated container app name

I selected the Managed Identity option

I selected the Managed Identity option

I selected the .env file

I selected the .env file

I confirmed the deployment finished

I confirmed the deployment finished

I ran a fresh copy of the inspector

I ran a fresh copy of the inspector

I tested the deployment using the URL https://business-central-mcp-new.thankfulsea-b533246e.eastus.azurecontainerapps.io/mcp

I tested the deployment using the URL https://business-central-mcp-new.thankfulsea-b533246e.eastus.azurecontainerapps.io/mcp

I used a Custom Connector to connect Copilot Studio to the Model Context Protocol server.

I created an Agent. I selected the Tools tab. I clicked the + Add a tool button

I created an Agent. I selected the Tools tab. I clicked the + Add a tool button

I selected Custom connector

I selected Custom connector

I selected the Import from Github option

I selected the Import from Github option

I selected the Custom radio button, I selected the dev branch, I selected the MCP-Streamable-HTTP Connector

I selected the Custom radio button, I selected the dev branch, I selected the MCP-Streamable-HTTP Connector

I entered the server url https://business-central-mcp-new.thankfulsea-b533246e.eastus.azurecontainerapps.io/mcp

I entered the server url https://business-central-mcp-new.thankfulsea-b533246e.eastus.azurecontainerapps.io/mcp

I refreshed the Model Context Protocol options

I refreshed the Model Context Protocol options

I clicked on the MCP Server Streamable HTTP option

I clicked on the MCP Server Streamable HTTP option

I clicked the Create button

I clicked the Create button

I clicked the Add and configure button

I clicked the Add and configure button

I confirmed the get_vendors tool was displayed

I confirmed the get_vendors tool was displayed

I was directed to the Create or pick a connection form. I clicked the Submit button

I was directed to the Create or pick a connection form. I clicked the Submit button

I confirmed the status of MCP-Streamable-HTTP was updated to Connected

I confirmed the status of MCP-Streamable-HTTP was updated to Connected

I had entered this prompt Please show me a table of vendors

I had entered this prompt Please show me a table of vendors

Copilot Studio's Large Language Model used the get_vendors tool to establish the context.

Copilot Studio's Large Language Model used the get_vendors tool to establish the context.

I opened LM Studio and navigated to the "Program" tab in the right sidebar. I clicked on "Install" and selected "Edit mcp.json", which opened the mcp.json file in LM Studio's in-app editor. I added my MCP server configuration to the mcp.json file.

I selected Chats in the left hand menu. I clicked the show settings button at the top right of the LM Studio application. I added the business central mcp server using the url https://business-central-mcp-new.thankfulsea-b533246e.eastus.azurecontainerapps.io/mcp. I enabled the mcp server connection.

I selected Chats in the left hand menu. I clicked the show settings button at the top right of the LM Studio application. I added the business central mcp server using the url https://business-central-mcp-new.thankfulsea-b533246e.eastus.azurecontainerapps.io/mcp. I enabled the mcp server connection.

I entered the prompt show me a table of vendor details

I entered the prompt show me a table of vendor details

main.ts

TYPESCRIPT
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3import { z } from "zod";
4
5const server = new McpServer({
6    name: "Business Central MCP Server",
7    version: "1.0.0"
8});
9
10server.tool(
11    'hello',
12    'A simple hello world tool',
13    {
14        name: z.string().describe("The name to greet")
15    },
16    async ({ name }) => {
17        return {
18            content: [
19                {
20                    type: "text",
21                    text: `Hello, ${name}!`
22                }
23            ]
24        }
25    }
26);
27
28const transport = new StdioServerTransport();
29server.connect(transport);

package.json

JSON
1{
2    "name": "business-central-mcp",
3    "version": "1.0.0",
4    "main": "index.js",
5    "scripts": {
6        "test": "echo \"Error: no test specified\" && exit 1"
7    },
8    "keywords": [],
9    "author": "",
10    "license": "ISC",
11    "description": "",
12    "type": "module",
13    "dependencies": {
14        "@modelcontextprotocol/sdk": "^1.17.3",
15        "zod": "^3.25.76"
16    }
17}

srcmain.ts

TYPESCRIPT
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3import { z } from "zod";
4import { ensureAuthenticated, makeApiCall } from "./business-central-auth.js";
5
6import { configDotenv } from "dotenv";
7configDotenv();
8
9const server = new McpServer({
10    name: "Business Central MCP Server",
11    version: "1.0.0"
12});
13
14
15server.tool(
16  'get_vendors',
17  'Retrieves vendor list from Business Central',
18  {
19    filter: z.string().optional().describe('OData filter for vendors'),
20    top: z.number().optional().describe('Maximum number of results')
21  },
22  async ({ filter, top = 100 }) => {
23    await ensureAuthenticated();
24
25    const params: any = {
26      $top: top
27    };
28    
29    if (filter) {
30      params.$filter = filter;
31    }
32
33    const result = await makeApiCall('vendors', params);
34    
35    return {
36      content: [
37        {
38          type: "text",
39          text: JSON.stringify({
40            success: true,
41            data: result.value || [],
42            count: result.value?.length || 0
43          }, null, 2)
44        }
45      ]
46    };
47  }
48);
49
50
51const transport = new StdioServerTransport();
52server.connect(transport);

srcbusiness-central-auth.ts

TYPESCRIPT
1import axios from 'axios';
2import qs from 'querystring';
3
4// Configuration interface
5interface BusinessCentralConfig {
6  tenantId: string;
7  clientId: string;
8  clientSecret: string;
9  baseUrl: string;
10  environment: string;
11  companyId: string;
12}
13
14// Token storage
15let accessToken: string | null = null;
16let tokenExpiry: Date | null = null;
17
18// Get configuration from environment variables
19function getConfig(): BusinessCentralConfig {
20  return {
21    tenantId: process.env.BC_TENANT_ID || '',
22    clientId: process.env.BC_CLIENT_ID || '',
23    clientSecret: process.env.BC_CLIENT_SECRET || '',
24    baseUrl: process.env.BC_BASE_URL || '',
25    environment: process.env.BC_ENVIRONMENT || 'Sandbox',
26    companyId: process.env.BC_COMPANY_ID || ''
27  };
28}
29
30// Authenticate with Business Central
31export async function ensureAuthenticated(): Promise<void> {
32  if (accessToken && tokenExpiry && new Date() < tokenExpiry) {
33    return;
34  }
35
36  const config = getConfig();
37  
38  try {
39    const tokenUrl = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
40    
41    const tokenData = {
42      grant_type: 'client_credentials',
43      client_id: config.clientId,
44      client_secret: config.clientSecret,
45      scope: 'https://api.businesscentral.dynamics.com/.default'
46    };
47
48    const response = await axios.post(tokenUrl, qs.stringify(tokenData), {
49      headers: {
50        'Content-Type': 'application/x-www-form-urlencoded'
51      }
52    });
53
54    accessToken = response.data.access_token;
55    tokenExpiry = new Date(Date.now() + (response.data.expires_in * 1000));
56    
57  } catch (error: any) {
58    throw new Error(`Authentication failed: ${error.response?.data?.error_description || error.message}`);
59  }
60}
61
62// Make API call to Business Central
63export async function makeApiCall(endpoint: string, params: any = {}): Promise<any> {
64  const config = getConfig();
65  const url = `${config.baseUrl}/${config.environment}/api/v2.0/companies(${config.companyId})/${endpoint}`;
66  
67  if (!accessToken) {
68    throw new Error('Not authenticated. Call ensureAuthenticated first.');
69  }
70  
71  try {
72    const response = await axios.get(url, {
73      headers: {
74        'Authorization': `Bearer ${accessToken}`,
75        'Accept': 'application/json'
76      },
77      params
78    });
79    
80    return response.data;
81  } catch (error: any) {
82    throw new Error(`API call failed: ${error.response?.data?.error?.message || error.message}`);
83  }
84}
85
86// Clear authentication (useful for testing or re-authentication)
87export function clearAuthentication(): void {
88  accessToken = null;
89  tokenExpiry = null;
90}

package.json

JSON
1{
2    "name": "business-central-mcp",
3    "version": "1.0.0",
4    "main": "index.js",
5    "scripts": {
6        "build": "tsc --outDir dist",
7        "start": "node ./dist/main.js",
8        "dev": "tsx main.ts"
9    },
10    "keywords": [],
11    "author": "",
12    "license": "ISC",
13    "description": "",
14    "type": "module",
15    "dependencies": {
16        "@modelcontextprotocol/sdk": "^1.17.3",
17        "axios": "^1.11.0",
18        "dotenv": "^17.2.1",
19        "zod": "^3.25.76"
20    },
21    "devDependencies": {
22        "@types/node": "^24.3.0",
23        "@typescript-eslint/eslint-plugin": "^8.41.0",
24        "@typescript-eslint/parser": "^8.41.0",
25        "tsx": "^4.20.5",
26        "typescript": "^5.9.2"
27    }
28}

tsconfig.json

JSON
1{
2  // Visit https://aka.ms/tsconfig to read more about this file
3  "compilerOptions": {
4    // File Layout
5    // "rootDir": "./src",
6    // "outDir": "./dist",
7
8    // Environment Settings
9    // See also https://aka.ms/tsconfig/module
10    "module": "nodenext",
11    "target": "esnext",
12    "types": [],
13    // For nodejs:
14    // "lib": ["esnext"],
15    // "types": ["node"],
16    // and npm install -D @types/node
17
18    // Other Outputs
19    "sourceMap": true,
20    "declaration": true,
21    "declarationMap": true,
22
23    // Stricter Typechecking Options
24    "noUncheckedIndexedAccess": true,
25    "exactOptionalPropertyTypes": true,
26
27    // Style Options
28    // "noImplicitReturns": true,
29    // "noImplicitOverride": true,
30    // "noUnusedLocals": true,
31    // "noUnusedParameters": true,
32    // "noFallthroughCasesInSwitch": true,
33    // "noPropertyAccessFromIndexSignature": true,
34
35    // Recommended Options
36    "strict": true,
37    "jsx": "react-jsx",
38    "verbatimModuleSyntax": true,
39    "isolatedModules": true,
40    "noUncheckedSideEffectImports": true,
41    "moduleDetection": "force",
42    "skipLibCheck": true,
43  }
44}

.env

TEXT
1# Configurazione Business Central OAuth 2.0
2BC_TENANT_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
3BC_CLIENT_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
4BC_CLIENT_SECRET=XXXXX~XX-XXXXXXXXXXXXXXXXXXXXXXXXXX.XXXX
5
6# Business Central API base URL
7BC_BASE_URL=https://api.businesscentral.dynamics.com/v2.0/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
8
9# Business Central Company ID
10BC_COMPANY_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
11
12# Sandbox or Production?
13BC_ENVIRONMENT=Production

srcmain.ts

TYPESCRIPT
1import express from "express";
2import { randomUUID } from "node:crypto";
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"
6import { ensureAuthenticated, makeApiCall } from "./business-central-auth.js";
7import { z } from "zod";
8
9const app = express();
10app.use(express.json());
11
12// Map to store transports by session ID
13const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
14
15// Handle POST requests for client-to-server communication
16app.post('/mcp', async (req, res) => {
17  // Check for existing session ID
18  const sessionId = req.headers['mcp-session-id'] as string | undefined;
19  let transport: StreamableHTTPServerTransport;
20
21  if (sessionId && transports[sessionId]) {
22    // Reuse existing transport
23    transport = transports[sessionId];
24  } else if (!sessionId && isInitializeRequest(req.body)) {
25    // New initialization request
26    transport = new StreamableHTTPServerTransport({
27      sessionIdGenerator: () => randomUUID(),
28      onsessioninitialized: (sessionId) => {
29        // Store the transport by session ID
30        transports[sessionId] = transport;
31      },
32      // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server
33      // locally, make sure to set:
34      // enableDnsRebindingProtection: true,
35      // allowedHosts: ['127.0.0.1'],
36    });
37
38    // Clean up transport when closed
39    transport.onclose = () => {
40      if (transport.sessionId) {
41        delete transports[transport.sessionId];
42      }
43    };
44    const server = new McpServer({
45      name: "example-server",
46      version: "1.0.0"
47    });
48
49    // ... set up server resources, tools, and prompts ...
50
51    server.tool(
52      'get_vendors',
53      'Retrieves vendor list from Business Central',
54      {
55        filter: z.string().optional().describe('OData filter for vendors'),
56        top: z.number().optional().describe('Maximum number of results')
57      },
58      async ({ filter, top = 100 }) => {
59        await ensureAuthenticated();
60        const params: any = { $top: top };
61        if (filter) params.$filter = filter;
62
63        const result = await makeApiCall('vendors', params);
64        return {
65          content: [{
66            type: "text",
67            text: JSON.stringify({
68              success: true,
69              data: result.value || [],
70              count: result.value?.length || 0
71            }, null, 2)
72          }]
73        };
74      }
75    );
76
77    // Connect to the MCP server
78    await server.connect(transport);
79  } else {
80    // Invalid request
81    res.status(400).json({
82      jsonrpc: '2.0',
83      error: {
84        code: -32000,
85        message: 'Bad Request: No valid session ID provided',
86      },
87      id: null,
88    });
89    return;
90  }
91
92  // Handle the request
93  await transport.handleRequest(req, res, req.body);
94});
95
96// Reusable handler for GET and DELETE requests
97const handleSessionRequest = async (req: express.Request, res: express.Response) => {
98  const sessionId = req.headers['mcp-session-id'] as string | undefined;
99  if (!sessionId || !transports[sessionId]) {
100    res.status(400).send('Invalid or missing session ID');
101    return;
102  }
103
104  const transport = transports[sessionId];
105  await transport.handleRequest(req, res);
106};
107
108// Handle GET requests for server-to-client notifications via SSE
109app.get('/mcp', handleSessionRequest);
110
111// Handle DELETE requests for session termination
112app.delete('/mcp', handleSessionRequest);
113
114app.listen(3000);

package.json

JSON
1{
2    "name": "business-central-mcp",
3    "version": "1.0.0",
4    "main": "index.js",
5    "scripts": {
6        "build": "tsc --outDir dist",
7        "start": "TRANSPORT=http node ./dist/main.js",
8        "dev:stdio": "tsx ./src/main.ts",
9        "dev:http": "TRANSPORT=http tsx ./src/main.ts",
10        "docker:build": "docker build -t business-central-mcp .",
11        "docker:run": "docker run -p 3000:3000 --env-file .env business-central-mcp"
12    },
13    "keywords": [],
14    "author": "",
15    "license": "ISC",
16    "description": "",
17    "type": "module",
18    "dependencies": {
19        "@modelcontextprotocol/sdk": "^1.17.3",
20        "axios": "^1.11.0",
21        "cors": "^2.8.5",
22        "dotenv": "^17.2.1",
23        "express": "^5.1.0",
24        "zod": "^3.25.76"
25    },
26    "devDependencies": {
27        "@types/cors": "^2.8.19",
28        "@types/express": "^5.0.3",
29        "@types/node": "^24.3.0",
30        "@typescript-eslint/eslint-plugin": "^8.41.0",
31        "@typescript-eslint/parser": "^8.41.0",
32        "tsx": "^4.20.5",
33        "typescript": "^5.9.2"
34    }
35}

dockerfile

TEXT
1FROM node:20-alpine
2WORKDIR /app
3
4# Install dependencies
5COPY package*.json ./
6RUN npm ci --only=production
7
8# Copy source code
9COPY . .
10
11# Expose port
12EXPOSE 3000
13
14# Set environment variables
15ENV TRANSPORT=http
16ENV PORT=3000
17
18# Start the server
19CMD ["npm", "start"]

docker-compose.yml

TEXT
1services:
2  business-central-mcp:
3    build: .
4    ports:
5      - "3000:3000"
6    environment:
7      - TRANSPORT=http
8      - PORT=3000
9      # Add your Business Central environment variables here
10      - BC_TENANT_ID=${BC_TENANT_ID}
11      - BC_CLIENT_ID=${BC_CLIENT_ID}
12      - BC_CLIENT_SECRET=${BC_CLIENT_SECRET}
13      - BC_BASE_URL=${BC_BASE_URL}
14      - BC_COMPANY_ID=${BC_COMPANY_ID}
15      - BC_ENVIRONMENT=${BC_ENVIRONMENT}
16    restart: unless-stopped