Connect your control system code, programming libraries, manufacturer documentation, ticketing systems, and field-service APIs to AVCodex agents.
A custom MCP server lets you wire any AV data source or internal API into your AVCodex-powered agents. This guide walks through protocol basics, two production patterns most AV teams need (REST API adapters and database servers), and the security and deployment steps to ship it.
The Model Context Protocol (MCP) is an open standard from Anthropic that defines how AI systems talk to external tools and data sources. Treat it as a universal adapter between your AVCodex agent and the systems your AV ops team already runs: ticketing, asset management, programming repos, room-monitoring dashboards, manufacturer APIs.
Why it matters for AV:
- Standardized interface: One protocol for Crestron XiO, Q-SYS Reflect, ServiceNow, your own Postgres job database, and anything else.
- AI-optimized: Designed for how LLMs discover and call tools. The Alchemist and your deployed agents pick the right one without hand-holding.
- Secure: Patterns for authentication and authorization are baked in.
- Flexible: Works with REST APIs, SQL databases, file stores, and message buses.
MCP uses JSON-RPC 2.0 for communication. Two core methods carry most of the weight:
tools/list#
Returns the tools an AVCodex agent can call.
tools/call#
Executes a tool with the supplied arguments.
The official MCP SDKs handle protocol details so you can focus on AV-specific business logic. This guide uses the TypeScript SDK because it has the most mature surface area and the best documentation. The Python SDK is a fine alternative if your control-system tooling already lives in Python.
Build a minimal server to see the moving parts before you wire it to anything real.
Project Setup#
mkdir avcodex-mcp-server
cd avcodex-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk express zod
npm install -D typescript @types/node @types/express ts-node
Minimal Server Code#
// src/server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { randomUUID } from "crypto";
const app = express();
app.use(express.json());
// Create MCP server
const server = new McpServer({
name: "avcodex-room-status",
version: "1.0.0"
});
// Register a simple tool
server.tool(
"room_status",
"Return a one-line status string for a conference room",
{
roomId: z.string().describe("Room ID, e.g. 'BOS-14-Boardroom'")
},
async ({ roomId }) => {
return {
content: [{ type: "text", text: `Room ${roomId}: online, displays awake, DSP healthy.` }]
};
}
);
// HTTP transport setup
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
let transport = transports[sessionId];
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await server.connect(transport);
}
const newSessionId = (transport as any)._sessionId;
if (newSessionId) transports[newSessionId] = transport;
await transport.handleRequest(req, res, req.body);
});
app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
const transport = transports[sessionId];
if (transport) await transport.handleRequest(req, res);
else res.status(404).json({ error: "Session not found" });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`MCP server running at http://localhost:${PORT}/mcp`);
});
Run it:
npx ts-node src/server.ts
You now have a working MCP server. Next, build something a programmer or field tech would actually use.
The most common case for AV: wrapping the REST APIs you already maintain (ticketing, room asset DB, internal programmer tooling) as MCP tools.
When to Use This Pattern#
- You have existing REST or GraphQL APIs (ServiceNow, Jira, an internal commissioning tracker).
- You want to expose internal services to your AVCodex agent without rewriting them.
- You need to add AI on top of established back-ends without touching the back-ends.
Complete Example: Site and Room Asset API#
This example wraps a typical AV ops API: search rooms, look up a room, register a new room after a deployment, update commissioning status, pull recent service tickets.
// src/rest-adapter-server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { randomUUID } from "crypto";
const app = express();
app.use(express.json());
// Configuration from environment
const API_BASE = process.env.API_BASE_URL || "https://api.avops.example.com";
const API_KEY = process.env.API_KEY || "";
// Helper for API calls
async function apiCall(
endpoint: string,
options: RequestInit = {}
): Promise<{ ok: boolean; data?: any; error?: string }> {
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
return { ok: false, error: `API error (${response.status}): ${errorText}` };
}
const data = await response.json();
return { ok: true, data };
} catch (err: any) {
return { ok: false, error: `Network error: ${err.message}` };
}
}
// Create MCP server
const server = new McpServer({
name: "av-site-asset-api",
version: "1.0.0"
});
// Tool 1: Search Rooms
server.tool(
"search_rooms",
"Search rooms by name, building, or room type. Returns matching room records.",
{
query: z.string().describe("Search query (matches room name, building, code)"),
limit: z.number().optional().describe("Max results (default: 10, max: 100)"),
offset: z.number().optional().describe("Pagination offset")
},
async ({ query, limit = 10, offset = 0 }) => {
const safeLimit = Math.min(limit, 100);
const result = await apiCall(
`/rooms/search?q=${encodeURIComponent(query)}&limit=${safeLimit}&offset=${offset}`
);
if (!result.ok) {
return {
content: [{ type: "text", text: `Error: ${result.error}` }],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
rooms: result.data.rooms,
total: result.data.total,
hasMore: result.data.total > offset + safeLimit
}, null, 2)
}]
};
}
);
// Tool 2: Get Room by ID
server.tool(
"get_room",
"Fetch the full record for a room: gear list, control system, commissioning notes.",
{
roomId: z.string().describe("Room ID, e.g. 'BOS-14-Boardroom'")
},
async ({ roomId }) => {
const result = await apiCall(`/rooms/${encodeURIComponent(roomId)}`);
if (!result.ok) {
return {
content: [{ type: "text", text: `Error: ${result.error}` }],
isError: true
};
}
return {
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }]
};
}
);
// Tool 3: Register Room
server.tool(
"register_room",
"Register a newly commissioned room. Returns the created record with its ID.",
{
name: z.string().describe("Room name (e.g. '14F Boardroom')"),
building: z.string().describe("Building code (e.g. 'BOS-HQ')"),
roomType: z.enum(["conference", "classroom", "broadcast", "command-center", "huddle", "auditorium"]).describe("Room category"),
controlSystem: z.string().optional().describe("Control system family (Crestron, Q-SYS, AMX, Extron)")
},
async ({ name, building, roomType, controlSystem }) => {
const result = await apiCall("/rooms", {
method: "POST",
body: JSON.stringify({ name, building, roomType, controlSystem })
});
if (!result.ok) {
return {
content: [{ type: "text", text: `Error registering room: ${result.error}` }],
isError: true
};
}
return {
content: [{
type: "text",
text: `Room registered:\n${JSON.stringify(result.data, null, 2)}`
}]
};
}
);
// Tool 4: Update Room
server.tool(
"update_room",
"Update a room record. Send only the fields you want to change.",
{
roomId: z.string().describe("Room ID to update"),
name: z.string().optional().describe("New room name"),
controlSystem: z.string().optional().describe("Updated control system family"),
commissioningStatus: z.enum(["pending", "in-progress", "complete", "punch-list"]).optional().describe("Commissioning state"),
notes: z.string().optional().describe("Free-form notes for the AS-built record"),
active: z.boolean().optional().describe("Whether the room is in service")
},
async ({ roomId, ...updates }) => {
// Filter out undefined values
const cleanUpdates = Object.fromEntries(
Object.entries(updates).filter(([_, v]) => v !== undefined)
);
if (Object.keys(cleanUpdates).length === 0) {
return {
content: [{ type: "text", text: "Error: No fields provided to update" }],
isError: true
};
}
const result = await apiCall(`/rooms/${encodeURIComponent(roomId)}`, {
method: "PATCH",
body: JSON.stringify(cleanUpdates)
});
if (!result.ok) {
return {
content: [{ type: "text", text: `Error updating room: ${result.error}` }],
isError: true
};
}
return {
content: [{
type: "text",
text: `Room updated:\n${JSON.stringify(result.data, null, 2)}`
}]
};
}
);
// Tool 5: List Recent Service Tickets for a Room
server.tool(
"list_room_tickets",
"Get recent service tickets and incidents for a room.",
{
roomId: z.string().describe("Room ID"),
days: z.number().optional().describe("Days of history (default: 7, max: 30)")
},
async ({ roomId, days = 7 }) => {
const safeDays = Math.min(days, 30);
const result = await apiCall(
`/rooms/${encodeURIComponent(roomId)}/tickets?days=${safeDays}`
);
if (!result.ok) {
return {
content: [{ type: "text", text: `Error: ${result.error}` }],
isError: true
};
}
return {
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }]
};
}
);
// HTTP transport setup (same as before)
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
let transport = transports[sessionId];
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await server.connect(transport);
}
const newSessionId = (transport as any)._sessionId;
if (newSessionId) transports[newSessionId] = transport;
await transport.handleRequest(req, res, req.body);
});
app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
const transport = transports[sessionId];
if (transport) await transport.handleRequest(req, res);
else res.status(404).json({ error: "Session not found" });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`AV asset API adapter running at http://localhost:${PORT}/mcp`);
});
Authentication Pass-Through#
If your back-end requires per-tech credentials (a field engineer's own ServiceNow token, for example), pass them through:
// Extract auth from MCP request context
server.tool(
"get_my_open_tickets",
"Get the calling tech's open service tickets",
{},
async (args, context) => {
// The client can pass user-specific tokens in the request
const userToken = context?.meta?.userToken as string;
const result = await apiCall("/me/tickets", {
headers: userToken ? { "Authorization": `Bearer ${userToken}` } : {}
});
// ... handle response
}
);
Expose a database to your AVCodex agent for room intelligence, asset rollups, and on-demand reporting (think: "how many DM-NVX endpoints are deployed across the West region?").
Warning: Never expose production databases directly to AI agents. Use a read replica, a sanitized copy, or a strictly read-only connection. The same rule that protects your headend protects your data.
Complete Example: PostgreSQL Server for AV Asset Data#
// src/database-server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { randomUUID } from "crypto";
import { Pool } from "pg";
const app = express();
app.use(express.json());
// Database connection (USE A READ REPLICA)
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Connection pool size
});
// Create MCP server
const server = new McpServer({
name: "av-data-explorer",
version: "1.0.0"
});
// Tool 1: List Tables
server.tool(
"list_tables",
"List all tables in the AV asset database with column counts.",
{
schema: z.string().optional().describe("Schema name (default: public)")
},
async ({ schema = "public" }) => {
try {
const result = await pool.query(`
SELECT
table_name,
(SELECT COUNT(*) FROM information_schema.columns c
WHERE c.table_name = t.table_name AND c.table_schema = t.table_schema) as column_count
FROM information_schema.tables t
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
ORDER BY table_name
`, [schema]);
return {
content: [{
type: "text",
text: JSON.stringify({
schema,
tables: result.rows,
count: result.rows.length
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Database error: ${err.message}` }],
isError: true
};
}
}
);
// Tool 2: Describe Table
server.tool(
"describe_table",
"Get column details for a specific table (e.g. 'rooms', 'devices', 'commissioning_log').",
{
tableName: z.string().describe("Table name"),
schema: z.string().optional().describe("Schema name (default: public)")
},
async ({ tableName, schema = "public" }) => {
// Validate table name to prevent SQL injection
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return {
content: [{ type: "text", text: "Error: Invalid table name format" }],
isError: true
};
}
try {
const result = await pool.query(`
SELECT
column_name,
data_type,
character_maximum_length,
is_nullable,
column_default,
(SELECT COUNT(*) FROM information_schema.key_column_usage k
WHERE k.column_name = c.column_name
AND k.table_name = c.table_name) > 0 as is_key
FROM information_schema.columns c
WHERE table_schema = $1 AND table_name = $2
ORDER BY ordinal_position
`, [schema, tableName]);
if (result.rows.length === 0) {
return {
content: [{ type: "text", text: `Table '${tableName}' not found in schema '${schema}'` }],
isError: true
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
table: tableName,
schema,
columns: result.rows
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Database error: ${err.message}` }],
isError: true
};
}
}
);
// Tool 3: Read-Only Query
server.tool(
"query_data",
"Run a read-only SQL SELECT against the AV database. SELECT only.",
{
query: z.string().describe("SQL SELECT query"),
limit: z.number().optional().describe("Max rows (default: 100, max: 1000)")
},
async ({ query, limit = 100 }) => {
const safeLimit = Math.min(limit, 1000);
// Normalize and validate query
const normalized = query.trim().toLowerCase();
// Only allow SELECT queries
if (!normalized.startsWith("select")) {
return {
content: [{ type: "text", text: "Error: Only SELECT queries are allowed" }],
isError: true
};
}
// Block dangerous patterns
const forbidden = [
"insert", "update", "delete", "drop", "alter", "truncate",
"create", "grant", "revoke", "execute", "exec"
];
for (const word of forbidden) {
if (normalized.includes(word)) {
return {
content: [{ type: "text", text: `Error: Query contains forbidden keyword: ${word}` }],
isError: true
};
}
}
try {
// Force read-only transaction and add limit
const client = await pool.connect();
try {
await client.query("SET TRANSACTION READ ONLY");
// Remove any existing LIMIT and add our safe limit
const limitedQuery = query.replace(/\s+limit\s+\d+/gi, "") + ` LIMIT ${safeLimit}`;
const result = await client.query(limitedQuery);
return {
content: [{
type: "text",
text: JSON.stringify({
rowCount: result.rowCount,
rows: result.rows,
fields: result.fields.map(f => ({ name: f.name, dataType: f.dataTypeID }))
}, null, 2)
}]
};
} finally {
client.release();
}
} catch (err: any) {
return {
content: [{ type: "text", text: `Query error: ${err.message}` }],
isError: true
};
}
}
);
// Tool 4: Sample a Table
server.tool(
"sample_table",
"Pull a small random sample of rows so the agent can understand a table's shape.",
{
tableName: z.string().describe("Table name"),
sampleSize: z.number().optional().describe("Rows to sample (default: 5, max: 20)")
},
async ({ tableName, sampleSize = 5 }) => {
// Validate table name
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return {
content: [{ type: "text", text: "Error: Invalid table name format" }],
isError: true
};
}
const safeSampleSize = Math.min(sampleSize, 20);
try {
const result = await pool.query(`
SELECT * FROM "${tableName}"
ORDER BY RANDOM()
LIMIT $1
`, [safeSampleSize]);
return {
content: [{
type: "text",
text: JSON.stringify({
table: tableName,
sampleSize: result.rows.length,
rows: result.rows
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Sample error: ${err.message}` }],
isError: true
};
}
}
);
// Tool 5: Table Statistics
server.tool(
"table_stats",
"Row count, on-disk size, and indexes for a table.",
{
tableName: z.string().describe("Table name")
},
async ({ tableName }) => {
// Validate table name
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return {
content: [{ type: "text", text: "Error: Invalid table name format" }],
isError: true
};
}
try {
const [countResult, sizeResult, indexResult] = await Promise.all([
pool.query(`SELECT COUNT(*) as row_count FROM "${tableName}"`),
pool.query(`
SELECT pg_size_pretty(pg_total_relation_size($1)) as total_size,
pg_size_pretty(pg_table_size($1)) as table_size,
pg_size_pretty(pg_indexes_size($1)) as index_size
`, [tableName]),
pool.query(`
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = $1
`, [tableName])
]);
return {
content: [{
type: "text",
text: JSON.stringify({
table: tableName,
rowCount: parseInt(countResult.rows[0].row_count),
size: sizeResult.rows[0],
indexes: indexResult.rows
}, null, 2)
}]
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Stats error: ${err.message}` }],
isError: true
};
}
}
);
// HTTP transport setup
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
let transport = transports[sessionId];
if (!transport) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await server.connect(transport);
}
const newSessionId = (transport as any)._sessionId;
if (newSessionId) transports[newSessionId] = transport;
await transport.handleRequest(req, res, req.body);
});
app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
const transport = transports[sessionId];
if (transport) await transport.handleRequest(req, res);
else res.status(404).json({ error: "Session not found" });
});
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("Shutting down...");
await pool.end();
process.exit(0);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`AV database MCP server running at http://localhost:${PORT}/mcp`);
});
How you shape your tools determines whether the agent makes good decisions on a real service call.
Tool Count Guidelines#
| Tool Count | Assessment | Recommendation |
|---|---|---|
| 3-10 | Ideal | Easy for the agent to navigate |
| 10-20 | Good | Group related tools |
| 20-50 | Complex | Agent may struggle to choose correctly |
| 50+ | Too many | Consolidate into workflow tools |
A field-tech assistant with 6 well-named tools (look up room, fetch device status, open ticket, attach photo, mark resolved, escalate) outperforms one with 40 finely sliced ones.
Your MCP server needs to verify requests come from authorized callers (your AVCodex agent, your dispatch tooling, you).
Note: Need per-user authentication so each tech connects with their own ServiceNow, Crestron XiO, or Microsoft account? See Consumer OAuth for Custom MCP Servers for the full setup.
Bearer Token Authentication#
// Middleware to verify Bearer token
function authenticate(req: express.Request, res: express.Response, next: express.NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing or invalid Authorization header" });
}
const token = authHeader.substring(7);
// Verify token (implement your own logic)
if (!isValidToken(token)) {
return res.status(401).json({ error: "Invalid token" });
}
next();
}
// Apply to MCP endpoints
app.post("/mcp", authenticate, async (req, res) => {
// ... handler
});
app.get("/mcp", authenticate, async (req, res) => {
// ... handler
});
API Key Authentication#
function authenticateApiKey(req: express.Request, res: express.Response, next: express.NextFunction) {
const apiKey = req.headers["x-api-key"] as string;
if (!apiKey) {
return res.status(401).json({ error: "Missing X-API-Key header" });
}
// Verify API key
if (!isValidApiKey(apiKey)) {
return res.status(401).json({ error: "Invalid API key" });
}
next();
}
Pick a deployment that matches your ops posture: managed cloud for simplicity, on-prem for sensitive control-system data.
Cloud Run Deployment#
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 8080
CMD ["node", "dist/server.js"]
# Deploy to Cloud Run
gcloud run deploy avcodex-mcp-server \
--source . \
--region us-central1 \
--no-allow-unauthenticated \
--set-env-vars="DATABASE_URL=..."
Development with ngrok#
# Start your local server
npm run dev
# In another terminal, expose it
ngrok http 3000
# Use the ngrok URL in AVCodex
# https://abc123.ngrok-free.app/mcp
Before connecting your server to a production AVCodex agent, verify:
- HTTPS only. No plaintext on any path that touches client data.
- Bearer token or API key required on every MCP endpoint.
- Read-only DB connections for data tools. No write paths the agent can call by accident.
- Input validation on every tool argument (table names, room IDs, device serials).
- Rate limits in place so a runaway agent loop cannot hammer your back-ends.
- Logs that capture tool name, caller, arguments, and outcome (see the audit logs guide).
- Secrets in a real secret store, not env files checked into Git.
Once your MCP server is deployed:
- Open your AVCodex agent's builder.
- Add a new Custom MCP Server.
- Paste the URL, transport, and credential.
- Save. AVCodex calls
tools/listand shows the tools your server exposed. - Enable the tools you want the agent to use, write a short description for each, and test.
Configuration Fields#
| Field | Description | Example |
|---|---|---|
| Server URL | Full URL to your MCP endpoint | https://avcodex-mcp.run.app/mcp |
| Transport | Protocol type | HTTP (recommended) |
| Auth Type | How to authenticate | Bearer or API Key |
| Token/Key | Your authentication credential | sk-abc123... |
After saving, AVCodex calls your server's tools/list method and displays the available tools. Enable the ones you want the agent to use.
Common Issues#
"Connection refused"
- Check that your server is running and reachable from the internet.
- Verify HTTPS is correctly configured.
- Check firewall rules for inbound traffic.
"Authentication failed"
- Verify your token or API key is correct.
- Confirm the Authorization header format matches what your server expects.
- Ensure tokens have not expired.
"Tool not found"
- Run
tools/listdirectly to confirm the tool is registered. - Check that tool names contain only safe characters.
- Verify the tool is enabled in AVCodex.
Slow response times
- Add connection pooling on database tools.
- Cache frequently requested data (room records, device profiles).
- Move your server to a region close to the AVCodex agent runtime.
Testing Your Server#
Use curl to hit your MCP server directly:
# Test tools/list
curl -X POST https://your-server.com/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Test tools/call
curl -X POST https://your-server.com/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"your_tool","arguments":{}}}'
AVCodex Documentation:
- Consumer OAuth for Custom MCP Servers. Per-user OAuth for third-party services.
- AVCodex MCP Server. Connect Claude Code to build and manage your AVCodex agents.
- Pro Actions Getting Started. Pre-built MCP integrations.
- Pro Actions Examples. Real AV-ops workflows.
- Custom Actions Guide. Simpler HTTP-based integrations.
- API Reference. Full API documentation.
Official MCP Documentation:
Example Repositories:
- REST-to-MCP Adapter. Auto-generate tools from OpenAPI specs.
- Postgres MCP Pro. Production-ready database server.
- DBHub. Multi-database gateway.
Deployment Guides:
*AVCodex · Your AV expertise. Amplified by AI.*