Skip to main content

MCP Adapter Basics

Model Context Protocol (MCP) adapters connect CitadelMesh agents to vendor building systems. This guide introduces MCP architecture and implementation patterns.

What is MCP?

Model Context Protocol (MCP) is a standard for tools that AI agents can call:

  • Standardized interface for diverse building systems
  • Type-safe tool definitions with JSON Schema
  • Multiple transports (stdio, HTTP/SSE)
  • Composable - agents use multiple adapters

MCP in CitadelMesh

Agent → MCP Client → MCP Server (Adapter) → Vendor API
↓ ↓ ↓
Tool call Tool implementation Building system

MCP Server Architecture

Core Components

  1. Server - Handles MCP protocol
  2. Tools - Available operations (read, write, control)
  3. Schemas - Type validation (Zod/JSON Schema)
  4. Transport - Communication channel (stdio/SSE)

EcoStruxure Adapter Example

From /mcp-servers/ecostruxure-ebo/src/index.ts:

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

// Define tool schemas
const WriteSetpointSchema = z.object({
entity_id: z.string().describe('Entity identifier'),
value: z.number().describe('Setpoint value'),
priority: z.number().default(8).describe('BACnet priority (1-16)')
});

// Create MCP server
const server = new Server(
{
name: 'ecostruxure-ebo',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);

// Register tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'write_setpoint',
description: 'Write HVAC setpoint with safety checks',
inputSchema: {
type: 'object',
properties: {
entity_id: { type: 'string' },
value: { type: 'number' },
priority: { type: 'number', default: 8 }
},
required: ['entity_id', 'value']
}
}
]
};
});

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;

if (name === 'write_setpoint') {
const params = WriteSetpointSchema.parse(args);

// Safety check
if (params.value < 65 || params.value > 78) {
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'FAILED',
message: 'Setpoint outside safe range [65, 78°F]'
})
}]
};
}

// Call vendor API (simplified)
const result = await callEcoStruxureAPI(params);

return {
content: [{
type: 'text',
text: JSON.stringify(result)
}]
};
}
});

// Start server
const transport = new StdioServerTransport();
await server.connect(transport);

Tool Definition Patterns

1. Read Operations

const ReadPointSchema = z.object({
entity_id: z.string().describe('Entity ID (e.g., "hvac.zone1.temp")')
});

function readPoint(params: z.infer<typeof ReadPointSchema>): string {
// Read from vendor system
const value = await vendorAPI.read(params.entity_id);

return JSON.stringify({
entity_id: params.entity_id,
value: value.current,
unit: value.unit,
quality: 'good',
timestamp: new Date().toISOString()
});
}

2. Write/Control Operations

const UnlockDoorSchema = z.object({
door_id: z.string(),
duration_seconds: z.number().min(1).max(900),
priority: z.enum(['PRIORITY_NORMAL', 'PRIORITY_EMERGENCY'])
});

async function unlockDoor(params: z.infer<typeof UnlockDoorSchema>) {
// Check with OPA before executing
const allowed = await checkOPAPolicy('door_unlock', params);

if (!allowed) {
throw new Error('Policy denied door unlock');
}

// Execute vendor command
const result = await vendorAPI.unlockDoor(params);

return {
command_id: result.id,
status: 'SUCCESS',
door_id: params.door_id,
expires_at: new Date(Date.now() + params.duration_seconds * 1000)
};
}

3. Batch Operations

const ReadBatchPointsSchema = z.object({
entity_ids: z.array(z.string())
});

async function readBatchPoints(params: z.infer<typeof ReadBatchPointsSchema>) {
// Parallel reads
const results = await Promise.all(
params.entity_ids.map(id => vendorAPI.read(id))
);

return {
points: results.map((r, i) => ({
entity_id: params.entity_ids[i],
value: r.value,
unit: r.unit,
timestamp: r.timestamp
})),
count: results.length
};
}

Transport Options

Best for local execution and subprocess communication:

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const transport = new StdioServerTransport();
await server.connect(transport);

Pros:

  • Simple, no networking
  • Works well with Docker
  • Low overhead

