---
title: "Building Custom MCP Servers — AVCodex Docs"
description: "Building Custom MCP Servers — AVCodex documentation for AV integrators, programmers, and ops teams."
lang: en
json-ld:
---

[](/)

Solutions

[Pricing](/pricing)[The Signal](/blog)[Resources](/resources)

Learn

[Free AI Assessment](/scorecard)[Get Started →](/pricing)

[Documentation Home](/docs)

Guides 

Getting Started

-   [The Alchemist Copilot](/docs/guides/the-alchemist-copilot)
-   [Choosing a Model](/docs/guides/choosing-a-model)
-   [Skills & Templates](/docs/guides/skills-and-templates)
-   [Pricing & Usage](/docs/guides/pricing-and-usage)
-   [Understanding Tokens](/docs/guides/understanding-tokens)
-   [Maximize AVCodex Capabilities](/docs/guides/maximize-avcodex-capabilities)

Knowledge & Memory

-   [How Knowledge Sources Work](/docs/guides/how-knowledge-sources-work)
-   [Knowledge Retrieval Settings](/docs/guides/knowledge-retrieval-settings)
-   [User Memory](/docs/guides/user-memory)
-   [Consumer Brain](/docs/guides/consumer-brain)

Agent Capabilities

-   [Image Recognition](/docs/guides/image-recognition)
-   [Image Generation](/docs/guides/image-generation)
-   [Video Generation](/docs/guides/video-generation)
-   [Deep Research and Deep Thinking](/docs/guides/deep-research-and-deep-thinking)
-   [Heartbeat (Proactive AI Outreach)](/docs/guides/heartbeat-proactive-ai-outreach)
-   [Database Connections](/docs/guides/database-connections)
-   [Agent-to-Agent Links](/docs/guides/agent-to-agent-links)
-   [Message Tagging](/docs/guides/message-tagging)
-   [Lead Generation Forms](/docs/guides/lead-generation-forms)
-   [Multilingual Apps](/docs/guides/multilingual-apps)
-   [Understanding Evaluations](/docs/guides/understanding-evaluations)

Design & Experience

-   [Style Studio](/docs/guides/style-studio)
-   [Component Studio](/docs/guides/component-studio)
-   [HQ Profile](/docs/guides/hq-profile)
-   [Multiplayer Chat](/docs/guides/multiplayer-chat)
-   [Circles](/docs/guides/circles)
-   [Desktop Agent](/docs/guides/desktop-agent)

Voice & Phone

-   [Phone Numbers](/docs/guides/phone-numbers)
-   [Outbound Calling](/docs/guides/outbound-calling)
-   [Voice Cloning](/docs/guides/voice-cloning)

Publish & Share

-   [Embed Chat Widget](/docs/guides/embed-chat-widget)
-   [Custom Domains](/docs/guides/custom-domains)
-   [PWA Installation](/docs/guides/pwa-installation)
-   [AVCodex Sites](/docs/guides/avcodex-sites)
-   [Embed on Kajabi](/docs/guides/embed-on-kajabi)
-   [How to Use AVCodex with Claude Code](/docs/guides/how-to-use-avcodex-with-claude-code)

Monetization & Access

-   [Selling Access](/docs/guides/selling-access)
-   [Consumer Monetization](/docs/guides/consumer-monetization)
-   [Access Control](/docs/guides/access-control)
-   [Bring Your Own Auth](/docs/guides/bring-your-own-auth)
-   [Clever SSO for Schools](/docs/guides/clever-sso-for-schools)

Analytics & Operations

-   [Analytics & Chat History](/docs/guides/analytics-and-chat-history)
-   [Performance Dashboard](/docs/guides/performance-dashboard)
-   [Programmatic Usage Stats](/docs/guides/programmatic-usage-stats)
-   [Session Lifecycle Webhooks](/docs/guides/session-lifecycle-webhooks)
-   [Audit Logs](/docs/guides/audit-logs)

Teams & White-Label

-   [Team Management](/docs/guides/team-management)
-   [Enterprise Whitelabel](/docs/guides/enterprise-whitelabel)

Alchemist Platform

-   [Alchemist Tickets](/docs/guides/alchemist-tickets)
-   [Alchemist Getting Started](/docs/guides/alchemist-getting-started)
-   [Alchemist Working with Tickets](/docs/guides/alchemist-working-with-tickets)
-   [Alchemist Local Development](/docs/guides/alchemist-local-development)

