Skip to main content

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


Build adapters for any vendor system! Continue to Testing Adapters.