Connecting My Local AI to GrowthBook with MCP (Part 6) — hero banner

Connecting My Local AI to GrowthBook with MCP (Part 6)

September 06, 2026·6 min read

I use GrowthBook feature flags for the site's interactive demos. One flag selects a scene, another controls an effect, and targeting rules can change what a particular visitor sees. I wanted to ask the site's chat a question like “What is dotc-fx set to?” and have it look up the answer.

The model was already running locally through Ollama, with the Cloudflare Tunnel from Part 3 connecting it to the site. I added a Docker service that exposes GrowthBook reads through Model Context Protocol, then wired the chat's Pages Functions to call it.

The first flag lookup returned FIRE. Getting that value into a normal chat answer involved flag evaluation, two routes through the same tunnel, and a couple of differences between testing in Node and running in Cloudflare Pages.

How the Request Travels

Model Context Protocol, or MCP, lets an application discover a server's tools and call them using defined input schemas. My server offers operations for listing permitted flags and evaluating a flag by key.

The Pages Function acts as the MCP client. It discovers those tools and includes their definitions in the request to Ollama. When the model asks for a flag lookup, Pages calls the MCP server, adds the result to the conversation, and asks the model to produce the answer.

Browser: Neon GPT or Station Intelligence
                 |
                 v
Cloudflare Pages: /api/llm or /api/ask
       |                           |
       v                           v
llm.chrishouse.io/v1     llm.chrishouse.io/mcp
       |                           |
       +---- Cloudflare Access ----+
                     |
           Existing Cloudflare Tunnel
                     |
              Desktop at home
              /             \
      Ollama :11434      Docker MCP :8792
      Qwen model               |
                               v
                      GrowthBook SDK endpoint

The site's article search still supplies background from posts, and Redis memory supplies conversation context. The flag tool adds a lookup at the time of the question. That matters for a value I can change in GrowthBook after publishing an article about it.

Building the MCP Server

I used Microsoft's MCP builder agent skill as a guide for the tool definitions, validation, and transport. The service is a TypeScript application under services/home-mcp/ in the blog repository, with its own dependencies, tests, and Dockerfile.

It uses the MCP TypeScript SDK's v1.x line and Streamable HTTP. Each request gets a fresh server and transport instance, with JSON responses. That gives the Pages Function an HTTP endpoint it can call with fetch.

The server registers three read operations:

Tool Result
growthbook_list_flags A paginated list of permitted flag keys and their evaluated values
growthbook_get_flag The evaluated value of one permitted flag
house_get_capabilities The service's configured integrations and availability

Each tool has a Zod input schema and an output schema. Results include structured JSON and a text representation. Both the server and the Pages client enforce allowlists: the server limits which flags can be read, and Pages limits which tool names it will execute.

The GrowthBook connection uses the public SDK payload and allows ten existing dotc-* flags. The service only needs read access to the configuration used by those demos.

Evaluating a Flag

A GrowthBook flag can have targeting rules and experiment assignments, so returning its defaultValue would miss part of the configuration. I pass the payload and attributes through the GrowthBook SDK evaluator.

The default evaluation context is:

{
  "id": "station-ai",
  "plan": "free"
}

The tools also accept id, plan, and dotId attributes. For example, the color demo targets by dotId. To investigate what a particular dot sees, the lookup needs that dot's identifier. The default context gives a consistent answer when the question supplies only a flag key.

This abbreviated response came from a local call using the real GrowthBook SDK endpoint:

{
  "key": "dotc-fx",
  "value": "FIRE",
  "on": true,
  "source": "force",
  "attributes": {
    "id": "station-ai",
    "plan": "free"
  }
}

The attributes and source help explain the returned value. They also make it possible to compare evaluations for different targeting contexts in the Flag Lab.

The full response includes fetched_at, when the service fetched the payload, and source_updated_at, the timestamp supplied by GrowthBook. The service caches payloads for fifteen seconds. After that, a failed refresh produces an error. Missing flags and keys outside the allowlist also produce errors, while a flag evaluated to false remains a successful lookup.

Running the Service in Docker

The Compose definition is in docker-compose.mcp.yml. These are the relevant settings:

services:
  home-mcp:
    build: ./services/home-mcp
    container_name: chrishouse-home-mcp
    restart: unless-stopped
    init: true
    env_file: .env.home-mcp
    ports:
      - "127.0.0.1:8792:8792"
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]

The Docker image compiles the TypeScript and runs the application as the non-root node user. Docker publishes port 8792 on the host's loopback interface, where the local cloudflared service can reach it. The container has no host filesystem or Docker socket mounted.

From the repository root:

