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
- Server - Handles MCP protocol
- Tools - Available operations (read, write, control)
- Schemas - Type validation (Zod/JSON Schema)
- 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
stdio Transport (Recommended)
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
- Validate all inputs with Zod schemas
- Implement safety checks before executing
- Support mock mode for development
- Handle errors gracefully with helpful messages
- Integrate OPA policies for safety
- Version your tools in API responses
- Document tool schemas thoroughly
- Test with real and mock data
Next Steps
- Creating MCP Adapters - Build your own adapter
- Testing MCP Adapters - Comprehensive testing
- Agent Integration - Use adapters in agents
MCP adapters unlock vendor integrations! Continue to Creating Adapters.