Alchemist Operations

-   [Alchemist Environment Variables](/docs/guides/alchemist-environment-variables)
-   [Alchemist Deploys and Domains](/docs/guides/alchemist-deploys-and-domains)
-   [Alchemist Self-Healing](/docs/guides/alchemist-self-healing)

Alchemist API & Automation

-   [Alchemist API Keys](/docs/guides/alchemist-api-keys)
-   [Alchemist MCP Server](/docs/guides/alchemist-mcp-server)
-   [Alchemist Pipeline Configuration](/docs/guides/alchemist-pipeline-configuration)
-   [Alchemist Pipeline Permutations](/docs/guides/alchemist-pipeline-permutations)

Developer Platform

-   [Building Custom MCP Servers](/docs/guides/building-custom-mcp-servers)
-   [Consumer OAuth for Custom MCP Servers](/docs/guides/consumer-oauth-for-custom-mcp-servers)

AVCodex MCP Server

-   [Overview](/docs/guides/overview)
-   [MCP Reference](/docs/guides/mcp-reference)
-   [Setup & Installation](/docs/guides/setup-and-installation)
-   [Authentication](/docs/guides/authentication)
-   [Tools Reference](/docs/guides/tools-reference)
-   [Common Workflows](/docs/guides/common-workflows)
-   [Rate Limits](/docs/guides/rate-limits)

Custom Actions 

Pro Actions 

API 

Builder API 

Agentic Commerce (ACP) 

Integrations 

[Docs](/docs)/ Guides / Developer Platform 

# Building Custom MCP Servers

Last updated · MAR 2026 · [Read as Markdown](/docs/guides/building-custom-mcp-servers.md)

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.

## [What is MCP?# ](#what-is-mcp)

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.

## [How MCP Works# ](#how-mcp-works)

MCP uses JSON-RPC 2.0 for communication. Two core methods carry most of the weight:

### [tools/list# ](#tools-list)

Returns the tools an AVCodex agent can call.

### [tools/call# ](#tools-call)

Executes a tool with the supplied arguments.

## [Choosing an SDK# ](#choosing-an-sdk)

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.

## [Quick Start: Your First MCP Server# ](#quick-start-your-first-mcp-server)

Build a minimal server to see the moving parts before you wire it to anything real.

### [Project Setup# ](#project-setup)

bash 

```
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# ](#minimal-server-code)

typescript 

```
// 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:

bash 

```
npx ts-node src/server.ts
```

You now have a working MCP server. Next, build something a programmer or field tech would actually use.

* * *

## [Pattern 1: REST API Adapter# ](#pattern-1-rest-api-adapter)

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# ](#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# ](#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.

typescript 

```
// 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# ](#authentication-pass-through)

If your back-end requires per-tech credentials (a field engineer's own ServiceNow token, for example), pass them through:

typescript 

```
// 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
  }
);
```

* * *

## [Pattern 2: Database Server# ](#pattern-2-database-server)

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# ](#complete-example-postgresql-server-for-av-asset-data)

typescript 

```
// 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`);
});
```

* * *

## [Tool Design Best Practices# ](#tool-design-best-practices)

How you shape your tools determines whether the agent makes good decisions on a real service call.

### [Tool Count Guidelines# ](#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.

* * *

## [Adding Authentication# ](#adding-authentication)

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](/docs/guides/consumer-oauth-mcp) for the full setup.

### [Bearer Token Authentication# ](#bearer-token-authentication)

typescript 

```
// 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# ](#api-key-authentication)

typescript 

```
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();
}
```

* * *

## [Deployment Options# ](#deployment-options)

Pick a deployment that matches your ops posture: managed cloud for simplicity, on-prem for sensitive control-system data.

### [Cloud Run Deployment# ](#cloud-run-deployment)

dockerfile 

```
# 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"]
```

bash 

