ServicesWorkJournalAboutContact
Start a project
Back to Selected Work

Building a Self-Hosted Google MCP Server for Claude

ClientInternal
ServicesMCP
StatZero third-party data hops
Building a Self-Hosted Google MCP Server for Claude

The problem

Every AI agent conversation about SEO or marketing eventually hits the same wall: the model has no idea what your Search Console numbers actually say. You end up exporting a CSV, pasting a screenshot, or copying rows into the chat by hand. It works, but it breaks the moment you want the agent to check something on its own, or run the same report every week without you doing the export again.

The Model Context Protocol (MCP) solves the connection problem in theory: it gives an AI client a standard way to call tools on a remote server. But almost every hosted MCP option for Google services either asks you to hand over a shared API key, or routes your Search Console and Gmail data through a third party's infrastructure before it reaches your AI client. For a Google Workspace with real client data sitting in Drive and Gmail, that is not a tradeoff worth making.

So the brief for this project was narrow: one MCP server, self-hosted, where each person logs in with their own Google account, sees only their own data, and nothing Google-related ever leaves the box we control.

What Googleflow MCP is

Googleflow MCP is a remote MCP server, written in TypeScript on Node.js, that exposes Google Search Console, Google Analytics 4, Google Drive, and Gmail as callable tools. Claude, Cursor, or any other MCP client connects to a single HTTPS endpoint, authenticates through a real Google OAuth login, and from that point on can call tools like gsc_search_analytics or gmail_search the same way it would call any local function.

It ships in two tiers. Phase 1, always on, covers the four services above with 14 tools. Phase 2, Google Ads and Google Business Profile, is gated behind environment variables and switches on the moment the developer token or quota approval comes through, no redeploy required.

Why self-host instead of using an existing connector

There were three non-negotiables going in, and none of them are satisfied by a shared, multi-tenant SaaS MCP:

Per-user data isolation.

If two people connect to the server, person A must never be able to see person B's Search Console properties or Gmail. That rules out any setup where one Google service account or one shared token covers everyone.

Tokens that never leave the server.

The AI client should never hold a raw Google access token. If a client library logs its tool call arguments, or a provider's infrastructure gets compromised, a leaked Google token is a much worse outcome than a leaked scoped session token that only works against our own /mcp endpoint.

Ownership of the failure mode.

When something breaks, whether that's a Google API quota, an expired token, or a scope mismatch, we needed to be the ones who can see the logs and fix it, not waiting on a vendor's support queue.

Those three requirements point in one direction: run the OAuth authorization server yourself, store nothing you cannot decrypt, and put a real per-user auth boundary between the AI client and the Google APIs.

Architecture

1784713058815 MCPclientandGoogleOAuth2.1flow

The server plays two roles at once. It is an OAuth 2.1 authorization server for MCP clients (issuing its own access and refresh tokens), and it is an OAuth client to Google (holding the actual Google tokens). The two token types never mix. An MCP client only ever holds our bearer token; Google's access and refresh tokens sit encrypted in SQLite and are never returned in any API response.

Stack: Express, the official @modelcontextprotocol/sdk, googleapis, better-sqlite3, and zod for input validation on every tool. Deployed as a single Docker container behind Caddy for automatic TLS, on a VPS we already run other infrastructure on.

Transport: Streamable HTTP in stateless mode. Every POST to /mcp builds a brand-new McpServer instance and a fresh transport, tied to the authenticated user for that single request, then tears both down when the response closes. There is no long-lived session object sitting in memory per connected client. It costs a small amount of per-request setup, and buys a server that restarts cleanly and never leaks state between users.

Auth boundary: Google OAuth login happens once per user. The consent screen requests the minimum Google scopes needed for whichever services are enabled (webmasters.readonly, analytics.readonly, drive, and the three Gmail scopes for Phase 1; adwords and business.manage are added automatically once Phase 2 is switched on). PKCE with S256 is mandatory on every authorization request, including from the MCP client side, matching the OAuth 2.1 spec MCP is built on.

