Google Gemini MCP Server for SMB Business Intelligence
A turnkey MCP server that lets SMB teams use natural language to query their business data from HubSpot, Stripe, and Google Workspace via a secure Gemini-powered interface.
Small business teams waste hours switching between apps to find sales metrics, invoices, and calendar events. They need a single natural language interface that understands their data across tools without building complex integrations.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This recipe builds a multi-tenant MCP server that lets small business teams query their CRM, billing, and calendar data through a natural language interface powered by Google Gemini. The server exposes eight MCP tools across HubSpot, Stripe, and Google Calendar, with budget enforcement and per-tenant isolation built in.
You’ll wire up the core MCP server, define business tools with typed handlers, connect a chat API route to Gemini, and set up observability with Langfuse. By the end you’ll have a runnable Next.js application serving both the MCP protocol endpoint and a chat endpoint.
Prerequisites
Node.js 22 or later (node --version confirms)
pnpm 10 (pnpm --version confirms)
HubSpot access token (private app token from your HubSpot developer account)
Stripe secret key (from the Stripe dashboard, sk_test_... or sk_live_...)
Google Cloud project with the Calendar API enabled and a service account credential file
Supabase project with the Supabase CLI authenticated
Langfuse account (cloud.langfuse.com) for observability, or set the env vars to empty strings to run without it
Gemini API key (GEMINI_API_KEY) or Vertex AI credentials (GOOGLE_GENAI_USE_VERTEXAI=true)
Basic familiarity with TypeScript, Next.js App Router, and REST APIs
Step 1: Configure environment variables
Copy .env.example to .env.local and fill in your credentials. Each integration has a dedicated section. The project validates all required variables at startup using Zod — if a required variable is missing, the server fails fast with a descriptive error.
terminal
cp .env.example .env.local
Open .env.local and set each value:
env
NODE_ENV=development# HubSpot — private app access tokenHUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx# Stripe — secret key from the Stripe dashboardSTRIPE_SECRET_KEY=sk_tes...xxxx# Google Cloud — project ID and Vertex AI modeGOOGLE_CLOUD_PROJECT=your-gcp-project-idGOOGLE_CLOUD_LOCATION=us-central1GOOGLE_GENAI_USE_VERTEXAI=true# Supabase — found in Project Settings > APISUPABASE_URL=https://your-project.supabase.coSUPABASE_SECRET_KEY=your-anon-or-service-role-key# Langfuse — from cloud.langfuse.com > Settings > API KeysLANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxLANGFUSE_SECRET_KEY=sk-lf-...xxxxLANGFUSE_BASE_URL=https://cloud.langfuse.com# Gemini — API key from aig.google.comGEMINI_API_KEY=AIza...# Tenant config — directory loaded by the gateway coreTENANT_CONFIG_PATH=./tenants
The server also reads PORT (defaults to 8080) and LOG_LEVEL (defaults to info).
Step 2: Add package dependencies
The project already pins all dependencies in package.json. If you need to add a new package, pin it to an exact version and run pnpm install. The key packages for this recipe:
Create src/lib/env.ts. It uses Zod to parse and validate process.env at startup. If any required variable is missing, the server throws before accepting any requests.
Create src/lib/pricing-provider.ts. It estimates the cost of a Gemini call based on token counts. The validateEstimateArgs method throws a descriptive TypeError before any arithmetic touches null or NaN.
Create src/lib/budget-enforcer.ts. It wraps the spend store, budget controller, and budget interceptor into a single entry point used by the chat route.
Create src/lib/tool-handlers/hubspot.ts. The HubSpot client uses a named import from @hubspot/api-client. Note the after: "0" string in search calls — HubSpot’s TypeScript examples use a string, not a number.
Create src/lib/tool-handlers/google-calendar.ts. Google Calendar uses google.auth.GoogleAuth with application default credentials. The service account credentials file should be set via GOOGLE_APPLICATION_CREDENTIALS in your environment, or the GCP metadata server provides credentials when running on Google Cloud.
Create src/lib/tool-handlers/index.ts. This factory constructs handler instances only when the corresponding env var is set. If HUBSPOT_ACCESS_TOKEN is missing, the HubSpot handler is null, and the MCP tool returns an error response at call time rather than crashing at startup.
ts
import { HubSpotHandler } from './hubspot.js';import { StripeHandler } from './stripe.js';import { GoogleCalendarHandler } from './google-calendar.js';interface EnvConfig { HUBSPOT_ACCESS_TOKEN?: string; STRIPE_SECRET_KEY?: string; GOOGLE_CLOUD_PROJECT?: string; [key: string]: unknown;}interface ToolHandlers { hubspot: HubSpotHandler | null; stripe: StripeHandler | null; calendar: GoogleCalendarHandler | null;}export function createToolHandlers(env: EnvConfig): ToolHandlers { return { hubspot: env.HUBSPOT_ACCESS_TOKEN ? new HubSpotHandler(env.HUBSPOT_ACCESS_TOKEN) : null, stripe: env.STRIPE_SECRET_KEY ? new StripeHandler(env.STRIPE_SECRET_KEY) : null, calendar: env.GOOGLE_CLOUD_PROJECT ? new GoogleCalendarHandler() : null, };}
Step 6: Define MCP tools
Create src/lib/tools-definitions.ts. It exports registerAllTools() and getAllToolDefinitions(). The latter returns a Vercel AI SDK-compatible tool shape so the chat route can pass tools directly to generateText.
ts
import { textContent } from '@reaatech/mcp-server-core';import type { ToolContext, ToolResponse } from '@reaatech/mcp-server-core';import { z } from 'zod';import { createToolHandlers } from './tool-handlers/index.js';export interface EnvConfig { HUBSPOT_ACCESS_TOKEN?: string; STRIPE_SECRET_KEY?: string; GOOGLE_CLOUD_PROJECT?: string; [key: string]: unknown;}export interface ToolDefinition { name: string; description
Step 7: Set up tenant resolution and auth
Create src/lib/tenant-resolver.ts. It loads tenants from YAML files in the ./tenants/ directory using loadTenantsAsync() from @reaatech/mcp-gateway-core, then resolves JWT tokens via Supabase Auth.
ts
import { createClient } from "@supabase/supabase-js";import { loadTenantsAsync, getTenant, type TenantConfig as GatewayTenantConfig } from "@reaatech/mcp-gateway-core";import type { TenantContext } from "./types.js";function toTenantConfig(gateway: GatewayTenantConfig): TenantContext["tenant"] { return { tenantId: gateway.tenantId, displayName: gateway.displayName, auth: gateway.auth, rateLimits: gateway.rateLimits, cache: gateway.cache, allowlist: gateway.allowlist, upstreams: gateway.upstreams, };}export class TenantResolver { private supabase: ReturnType<typeof createClient>; constructor(supabaseUrl: string, supabaseKey: string) { this.supabase = createClient(supabaseUrl, supabaseKey); } async initialize(): Promise<void> { await loadTenantsAsync(); } async resolveTenant(jwtToken: string): Promise<TenantContext> { const { data, error } = await this.supabase.auth.getUser(jwtToken); if (error) { throw new Error(`Authentication failed: ${error.message}`); } const user = data.user; const tenantIdEntry = (user.app_metadata as Record<string, unknown>).tenant_id; if (typeof tenantIdEntry !== "string") { throw new Error("No tenant_id in user metadata"); } const tenant = getTenant(tenantIdEntry); if (!tenant) { throw new Error("tenant not found"); } const rawScopes = (user.app_metadata as Record<string, unknown>).scopes; const scopes: string[] = Array.isArray(rawScopes) ? rawScopes.filter((s): s is string => typeof s === "string") : []; return { tenantId: tenantIdEntry, userId: user.id, scopes, tenant: toTenantConfig(tenant), }; } resolveTenantFromHeader(tenantIdHeader: string): TenantContext | null { const tenant = getTenant(tenantIdHeader); if (!tenant) return null; return { tenantId: tenantIdHeader, userId: "anonymous", scopes: [], tenant: toTenantConfig(tenant), }; }}
Create src/lib/auth-middleware.ts. It extracts the Authorization: Bearer token and falls back to the x-tenant-id header when no token is present.
ts
import { type NextRequest } from "next/server";import { TenantResolver } from "./tenant-resolver.js";import type { TenantContext } from "./types.js";let resolver: TenantResolver | null = null;function getResolver(): TenantResolver { if (!resolver) { resolver = new TenantResolver( process.env.SUPABASE_URL ?? "", process.env.SUPABASE_SECRET_KEY ?? "", ); } return resolver;}export function extractTenantFromHeaders(req: NextRequest): string | null { return req.headers.get("x-tenant-id");}export async function authenticateRequest(req: NextRequest): Promise<TenantContext> { const authHeader = req.headers.get("authorization"); if (!authHeader) { const tenantId = extractTenantFromHeaders(req); if (tenantId) { const r = getResolver(); const ctx = r.resolveTenantFromHeader(tenantId); if (ctx) return ctx; } throw new Error("No authentication provided"); } const token = authHeader.replace(/^Bearer\s+/i, "").trim(); if (!token) { throw new Error("Empty Bearer token"); } const r = getResolver(); await r.initialize(); return r.resolveTenant(token);}
Step 8: Create the MCP server factory
Create src/lib/mcp-server-factory.ts. It uses @modelcontextprotocol/sdk’s McpServer class to register all defined tools and exposes a factory for creating server instances.
ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';import { getTools, registerAllTools, type EnvConfig } from './tools-definitions.js';export function buildToolContext(sessionId?: string): import('@reaatech/mcp-server-core').ToolContext { return { request: { requestId: crypto.randomUUID(), sessionId, }, session: sessionId ? { id: sessionId, createdAt: Date.now(), lastAccessedAt: Date.now() } : undefined, };}export function adaptContent(content: unknown[]): CallToolResult["content"] { return content.map((block) => { const b = block as Record<string, unknown>; if (b.type === "text" && typeof b.text === "string") { return { type: "text" as const, text: b.text }; } return { type: "text" as const, text: JSON.stringify(b) }; });}export function createMcpServerInstance(): McpServer { registerAllTools(process.env as EnvConfig); const server = new McpServer({ name: "google-gemini-mcp-server-for-smb-business-intelligence", version: "0.1.0", }); const tools = getTools(); for (const tool of tools) { server.registerTool( tool.name, { description: tool.description, inputSchema: tool.inputSchema, }, async (args: Record<string, unknown>, extra: Record<string, unknown>): Promise<CallToolResult> => { const sessionId = typeof extra.sessionId === 'string' ? extra.sessionId : undefined; const result = await tool.handler(args, buildToolContext(sessionId)); return { content: adaptContent(result.content), isError: result.isError }; }, ); } return server;}
Step 9: Wire the API routes
Create app/api/mcp/route.ts. It uses the MCP SDK’s WebStandardStreamableHTTPServerTransport directly since we are in a Next.js App Router environment, not Express. The transport manages sessions and handles the JSON-RPC protocol.
ts
import { NextResponse } from "next/server";import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";import { createMcpServerInstance } from "../../../src/lib/mcp-server-factory.js";const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();function getSessionId(req: Request): string | null { return req.headers.get("mcp-session-id");}export async function POST(req: Request): Promise<Response> { try { const sessionId = getSessionId(req); let body: unknown; try { body = await req.json(); } catch { return NextResponse.json( { jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }, { status: 400 }, ); } let transport: WebStandardStreamableHTTPServerTransport | undefined; if (sessionId && transports.has(sessionId)) { transport = transports.get(sessionId); } else if (!sessionId && typeof body === "object" && body !== null && isInitializeRequest(body as Record<string, unknown>)) { transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (sid: string) => { if (transport) transports.set(sid, transport); }, }); const server = createMcpServerInstance(); await server.connect(transport); } if (!transport) { return NextResponse.json( { jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: No valid session ID provided" }, id: null }, { status: 400 }, ); } return await transport.handleRequest(req, { parsedBody: body }); } catch { return NextResponse.json( { jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }, { status: 500 }, ); }}export async function DELETE(req: Request): Promise<Response> { const sessionId = req.headers.get("mcp-session-id"); if (!sessionId || !transports.has(sessionId)) { return NextResponse.json({ ok: false, error: "Session not found" }, { status: 404 }); } const transport = transports.get(sessionId); if (!transport) { return NextResponse.json({ ok: false, error: "Session not found" }, { status: 404 }); } try { await transport.handleRequest(req); } finally { transports.delete(sessionId); try { await transport.close(); } catch { // best effort cleanup } } return NextResponse.json({ ok: true }, { status: 200 });}
Create app/api/chat/route.ts. It authenticates the request, checks the budget, calls Gemini with the tool definitions, and records the actual spend afterward.
ts
import { type NextRequest, NextResponse } from "next/server";import { generateText } from "ai";import { google } from "@ai-sdk/google";import { z } from "zod";import { authenticateRequest } from "../../../src/lib/auth-middleware.js";import { getAllToolDefinitions } from "../../../src/lib/tools-definitions.js";import { GeminiPricingProvider } from "../../../src/lib/pricing-provider.js";import { BudgetEnforcer } from "../../../src/lib/budget-enforcer.js";import { BudgetScope } from "../../../src/lib/budget-types.js";import { DEFAULT_MODEL, MAX_TOKENS } from "../../../src/lib/constants.js";
Step 10: Set up observability
Create src/lib/logger.ts using the gateway core’s child logger:
Create src/lib/observability.ts. It initializes Langfuse if the required env vars are present; otherwise it logs a warning and continues without crashing.
ts
import { Langfuse } from "langfuse";import { logger } from "@reaatech/mcp-gateway-core";export function initObservability(): void { const publicKey = process.env.LANGFUSE_PUBLIC_KEY; const secretKey = process.env.LANGFUSE_SECRET_KEY; const baseUrl = process.env.LANGFUSE_BASE_URL ?? "https://cloud.langfuse.com"; if (!publicKey || !secretKey) { logger.warn("Langfuse env vars not set — observability disabled"); return; } try { new Langfuse({ publicKey, secretKey, baseUrl }); logger.info("Langfuse initialized"); } catch (err) { logger.warn({ err }, "Failed to initialize Langfuse"); }}
Create src/instrumentation.ts. This enables the Next.js instrumentation hook so observability initializes once at startup in Node.js environments. Without instrumentationHook: true in next.config.ts, this file is dead code.
ts
export async function register(): Promise<void> { if (process.env.NEXT_RUNTIME !== "nodejs") return; try { const { initObservability } = await import("./lib/observability.js"); initObservability(); } catch { // Observability is optional }}
Update next.config.ts to enable the instrumentation hook:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, serverComponentsExternalPackages: ['@modelcontextprotocol/sdk'], },};export default nextConfig;
Step 11: Run the server
Install dependencies and run the dev server:
terminal
pnpm installpnpm dev
Expected output: Ready followed by the local URL (typically http://localhost:3000).
Send a chat request to test the full flow. The chat route accepts POST /api/chat with a JSON body containing messages and tenantId. If you pass an Authorization: Bearer *** header, the server resolves the tenant from Supabase. Otherwise it falls back to x-tenant-id.
Expected output: an initialize response from the MCP server. Subsequent requests to /api/mcp include the mcp-session-id header returned in the first response.
Next steps
Add tenant YAML config files under ./tenants/ so loadTenantsAsync() resolves real tenant configs with rateLimits, cache, and allowlist settings.
Replace the placeholder app/page.tsx with a real chat UI — the /api/chat route already accepts the shape the AI SDK UI components send.
Extend the budget controller with persistent storage (Redis) instead of the in-memory SpendStore so budget state survives server restarts.
Add tools/discoverTools() auto-discovery for a future plugin directory of additional MCP tools.
Wire Langfuse traces into the chat route to capture full request/response traces for evaluation and debugging.
if (!handlers.calendar) return { content: [textContent("Google Calendar not configured")], isError: true };
return handlers.calendar.listEvents(args.calendarId as string | undefined, args.timeMin as string | undefined, args.timeMax as string | undefined, args.maxResults as number | undefined);
const systemPrompt = "You are a business intelligence assistant for SMB teams. You help users query their business data from HubSpot, Stripe, and Google Calendar. Be concise and data-driven.";