# Building Custom MCP Servers

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?

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

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.

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

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

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

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

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.

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

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

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

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

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.

---

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

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

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

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

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

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

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

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

| 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

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

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

**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.*