Token storage: Google access and refresh tokens are encrypted with AES-256-GCM using a 32-byte key generated once at deploy time (openssl rand -base64 32), stored in SQLite alongside a per-record IV and auth tag. googleapis's OAuth2 client auto-refreshes expired access tokens in the background; a tokens event listener writes the refreshed token straight back to the encrypted store, so a user's session survives well past the 1-hour access token lifetime without them noticing.

Technical decisions worth explaining

Dynamic Client Registration (RFC 7591), not a fixed client list. MCP clients register themselves at /register before ever hitting /authorize. This is what lets Claude.ai, Claude Code, and any future MCP client connect without us hardcoding a client ID for each one. The tradeoff is that /register has to validate redirect_uris itself (HTTPS only, with an explicit loopback exception for local development clients), since there is no manual approval step in between.

Stateless MCP sessions. The alternative was a persistent SSE-style session, matching one long-lived connection to one user. We went stateless instead: sessionIdGenerator: undefined on the transport, a new server per request. For a tool-calling workload where each call is naturally its own request/response, this is simpler to reason about and removes an entire class of "which user does this open connection belong to" bugs. The real cost shows up as two 405 responses on GET and DELETE /mcp, since there is no session to resume or close.

Scope minimization over one-size-fits-all. googleScopes() in config.ts only requests adwords if ADS_DEVELOPER_TOKEN is set, and only requests business.manage if GBP_ENABLED is true. A user who connects before Phase 2 goes live never sees an Ads or GBP consent prompt they don't need, and doesn't have to grant scopes for tools that don't exist yet.

SQLite over a hosted database. With one server, one Docker volume, and traffic measured in individual users, not thousands of concurrent sessions, a hosted Postgres instance would have added an external dependency and a bill for no real benefit. better-sqlite3 is synchronous and fast enough that it never shows up as a bottleneck at this scale, and the entire dataset backs up with one docker compose cp.

Challenges

Google's verification and CASA requirement. Using the broad drive scope and the restricted Gmail scopes (gmail.readonly, gmail.compose, gmail.send) in production means Google requires app verification plus an annual CASA security assessment before opening the app to real users outside a 100-person testing allowlist. That is weeks of process and real cost, not a checkbox. The workaround documented for client-facing deployments is to drop the Gmail scopes from googleScopes() entirely, or swap the drive scope for drive.file, which only grants access to files the app itself created or opened. That trims functionality but avoids CASA outright for teams that don't need full mailbox and full Drive access from day one.

Port collisions on a shared VPS. Ports 3000 through 3003 were already in use by other services on the same box. The container listens on 3000 internally but the Docker Compose file maps it to host port 3004, with Caddy or an existing nginx reverse proxy handling the public-facing 443. It's a small detail, but it's the kind of thing that costs an hour of confusion the first time a health check silently fails against the wrong port.

No in-place key rotation. ENCRYPTION_KEY is not rotatable without invalidating every stored Google token. Rotating it means every connected user has to reconnect and re-consent. That's an accepted limitation for now: the key is generated once at deploy time and treated as effectively permanent unless there's a compromise, in which case forcing a full reconnect is the correct response anyway.

Streaming responses through a reverse proxy. Because the transport is Streamable HTTP, proxy_buffering off has to be set explicitly on the nginx config for teams not using the bundled Caddy container, along with a longer proxy_read_timeout. Missing this doesn't fail loudly, it just makes tool calls hang until the proxy's default timeout kills them.

Implementation walkthrough