Cons:

  • One client per process
  • No remote access

SSE Transport

For HTTP-based communication:

import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import express from 'express';

const app = express();

app.post('/mcp/sse', async (req, res) => {
const transport = new SSEServerTransport('/mcp/sse', res);
await server.connect(transport);
});

app.listen(3000);

Pros:

  • Multiple clients
  • Remote access
  • Web-friendly

Cons:

  • Requires HTTP server
  • More complex

Mock Mode Implementation

Support development without vendor systems:

const MOCK_MODE = process.env.ENABLE_MOCK_MODE === 'true';

async function writeSetpoint(params: WriteSetpointParams) {
if (MOCK_MODE) {
// Return mock response
return {
command_id: `mock-${Date.now()}`,
status: 'SUCCESS',
message: `[MOCK] Set ${params.entity_id} to ${params.value}`
};
}

// Real implementation
return await vendorAPI.writeSetpoint(params);
}

Error Handling

Graceful Failures

server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
// Execute tool
const result = await executeTool(request.params);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };

} catch (error) {
if (error instanceof z.ZodError) {
// Validation error
throw new Error(`Invalid arguments: ${error.message}`);
}

if (error instanceof VendorAPIError) {
// Vendor system error
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'FAILED',
error: error.message,
retry_allowed: error.retryable
})
}],
isError: true
};
}

throw error; // Unexpected error
}
});

OPA Integration

Check policies before executing commands:

import axios from 'axios';

async function checkOPAPolicy(action: string, params: any): Promise<boolean> {
const OPA_URL = process.env.OPA_URL || 'http://localhost:8181';

const response = await axios.post(
`${OPA_URL}/v1/data/citadel/security/allow`,
{
input: {
action,
...params
}
}
);

return response.data.result === true;
}

// Use in tool implementation
async function unlockDoor(params: UnlockDoorParams) {
const allowed = await checkOPAPolicy('door_unlock', params);

if (!allowed) {
return {
status: 'DENIED',
message: 'Policy violation: Door unlock not permitted'
};
}

// Execute if allowed
return await vendorAPI.unlockDoor(params);
}

Testing MCP Servers

Unit Tests

import { describe, it, expect, vi } from 'vitest';

describe('EcoStruxure Adapter', () => {
it('should read point successfully', async () => {
const result = await readPoint({
entity_id: 'hvac.zone1.temp'
});

const data = JSON.parse(result);

expect(data.entity_id).toBe('hvac.zone1.temp');
expect(data.value).toBeDefined();
expect(data.unit).toBe('degF');
});

it('should reject setpoint outside range', async () => {
const result = await writeSetpoint({
entity_id: 'hvac.zone1.setpoint',
value: 90, // Too high
priority: 8
});

const data = JSON.parse(result);

expect(data.status).toBe('FAILED');
expect(data.message).toContain('outside safe range');
});
});

Integration Tests

import { spawn } from 'child_process';

describe('MCP Server Integration', () => {
let serverProcess;

beforeEach(() => {
// Start MCP server
serverProcess = spawn('node', ['dist/index.js'], {
stdio: ['pipe', 'pipe', 'pipe']
});
});

afterEach(() => {
serverProcess.kill();
});

it('should list available tools', (done) => {
const request = {
jsonrpc: '2.0',
id: 1,
method: 'tools/list'
};

serverProcess.stdin.write(JSON.stringify(request) + '\n');

serverProcess.stdout.once('data', (data) => {
const response = JSON.parse(data.toString());

expect(response.result.tools).toHaveLength(5);
expect(response.result.tools[0].name).toBe('read_point');

done();
});
});
});

Best Practices

  1. Validate all inputs with Zod schemas
  2. Implement safety checks before executing
  3. Support mock mode for development
  4. Handle errors gracefully with helpful messages
  5. Integrate OPA policies for safety
  6. Version your tools in API responses
  7. Document tool schemas thoroughly
  8. Test with real and mock data

Next Steps


MCP adapters unlock vendor integrations! Continue to Creating Adapters.