Model Context Protocol (Part 2)

Neil Haddley • September 21, 2025

Building a Model Context Protocol (MCP) server for Microsoft Business Central from scratch, deploying it to Azure Container Apps over Streamable HTTP, and connecting it to Copilot Studio and LM Studio

Business CentralAIclaude-codemcpmodel-context-protocolbusiness-central

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