●SIRI — WWDC 2026 confirms the revamped Siri runs on a Google Gemini model, though it won't ship in the EU at iOS 27 due to the DMA●FLASH3.5 — Gemini 3.5 Flash is now GA, the top Flash model for sustained frontier performance on agentic and coding tasks●IMAGE-GA — Gemini 3.1 Flash Image and 3.1 Pro Image are GA as native visual models; the preview versions shut down Jun 25●MANAGED-AGENTS — Managed Agents launch in public preview in the Gemini API, running autonomous agents in Google-hosted isolated Linux sandboxes●FILE-SEARCH — File Search now supports multimodal search, with native image embedding and retrieval via gemini-embedding-2●DEPRECATION — gemini-3.1-flash-image-preview and gemini-3-pro-image-preview shut down Jun 25 — migrate to the GA models soon●SIRI — WWDC 2026 confirms the revamped Siri runs on a Google Gemini model, though it won't ship in the EU at iOS 27 due to the DMA●FLASH3.5 — Gemini 3.5 Flash is now GA, the top Flash model for sustained frontier performance on agentic and coding tasks●IMAGE-GA — Gemini 3.1 Flash Image and 3.1 Pro Image are GA as native visual models; the preview versions shut down Jun 25●MANAGED-AGENTS — Managed Agents launch in public preview in the Gemini API, running autonomous agents in Google-hosted isolated Linux sandboxes●FILE-SEARCH — File Search now supports multimodal search, with native image embedding and retrieval via gemini-embedding-2●DEPRECATION — gemini-3.1-flash-image-preview and gemini-3-pro-image-preview shut down Jun 25 — migrate to the GA models soon
Building Custom MCP Servers for Gemini API — Extending AI Agents with TypeScript
Learn how to build custom Model Context Protocol (MCP) servers in TypeScript and integrate them with Gemini API. Covers architecture, authentication, error handling, and production deployment patterns.
Setup and context — Why MCP Is Reshaping AI Agent Development
Model Context Protocol (MCP) is a standard protocol for connecting LLM applications with external tools and data sources. Originally proposed by Anthropic, it has gained rapid adoption across the AI ecosystem — including Google's Gemini CLI and numerous developer tools.
Before MCP, giving an AI agent the ability to interact with external tools required building custom adapters for each integration. MCP solves this by establishing a universal contract: if a tool provider publishes an MCP server, any MCP-compatible AI client can use it immediately.
In this guide, we'll build a custom MCP server from scratch in TypeScript and integrate it with Gemini API. This isn't a basic tutorial — we cover authentication, rate limiting, error handling, and production deployment patterns that you'll need for real-world applications.
MCP Architecture Overview
MCP is built on JSON-RPC 2.0 and consists of three primary components:
MCP Host: The AI application itself (Gemini CLI, Claude Code, etc.)
MCP Client: The component within the host that communicates with servers
MCP Server: The service that provides tools, resources, and prompts
Communication happens over stdio (standard I/O) or SSE (Server-Sent Events) / Streamable HTTP. Stdio works well for local execution, while SSE and HTTP are suited for remote servers.
The Three MCP Primitives
Tools — Functions the LLM can invoke (API calls, DB queries, etc.)
Resources — Data sources the LLM can read (files, DB records, etc.)
Prompts — Reusable prompt templates
Tools are the most frequently used primitive for AI agents. They're conceptually similar to Gemini API's Function Calling — MCP tool definitions map directly to Gemini's functionDeclarations. If you're new to Function Calling, we recommend reading our Gemini API Function Calling Practical Guide first.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Fully understand MCP server internals and the communication protocol architecture
✦Build production-quality custom MCP servers from scratch using TypeScript
✦Master agent design patterns that integrate Gemini API Function Calling with MCP tools
Secure payment via Stripe · Cancel anytime
Setting Up the Development Environment
Required Packages
# Initialize the projectmkdir gemini-mcp-server && cd gemini-mcp-servernpm init -y# MCP SDK and dependenciesnpm install @modelcontextprotocol/sdk zodnpm install -D typescript @types/node tsx# Gemini API client (for testing)npm install @google/genai
Before diving into code, it's worth establishing the design principles that separate well-built MCP servers from ones that frustrate both AI models and developers.
Write Descriptions for LLMs, Not Humans
The tool descriptions you write are consumed by the LLM to decide when and how to call each tool. Generic descriptions like "manages tasks" won't give the model enough context to make good decisions. Instead, be explicit about what the tool does, what inputs it expects, and what it returns.
A poor description: "Gets data from the database." A good description: "Search tasks matching given criteria. Returns all tasks when no filters are applied. Results include task ID, title, status, priority, assignee, and timestamps."
Design for Composability
Individual tools should do one thing well. Rather than building a monolithic "manage_project" tool that handles creation, updates, queries, and deletion, break it into focused tools like create_task, query_tasks, update_task_status, and delete_task. This gives the LLM maximum flexibility to compose multi-step workflows.
Handle Failures Gracefully
MCP tools should never throw unhandled exceptions. Every failure path should return a structured error response with the isError flag set to true and a human-readable message. This allows the LLM to understand what went wrong and potentially retry with corrected parameters.
Keep State Minimal
MCP servers can maintain internal state, but be cautious about it. Since clients may disconnect and reconnect, any critical state should be persisted to a database rather than held in memory. Design your tools to be as stateless as possible — each invocation should be self-contained.
Building a Basic MCP Server
Let's start with a minimal MCP server to understand the fundamentals. We'll build a weather information tool as our first example.
Server Skeleton
// src/server.tsimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";// Create server instanceconst server = new McpServer({ name: "weather-tools", version: "1.0.0", capabilities: { tools: {}, // Provide tools resources: {} // Can also provide resources }});// Define a toolserver.tool( "get_weather", // Tool name "Get current weather for a specified city", // Description { city: z.string().describe("City name (e.g., Tokyo, New York)"), unit: z.enum(["celsius", "fahrenheit"]) .default("celsius") .describe("Temperature unit") }, async ({ city, unit }) => { // Actual API call (mocked here) const weatherData = await fetchWeatherAPI(city, unit); return { content: [{ type: "text", text: JSON.stringify(weatherData, null, 2) }] }; });// Start the serverasync function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("[weather-tools] MCP server running on stdio");}main().catch(console.error);
Designing Tool Responses
MCP tool responses are returned as content arrays. They can include multiple content types — text, images, and embedded resources.
// src/types/index.tsimport { z } from "zod";// Task schema definitionexport const TaskSchema = z.object({ id: z.string().uuid(), title: z.string().min(1).max(200), description: z.string().optional(), status: z.enum(["todo", "in_progress", "done", "blocked"]), priority: z.enum(["low", "medium", "high", "critical"]), assignee: z.string().optional(), dueDate: z.string().datetime().optional(), tags: z.array(z.string()).default([]), createdAt: z.string().datetime(), updatedAt: z.string().datetime()});export type Task = z.infer<typeof TaskSchema>;// Tool input parameter schemasexport const CreateTaskInput = z.object({ title: z.string().describe("Task title"), description: z.string().optional().describe("Detailed description"), priority: z.enum(["low", "medium", "high", "critical"]) .default("medium") .describe("Priority level"), assignee: z.string().optional().describe("Assignee user ID"), dueDate: z.string().optional().describe("Due date (ISO 8601 format)"), tags: z.array(z.string()).default([]).describe("List of tags")});export const QueryTasksInput = z.object({ status: z.enum(["todo", "in_progress", "done", "blocked"]) .optional() .describe("Filter by status"), priority: z.enum(["low", "medium", "high", "critical"]) .optional() .describe("Filter by priority"), assignee: z.string().optional().describe("Filter by assignee"), limit: z.number().min(1).max(100).default(20) .describe("Maximum number of results")});
Tool Implementation — Task Management
// src/tools/tasks.tsimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { CreateTaskInput, QueryTasksInput } from "../types/index.js";import { db } from "../db/client.js";import { randomUUID } from "crypto";export function registerTaskTools(server: McpServer) { // Create task server.tool( "create_task", "Create a new task. Title is required.", CreateTaskInput.shape, async (params) => { const task = { id: randomUUID(), ...params, status: "todo" as const, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; await db.tasks.insert(task); return { content: [{ type: "text" as const, text: `Task created:\n${JSON.stringify(task, null, 2)}` }] }; } ); // Query tasks server.tool( "query_tasks", "Search tasks matching given criteria. Returns all tasks when no filters are applied.", QueryTasksInput.shape, async (params) => { const tasks = await db.tasks.find({ ...(params.status && { status: params.status }), ...(params.priority && { priority: params.priority }), ...(params.assignee && { assignee: params.assignee }) }, { limit: params.limit }); return { content: [{ type: "text" as const, text: tasks.length > 0 ? `Found ${tasks.length} tasks:\n${JSON.stringify(tasks, null, 2)}` : "No tasks found matching the given criteria." }] }; } ); // Update task status server.tool( "update_task_status", "Update the status of an existing task.", { taskId: CreateTaskInput.shape.title, newStatus: QueryTasksInput.shape.status.unwrap() }, async ({ taskId, newStatus }) => { const updated = await db.tasks.updateStatus(taskId, newStatus); if (!updated) { return { isError: true, content: [{ type: "text" as const, text: `Task with ID "${taskId}" not found.` }] }; } return { content: [{ type: "text" as const, text: `Updated task "${updated.title}" status to "${newStatus}".` }] }; } );}
Authentication and Security
Authentication is essential for production deployments. MCP supports OAuth 2.0-based authentication when using SSE or HTTP transports.
Bearer Token Authentication Middleware
// src/middleware/auth.tsimport { Request, Response, NextFunction } from "express";interface AuthConfig { apiKeys: Set<string>; rateLimitPerMinute: number;}const requestCounts = new Map<string, { count: number; resetAt: number }>();export function createAuthMiddleware(config: AuthConfig) { return (req: Request, res: Response, next: NextFunction) => { // Validate API key const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { return res.status(401).json({ error: "Authorization header with Bearer token required" }); } const apiKey = authHeader.slice(7); if (!config.apiKeys.has(apiKey)) { return res.status(403).json({ error: "Invalid API key" }); } // Rate limit check const now = Date.now(); const record = requestCounts.get(apiKey); if (record && record.resetAt > now) { if (record.count >= config.rateLimitPerMinute) { return res.status(429).json({ error: "Rate limit exceeded", retryAfter: Math.ceil((record.resetAt - now) / 1000) }); } record.count++; } else { requestCounts.set(apiKey, { count: 1, resetAt: now + 60_000 }); } next(); };}
Exposing via SSE Transport
// src/remote-server.tsimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";import express from "express";import { createAuthMiddleware } from "./middleware/auth.js";const app = express();const server = new McpServer({ name: "project-manager", version: "1.0.0"});// Register tools (registerTaskTools etc.)// registerTaskTools(server);// Authentication middlewareapp.use("/mcp", createAuthMiddleware({ apiKeys: new Set(process.env.MCP_API_KEYS?.split(",") ?? []), rateLimitPerMinute: 60}));// SSE endpointapp.get("/mcp/sse", async (req, res) => { const transport = new SSEServerTransport("/mcp/messages", res); await server.connect(transport);});app.post("/mcp/messages", async (req, res) => { // Message handling});app.listen(3001, () => { console.log("[project-manager] MCP server listening on port 3001");});
Integrating with Gemini API
With your MCP server built, the next step is connecting it to Gemini API. While Gemini CLI natively supports MCP, using it through the API requires a bridge layer.
Pattern 1: Direct Connection via Gemini CLI
The simplest approach is registering your MCP server in the Gemini CLI settings.json:
When Gemini CLI launches, it automatically discovers your MCP server's tools and makes them available as Function Calling targets.
Pattern 2: API-Level MCP Tool Bridge
When calling Gemini API programmatically, you need a bridge that converts MCP tools into Gemini's functionDeclarations format.
// src/bridge/gemini-mcp-bridge.tsimport { GoogleGenAI } from "@google/genai";import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";class GeminiMCPBridge { private genai: GoogleGenAI; private mcpClient: Client; constructor(apiKey: string) { this.genai = new GoogleGenAI({ apiKey }); this.mcpClient = new Client( { name: "gemini-bridge", version: "1.0.0" } ); } async connect(command: string, args: string[]) { const transport = new StdioClientTransport({ command, args }); await this.mcpClient.connect(transport); } // Convert MCP tools to Gemini Function Declarations async getGeminiFunctionDeclarations() { const { tools } = await this.mcpClient.listTools(); return tools.map(tool => ({ name: tool.name, description: tool.description ?? "", parameters: this.convertJsonSchemaToGemini(tool.inputSchema) })); } // Run the agent loop async run(userMessage: string) { const functions = await this.getGeminiFunctionDeclarations(); const model = "gemini-2.5-pro"; let messages = [{ role: "user" as const, parts: [{ text: userMessage }] }]; const tools = [{ functionDeclarations: functions }]; while (true) { const response = await this.genai.models.generateContent({ model, contents: messages, config: { tools } }); const candidate = response.candidates?.[0]; if (!candidate) break; // If function calls exist, forward them to the MCP server const functionCalls = candidate.content.parts?.filter( (p: any) => p.functionCall ); if (!functionCalls || functionCalls.length === 0) { // Text response — exit the loop const text = candidate.content.parts ?.map((p: any) => p.text) .join(""); return text; } // Execute MCP tool calls and collect results messages.push({ role: "model" as const, parts: candidate.content.parts }); const functionResponses = []; for (const part of functionCalls) { const { name, args } = (part as any).functionCall; const result = await this.mcpClient.callTool({ name, arguments: args ?? {} }); functionResponses.push({ functionResponse: { name, response: { content: result.content .map((c: any) => c.text) .join("\n") } } }); } messages.push({ role: "user" as const, parts: functionResponses }); } } private convertJsonSchemaToGemini(schema: any) { // JSON Schema → Gemini parameter format conversion return { type: "object", properties: schema.properties ?? {}, required: schema.required ?? [] }; }}// Usage exampleasync function main() { const bridge = new GeminiMCPBridge("YOUR_GEMINI_API_KEY"); await bridge.connect("npx", ["tsx", "./src/server.ts"]); const result = await bridge.run( "Create 3 high-priority tasks and tag each with 'AI'" ); console.log(result);}
This bridge pattern enables Gemini API to seamlessly invoke MCP tools through an agent loop.
Pattern 3: Google ADK with MCP Integration
If you're building a more complex agent system, Google's Agent Development Kit (ADK) provides built-in MCP support. ADK handles the tool discovery, parameter marshaling, and agent loop management automatically.
// Using ADK's MCP tool integrationimport { Agent } from "@google/adk";import { MCPToolset } from "@google/adk/toolsets";const mcpToolset = new MCPToolset({ connectionParams: { command: "npx", args: ["tsx", "./src/server.ts"] }});const tools = await mcpToolset.getTools();const agent = new Agent({ name: "project-assistant", model: "gemini-2.5-pro", instruction: "You are a project management assistant. Use the available tools to help users manage their tasks efficiently.", tools});
ADK abstracts away the bridge layer entirely, making it the recommended approach for production multi-agent systems where you need orchestration capabilities beyond simple tool calling.
Error Handling and Retry Strategies
Production environments demand robust error handling for network failures, timeouts, and unexpected input.
Open http://localhost:5173 in your browser to browse the tool catalog, test tools with custom parameters, and verify responses — all through a visual interface.
Unit Testing
// tests/tools/tasks.test.tsimport { describe, it, expect, beforeEach } from "vitest";import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";import { createServer } from "../src/server.js";describe("Task Tools", () => { let client: Client; beforeEach(async () => { const server = createServer(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); client = new Client({ name: "test", version: "1.0.0" }); await client.connect(clientTransport); }); it("should create a task", async () => { const result = await client.callTool({ name: "create_task", arguments: { title: "Test task", priority: "high" } }); expect(result.isError).toBeFalsy(); const text = (result.content[0] as any).text; expect(text).toContain("Test task"); }); it("should return error for invalid input", async () => { const result = await client.callTool({ name: "create_task", arguments: { title: "" } // Empty string is invalid }); expect(result.isError).toBeTruthy(); });});
Implementing MCP Resources
While Tools are the primary mechanism for agent interactions, Resources enable your MCP server to expose structured data that the LLM can read contextually. Think of resources as read-only data endpoints.
Resources are particularly useful when you want the AI to have access to contextual information without requiring explicit tool calls. The LLM client can proactively read resources to inform its responses and tool usage decisions.
Proper logging is essential for debugging MCP servers in production. Since MCP uses stderr for server-side logging (stdout is reserved for the protocol), you need structured logging that doesn't interfere with the transport layer.
You can also expose a get_server_logs tool that lets the AI model itself diagnose issues by reading recent log entries — a powerful pattern for self-healing agent systems.
Real-World Use Cases
To illustrate the practical value of custom MCP servers, here are several real-world scenarios where this architecture shines:
Internal Developer Tools: Build an MCP server that wraps your CI/CD pipeline, allowing developers to trigger builds, check deployment status, and roll back releases through their AI assistant. The server validates permissions, manages environment-specific configurations, and provides structured feedback about build outcomes.
Customer Support Intelligence: Create an MCP server that connects to your CRM, ticket system, and knowledge base. Support agents using AI assistants can instantly pull customer history, search for similar resolved tickets, and draft responses — all through natural language conversations.
Data Pipeline Orchestration: Wrap your ETL workflows in an MCP server that lets data engineers trigger pipeline runs, inspect data quality metrics, and investigate failures. The structured tool responses make it easy for the AI to reason about complex pipeline dependencies and suggest remediation steps.
Content Management: Build a server that integrates with your CMS, image processing service, and publishing workflow. Content teams can ask the AI to draft posts, resize images, schedule publications, and check SEO scores — all through a single conversational interface.
Each of these use cases follows the same pattern: identify repetitive workflows that involve multiple systems, encapsulate the integrations behind well-defined MCP tools, and let the AI model orchestrate the workflow based on natural language instructions.
Summary
Building custom MCP servers is one of the most powerful ways to extend AI agent capabilities. Here's what we covered:
MCP architecture (host, client, server) and its three primitives (Tools, Resources, Prompts)
Type-safe tool definitions using TypeScript and Zod
Production patterns for authentication, rate limiting, and error handling
Gemini API integration via direct CLI connection and the bridge pattern
Testing strategies and Docker deployment
The MCP ecosystem is expanding rapidly, and the ability to build custom servers is becoming an increasingly valuable skill. For foundational MCP setup, check our Gemini × MCP Server Integration Guide, and for multi-agent architectures, see the Google ADK × Python Multi-Agent Development Guide. Start with a small tool server, iterate, and gradually expand its capabilities.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.