Skip to main content
Glama
dhrubajyoti-giri

Google Workspace MCP Server

README.md
# Google Workspace MCP

A standalone MCP server exposing Google Workspace tools (Gmail, Drive, Docs, Sheets, Calendar, Slides, Tasks) via **MCP OAuth 2.0** — no static bearer tokens, no per-user config.

## How It Works

```
MCP Client (QwenPaw / Claude Desktop / Claude Code)
  │
  │ POST /mcp/ (no token)
  ├── 401 with WWW-Authenticate: Bearer resource_metadata="https://.../oauth-protected-resource/mcp"
  │
  │ GET /.well-known/oauth-protected-resource/mcp   → discovers auth server URL
  │ GET /.well-known/oauth-authorization-server       → discovers endpoints (/authorize, /token, /register, /revoke)
  │ POST /register                                  → gets client_id
  │ GET /authorize                                  → browser: Google login + scope selection
  │ POST /token                                     → receives MCP JWT (access_token + refresh_token)
  │
  │ POST /mcp/  (Authorization: Bearer <JWT>)
  │
  │ JWT "sub" claim = Google email → GoogleClient looks up credentials in access_tokens.json → calls Google API
```

- **Token format**: JWT signed with `MCP_JWT_SECRET`. `sub` = Google email.
- **Token store**: `access_tokens.json` (per-user Google refresh tokens + granted scopes)
- **OAuth flow**: Two-step — identify (openid/email) → scope selection UI → authorize
- **Token refresh**: MCP access tokens expire in 8h; clients auto-refresh using the 30d refresh token
- **Scope changes**: Re-authorize via the client's "re-connect" button (existing scopes pre-selected)

## Prerequisites

- Docker + Docker Compose
- A publicly reachable HTTPS URL (`EXTERNAL_URL`) behind a TLS proxy (Caddy, nginx, Cloudflare Tunnel)
- Google Cloud OAuth 2.0 credentials (`client_secret.json`) — [instructions](#getting-google-oauth-credentials)

## Quick Start

### 1. Configure

```bash
cp .env.example .env

# Edit .env:
#   EXTERNAL_URL=https://mcp.yourdomain.com      # your public HTTPS domain
#   MCP_JWT_SECRET=$(openssl rand -hex 32)
#   SECRETS_VOLUME_PATH=/absolute/path/to/secrets  # host dir for OAuth files
```

### 2. Add Google credentials

```bash
cp your-downloaded-client_secret.json /absolute/path/to/secrets/client_secret.json
```

### 3. Start

```bash
docker compose up -d --build
```

### 4. Verify

```bash
curl http://localhost:8000/healthz
# Expected: {"status":"ok",...}  (may show "degraded" until first user authorizes)
```

### 5. Add to your MCP client (no bearer token needed)

The MCP client auto-discovers OAuth on first connect — **no token in the config**.

**QwenPaw** — place this JSON as a new driver config:

```json
{
  "key": "google-workspace-mcp",
  "name": "Google Workspace MCP",
  "description": "Gmail, Drive, Docs, Sheets, Calendar, Slides",
  "enabled": true,
  "transport": "streamable_http",
  "url": "http://127.0.0.1:8000/mcp/"
}
```

> QwenPaw fields `oauth_status`, `access_summary`, `tools`, and `headers` are auto-populated after the first connection. Set only `key`, `name`, `transport`, and `url`.

**Claude Desktop** — add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "google-workspace-mcp": {
      "url": "http://127.0.0.1:8000/mcp/"
    }
  }
}
```

**Claude Code**:

```bash
claude mcp add google-workspace-mcp --transport http --url http://127.0.0.1:8000/mcp/
```

### 6. Authorize

On first MCP tool call, your client will:
1. Auto-discover OAuth endpoints (via the 401 → `WWW-Authenticate` → `/.well-known/...`)
2. Register a dynamic client
3. Open a browser to Google login + scope selection
4. Store the MCP token internally

After authorization, `oauth_status` in the client shows `"connected"` and tools are available.

## Getting Google OAuth Credentials

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project (or use existing)
3. Enable APIs: Gmail API, Drive API, Docs API, Sheets API, Calendar API, Slides API
4. Go to APIs & Services → Credentials → Create Credentials → OAuth client ID
5. Application type: **Web application**
6. Name: `google-workspace-mcp`
7. **Authorized redirect URIs**: Add `https://mcp.yourdomain.com/oauth/callback`
8. Download the JSON → save as `secrets/client_secret.json`
9. Set `EXTERNAL_URL` in `.env` to match your domain (for OAuth callback + discovery)

## Scope Selection

The `/authorize` flow shows a scope selector with all Google API scopes. Existing grants are shown pre-selected on re-authorization.

| Service | Read scopes | Write scopes |
|---|---|---|
| Gmail | `gmail.readonly` | `gmail.modify`, `gmail.send`, `gmail.compose`, `gmail.metadata`, `gmail.settings.basic`, `gmail.labels`, `gmail.insert` |
| Drive | `drive.readonly` | `drive`, `drive.file` |
| Docs | `documents.readonly` | `documents` |
| Sheets | `spreadsheets.readonly` | `spreadsheets` |
| Calendar | `calendar.readonly` | `calendar` |
| Slides | `presentations.readonly` | `presentations` |
| Other | `contacts.readonly`, `tasks.readonly` | `contacts`, `tasks`, `chat.bot`, `chat.messages`, `chat.spaces` |

Plus always included: `openid`, `userinfo.email`, `userinfo.profile`

## Environment Variables

| Variable | Default | Description |
|---|---|---|
| `EXTERNAL_URL` | `https://mcp.example.com` | Public HTTPS URL (OAuth callbacks + metadata discovery) |
| `MCP_JWT_SECRET` | `change-me-in-production` | Secret for signing MCP JWT access/refresh tokens |
| `MCP_PORT` | `8000` | Container listen port |
| `GOOGLE_CLIENT_SECRET_FILE` | `/secrets/client_secret.json` | Path to Google OAuth credentials (mounted) |
| `GOOGLE_REGISTRY_FILE` | `/secrets/access_tokens.json` | Per-user token registry (auto-populated) |
| `GOOGLE_SCOPES` | *(see .env)* | Default Google API scopes if MCP client doesn't request any |
| `MCP_ACCESS_TOKEN_TTL` | `28800` | MCP access token lifetime in seconds (8h) — client auto-refreshes |
| `MCP_REFRESH_TOKEN_TTL` | `2592000` | MCP refresh token lifetime in seconds (30d) |
| `MCP_AUTH_CODE_TTL` | `600` | MCP authorization code lifetime in seconds (10 min) |
| `SCOPE_SELECTOR_MODE` | `all` | `all` (default — show every available Google scope) · `requested` (only MCP-client-requested scopes) |
| `SCOPE_SELECTOR_MODE` | `all` | `all` (default — show every available scope in selector) · `requested` (show only MCP-client-requested scopes) |
| `SECRETS_VOLUME_PATH` | `/path/to/secrets` | Host directory for `client_secret.json` + `access_tokens.json` |
| `TZ` | `Asia/Kolkata` | Timezone |
| `LOG_LEVEL` | `INFO` | Log verbosity |

## Token Lifetimes

| Token | Lifetime | Auto-managed |
|---|---|---|
| MCP access token (JWT) | 8 hours (`MCP_ACCESS_TOKEN_TTL`) | Client refreshes via refresh token |
| MCP refresh token (JWT) | 30 days (`MCP_REFRESH_TOKEN_TTL`) | Rotated on each refresh |
| MCP auth code | 10 minutes (`MCP_AUTH_CODE_TTL`) | Exchanged once for tokens |
| Google refresh token | Unlimited (until revoked) | Auto-refreshed by server when access expires |

All MCP token lifetimes are configurable. Google's token refresh is transparent — `GoogleClient.get_credentials()` refreshes silently when the access token expires.

## Docker Compose

```yaml
# docker-compose.yml uses env_file: .env only (no environment: block).
# Secrets volume mounted at /secrets in container.
services:
  google-workspace-mcp:
    build: .
    env_file: .env
    volumes:
      - ${SECRETS_VOLUME_PATH}:/secrets:rw
```

## Caddy Proxy (recommended)

```caddyfile
mcp.yourdomain.com {
    # Block /mcp from public internet (clients connect via localhost)
    route /mcp/* {
        respond 403
    }
    # Proxy OAuth + health endpoints
    reverse_proxy google-workspace-mcp:8000
}
```

> **Why block `/mcp/*` with 403?** The MCP bearer-token (JWT) *is* enforced
> application-side, but the 403 is defense-in-depth — a network-level block
> so the MCP interface is never reachable from the public internet.
> If you'd rather expose `/mcp/*` publicly and rely solely on JWT auth,
> switch to `Caddyfile.example-full-proxy` instead.

## Token Persistence

Once a Google account is authorized (entry in `access_tokens.json`), the user
does **not** need to re-authorize until that entry is removed or Google
revokes the refresh token. Google access tokens expire silently; the bridge
auto-refreshes them using the stored refresh token — no user interaction
required. Remove an account by deleting its entry from `access_tokens.json`.

## Troubleshooting

| Problem | Solution |
|---|---|
| `401` on `/mcp/` after auth | MCP client should auto-refresh. If stuck, restart client or re-authorize |
| "No Google credentials found for user@..." | User hasn't completed OAuth. Trigger OAuth via your MCP client (it discovers endpoints automatically) |
| `403 insufficient_scope` | Re-authorize with additional scopes via your MCP client (disconnect + reconnect) |
| `404` on `/.well-known/oauth-authorization-server` | Check `EXTERNAL_URL` matches your Caddy domain |
| Client can't discover OAuth | Verify `EXTERNAL_URL` is publicly reachable over HTTPS |
| `client_secret.json not found` | Mount it at `/secrets/client_secret.json` (or your configured path) |
| `500` on `/oauth/callback` after scope change | Google OAuth scope-mismatch Warning caught — token is recovered automatically; restart the flow (codes are single-use) |

## Project Structure

```
google-workspace-mcp/
├── app/
│   ├── config.py            # Settings (env-driven, no hardcoded values)
│   ├── main.py              # FastAPI + MCP OAuth middleware + routes
│   ├── mcp_server.py        # FastMCP server with all tools
│   ├── oauth.py             # Two-step Google OAuth web UI (/oauth/*)
│   ├── oauth_provider.py    # OAuthAuthorizationServerProvider implementation
│   ├── registry.py          # JSON-backed per-user token registry
│   ├── google_client.py     # Per-user Google credentials (via JWT subject)
│   ├── generate_config.py   # Optional config generator (manual use only)
│   └── tools/               # Tool implementations (gmail, drive, docs, ...)
├── Caddyfile                # Proxy: blocks /mcp, proxies OAuth + healthz
├── Caddyfile.example-full-proxy
├── docker-compose.yml       # env_file only (no environment: block)
├── Dockerfile
├── .env / .env.example
└── README.md
```

## Works With

Any MCP client supporting OAuth 2.0 Dynamic Client Registration + PKCE:
- QwenPaw
- Claude Desktop
- Claude Code
- Any compliant implementation