node scripts/configure-home-mcp.mjs
docker compose -f docker-compose.mcp.yml up --build -d
npm --prefix services/home-mcp ci
npm --prefix services/home-mcp run verify

The setup helper creates a random bearer token in the ignored .env.home-mcp file and writes the matching token and local URL to .dev.vars. The verification command checks the running MCP service.

For local development, Gatsby serves the frontend on port 8000 and the Pages runtime serves the API on 8788. Restart the Pages process after changing .dev.vars so it loads the new credentials.

Reusing the Existing Tunnel

Ollama already had a route at llm.chrishouse.io/v1. I added /mcp to the same hostname and pointed it at the Docker service. The relevant ingress rules are:

ingress:
  - hostname: llm.chrishouse.io
    path: ^/mcp/?$
    service: http://127.0.0.1:8792
    originRequest:
      httpHostHeader: localhost:8792

  - hostname: llm.chrishouse.io
    path: ^/v1/.*
    service: http://localhost:11434
    originRequest:
      httpHostHeader: localhost:11434

  - hostname: llm.chrishouse.io
    service: http_status:403

  # Other hostname rules remain above the final catch-all.
  - service: http_status:404

Cloudflared matches rules in order. The /mcp rule belongs above the hostname's 403 rule. This is an excerpt from a shared tunnel configuration; its other hostname routes stay in place.

I validated the configuration and restarted the Windows service to load it. The commands, run from Administrator PowerShell, are:

cloudflared tunnel --config "$env:USERPROFILE\.cloudflared\config.yml" ingress validate
Restart-Service Cloudflared

Pages uses HOME_MCP_URL and HOME_MCP_TOKEN for the MCP connection. The repository's helper can upload those two secrets to the site's Pages project:

node scripts/configure-home-mcp.mjs --cloudflare

Cloudflare Access supplies the outer authentication layer. Because MCP and Ollama share an origin here, the Pages client reuses the existing Ollama Access service credentials. The MCP server also checks its own bearer token, validates the host, and rejects browser Origin headers. Both sets of credentials stay in Pages Functions; the browser sends its chat requests to the site's API.

Connecting the Chat

Neon GPT enables the tool flow with home_tools: true in its request to /api/llm. Station Intelligence uses the same integration in /api/ask, alongside article retrieval.

The tool loop lives in functions/api/lib/home-mcp.js. It initializes MCP, discovers permitted tools, and translates their schemas into the tool definitions used by the chat completion API. A requested tool call goes through the client's allowlist before execution. Its result then becomes another message in the model's conversation.

I limited the loop to three calls per round and two rounds before the final answer. Individual MCP requests have an eight-second timeout, with a two-minute deadline around the completion flow. If the service is unavailable, the model receives that failure so it can explain why a current flag value could not be retrieved.

Qwen Sometimes Returns the Tool Request as Text

During testing, qwen2.5-coder:14B sometimes put this JSON in the assistant's text instead of the API's tool_calls field:

{
  "name": "growthbook_get_flag",
  "arguments": {
    "key": "dotc-fx"
  }
}

The original handler displayed the JSON and never performed the lookup. I added support for a whole-message JSON tool request whose name matches an offered tool. It follows the same validation and execution path as a native tool call. Requiring the entire message to match keeps ordinary prose with embedded JSON examples out of that path.

Pages Exposed a Fetch Difference

The MCP client initially used redirect: "error". Its Node test passed, but the Pages runtime rejected that fetch option. Switching to redirect: "manual" and explicitly rejecting redirects fixed the request in Pages.

This also handles an Access login redirect as a connection failure. The MCP client stops at that response instead of following it with the bearer token. Sending a request through the local Pages runtime caught the issue that the Node test had missed.

A Flag Lookup Through the Chat API

With Docker, Ollama, and the local Pages runtime running, a request to /api/llm returned this abbreviated response:

{
  "content": "The current value of the `dotc-scene` flag is `\"fx\"`.",
  "backend": "house",
  "home_tools": {
    "status": "connected",
    "calls": [
      { "name": "growthbook_get_flag", "ok": true }
    ]
  }
}

The home_tools.calls entry records the successful lookup alongside the answer. A request through Station Intelligence's /api/ask also called growthbook_get_flag and returned FIRE for dotc-fx.

The repository includes a chat verification script for exercising the integration with Ollama:

node services/home-mcp/scripts/verify-chat.mjs "Read the current dotc-fx flag using your tool."

For a flag question, I can now trace the answer through the tool call to its evaluated value and attributes. If the result differs from what a demo displays, those attributes are the first place to look: the tool's default station-ai context and the visitor's context can match different GrowthBook rules.

Enjoyed this post? Give it a clap!

SeriesSelf-Hosting an LLM
Part 6 of 6

Comments