GC-301d · Module 3

Building for Gemini CLI

4 min read

Building a custom MCP server starts with the MCP SDK — @modelcontextprotocol/sdk for TypeScript or the mcp Python package. The server is a process that speaks JSON-RPC over stdin/stdout. You register tools (functions Gemini can call), resources (data Gemini can read), and prompts (templates Gemini can invoke). For Gemini CLI specifically, tools are the primary integration surface. Resources and prompts are supported but tools are where the interaction happens.

Gemini-specific design considerations differentiate a good custom server from a generic one. First, tool descriptions matter enormously — Gemini uses the description to decide when and how to call a tool. Write descriptions as if explaining the tool to a junior developer: what it does, when to use it, what the parameters mean, and what the output format is. Second, Gemini's 1M context window means tool responses can be large without truncation — but they should not be. Return structured, concise data. A 50-line JSON response is better than a 5,000-line dump.

Error handling in custom MCP servers must be explicit. When a tool call fails, return a structured error with a clear message — not a stack trace. Gemini interprets the error message and decides what to do next. A message like "Database connection refused — check DATABASE_URL environment variable" helps Gemini self-diagnose. A raw ECONNREFUSED stack trace does not.

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

const server = new McpServer({
  name: "internal-api",
  version: "1.0.0",
  description: "Tools for interacting with the internal company API"
});

// Tool with detailed description for Gemini
server.tool(
  "get_customer",
  {
    description: "Fetch a customer record by ID or email. Returns name, plan, MRR, and last activity date. Use this when you need customer details for analysis or debugging.",
    customerId: z.string().optional().describe("Customer UUID"),
    email: z.string().optional().describe("Customer email address"),
  },
  async ({ customerId, email }) => {
    if (!customerId && !email) {
      return { content: [{ type: "text", text: "Error: Provide either customerId or email" }], isError: true };
    }
    try {
      const customer = await fetchCustomer({ customerId, email });
      return { content: [{ type: "text", text: JSON.stringify(customer, null, 2) }] };
    } catch (err) {
      return { content: [{ type: "text", text: `Error: ${(err as Error).message}` }], isError: true };
    }
  }
);

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