Skip to main content
Glama
daviiiL

gmail-mcp-server-typescript

by daviiiL
README.md
# gmail-mcp-server-typescript

An authenticating proxy for Google's **remote** Gmail MCP server.

Google's Gmail MCP server is a managed service at
`https://gmailmcp.googleapis.com/mcp/v1` — there is nothing to self-host. It
authenticates with OAuth 2.0, and access tokens expire hourly. OpenHuman's MCP
client can hold a static credential but cannot refresh an OAuth one, and its
dynamic OAuth path requires RFC 7591 dynamic client registration, which Google
does not offer.

This proxy bridges that gap: it holds a long-lived **refresh token**, mints
access tokens, and injects them upstream. Downstream it speaks plain MCP, so the
client authenticates with static credentials that never expire.

```
OpenHuman --Basic--> Nginx Proxy Manager --> gmail-mcp-proxy:8080 --Bearer--> Google
                      (both containers on the shared `npm` network)
```

Design rationale, including the OpenHuman source constraints this is built
around: [`docs/superpowers/specs/2026-08-08-gmail-mcp-proxy-design.md`](docs/superpowers/specs/2026-08-08-gmail-mcp-proxy-design.md).

## Setup

### 1. Google Cloud

```bash
gcloud services enable gmail.googleapis.com     --project=PROJECT_ID
gcloud services enable gmailmcp.googleapis.com  --project=PROJECT_ID
```

Configure the OAuth consent screen with scopes:

- `https://www.googleapis.com/auth/gmail.readonly`
- `https://www.googleapis.com/auth/gmail.compose`

**Set the publishing status to "In production".** While it is *Testing* +
*External*, Google expires refresh tokens after **7 days** and this proxy will
break weekly. Verification is not required — you click through an
"unverified app" warning once during consent.

Create an OAuth 2.0 Client ID of type **Web application**, and note the client
ID and secret.

### 2. Configure (local only)

```bash
cp .env.example .env
$EDITOR .env     # fill in GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
```

### 3. Mint the refresh token

One-time, using the credentials already in `.env`:

```bash
# Register http://localhost:8080/callback on the OAuth client first
pnpm mint
```

It prints an authorization URL, waits on a throwaway local callback server,
exchanges the code, and prints the refresh token on its own line for easy
copying. Paste it into `.env` as `GOOGLE_REFRESH_TOKEN`.

The script only ever **reads** `.env` — it never writes to it.

| Flag / var | Effect |
| --- | --- |
| `--open` | Launch a browser at the authorization URL |
| `PORT` | Callback port (default 8080) — must match the registered redirect URI |
| `ENV_FILE` | Read a different env file |

The script sets `access_type=offline` and `prompt=consent`. Both matter:
without the first Google returns no refresh token at all, and without the
second it only issues one on the *first* authorization for a given
client+account — so a re-run appears to succeed while producing nothing
usable. If you still get no refresh token, revoke at
[myaccount.google.com/permissions](https://myaccount.google.com/permissions)
and run it again.

Alternatively use the [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/)
(gear icon → "Use your own OAuth credentials"), registering
`https://developers.google.com/oauthplayground` as the redirect URI instead.

### 4. Build the image

```bash
docker build -t gmail-mcp-proxy:latest .
```

### 5. Create the shared network

Nginx Proxy Manager runs in its own container, so both need a network they can
address each other on. Once, on the host:

```bash
docker network create npm
```

Then attach the NPM container to it as well — add `npm` to its `networks:` and
redeploy, or `docker network connect npm <npm-container>`.

### 6. Deploy on TrueNAS

`docker-compose.yml` is written for **TrueNAS SCALE → Apps → Custom App**.
Paste it in, having replaced the three `REPLACE_ME` values.

Three things about that file are deliberate:

- **Values are literal, not `${...}`.** TrueNAS has no `.env` beside the YAML
  to interpolate from, so a `${GOOGLE_CLIENT_ID}` would resolve to an empty
  string and the container would exit at boot.
- **There is no `build:` key.** Custom apps have no build context; the image
  must already exist on the host, hence step 4.
- **The published port is loopback-only.** `127.0.0.1:40001:8080` is a
  debugging door on the NAS itself, not the path NPM uses — NPM is in its own
  container and reaches the proxy over the shared network as
  `http://gmail-mcp-proxy:8080`. See *Security notes* before widening it.

Verify once it's up, from the NAS:

```bash
curl localhost:40001/healthz     # => {"ok":true}
```

`.env` is only used by local development (`pnpm dev`) and `pnpm mint`. It plays
no part in the deployed container.

## Environment variables

| Variable | Required | Default | Purpose |
| --- | --- | --- | --- |
| `GOOGLE_CLIENT_ID` | **yes** | — | OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | **yes** | — | OAuth client secret |
| `GOOGLE_REFRESH_TOKEN` | **yes** | — | Long-lived token minted in step 3 |
| `UPSTREAM_URL` | no | `https://gmailmcp.googleapis.com/mcp/v1` | Google's MCP endpoint |
| `PORT` | no | `8080` | Port inside the container |
| `BIND_HOST` | no | `127.0.0.1` | Interface to bind. Defaults to loopback so a bare `pnpm dev` is never exposed. **Containers must set `0.0.0.0`**, or the NPM container cannot reach them over the shared network. |
| `LOG_LEVEL` | no | `info` | `debug` \| `info` \| `warn` \| `error` |
| `MAX_BODY_BYTES` | no | `41943040` (40 MB) | Request body cap. Gmail allows 25 MB attachments and base64 inflates ~33%, so a compose call can legitimately reach ~34 MB. Bodies are buffered to allow replay on a 401 retry, so this is also a memory bound. |
| `TOKEN_ENDPOINT` | no | `https://oauth2.googleapis.com/token` | Override for testing |

Missing required variables abort at boot with a clear message rather than
failing later as an opaque 502.

## Nginx Proxy Manager

Add a Proxy Host with **Forward Hostname** `gmail-mcp-proxy` and **Forward
Port** `8080` — the container name on the shared `npm` network, not a host IP
and port. Nothing is published on the host, so a `<truenas-ip>:<port>` target would
not resolve.

**Access List** (this is NPM's Basic auth): *Access Lists → Add → Authorization
tab* → add a username and password, then select that list on the Proxy Host.

> **Leave "Satisfy Any" OFF.** With it on and a LAN range in the Access tab,
> nginx treats IP-match *or* password as sufficient, so your whole LAN reaches
> Gmail unauthenticated. See NginxProxyManager issue #4984.

**Advanced tab** — required, or SSE stalls and MCP notifications arrive batched:

```nginx
proxy_buffering off;
proxy_read_timeout 3600s;
```

## OpenHuman

```toml
[[mcp_client.servers]]
name         = "gmail"
endpoint     = "https://gmail-mcp.your.lan/mcp/v1"
enabled      = true
timeout_secs = 60

  [mcp_client.servers.auth]
  kind     = "basic"
  username = "svc-openhuman"
  password = "<NPM Access List password>"
```

`timeout_secs = 60` because a cold token refresh plus a Gmail round-trip can
exceed the 30s default on the first call.

## Development

```bash
pnpm install
pnpm typecheck
pnpm test          # 30 tests, no network
pnpm dev           # runs src/ directly, no build step
```

## Troubleshooting

| Symptom | Cause |
| --- | --- |
| `503` + `refresh token is dead` | Re-mint `GOOGLE_REFRESH_TOKEN`. Check publishing status is "In production", not "Testing". |
| `502` + a message about scopes | A *fresh* token was rejected — the OAuth client is missing `gmail.readonly` / `gmail.compose`. Not an expiry problem. |
| Notifications arrive in batches | `proxy_buffering off;` missing from the NPM Advanced tab. |
| 401 from the edge, never reaching the proxy | NPM Access List credentials do not match the OpenHuman config. |
| Works for a week, then stops | Consent screen is still *Testing* — Google expired the refresh token at 7 days. |

## Security notes

- Request and response headers are filtered through an **allowlist**. The
  inbound `Authorization` (your NPM Basic credentials, which nginx still
  forwards upstream) is dropped and replaced, so it can never reach Google.
- The container runs `read_only` as the `node` user with
  `no-new-privileges`, and carries no runtime dependencies — only Node
  built-ins and global `fetch`.
- The proxy itself is unauthenticated by design; NPM's Access List is the
  boundary. The published port is therefore **`127.0.0.1:40001`** — reachable
  from the NAS itself for debugging, but with no route from the LAN, so the
  Access List stays the only way in.
- **Do not change it to `40001:8080`.** That binds `0.0.0.0`, and since the
  proxy injects a `gmail.readonly + gmail.compose` bearer onto every request it
  receives, any LAN host could then read or send mail without touching NPM.
  Docker's published-port DNAT is also evaluated *ahead of* host firewall
  rules, so a `ufw`/`iptables` deny would not close it after the fact.
- `BIND_HOST` is `0.0.0.0` **inside** the container so the NPM container can
  reach it over the shared network. That is not an exposure: with no published
  port, "all interfaces" means only the container's own network interfaces. The
  application defaults to `127.0.0.1` so a bare `pnpm dev` on your laptop is
  never exposed.