A new user connecting from Claude Desktop goes through this sequence:

  1. Claude registers itself as an OAuth client at /register, gets back a client_id.

  2. Claude opens /authorize with that client_id, a PKCE code_challenge, and its own redirect_uri. The server checks the redirect URI against what was registered, then redirects to Google's consent screen with the scopes appropriate for whichever phase is active.

  3. The user logs into Google and approves. Google redirects back to /oauth/google/callback with an authorization code.

  4. The server exchanges that code for Google tokens, fetches the user's email via userinfo, checks it against ALLOWED_DOMAINS if that's configured, encrypts and stores the Google tokens, and issues its own short-lived authorization code back to Claude.

  5. Claude exchanges that code at /token, presenting the original PKCE verifier. The server checks the verifier against the stored challenge, and only then issues an access token (1 hour) and refresh token scoped to that user.

  6. Every subsequent /mcp call carries that bearer token. requireAuth middleware resolves it to a userId, and buildMcpServer(userId) registers the Phase 1 (and, if enabled, Phase 2) tools bound to that specific user's Google credentials for that one request.

Each tool handler follows the same shape: validate input with a zod schema, call the relevant Google API through the per-user authenticated client, and return either a JSON-serialized result or a structured error, using two small shared helpers (ok() and fail()) so every tool's success and failure format is consistent regardless of which Google API is underneath it.

Current state and results

As of this build, Googleflow MCP has 14 tools live across Search Console, GA4, Drive, and Gmail, running in production behind our own domain, with the full OAuth 2.1 and PKCE flow passing real connections from Claude Desktop and Claude Code. It's in daily use internally for pulling live Search Console and GA4 numbers into reporting work instead of exporting CSVs by hand, and it's the same deployment used to onboard the first pilot clients who wanted Claude connected to their own Google Workspace without sharing credentials.

Google Ads and Business Profile tools (4 more, ads_list_accounts, ads_query, gbp_list_accounts, gbp_list_locations) are fully written and tested against the API, waiting on Google's own approval process: a developer token application for Ads, and a quota increase request for Business Profile. Both switch on with an environment variable and a restart once approved, with no code changes needed.

What's planned next

  • Phase 2 activation. Get the Ads developer token approved and Business Profile quota granted, then flip both on for the accounts already connected. Anyone who authenticated before Phase 2 goes live will need to reconnect once so their consent covers the new scopes, since Google doesn't let you silently add scopes to an existing grant.

  • Reducing the Gmail verification burden for client rollouts. Building a lighter deployment profile that drops the restricted Gmail scopes and swaps drive for drive.file by default for new client instances, so most teams can be onboarded without waiting on Google's CASA review, with the full-scope version reserved for teams that specifically need whole-mailbox or whole-Drive access.

  • Basic usage logging. Right now the server logs errors to stdout and nothing else. The next useful addition is a lightweight table tracking which tool was called, by which user, and whether it succeeded, mostly to catch quota exhaustion or a broken scope before a client notices it themselves.

  • Encryption key rotation. A proper rotation path (decrypt with the old key, re-encrypt with the new one, on a scheduled maintenance window) instead of the current all-or-nothing "everyone reconnects."

  • More Google services as real use cases show up. Calendar and Google Chat are the two next-most-requested internally, but neither is being added speculatively. The pattern established here, one file per service under src/tools/, one scope addition in config.ts, one registration line in mcpServer.ts, makes adding a new service a contained, predictable change rather than a rewrite.

Takeaways for anyone building something similar

Building an OAuth 2.1 authorization server from scratch sounds heavier than it is once you separate the two token relationships clearly: your server's tokens to the MCP client, and Google's tokens to your server. Mixing those two up is the mistake that leads to a client holding a raw Google token it should never see.

Going stateless on the MCP transport removed more bugs than it added latency. For a tool-calling workload, a request-scoped server instance is easier to reason about than session lifecycle management, right up until you need something session-shaped like a long streaming subscription, which this use case never did.

And the Google verification and CASA process is the real cost center in a project like this, not the code. Anyone scoping a similar build for a client should budget that timeline explicitly, and default to the narrowest scopes (drive.file over drive, no Gmail scopes unless genuinely needed) unless the use case demands otherwise.

Share Case Study