Model Context Protocol (Part 2)
Neil Haddley • September 21, 2025
Business 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 ran npm install @modelcontextprotocol/sdk and npm install zod.

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

The hello tool returned success

I replaced the hello tool with a get-vendors tool

I ran npx -y @modelcontextprotocol/inspector npm run dev. The get_vendors tools returned success.Later I ran npm run build and then npx -y @modelcontextprotocol/inspector npm run start
For remote servers, 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 npx -y @modelcontextprotocol/inspector

I ran the get_vendors tool
The Azure Container Apps extension for Visual Studio Code enables you to choose existing Container Apps resources, or create new ones to deploy your applications to.
https://learn.microsoft.com/en-us/azure/container-apps/deploy-visual-studio-code

I entered the name of new container apps environment

I selected a location

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

I accepted the generated container app name

I selected the Managed Identity option

I selected the .env file

The deployment finished

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 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 selected Custom connector

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 entered the server url https://business-central-mcp-new.thankfulsea-b533246e.eastus.azurecontainerapps.io/mcp

I refreshed the Model Context Protocol options

I clicked on the MCP Server Streamable HTTP option

I clicked the Create button

I clicked the Add and configure button

The get_vendors tool was displayed

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

The status if MCP-Streamable-HTTP was updated to Connected

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.
Open LM Studio and navigate to the "Program" tab in the right sidebar.
Click on "Install" and select "Edit mcp.json". This opens the mcp.json file in LM Studio's in-app editor.
Add your 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 entered the prompt show me a table of vendor details
main.ts
TEXT
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
TEXT
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
TEXT
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
TEXT
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
TEXT
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
TEXT
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
TEXT
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
TEXT
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