Creating a New MCP Adapter
Step-by-step guide to building a vendor adapter for CitadelMesh using the Model Context Protocol.
Adapter Template
Every MCP adapter follows this structure:
mcp-servers/my-vendor/
├── src/
│ ├── index.ts # MCP server entry point
│ ├── vendor-client.ts # Vendor API wrapper
│ ├── tools/ # Tool implementations
│ │ ├── read-operations.ts
│ │ └── write-operations.ts
│ └── types.ts # TypeScript types
├── package.json
├── tsconfig.json
└── README.md
Step-by-Step: Avigilon Camera Adapter
Let's build an adapter for Avigilon camera analytics.
Step 1: Project Setup
# Create adapter directory
mkdir -p mcp-servers/avigilon-adapter
cd mcp-servers/avigilon-adapter
# Initialize npm project
npm init -y
# Install dependencies
npm install @modelcontextprotocol/sdk zod axios
npm install -D typescript @types/node tsx
# Setup TypeScript
npx tsc --init
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
package.json:
{
"name": "avigilon-adapter",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"avigilon-adapter": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.18.2",
"axios": "^1.6.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.7.0",
"typescript": "^5.7.3"
}
}
Step 2: Define Types and Schemas
src/types.ts:
import { z } from 'zod';
// Person detection
export const DetectPersonsSchema = z.object({
zone: z.string().describe('Zone to monitor (e.g., "entrance", "lobby")'),
confidence_threshold: z.number().min(0).max(1).default(0.8)
.describe('Minimum confidence (0.0-1.0)')
});
export type DetectPersonsParams = z.infer<typeof DetectPersonsSchema>;
// Track person
export const TrackPersonSchema = z.object({
person_id: z.string().describe('Person identifier from detection'),
duration_seconds: z.number().default(300).describe('Tracking duration')
});
export type TrackPersonParams = z.infer<typeof TrackPersonSchema>;
// Get incidents
export const GetIncidentsSchema = z.object({
zone: z.string().optional().describe('Filter by zone'),
severity: z.enum(['low', 'medium', 'high', 'critical']).optional()
});
export type GetIncidentsParams = z.infer<typeof GetIncidentsSchema>;
// Vendor API response types
export interface PersonDetection {
detection_id: string;
person_id: string;
zone: string;
confidence: number;
bounding_box: [number, number, number, number];
timestamp: string;
}
export interface TrackingData {
track_id: string;
person_id: string;
camera_id: string;
location: { x: number; y: number };
timestamp: string;
}
export interface SecurityIncident {
incident_id: string;
type: string;
severity: string;
zone: string;
description: string;
timestamp: string;
}
Step 3: Vendor API Client
src/vendor-client.ts:
import axios, { AxiosInstance } from 'axios';
import type { PersonDetection, TrackingData, SecurityIncident } from './types.js';
export class AvigilonClient {
private client: AxiosInstance;
private mockMode: boolean;
constructor(apiUrl?: string, apiKey?: string) {
this.mockMode = !apiUrl || process.env.ENABLE_MOCK_MODE === 'true';
if (this.mockMode) {
console.log('[MOCK MODE] Avigilon client in mock mode');
this.client = axios.create(); // Won't be used
} else {
this.client = axios.create({
baseURL: apiUrl,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
timeout: 10000
});
}
}
async detectPersons(zone: string, threshold: number): Promise<PersonDetection[]> {
if (this.mockMode) {
// Mock response
return [{
detection_id: `det-${Date.now()}`,
person_id: `person-${Math.random().toString(36).slice(2)}`,
zone,
confidence: 0.95,
bounding_box: [100, 200, 50, 150],
timestamp: new Date().toISOString()
}];
}
const response = await this.client.post('/analytics/detect', {
zone,
confidence_threshold: threshold
});
return response.data.detections;
}
async trackPerson(personId: string, duration: number): Promise<TrackingData[]> {
if (this.mockMode) {
return [{
track_id: `track-${Date.now()}`,
person_id: personId,
camera_id: 'cam-lobby-01',
location: { x: 100, y: 200 },
timestamp: new Date().toISOString()
}];
}
const response = await this.client.post('/tracking/start', {
person_id: personId,
duration_seconds: duration
});
return response.data.tracks;
}
async getIncidents(zone?: string, severity?: string): Promise<SecurityIncident[]> {
if (this.mockMode) {
return [{
incident_id: `inc-${Date.now()}`,
type: 'loitering',
severity: severity || 'medium',
zone: zone || 'entrance',
description: 'Person loitering detected',
timestamp: new Date().toISOString()
}];
}
const response = await this.client.get('/incidents', {
params: { zone, severity }
});
return response.data.incidents;
}
async listCameras() {
if (this.mockMode) {
return [
{ camera_id: 'cam-lobby-01', status: 'online', zone: 'entrance' },
{ camera_id: 'cam-corridor-01', status: 'online', zone: 'corridor' }
];
}
const response = await this.client.get('/cameras');
return response.data.cameras;
}
}
Step 4: OPA Safety Integration
src/safety.ts:
import axios from 'axios';
export class SafetyChecker {
private opaUrl: string;
constructor(opaUrl: string = 'http://localhost:8181') {
this.opaUrl = opaUrl;
}
async checkPolicy(action: string, params: any): Promise<{ allowed: boolean; reason?: string }> {
try {
const response = await axios.post(
`${this.opaUrl}/v1/data/citadel/security`,
{
input: {
action,
...params,
timestamp: Date.now()
}
},
{ timeout: 2000 }
);
const result = response.data.result;
return {
allowed: result.allow === true,
reason: result.violation_reason
};
} catch (error) {
console.error('OPA policy check failed:', error);
// Fail safe: deny action if OPA is unreachable
return {
allowed: false,
reason: 'Policy engine unavailable'
};
}
}
}
Step 5: MCP Server Implementation
src/index.ts:
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { AvigilonClient } from './vendor-client.js';
import { SafetyChecker } from './safety.js';
import {
DetectPersonsSchema,
TrackPersonSchema,
GetIncidentsSchema,
type DetectPersonsParams,
type TrackPersonParams,
type GetIncidentsParams
} from './types.js';
// Initialize clients
const avigilon = new AvigilonClient(
process.env.AVIGILON_API_URL,
process.env.AVIGILON_API_KEY
);
const safety = new SafetyChecker(
process.env.OPA_URL || 'http://localhost:8181'
);
// Create MCP server
const server = new Server(
{
name: 'avigilon-adapter',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// List tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'detect_persons',
description: 'Detect persons in a specific zone using camera analytics',
inputSchema: {
type: 'object',
properties: {
zone: {
type: 'string',
description: 'Zone to monitor (e.g., "entrance", "lobby")'
},
confidence_threshold: {
type: 'number',
description: 'Minimum confidence (0.0-1.0)',
default: 0.8
}
},
required: ['zone']
}
},
{
name: 'track_person',
description: 'Track a detected person across cameras',
inputSchema: {
type: 'object',
properties: {
person_id: {
type: 'string',
description: 'Person identifier from detection'
},
duration_seconds: {
type: 'number',
description: 'Tracking duration in seconds',
default: 300
}
},
required: ['person_id']
}
},
{
name: 'get_incidents',
description: 'Get active security incidents',
inputSchema: {
type: 'object',
properties: {
zone: {
type: 'string',
description: 'Filter by zone'
},
severity: {
type: 'string',
enum: ['low', 'medium', 'high', 'critical'],
description: 'Filter by severity'
}
}
}
},
{
name: 'list_cameras',
description: 'List all available cameras and their status',
inputSchema: {
type: 'object',
properties: {}
}
}
]
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'detect_persons': {
const params = DetectPersonsSchema.parse(args);
// Check OPA policy
const policyCheck = await safety.checkPolicy('camera_detect', {
zone: params.zone,
agent_id: 'avigilon-adapter'
});
if (!policyCheck.allowed) {
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'DENIED',
message: policyCheck.reason
})
}],
isError: true
};
}
// Execute detection
const detections = await avigilon.detectPersons(
params.zone,
params.confidence_threshold
);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'SUCCESS',
detections,
count: detections.length
})
}]
};
}
case 'track_person': {
const params = TrackPersonSchema.parse(args);
// Check OPA policy
const policyCheck = await safety.checkPolicy('camera_track', {
person_id: params.person_id,
duration_seconds: params.duration_seconds
});
if (!policyCheck.allowed) {
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'DENIED',
message: policyCheck.reason
})
}],
isError: true
};
}
// Execute tracking
const tracks = await avigilon.trackPerson(
params.person_id,
params.duration_seconds
);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'SUCCESS',
tracks,
count: tracks.length
})
}]
};
}
case 'get_incidents': {
const params = GetIncidentsSchema.parse(args);
const incidents = await avigilon.getIncidents(
params.zone,
params.severity
);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'SUCCESS',
incidents,
count: incidents.length
})
}]
};
}
case 'list_cameras': {
const cameras = await avigilon.listCameras();
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'SUCCESS',
cameras,
count: cameras.length
})
}]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'ERROR',
message: error instanceof Error ? error.message : 'Unknown error'
})
}],
isError: true
};
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[Avigilon Adapter] MCP server started');
}
main().catch(console.error);
Step 6: Configuration
.env:
# Vendor API
AVIGILON_API_URL=https://avigilon.example.com/api/v1
AVIGILON_API_KEY=your_api_key_here
# Safety
OPA_URL=http://localhost:8181
# Development
ENABLE_MOCK_MODE=false
LOG_LEVEL=info
Step 7: Build and Test
# Build
npm run build
# Test manually
npm start
# In another terminal, test with MCP client
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | npm start
Best Practices Checklist
- Type safety with Zod schemas
- Mock mode for development
- OPA integration for safety
- Error handling with helpful messages
- Environment variables for configuration
- Logging for debugging
- Documentation in README
- Tests (unit and integration)
- Version in package.json
Next Steps
- Testing MCP Adapters - Comprehensive testing
- Agent Integration - Use adapter in agents
- Production Deployment - Deploy adapters
Build adapters for any vendor system! Continue to Testing Adapters.