```
# 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# ](#development-with-ngrok)

bash 

```
# 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
```

* * *

## [Security Checklist# ](#security-checklist)

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.

* * *

## [Connecting to AVCodex# ](#connecting-to-avcodex)

Once your MCP server is deployed:

1.  Open your AVCodex agent's builder.
2.  Add a new **Custom MCP Server**.
3.  Paste the URL, transport, and credential.
4.  Save. AVCodex calls `tools/list` and shows the tools your server exposed.
5.  Enable the tools you want the agent to use, write a short description for each, and test.

### [Configuration Fields# ](#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.

* * *

## [Troubleshooting# ](#troubleshooting)

### [Common Issues# ](#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/list` directly 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# ](#testing-your-server)

Use curl to hit your MCP server directly:

bash 

```
# 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":{}}}'
```

* * *

## [Resources# ](#resources)

**AVCodex Documentation:**

-   [Consumer OAuth for Custom MCP Servers](/docs/guides/consumer-oauth-mcp). 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:**

-   [MCP Specification](https://modelcontextprotocol.io/specification/2025-06-18)
-   [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
-   [Python SDK](https://github.com/modelcontextprotocol/python-sdk)

**Example Repositories:**

-   [REST-to-MCP Adapter](https://github.com/pawneetdev/rest-to-mcp-adapter). Auto-generate tools from OpenAPI specs.
-   [Postgres MCP Pro](https://github.com/crystaldba/postgres-mcp). Production-ready database server.
-   [DBHub](https://github.com/bytebase/dbhub). Multi-database gateway.

**Deployment Guides:**

-   [Google Cloud Run Tutorial](https://cloud.google.com/blog/topics/developers-practitioners/build-and-deploy-a-remote-mcp-server-to-google-cloud-run-in-under-10-minutes)
-   [Cloudflare Workers Guide](https://developers.cloudflare.com/agents/guides/remote-mcp-server/)

\*AVCodex · Your AV expertise. Amplified by AI.\*

Was this helpful? 

[Edit this page →](#)

[

Previous

Alchemist Pipeline Permutations

](/docs/guides/alchemist-pipeline-permutations)[

Next

Consumer OAuth for Custom MCP Servers

](/docs/guides/consumer-oauth-for-custom-mcp-servers)

On this page

-   [What is MCP?](#what-is-mcp)
-   [How MCP Works](#how-mcp-works)
-   [tools/list](#tools-list)
-   [tools/call](#tools-call)
-   [Choosing an SDK](#choosing-an-sdk)
-   [Quick Start: Your First MCP Server](#quick-start-your-first-mcp-server)
-   [Project Setup](#project-setup)
-   [Minimal Server Code](#minimal-server-code)
-   [Pattern 1: REST API Adapter](#pattern-1-rest-api-adapter)
-   [When to Use This Pattern](#when-to-use-this-pattern)
-   [Complete Example: Site and Room Asset API](#complete-example-site-and-room-asset-api)
-   [Authentication Pass-Through](#authentication-pass-through)
-   [Pattern 2: Database Server](#pattern-2-database-server)
-   [Complete Example: PostgreSQL Server for AV Asset Data](#complete-example-postgresql-server-for-av-asset-data)
-   [Tool Design Best Practices](#tool-design-best-practices)
-   [Tool Count Guidelines](#tool-count-guidelines)
-   [Adding Authentication](#adding-authentication)
-   [Bearer Token Authentication](#bearer-token-authentication)
-   [API Key Authentication](#api-key-authentication)
-   [Deployment Options](#deployment-options)
-   [Cloud Run Deployment](#cloud-run-deployment)
-   [Development with ngrok](#development-with-ngrok)
-   [Security Checklist](#security-checklist)
-   [Connecting to AVCodex](#connecting-to-avcodex)
-   [Configuration Fields](#configuration-fields)
-   [Troubleshooting](#troubleshooting)
-   [Common Issues](#common-issues)
-   [Testing Your Server](#testing-your-server)
-   [Resources](#resources)

[](/)

The AI platform built exclusively for professional AV. Build, deploy, and sell AI tools that understand your industry.

### Platform

-   What You Can Build
-   Templates
-   [Pricing](/pricing)

### Services

-   [Done-For-You](/pricing)
-   [Academy](/academy)
-   [Contact](/contact)

### Company

-   About
-   [The Signal](/blog)
-   [Docs](/docs)
-   [LinkedIn](#)

© 2026 AVCodex. A Future Ready Holdings Inc. product. SOC 2 Type II Certified · HIPAA Compliant