Every developer has apps that don't talk to each other. You copy data from one tool, paste it into another, switch tabs, repeat. What if your AI assistant could do that for you — read from your app, process the data, and write it back — all from a single prompt?

In this article, we will build a custom MCP (Model Context Protocol) server in TypeScript that connects an AI tool to a real Firebase-backed web application. We'll create CRUD tools, test them independently using MCP Inspector, and register the server locally so that an AI assistant can use it end-to-end. Before starting, I assume you have basic knowledge of TypeScript and Firebase/Firestore.

What is MCP?

MCP — Model Context Protocol is an open standard by Anthropic that lets AI tools (like Claude, Copilot, or any MCP-compatible client) communicate with external systems through a defined set of "tools." Think of it as a bridge between your AI assistant and your existing applications.

What is MCP Inspector?

MCP Inspector is a browser-based debugging tool that lets you test your MCP server without connecting it to any AI client. You can see available tools, run them manually, and inspect responses — similar to how Postman works for REST APIs.

The Architecture

MCP has three parts:

Client — The AI tool you use (Claude Desktop, Cowork, VS Code). It decides which tool to call based on context.

Server — The middleware you build. It exposes tools like "list items," "get details," "update record." This is what we're building today.

App — Your data layer. In our case, a Firebase web app with Firestore as the database.

  
    Client (AI Tool) ←→ MCP Server ←→ Firestore ←→ Your Web App
  

Prerequisites

Step 1: Create the MCP Server Skeleton

Let's start by creating a new project and installing dependencies.

  
    mkdir content-board-mcp && cd content-board-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod firebase-admin
npm install -D typescript tsx @types/node
  

Now, create src/index.ts — this is our entry point.

  
    import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new McpServer({
  name: "content-board-mcp",
  version: "1.0.0",
});

// Health check tool — verifies the server skeleton works
server.tool("ping", "Check if MCP server is running", {}, async () => ({
  content: [{ type: "text", text: "pong — server is alive" }],
}));

const transport = new StdioServerTransport();
await server.connect(transport);

Note: We're using stdio transport, which means the server communicates through standard input/output — ideal for local development. For remote/cloud deployment, you'd use HTTP+SSE transport instead. That's a separate topic.

Step 2: Test with MCP Inspector

Before writing any real tools, let's verify the skeleton works. Run MCP Inspector:

  
    npx @modelcontextprotocol/inspector npx tsx src/index.ts
  

This opens a browser UI. Click "Connect," then navigate to "Tools." You should see the ping tool. Run it, and you'll get back "pong — server is alive."

Note: This is the development pattern you'll use throughout — define a tool, test in Inspector, iterate. No need to connect to Claude or any AI client during development.

Step 3: Connect to Firestore

Create a .env file with your Firebase service account path:

  
    GOOGLE_APPLICATION_CREDENTIALS=./path-to-your-service-account-key.json
  

Now, create src/firestore.ts to initialize the connection:

  
    import { initializeApp, cert } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";

const app = initializeApp({
  credential: cert(process.env.GOOGLE_APPLICATION_CREDENTIALS!),
});

export const db = getFirestore(app);
  

Note: Never commit your service account key to Git. Add the key file path to .gitignore immediately.

Step 4: Build the List Tool

Now, add src/tools/list-content.ts — our first real tool:

  
    import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { db } from "../firestore.js";

export function registerListContent(server: McpServer) {
  server.tool(
    "list_content",
    `List content items from Content Board.
    Returns: id, title, status, phase, description (truncated to 200 chars).
    Use this to browse what's in the pipeline or filter by status.
    Supported statuses: idea, draft, in-progress, published.
    This tool is READ-ONLY — it will never modify data.`,
    {
      status: z.string().optional().describe("Filter by content status"),
    },
    async ({ status }) => {
      let query = db.collection("contents").orderBy("order");
      if (status) {
        query = query.where("status", "==", status) as any;
      }
      const snapshot = await query.get();
      const items = snapshot.docs.map((doc) => ({
        id: doc.id,
        title: doc.data().title,
        status: doc.data().status,
        phase: doc.data().phase,
        description: doc.data().description?.substring(0, 200),
      }));
      return {
        content: [{ type: "text", text: JSON.stringify(items, null, 2) }],
      };
    }
  );
}
  

Import and register it in index.ts :

  
    import { registerListContent } from "./tools/list-content.js";

// After server creation, before transport connection
registerListContent(server);
  

Now restart MCP Inspector — but here's where it gets interesting.

Note: MCP Inspector runs in its own process. Environment variables from your .env file aren't automatically available to Inspector. You need to add them in Inspector's environment configuration panel. This is a common gotcha — the server will fail to connect to Firestore if the credential path is missing from Inspector's environment.

Step 5: The Most Important Part — Tool Descriptions

Here's what most MCP tutorials skip. The tool description isn't documentation — it's an instruction for the AI client.

When your AI assistant sees available tools, it reads descriptions to decide which tool to call, what parameters to pass, and what to expect back. A vague description leads to wrong tool calls and unpredictable behavior.

Good tool description includes:

This is the single most impactful design decision in your MCP server.

Step 6: Add Remaining Tools

Follow the same pattern for get_content , create_content , and update_content . Each tool gets its own file, its own clear description, and its own Zod schema for input validation.

For the update_content tool, a key design choice — only update fields that are explicitly provided:

  
    server.tool(
  "update_content",
  `Update specific fields of a content item.
  Only provided fields will be updated — others remain unchanged.
  Requires content_id. At least one field must be provided.
  Returns the updated content summary.
  This tool MODIFIES data.`,
  {
    content_id: z.string().describe("Firestore document ID"),
    title: z.string().optional(),
    description: z.string().optional(),
    status: z.string().optional(),
    shootingScript: z.string().optional(),
  },
  async (params) => {
    const { content_id, ...fields } = params;
    const updateData = Object.fromEntries(
      Object.entries(fields).filter(([_, v]) => v !== undefined)
    );
    await db.collection("contents").doc(content_id).update(updateData);
    return {
      content: [{ type: "text", text: `Updated ${Object.keys(updateData).join(", ")} for ${content_id}` }],
    };
  }
);
  

Test each tool in MCP Inspector. Verify the data matches what you see in your actual app. Only move on once every tool returns correct results.

Step 7: Register Locally and Use It

Build the project:

  
    npx tsc
  

Then add your server to claude_desktop_config.json (found in your user's AppData/Roaming/Claude folder on Windows, or ~/Library/Application Support/Claude on Mac):

  
    {
  "mcpServers": {
    "content-board": {
      "command": "node",
      "args": ["C:/path/to/content-board-mcp/dist/index.js"],
      "env": {
        "GOOGLE_APPLICATION_CREDENTIALS": "C:/path/to/service-account-key.json"
      }
    }
  }
}
  

Restart your AI client. The server appears as a connector. Now any prompt can use your tools.

The Result

One prompt to my AI assistant: "Here's the video ID — update the shooting script in Content Board."

The assistant called get_content to load the current data, then update_content to write the script. Content Board reflected the change on page refresh. Read, process, update — one prompt, zero copy-paste.

Summary

MCP is a bridge — three parts (Client, Server, App), and your job is building the Server. Tool descriptions are the most important design decision — they're instructions for the AI, not documentation for humans. The development pattern is always the same: define tools, test in Inspector, register locally, use.

If you're a .NET developer, the MCP SDK supports C# — the exact same architecture applies. Swap Firestore for your data layer of choice and follow the same pattern. In a follow-up article, I'll walk through building an MCP server in C# with SQL Server.

Both repos used in this demo are open source: