Building MCP servers with Google Gemini 2.0

Every time you wire an external tool to an LLM, you end up writing the same glue code twice: a JSON schema telling the model what arguments to pass, and a custom dispatcher that parses the model's tool call, runs the function, and formats the response. Switch from Claude to Gemini or move the tool into another service, and you rewrite the adapter layer all over again.

Anthropic's Model Context Protocol turns that tool boundary into a standard client-server interface over stdio or HTTP. The MCP server hosts the tool definitions and executes handlers, while the MCP client discovers those schemas and bridges them to whichever model you run. Any function you can write in TypeScript, whether it queries a database, processes a local PDF, calls a REST API, or controls hardware, only needs to be wrapped once.

Below we will build a TypeScript MCP server around Brave Search and wire it into Google's gemini-2.0-pro-exp-02-05 model. The complete runnable project is in the MCP Gemini Demo Repository, built on top of the official MCP client documentation.

Initialize the Bun project

Install the MCP TypeScript SDK and Google's Generative AI package with Bun. You can inspect the full package API and release history in the MCP SDK Repository.

mkdir mcp-gemini
cd mcp-gemini
bun init -y
bun add @modelcontextprotocol/sdk@^1.7.0 @google/generative-ai

Define the tool schema and handler

An MCP tool definition has three fields: a unique name, a description telling the model when to call it, and a declarative inputSchema written in JSON Schema. Our server exposes brave_web_search for general web queries and brave_local_search for local business lookups:

// Web Search Tool Definition (simplified)
export const WEB_SEARCH_TOOL: Tool = {
  name: "brave_web_search",
  description: "Performs a web search using the Brave Search API...",
  inputSchema: {
    type: "object",
    properties: {
      query: {
        type: "string",
        description: "Search query",
      },
      count: {
        type: "number",
        description: "Number of results (1-20, default 10)",
        default: 10,
      },
      // Other parameters...
    },
    required: ["query"],
  },
};

When a client calls the tool, the server passes request.params.arguments into your handler as unknown. Always validate the incoming arguments before calling your upstream API, because models occasionally omit required fields or coerce numbers into strings:

// Web search handler (simplified)
export async function webSearchHandler(args: unknown) {
  // Validate arguments
  if (!isValidArgs(args)) {
    throw new Error("Invalid arguments");
  }

  const { query, count } = args;
  
  // Call your API, function, or any code you want
  const results = await performWebSearch(query, count);

  // Return formatted results in MCP response format
  return {
    content: [{ type: "text", text: results }],
    isError: false,
  };
}

Wire the MCP server handlers

The Server class from @modelcontextprotocol/sdk connects your schemas to your handler functions through two request schemas: ListToolsRequestSchema for tool discovery and CallToolRequestSchema for routing each tool invocation by name. Adding a new capability later only takes registering its schema in the tools array and adding a branch to the switch statement:

// Create MCP server (simplified)
export function createServer() {
  const server = new Server(
    {
      name: SERVER_CONFIG.name,
      version: SERVER_CONFIG.version,
    },
    {
      capabilities: {
        tools: {
          tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL],
        },
      },
    }
  );

  // Register handlers
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL],
  }));

  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
    
    // Route to appropriate handler
    switch (name) {
      case "brave_web_search":
        return await webSearchHandler(args);
      case "brave_local_search":
        return await localSearchHandler(args);
      default:
        // Handle unknown tools
    }
  });

  return server;
}

Verify the server with a stdio client

Before putting an LLM in front of the server, test tool discovery and execution directly with StdioClientTransport. This spawns bun index.ts as a subprocess, connects over stdin and stdout, lists the registered schemas, and runs a live search call:

// Create MCP client and connect (simplified)
const transport = new StdioClientTransport({
  command: "bun",
  args: ["index.ts"], 
});

const client = new Client(
  { name: "brave-search-demo-client", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

// Connect to server
await client.connect(transport);

// List available tools
const { tools } = await client.listTools();

// Call a tool
const result = await client.callTool({
  name: "brave_web_search",
  arguments: {
    query: "latest AI research papers",
    count: 3,
  },
});

Connect the MCP client to Gemini 2.0

To let Gemini invoke the MCP server, pass the tool schemas into functionDeclarations when initializing gemini-2.0-pro-exp-02-05. When a user prompt needs fresh web results, Gemini returns a functionCall payload containing the chosen tool name and structured args. Forward that payload to client.callTool, then pass the tool output back to Gemini so it can synthesize the final answer:

// Configure Gemini with function declarations (simplified)
const model = googleGenAi.getGenerativeModel({
  model: "gemini-2.0-pro-exp-02-05",
  tools: [
    {
      functionDeclarations: [
        {
          name: "brave_web_search",
          description: "Search the web using Brave Search API",
          parameters: {
            // Schema matching our MCP tool
          },
        },
        // Other tools...
      ],
    },
  ],
});

// Process user queries with Gemini and MCP tools
async function processQuery(userQuery) {
  // Generate a response with Gemini
  const result = await model.generateContent({
    contents: [{ role: "user", parts: [{ text: userQuery }] }],
  });

  // Check if Gemini wants to call a function
  if (hasFunctionCall(result)) {
    const functionCall = extractFunctionCall(result);
    
    // Call our MCP tool
    const searchResults = await client.callTool({
      name: functionCall.name,
      arguments: functionCall.args,
    });

    // Send function results back to Gemini for final response
    return await generateFinalResponse(userQuery, functionCall, searchResults);
  }

  return result.text();
}

Google's Gemini function calling documentation covers multi-turn tool calls and parameter constraints in detail.

Run the examples locally

Export your Brave Search and Google AI Studio API keys in your shell:

export BRAVE_API_KEY="your_brave_api_key"
export GOOGLE_API_KEY="your_google_api_key"

Run either the standalone stdio client or the Gemini tool-calling script with Bun:

# Basic client
bun examples/basic-client.ts

# Gemini integration
bun examples/gemini-tool-function.ts

To expose other backends such as database queries, file parsers, or internal APIs, follow the same three-step pattern and compare against the reference servers in the MCP examples catalog.