From 850e81437b9755ba0a4c632f65687cde1d21e9aa Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:18:09 -0400 Subject: [PATCH 1/7] Add deploy infra for contacts.nlma.io Dockerfile + docker-compose (loopback-only bind), systemd unit as alternative runtime, nginx reverse-proxy example with TLS + basic auth, and deploy/README.md runbook. Extends .gitignore to cover .env files. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 3 + .gitignore | 5 +- Dockerfile | 24 ++++++ deploy/README.md | 123 ++++++++++++++++++++++++++++ deploy/nginx.conf.example | 58 +++++++++++++ deploy/systemd/contacts-mcp.service | 22 +++++ docker-compose.yml | 17 ++++ 7 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 deploy/README.md create mode 100644 deploy/nginx.conf.example create mode 100644 deploy/systemd/contacts-mcp.service create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d9614e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REFRESH_TOKEN= diff --git a/.gitignore b/.gitignore index dd59d64..d629f86 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ wheels/ # Virtual environments .venv token.json -credentials.json \ No newline at end of file +credentials.json +.env +.env.* +!.env.example \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..09d6e50 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml setup.py README.md LICENSE ./ +COPY mcp_google_contacts_server ./mcp_google_contacts_server + +RUN pip install --no-cache-dir . + +RUN useradd --system --uid 1001 --home /app --shell /usr/sbin/nologin app \ + && chown -R app:app /app +USER app + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/ || exit 1 + +ENTRYPOINT ["mcp-google-contacts"] +CMD ["--transport", "http", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..66c9e0f --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,123 @@ +# Deploying `contacts.nlma.io` + +Runbook for putting this MCP server behind the public hostname `contacts.nlma.io` on the NLMA VPS. + +## Architecture + +``` +client --TLS--> nginx (443, auth) --HTTP--> 127.0.0.1:8000 (MCP HTTP transport) +``` + +The app binds only to loopback. Nginx terminates TLS and enforces auth. Two runtime options are provided: + +- **Docker Compose** (recommended) — `Dockerfile` + `docker-compose.yml` in repo root. +- **systemd + venv** — `deploy/systemd/contacts-mcp.service`. + +## Prerequisites + +- VPS with Ubuntu/Debian, root or sudo access. +- Python 3.12 available (Docker option ships its own). +- DNS A/AAAA for `contacts.nlma.io` pointed at the VPS. +- Google OAuth credentials (`credentials.json`) or `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `GOOGLE_REFRESH_TOKEN`. +- nginx + certbot installed on the VPS. + +## One-time host setup + +```bash +sudo apt update +sudo apt install -y nginx certbot python3-certbot-nginx apache2-utils +sudo mkdir -p /opt/contacts-mcp +sudo chown "$USER":"$USER" /opt/contacts-mcp +``` + +## Option 1 — Docker Compose + +```bash +# 1. On the VPS +cd /opt/contacts-mcp +git clone https://github.com//mcp-google-contacts-server.git . +git checkout deploy/contacts-nlma-io + +# 2. Credentials +cp .env.example .env +# Edit .env with GOOGLE_CLIENT_ID / _SECRET / _REFRESH_TOKEN +# OR place credentials.json in this directory (docker-compose mounts it read-only) + +# 3. Build and start +docker compose up -d --build +docker compose logs -f # verify startup +curl -fsS http://127.0.0.1:8000/ # sanity check +``` + +## Option 2 — systemd + venv + +```bash +cd /opt/contacts-mcp +git clone https://github.com//mcp-google-contacts-server.git . +git checkout deploy/contacts-nlma-io + +python3.12 -m venv .venv +.venv/bin/pip install . + +cp .env.example .env # fill in creds +# place credentials.json at /opt/contacts-mcp/credentials.json (optional) + +sudo useradd --system --home /opt/contacts-mcp --shell /usr/sbin/nologin contacts-mcp +sudo chown -R contacts-mcp:contacts-mcp /opt/contacts-mcp + +sudo cp deploy/systemd/contacts-mcp.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now contacts-mcp +sudo systemctl status contacts-mcp +``` + +## Nginx + TLS + +```bash +sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/contacts.nlma.io +sudo ln -s /etc/nginx/sites-available/contacts.nlma.io /etc/nginx/sites-enabled/ + +# Basic auth credentials (required by the example config) +sudo htpasswd -c /etc/nginx/.contacts_htpasswd nlma + +sudo nginx -t +sudo systemctl reload nginx + +# Issue TLS cert +sudo certbot --nginx -d contacts.nlma.io +``` + +## Verify + +```bash +curl -u nlma: -fsS https://contacts.nlma.io/ +``` + +## Update + +```bash +cd /opt/contacts-mcp +git pull + +# Docker: +docker compose up -d --build + +# systemd: +.venv/bin/pip install . +sudo systemctl restart contacts-mcp +``` + +## Rollback + +```bash +cd /opt/contacts-mcp +git log --oneline -5 +git checkout +docker compose up -d --build # or: sudo systemctl restart contacts-mcp +``` + +## Security notes + +- The MCP server has no built-in auth. Do **not** expose port 8000 directly. Always front with nginx basic auth, a bearer-token check, or Cloudflare Access. +- `.env` and `credentials.json` are gitignored. Transfer them out-of-band (`scp`), never commit. +- Rotate `GOOGLE_REFRESH_TOKEN` if the VPS is ever compromised. diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..d69aa3c --- /dev/null +++ b/deploy/nginx.conf.example @@ -0,0 +1,58 @@ +# /etc/nginx/sites-available/contacts.nlma.io +# Symlink into sites-enabled and reload nginx after editing. +# TLS certs managed by certbot: certbot --nginx -d contacts.nlma.io + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + listen [::]:80; + server_name contacts.nlma.io; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name contacts.nlma.io; + + ssl_certificate /etc/letsencrypt/live/contacts.nlma.io/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/contacts.nlma.io/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Option A — HTTP basic auth. Generate with: + # sudo htpasswd -c /etc/nginx/.contacts_htpasswd + auth_basic "contacts-mcp"; + auth_basic_user_file /etc/nginx/.contacts_htpasswd; + + # Option B — shared bearer token (delete auth_basic above if using this). + # set $expected_token "CHANGE_ME"; + # if ($http_authorization != "Bearer $expected_token") { return 401; } + + client_max_body_size 2m; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_buffering off; + } +} diff --git a/deploy/systemd/contacts-mcp.service b/deploy/systemd/contacts-mcp.service new file mode 100644 index 0000000..3e0b0e5 --- /dev/null +++ b/deploy/systemd/contacts-mcp.service @@ -0,0 +1,22 @@ +[Unit] +Description=MCP Google Contacts Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=contacts-mcp +Group=contacts-mcp +WorkingDirectory=/opt/contacts-mcp +EnvironmentFile=/opt/contacts-mcp/.env +ExecStart=/opt/contacts-mcp/.venv/bin/mcp-google-contacts --transport http --host 127.0.0.1 --port 8000 --credentials-file /opt/contacts-mcp/credentials.json +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/opt/contacts-mcp + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..39ee34d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + contacts-mcp: + build: . + image: contacts-mcp:latest + container_name: contacts-mcp + restart: unless-stopped + env_file: + - .env + ports: + - "127.0.0.1:8000:8000" + volumes: + - ./credentials.json:/app/credentials.json:ro + - contacts-mcp-token:/app/.token + command: ["--transport", "http", "--host", "0.0.0.0", "--port", "8000"] + +volumes: + contacts-mcp-token: From 49a7d9d31243f380fc1a350f7c97c96b4a4f3b1c Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:01:48 -0400 Subject: [PATCH 2/7] Align deploy infra with sibling *.nlma.io MCP convention Drop Dockerfile/compose in favor of the house pattern: systemd + Python 3.12 venv at /opt/contacts-mcp, nginx proxy to 127.0.0.1:8020, certbot-managed TLS. Unit runs as root to match hospitable-mcp / baselane-mcp services. Nginx config mirrors hospitable.nlma.io's shape so certbot's --nginx flow slots in without surprises. Runbook rewritten to target the NLMA Hostinger VPS directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 24 ------ deploy/README.md | 115 ++++++++++------------------ deploy/nginx.conf.example | 66 ++++------------ deploy/systemd/contacts-mcp.service | 14 +--- docker-compose.yml | 17 ---- 5 files changed, 61 insertions(+), 175 deletions(-) delete mode 100644 Dockerfile delete mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 09d6e50..0000000 --- a/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -COPY pyproject.toml setup.py README.md LICENSE ./ -COPY mcp_google_contacts_server ./mcp_google_contacts_server - -RUN pip install --no-cache-dir . - -RUN useradd --system --uid 1001 --home /app --shell /usr/sbin/nologin app \ - && chown -R app:app /app -USER app - -EXPOSE 8000 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD curl -fsS http://127.0.0.1:8000/ || exit 1 - -ENTRYPOINT ["mcp-google-contacts"] -CMD ["--transport", "http", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deploy/README.md b/deploy/README.md index 66c9e0f..81571aa 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,96 +1,66 @@ # Deploying `contacts.nlma.io` -Runbook for putting this MCP server behind the public hostname `contacts.nlma.io` on the NLMA VPS. - -## Architecture +Runbook for the NLMA Hostinger VPS (`178.16.141.166`), matching the +conventions used by the other `*.nlma.io` MCP servers. ``` -client --TLS--> nginx (443, auth) --HTTP--> 127.0.0.1:8000 (MCP HTTP transport) +client --TLS--> nginx (443) --proxy--> 127.0.0.1:8020 (MCP HTTP transport) ``` -The app binds only to loopback. Nginx terminates TLS and enforces auth. Two runtime options are provided: - -- **Docker Compose** (recommended) — `Dockerfile` + `docker-compose.yml` in repo root. -- **systemd + venv** — `deploy/systemd/contacts-mcp.service`. +- Install path: `/opt/contacts-mcp` +- Internal port: `8020` (loopback only) +- Runtime: systemd + Python 3.12 venv (`User=root`, like sibling services) +- TLS: certbot on the VPS +- Auth: none at nginx by default (matches sibling MCPs). Add basic auth + or a bearer check in `contacts.nlma.io` if needed. ## Prerequisites -- VPS with Ubuntu/Debian, root or sudo access. -- Python 3.12 available (Docker option ships its own). -- DNS A/AAAA for `contacts.nlma.io` pointed at the VPS. -- Google OAuth credentials (`credentials.json`) or `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `GOOGLE_REFRESH_TOKEN`. -- nginx + certbot installed on the VPS. +- DNS A record for `contacts.nlma.io` → `178.16.141.166`. +- Google OAuth: either `credentials.json` or `GOOGLE_CLIENT_ID` / + `GOOGLE_CLIENT_SECRET` / `GOOGLE_REFRESH_TOKEN` in `.env`. -## One-time host setup +## Provision ```bash -sudo apt update -sudo apt install -y nginx certbot python3-certbot-nginx apache2-utils -sudo mkdir -p /opt/contacts-mcp -sudo chown "$USER":"$USER" /opt/contacts-mcp -``` +ssh root@178.16.141.166 -## Option 1 — Docker Compose - -```bash -# 1. On the VPS +mkdir -p /opt/contacts-mcp cd /opt/contacts-mcp -git clone https://github.com//mcp-google-contacts-server.git . +git clone https://github.com/NextLevelManagementAdvisors/mcp-google-contacts-server.git . git checkout deploy/contacts-nlma-io -# 2. Credentials +python3.12 -m venv .venv +.venv/bin/pip install --upgrade pip +.venv/bin/pip install . + cp .env.example .env # Edit .env with GOOGLE_CLIENT_ID / _SECRET / _REFRESH_TOKEN -# OR place credentials.json in this directory (docker-compose mounts it read-only) - -# 3. Build and start -docker compose up -d --build -docker compose logs -f # verify startup -curl -fsS http://127.0.0.1:8000/ # sanity check +# OR scp credentials.json to /opt/contacts-mcp/credentials.json ``` -## Option 2 — systemd + venv +## systemd ```bash -cd /opt/contacts-mcp -git clone https://github.com//mcp-google-contacts-server.git . -git checkout deploy/contacts-nlma-io - -python3.12 -m venv .venv -.venv/bin/pip install . - -cp .env.example .env # fill in creds -# place credentials.json at /opt/contacts-mcp/credentials.json (optional) +cp /opt/contacts-mcp/deploy/systemd/contacts-mcp.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now contacts-mcp +systemctl status contacts-mcp +journalctl -u contacts-mcp -n 50 --no-pager -sudo useradd --system --home /opt/contacts-mcp --shell /usr/sbin/nologin contacts-mcp -sudo chown -R contacts-mcp:contacts-mcp /opt/contacts-mcp - -sudo cp deploy/systemd/contacts-mcp.service /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now contacts-mcp -sudo systemctl status contacts-mcp +curl -fsS http://127.0.0.1:8020/ # sanity check ``` -## Nginx + TLS +## nginx + TLS ```bash -sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/contacts.nlma.io -sudo ln -s /etc/nginx/sites-available/contacts.nlma.io /etc/nginx/sites-enabled/ - -# Basic auth credentials (required by the example config) -sudo htpasswd -c /etc/nginx/.contacts_htpasswd nlma - -sudo nginx -t -sudo systemctl reload nginx +cp /opt/contacts-mcp/deploy/nginx.conf.example /etc/nginx/sites-available/contacts.nlma.io +ln -s /etc/nginx/sites-available/contacts.nlma.io /etc/nginx/sites-enabled/ +nginx -t && systemctl reload nginx -# Issue TLS cert -sudo certbot --nginx -d contacts.nlma.io -``` - -## Verify +certbot --nginx -d contacts.nlma.io -```bash -curl -u nlma: -fsS https://contacts.nlma.io/ +curl -fsS https://contacts.nlma.io/ ``` ## Update @@ -98,13 +68,8 @@ curl -u nlma: -fsS https://contacts.nlma.io/ ```bash cd /opt/contacts-mcp git pull - -# Docker: -docker compose up -d --build - -# systemd: .venv/bin/pip install . -sudo systemctl restart contacts-mcp +systemctl restart contacts-mcp ``` ## Rollback @@ -113,11 +78,13 @@ sudo systemctl restart contacts-mcp cd /opt/contacts-mcp git log --oneline -5 git checkout -docker compose up -d --build # or: sudo systemctl restart contacts-mcp +.venv/bin/pip install . +systemctl restart contacts-mcp ``` ## Security notes -- The MCP server has no built-in auth. Do **not** expose port 8000 directly. Always front with nginx basic auth, a bearer-token check, or Cloudflare Access. -- `.env` and `credentials.json` are gitignored. Transfer them out-of-band (`scp`), never commit. -- Rotate `GOOGLE_REFRESH_TOKEN` if the VPS is ever compromised. +- MCP server binds loopback only; public access is nginx → proxy. +- `.env` and `credentials.json` are gitignored. Transfer out-of-band; never commit. +- If this hostname ever gets customer-facing traffic, add basic auth or a + bearer token check in the nginx config before that happens. diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index d69aa3c..577e36a 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -1,58 +1,24 @@ # /etc/nginx/sites-available/contacts.nlma.io -# Symlink into sites-enabled and reload nginx after editing. -# TLS certs managed by certbot: certbot --nginx -d contacts.nlma.io - -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} +# Matches the convention used by other *.nlma.io MCP servers. +# TLS stanzas are added by certbot on first run: +# sudo certbot --nginx -d contacts.nlma.io server { - listen 80; - listen [::]:80; server_name contacts.nlma.io; - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - + location /.well-known/acme-challenge/ { root /var/www/html; } location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name contacts.nlma.io; - - ssl_certificate /etc/letsencrypt/live/contacts.nlma.io/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/contacts.nlma.io/privkey.pem; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; - - # Option A — HTTP basic auth. Generate with: - # sudo htpasswd -c /etc/nginx/.contacts_htpasswd - auth_basic "contacts-mcp"; - auth_basic_user_file /etc/nginx/.contacts_htpasswd; - - # Option B — shared bearer token (delete auth_basic above if using this). - # set $expected_token "CHANGE_ME"; - # if ($http_authorization != "Bearer $expected_token") { return 401; } - - client_max_body_size 2m; - proxy_read_timeout 600s; - proxy_send_timeout 600s; - - location / { - proxy_pass http://127.0.0.1:8000; + proxy_pass http://127.0.0.1:8020; proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_buffering off; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; } + + listen 80; } diff --git a/deploy/systemd/contacts-mcp.service b/deploy/systemd/contacts-mcp.service index 3e0b0e5..820fa16 100644 --- a/deploy/systemd/contacts-mcp.service +++ b/deploy/systemd/contacts-mcp.service @@ -1,22 +1,16 @@ +# /etc/systemd/system/contacts-mcp.service [Unit] Description=MCP Google Contacts Server -After=network-online.target -Wants=network-online.target +After=network.target [Service] Type=simple -User=contacts-mcp -Group=contacts-mcp +User=root WorkingDirectory=/opt/contacts-mcp EnvironmentFile=/opt/contacts-mcp/.env -ExecStart=/opt/contacts-mcp/.venv/bin/mcp-google-contacts --transport http --host 127.0.0.1 --port 8000 --credentials-file /opt/contacts-mcp/credentials.json +ExecStart=/opt/contacts-mcp/.venv/bin/mcp-google-contacts --transport http --host 127.0.0.1 --port 8020 --credentials-file /opt/contacts-mcp/credentials.json Restart=on-failure RestartSec=5 -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=true -PrivateTmp=true -ReadWritePaths=/opt/contacts-mcp [Install] WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 39ee34d..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - contacts-mcp: - build: . - image: contacts-mcp:latest - container_name: contacts-mcp - restart: unless-stopped - env_file: - - .env - ports: - - "127.0.0.1:8000:8000" - volumes: - - ./credentials.json:/app/credentials.json:ro - - contacts-mcp-token:/app/.token - command: ["--transport", "http", "--host", "0.0.0.0", "--port", "8000"] - -volumes: - contacts-mcp-token: From 2573a09951ca89b8098828d2c5be4999b1f457db Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:44:22 -0400 Subject: [PATCH 3/7] Fix HTTP transport to use current FastMCP API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastMCP.run() takes only (transport, mount_path) and transport must be one of stdio/sse/streamable-http — not the plain 'http' string. Host and port are set via mcp.settings before run(). Without this the service crashed on boot with TypeError: unexpected keyword argument 'host'. Also ignore the local .venv-oauth helper venv and oauth_consent.py one-shot script used during first-time deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 ++ mcp_google_contacts_server/main.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d629f86..de11fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ wheels/ # Virtual environments .venv +.venv-oauth/ +oauth_consent.py token.json credentials.json .env diff --git a/mcp_google_contacts_server/main.py b/mcp_google_contacts_server/main.py index f1503ec..72eb59a 100644 --- a/mcp_google_contacts_server/main.py +++ b/mcp_google_contacts_server/main.py @@ -98,7 +98,9 @@ def main(): mcp.run(transport='stdio') else: print(f"Running with HTTP transport on {args.host}:{args.port}") - mcp.run(transport='http', host=args.host, port=args.port) + mcp.settings.host = args.host + mcp.settings.port = args.port + mcp.run(transport='streamable-http') if __name__ == "__main__": main() \ No newline at end of file From 1431a8424d897da8064c212bb199b20e9c83a391 Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:51:49 -0400 Subject: [PATCH 4/7] Allow extra hosts via MCP_ALLOWED_HOSTS env var FastMCP's transport_security rejects any Host header not in its allowlist (DNS rebinding protection). Behind a reverse proxy with a public hostname, this meant every external POST /mcp returned 421 Misdirected Request until the vhost was whitelisted. Reads a comma- separated list from MCP_ALLOWED_HOSTS and appends to the allowlist before run(). Co-Authored-By: Claude Opus 4.7 (1M context) --- mcp_google_contacts_server/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mcp_google_contacts_server/main.py b/mcp_google_contacts_server/main.py index 72eb59a..84467f7 100644 --- a/mcp_google_contacts_server/main.py +++ b/mcp_google_contacts_server/main.py @@ -100,6 +100,9 @@ def main(): print(f"Running with HTTP transport on {args.host}:{args.port}") mcp.settings.host = args.host mcp.settings.port = args.port + extra_hosts = [h for h in os.environ.get("MCP_ALLOWED_HOSTS", "").split(",") if h] + if extra_hosts: + mcp.settings.transport_security.allowed_hosts.extend(extra_hosts) mcp.run(transport='streamable-http') if __name__ == "__main__": From 0f27e6105b0f5c9b5208f506e0cf128b4f8d14f0 Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:56:37 -0400 Subject: [PATCH 5/7] Refresh env-provided creds instead of launching browser flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GOOGLE_REFRESH_TOKEN is supplied via env, the constructed Credentials object has a refresh token but no access token, so .valid is False while .expired is also False (no expiry to compare). The old gate required .expired=True before calling .refresh(), so the code fell through to InstalledAppFlow .run_local_server() — which fails on a headless server with "could not locate runnable browser" and leaves the service in "Google Contacts service is not available" state despite a perfectly good refresh token in the environment. Drop the .expired check: if we have a refresh token and the creds aren't valid (no access token yet, or expired), refresh is always the correct action. Co-Authored-By: Claude Opus 4.7 (1M context) --- mcp_google_contacts_server/google_contacts_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp_google_contacts_server/google_contacts_service.py b/mcp_google_contacts_server/google_contacts_service.py index b3159f9..6b58a30 100644 --- a/mcp_google_contacts_server/google_contacts_service.py +++ b/mcp_google_contacts_server/google_contacts_service.py @@ -127,7 +127,7 @@ def _authenticate(self): # If credentials don't exist or are invalid, go through auth flow if not creds or not creds.valid: - if creds and creds.expired and creds.refresh_token: + if creds and creds.refresh_token: creds.refresh(Request()) else: if not self.credentials_info: From fd29d7f5dd087089c880e381f6cf5d3be26d99ef Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:14:32 -0400 Subject: [PATCH 6/7] Add AUTH_MODE=multi per-user Google OAuth broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps today's single-tenant mode fully working (AUTH_MODE defaults to 'single'; the old env-provided refresh token code path is unchanged). When AUTH_MODE=multi: - The server runs as an OAuth 2.1 authorization server for its MCP clients (SDK-provided endpoints: /.well-known/oauth-authorization-server, /authorize, /token, /register) and as a resource server on /mcp. - GoogleOAuthProvider in auth_provider.py implements the OAuthAuthorizationServerProvider protocol. authorize() redirects the user through Google consent using a Web OAuth client; the browser lands back at /oauth/google/callback (new Starlette route in google_oauth_routes.py), where we exchange the Google code for a refresh token, upsert the user keyed by verified email, and mint our own authorization code for the MCP client. - db.py (stdlib sqlite3, WAL) persists: users (email -> refresh_token), oauth_states, auth_codes, access_tokens, refresh_tokens, clients (for DCR). DB path defaults to /opt/contacts-mcp/data.db. - tools.py now has _resolve_service(): in multi mode it reads the caller's identity from get_access_token() (populated by AuthContextMiddleware via our GoogleAccessToken subclass, which carries google_email), looks up the user's refresh_token, and builds a per-caller GoogleContactsService via a new from_tokens() classmethod on GoogleContactsService. A small thread-safe OrderedDict LRU caches the built services briefly so consecutive tool calls in a session don't hammer Google's token endpoint. - GoogleContactsService gained a cache_token flag (token.json is off in multi mode so users don't stomp each other) and an explicit refresh_token constructor arg that outranks the env var. Config additions (env): AUTH_MODE, GOOGLE_WEB_CLIENT_ID, GOOGLE_WEB_CLIENT_SECRET, OAUTH_ISSUER_URL, GOOGLE_OAUTH_REDIRECT_URI, DB_PATH. All optional; main.py validates them when multi mode is chosen and fails fast at startup if any are missing. Nginx config is unchanged — the vhost already proxies all paths to 127.0.0.1:8020, so /authorize, /token, /oauth/google/callback, and /.well-known/* pass through without edits. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/README.md | 22 ++ mcp_google_contacts_server/auth_provider.py | 244 +++++++++++++++ mcp_google_contacts_server/config.py | 42 ++- mcp_google_contacts_server/db.py | 292 ++++++++++++++++++ .../google_contacts_service.py | 91 ++++-- .../google_oauth_routes.py | 144 +++++++++ mcp_google_contacts_server/main.py | 142 +++++---- mcp_google_contacts_server/tools.py | 92 +++++- 8 files changed, 962 insertions(+), 107 deletions(-) create mode 100644 mcp_google_contacts_server/auth_provider.py create mode 100644 mcp_google_contacts_server/db.py create mode 100644 mcp_google_contacts_server/google_oauth_routes.py diff --git a/deploy/README.md b/deploy/README.md index 81571aa..d5b78ac 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -88,3 +88,25 @@ systemctl restart contacts-mcp - `.env` and `credentials.json` are gitignored. Transfer out-of-band; never commit. - If this hostname ever gets customer-facing traffic, add basic auth or a bearer token check in the nginx config before that happens. + +## Multi-tenant (per-user Google OAuth) mode + +Set `AUTH_MODE=multi` to let any Google user sign in and query their own +contacts. Requires a **Web application** OAuth client (not Desktop) with +redirect URI `https://contacts.nlma.io/oauth/google/callback` and a +published OAuth consent screen (External, test users for launch). + +Additional `.env` keys: + +``` +AUTH_MODE=multi +GOOGLE_WEB_CLIENT_ID=... +GOOGLE_WEB_CLIENT_SECRET=... +OAUTH_ISSUER_URL=https://contacts.nlma.io +GOOGLE_OAUTH_REDIRECT_URI=https://contacts.nlma.io/oauth/google/callback +# DB_PATH=/opt/contacts-mcp/data.db # optional override +``` + +Per-user refresh tokens land in `/opt/contacts-mcp/data.db`. Treat that +file like a secret: `chmod 600`, no world-readable backups. Remove a +user: `sqlite3 data.db "delete from users where google_email='x@y'; delete from access_tokens where google_email='x@y'; delete from refresh_tokens where google_email='x@y';"` diff --git a/mcp_google_contacts_server/auth_provider.py b/mcp_google_contacts_server/auth_provider.py new file mode 100644 index 0000000..30fcff2 --- /dev/null +++ b/mcp_google_contacts_server/auth_provider.py @@ -0,0 +1,244 @@ +"""OAuth 2.1 authorization-server provider that brokers Google identity. + +Flow summary: + MCP client --> /authorize --> provider.authorize() --> Google consent URL + Google --> /oauth/google/callback (separate Starlette route in google_oauth_routes.py) + --> exchange Google code, upsert user, mint our auth code, redirect MCP client + MCP client --> /token --> provider.exchange_authorization_code() --> OAuthToken + MCP client --> /mcp (with Authorization: Bearer ...) --> provider.load_access_token() +""" +import secrets +import time +import urllib.parse +from typing import Optional, List + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, + RegistrationError, + TokenError, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +from pydantic import AnyUrl + +from mcp_google_contacts_server.config import config +from mcp_google_contacts_server.db import Db + + +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" + + +class GoogleAccessToken(AccessToken): + """AccessToken with the Google user's email threaded through so tools can look up creds.""" + + google_email: str + + +class GoogleRefreshToken(RefreshToken): + google_email: str + + +class GoogleAuthorizationCode(AuthorizationCode): + google_email: str + + +def _new_token() -> str: + return secrets.token_urlsafe(32) + + +class GoogleOAuthProvider(OAuthAuthorizationServerProvider[ + GoogleAuthorizationCode, GoogleRefreshToken, GoogleAccessToken +]): + """Bridges our MCP clients' OAuth flow to Google OAuth.""" + + def __init__(self, db: Db): + self.db = db + + # ---- dynamic client registration --------------------------------------- + + async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]: + row = self.db.get_client(client_id) + if not row: + return None + return OAuthClientInformationFull.model_validate(row) + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + if client_info.client_id is None: + raise RegistrationError(error="invalid_client_metadata", error_description="missing client_id") + self.db.put_client( + client_info.client_id, + client_info.model_dump(mode="json", exclude_none=True), + ) + + # ---- /authorize entry: redirect user through Google ------------------- + + async def authorize( + self, client: OAuthClientInformationFull, params: AuthorizationParams + ) -> str: + if not config.google_web_client_id: + raise RuntimeError("GOOGLE_WEB_CLIENT_ID not configured; multi-tenant mode needs a Web OAuth client") + if not config.google_oauth_redirect_uri: + raise RuntimeError("GOOGLE_OAUTH_REDIRECT_URI not configured") + + state = _new_token() + self.db.put_state( + state, + { + "mcp_client_id": client.client_id or "", + "mcp_redirect_uri": str(params.redirect_uri), + "mcp_redirect_uri_explicit": params.redirect_uri_provided_explicitly, + "mcp_code_challenge": params.code_challenge, + "mcp_scopes": params.scopes or [], + "mcp_resource": params.resource, + "mcp_state": params.state, + }, + ttl_seconds=config.oauth_state_ttl, + ) + + google_scopes = list(config.scopes) + ["openid", "email", "profile"] + query = urllib.parse.urlencode( + { + "client_id": config.google_web_client_id, + "redirect_uri": config.google_oauth_redirect_uri, + "response_type": "code", + "scope": " ".join(google_scopes), + "access_type": "offline", + "prompt": "consent", + "state": state, + "include_granted_scopes": "true", + } + ) + return f"{GOOGLE_AUTH_URL}?{query}" + + # ---- authorization codes (MCP side) ----------------------------------- + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> Optional[GoogleAuthorizationCode]: + row = self.db.get_auth_code(authorization_code) + if not row or row["mcp_client_id"] != (client.client_id or ""): + return None + return GoogleAuthorizationCode( + code=row["code"], + scopes=row["mcp_scopes"], + expires_at=float(row["expires_at"]), + client_id=row["mcp_client_id"], + code_challenge=row["mcp_code_challenge"], + redirect_uri=AnyUrl(row["mcp_redirect_uri"]), + redirect_uri_provided_explicitly=row["mcp_redirect_uri_explicit"], + resource=row["mcp_resource"], + google_email=row["google_email"], + ) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: GoogleAuthorizationCode + ) -> OAuthToken: + self.db.delete_auth_code(authorization_code.code) + + now = int(time.time()) + access = _new_token() + refresh = _new_token() + self.db.put_access_token( + access, + google_email=authorization_code.google_email, + mcp_client_id=authorization_code.client_id, + scopes=authorization_code.scopes, + expires_at=now + config.access_token_ttl, + resource=authorization_code.resource, + ) + self.db.put_refresh_token( + refresh, + google_email=authorization_code.google_email, + mcp_client_id=authorization_code.client_id, + scopes=authorization_code.scopes, + expires_at=now + config.refresh_token_ttl, + ) + return OAuthToken( + access_token=access, + token_type="Bearer", + expires_in=config.access_token_ttl, + refresh_token=refresh, + scope=" ".join(authorization_code.scopes), + ) + + # ---- refresh ---------------------------------------------------------- + + async def load_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: str + ) -> Optional[GoogleRefreshToken]: + row = self.db.get_refresh_token(refresh_token) + if not row or row["mcp_client_id"] != (client.client_id or ""): + return None + return GoogleRefreshToken( + token=row["token"], + client_id=row["mcp_client_id"], + scopes=row["scopes"], + expires_at=row["expires_at"], + google_email=row["google_email"], + ) + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: GoogleRefreshToken, + scopes: List[str], + ) -> OAuthToken: + # Rotate: issue a new access + refresh, invalidate the old refresh. + self.db.delete_refresh_token(refresh_token.token) + + # Scope narrowing allowed; expanding is not. + new_scopes = scopes or refresh_token.scopes + if set(new_scopes) - set(refresh_token.scopes): + raise TokenError(error="invalid_scope", error_description="scopes cannot expand") + + now = int(time.time()) + access = _new_token() + refresh = _new_token() + self.db.put_access_token( + access, + google_email=refresh_token.google_email, + mcp_client_id=refresh_token.client_id, + scopes=new_scopes, + expires_at=now + config.access_token_ttl, + ) + self.db.put_refresh_token( + refresh, + google_email=refresh_token.google_email, + mcp_client_id=refresh_token.client_id, + scopes=new_scopes, + expires_at=now + config.refresh_token_ttl, + ) + return OAuthToken( + access_token=access, + token_type="Bearer", + expires_in=config.access_token_ttl, + refresh_token=refresh, + scope=" ".join(new_scopes), + ) + + # ---- access-token validation (called per MCP request) ----------------- + + async def load_access_token(self, token: str) -> Optional[GoogleAccessToken]: + row = self.db.get_access_token(token) + if not row: + return None + return GoogleAccessToken( + token=row["token"], + client_id=row["mcp_client_id"], + scopes=row["scopes"], + expires_at=row["expires_at"], + resource=row["resource"], + google_email=row["google_email"], + ) + + # ---- revoke ----------------------------------------------------------- + + async def revoke_token(self, token) -> None: + # token is either GoogleAccessToken or GoogleRefreshToken; both have .token + if isinstance(token, AccessToken): + self.db.delete_access_token(token.token) + else: + self.db.delete_refresh_token(token.token) diff --git a/mcp_google_contacts_server/config.py b/mcp_google_contacts_server/config.py index ad9b978..6c9555e 100644 --- a/mcp_google_contacts_server/config.py +++ b/mcp_google_contacts_server/config.py @@ -37,6 +37,36 @@ class ContactsConfig(BaseModel): description="OAuth scopes required for the application" ) + # --- Multi-tenant mode (AUTH_MODE=multi) -------------------------------- + auth_mode: str = Field( + default="single", + description="'single' = legacy one-refresh-token mode. 'multi' = per-user OAuth broker." + ) + google_web_client_id: Optional[str] = Field( + default=None, + description="Google Web OAuth client ID (multi mode)" + ) + google_web_client_secret: Optional[str] = Field( + default=None, + description="Google Web OAuth client secret (multi mode)" + ) + oauth_issuer_url: Optional[str] = Field( + default=None, + description="Public base URL of this MCP server (e.g. https://contacts.nlma.io)" + ) + google_oauth_redirect_uri: Optional[str] = Field( + default=None, + description="Redirect URI registered with Google for the Web OAuth client" + ) + db_path: Path = Field( + default=Path("/opt/contacts-mcp/data.db"), + description="SQLite database for multi-tenant OAuth state" + ) + access_token_ttl: int = Field(default=3600, description="Access token lifetime (seconds)") + refresh_token_ttl: int = Field(default=60 * 60 * 24 * 30, description="Refresh token lifetime (seconds)") + auth_code_ttl: int = Field(default=600, description="Authorization code lifetime (seconds)") + oauth_state_ttl: int = Field(default=600, description="OAuth state row lifetime (seconds)") + def load_config() -> ContactsConfig: """Load configuration from environment variables and defaults.""" # Default credentials paths to check @@ -50,13 +80,21 @@ def load_config() -> ContactsConfig: token_dir = Path.home() / ".config" / "google-contacts-mcp" token_dir.mkdir(parents=True, exist_ok=True) - return ContactsConfig( + kwargs = dict( google_client_id=os.environ.get("GOOGLE_CLIENT_ID"), google_client_secret=os.environ.get("GOOGLE_CLIENT_SECRET"), google_refresh_token=os.environ.get("GOOGLE_REFRESH_TOKEN"), credentials_paths=default_paths, - token_path=token_dir / "token.json" + token_path=token_dir / "token.json", + auth_mode=os.environ.get("AUTH_MODE", "single"), + google_web_client_id=os.environ.get("GOOGLE_WEB_CLIENT_ID"), + google_web_client_secret=os.environ.get("GOOGLE_WEB_CLIENT_SECRET"), + oauth_issuer_url=os.environ.get("OAUTH_ISSUER_URL"), + google_oauth_redirect_uri=os.environ.get("GOOGLE_OAUTH_REDIRECT_URI"), ) + if db_path := os.environ.get("DB_PATH"): + kwargs["db_path"] = Path(db_path) + return ContactsConfig(**kwargs) # Global configuration instance config = load_config() diff --git a/mcp_google_contacts_server/db.py b/mcp_google_contacts_server/db.py new file mode 100644 index 0000000..09cca20 --- /dev/null +++ b/mcp_google_contacts_server/db.py @@ -0,0 +1,292 @@ +"""SQLite-backed storage for multi-tenant OAuth state. + +Single file at `config.db_path`. WAL mode, short-lived connections, no ORM. +All timestamps are unix epoch seconds (integers). +""" +import json +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional + +SCHEMA_VERSION = 1 + +_SCHEMA_V1 = """ +CREATE TABLE IF NOT EXISTS users ( + google_email TEXT PRIMARY KEY, + google_refresh_token TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS oauth_states ( + state TEXT PRIMARY KEY, + mcp_client_id TEXT NOT NULL, + mcp_redirect_uri TEXT NOT NULL, + mcp_redirect_uri_explicit INTEGER NOT NULL, + mcp_code_challenge TEXT NOT NULL, + mcp_scopes TEXT NOT NULL, + mcp_resource TEXT, + mcp_state TEXT, + expires_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_codes ( + code TEXT PRIMARY KEY, + google_email TEXT NOT NULL, + mcp_client_id TEXT NOT NULL, + mcp_redirect_uri TEXT NOT NULL, + mcp_redirect_uri_explicit INTEGER NOT NULL, + mcp_code_challenge TEXT NOT NULL, + mcp_scopes TEXT NOT NULL, + mcp_resource TEXT, + expires_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS access_tokens ( + token TEXT PRIMARY KEY, + google_email TEXT NOT NULL, + mcp_client_id TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER NOT NULL, + resource TEXT +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + token TEXT PRIMARY KEY, + google_email TEXT NOT NULL, + mcp_client_id TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER +); + +CREATE TABLE IF NOT EXISTS clients ( + client_id TEXT PRIMARY KEY, + metadata_json TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_access_tokens_email ON access_tokens(google_email); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_email ON refresh_tokens(google_email); +""" + + +class Db: + """Thin wrapper around sqlite3. Construct once at startup, share across requests.""" + + def __init__(self, path: Path): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._init_schema() + + @contextmanager + def _conn(self) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(self.path, isolation_level=None) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + def _init_schema(self) -> None: + with self._conn() as c: + c.executescript(_SCHEMA_V1) + c.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + + # ---- users ------------------------------------------------------------- + + def upsert_user(self, google_email: str, google_refresh_token: str) -> None: + now = int(time.time()) + with self._conn() as c: + c.execute( + """INSERT INTO users(google_email, google_refresh_token, created_at, last_seen_at) + VALUES(?, ?, ?, ?) + ON CONFLICT(google_email) DO UPDATE SET + google_refresh_token=excluded.google_refresh_token, + last_seen_at=excluded.last_seen_at""", + (google_email, google_refresh_token, now, now), + ) + + def get_user(self, google_email: str) -> Optional[Dict[str, Any]]: + with self._conn() as c: + row = c.execute( + "SELECT * FROM users WHERE google_email = ?", (google_email,) + ).fetchone() + return dict(row) if row else None + + def touch_user(self, google_email: str) -> None: + with self._conn() as c: + c.execute( + "UPDATE users SET last_seen_at = ? WHERE google_email = ?", + (int(time.time()), google_email), + ) + + # ---- oauth_states (CSRF / flow state for outbound Google redirect) ----- + + def put_state(self, state: str, data: Dict[str, Any], ttl_seconds: int = 600) -> None: + with self._conn() as c: + c.execute( + """INSERT INTO oauth_states(state, mcp_client_id, mcp_redirect_uri, + mcp_redirect_uri_explicit, mcp_code_challenge, mcp_scopes, + mcp_resource, mcp_state, expires_at) + VALUES(?,?,?,?,?,?,?,?,?)""", + ( + state, + data["mcp_client_id"], + data["mcp_redirect_uri"], + int(data["mcp_redirect_uri_explicit"]), + data["mcp_code_challenge"], + json.dumps(data["mcp_scopes"]), + data.get("mcp_resource"), + data.get("mcp_state"), + int(time.time()) + ttl_seconds, + ), + ) + + def pop_state(self, state: str) -> Optional[Dict[str, Any]]: + with self._conn() as c: + row = c.execute( + "SELECT * FROM oauth_states WHERE state = ? AND expires_at > ?", + (state, int(time.time())), + ).fetchone() + c.execute("DELETE FROM oauth_states WHERE state = ?", (state,)) + if not row: + return None + d = dict(row) + d["mcp_scopes"] = json.loads(d["mcp_scopes"]) + d["mcp_redirect_uri_explicit"] = bool(d["mcp_redirect_uri_explicit"]) + return d + + # ---- auth_codes (short-lived, one-time) -------------------------------- + + def put_auth_code(self, code: str, data: Dict[str, Any], ttl_seconds: int = 600) -> None: + with self._conn() as c: + c.execute( + """INSERT INTO auth_codes(code, google_email, mcp_client_id, mcp_redirect_uri, + mcp_redirect_uri_explicit, mcp_code_challenge, mcp_scopes, mcp_resource, + expires_at) + VALUES(?,?,?,?,?,?,?,?,?)""", + ( + code, + data["google_email"], + data["mcp_client_id"], + data["mcp_redirect_uri"], + int(data["mcp_redirect_uri_explicit"]), + data["mcp_code_challenge"], + json.dumps(data["mcp_scopes"]), + data.get("mcp_resource"), + int(time.time()) + ttl_seconds, + ), + ) + + def get_auth_code(self, code: str) -> Optional[Dict[str, Any]]: + with self._conn() as c: + row = c.execute( + "SELECT * FROM auth_codes WHERE code = ? AND expires_at > ?", + (code, int(time.time())), + ).fetchone() + if not row: + return None + d = dict(row) + d["mcp_scopes"] = json.loads(d["mcp_scopes"]) + d["mcp_redirect_uri_explicit"] = bool(d["mcp_redirect_uri_explicit"]) + return d + + def delete_auth_code(self, code: str) -> None: + with self._conn() as c: + c.execute("DELETE FROM auth_codes WHERE code = ?", (code,)) + + # ---- access_tokens ----------------------------------------------------- + + def put_access_token( + self, + token: str, + google_email: str, + mcp_client_id: str, + scopes: List[str], + expires_at: int, + resource: Optional[str] = None, + ) -> None: + with self._conn() as c: + c.execute( + """INSERT INTO access_tokens(token, google_email, mcp_client_id, scopes, + expires_at, resource) + VALUES(?,?,?,?,?,?)""", + (token, google_email, mcp_client_id, json.dumps(scopes), expires_at, resource), + ) + + def get_access_token(self, token: str) -> Optional[Dict[str, Any]]: + with self._conn() as c: + row = c.execute( + "SELECT * FROM access_tokens WHERE token = ? AND expires_at > ?", + (token, int(time.time())), + ).fetchone() + if not row: + return None + d = dict(row) + d["scopes"] = json.loads(d["scopes"]) + return d + + def delete_access_token(self, token: str) -> None: + with self._conn() as c: + c.execute("DELETE FROM access_tokens WHERE token = ?", (token,)) + + # ---- refresh_tokens ---------------------------------------------------- + + def put_refresh_token( + self, + token: str, + google_email: str, + mcp_client_id: str, + scopes: List[str], + expires_at: Optional[int] = None, + ) -> None: + with self._conn() as c: + c.execute( + """INSERT INTO refresh_tokens(token, google_email, mcp_client_id, scopes, expires_at) + VALUES(?,?,?,?,?)""", + (token, google_email, mcp_client_id, json.dumps(scopes), expires_at), + ) + + def get_refresh_token(self, token: str) -> Optional[Dict[str, Any]]: + with self._conn() as c: + row = c.execute( + "SELECT * FROM refresh_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > ?)", + (token, int(time.time())), + ).fetchone() + if not row: + return None + d = dict(row) + d["scopes"] = json.loads(d["scopes"]) + return d + + def delete_refresh_token(self, token: str) -> None: + with self._conn() as c: + c.execute("DELETE FROM refresh_tokens WHERE token = ?", (token,)) + + def delete_tokens_for_user(self, google_email: str) -> None: + """Revoke all access + refresh tokens for a user (e.g. on sign-out).""" + with self._conn() as c: + c.execute("DELETE FROM access_tokens WHERE google_email = ?", (google_email,)) + c.execute("DELETE FROM refresh_tokens WHERE google_email = ?", (google_email,)) + + # ---- clients (Dynamic Client Registration) ----------------------------- + + def put_client(self, client_id: str, metadata: Dict[str, Any]) -> None: + with self._conn() as c: + c.execute( + """INSERT INTO clients(client_id, metadata_json, created_at) + VALUES(?, ?, ?) + ON CONFLICT(client_id) DO UPDATE SET metadata_json=excluded.metadata_json""", + (client_id, json.dumps(metadata), int(time.time())), + ) + + def get_client(self, client_id: str) -> Optional[Dict[str, Any]]: + with self._conn() as c: + row = c.execute( + "SELECT metadata_json FROM clients WHERE client_id = ?", (client_id,) + ).fetchone() + return json.loads(row["metadata_json"]) if row else None diff --git a/mcp_google_contacts_server/google_contacts_service.py b/mcp_google_contacts_server/google_contacts_service.py index 6b58a30..8a7a2bf 100644 --- a/mcp_google_contacts_server/google_contacts_service.py +++ b/mcp_google_contacts_server/google_contacts_service.py @@ -18,15 +18,26 @@ class GoogleContactsError(Exception): class GoogleContactsService: """Service to interact with Google Contacts API.""" - def __init__(self, credentials_info: Optional[Dict[str, Any]] = None, token_path: Optional[Path] = None): + def __init__( + self, + credentials_info: Optional[Dict[str, Any]] = None, + token_path: Optional[Path] = None, + cache_token: bool = True, + refresh_token: Optional[str] = None, + ): """Initialize the Google Contacts service with credentials info. - + Args: credentials_info: OAuth client credentials information - token_path: Path to store the token file + token_path: Path to store the token file (defaults to config.token_path) + cache_token: If False, skip all token.json read/write. Required for + multi-tenant use where one process serves many users. + refresh_token: Explicit refresh token. Takes precedence over + GOOGLE_REFRESH_TOKEN env var when provided. """ self.credentials_info = credentials_info - self.token_path = token_path or config.token_path + self.refresh_token = refresh_token + self.token_path = (token_path or config.token_path) if cache_token else None self.service = self._authenticate() @classmethod @@ -87,7 +98,35 @@ def from_env(cls, token_path: Optional[Path] = None) -> 'GoogleContactsService': } return cls(credentials_info, token_path) - + + @classmethod + def from_tokens( + cls, + client_id: str, + client_secret: str, + refresh_token: str, + ) -> 'GoogleContactsService': + """Create service for a single caller in multi-tenant mode. + + Skips token.json caching so concurrent users don't stomp each other. + The refresh_token is used in-memory to mint access tokens on demand. + """ + credentials_info = { + "installed": { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"], + } + } + return cls( + credentials_info=credentials_info, + cache_token=False, + refresh_token=refresh_token, + ) + def _authenticate(self): """Authenticate with Google using credentials info. @@ -100,18 +139,19 @@ def _authenticate(self): try: creds = None token_path = self.token_path - - # Ensure token directory exists - token_path.parent.mkdir(parents=True, exist_ok=True) - - # Check if we have existing token - if token_path.exists(): - with open(token_path, 'r') as token_file: - creds = Credentials.from_authorized_user_info( - json.load(token_file), config.scopes) - - # Check for refresh token in environment - refresh_token = os.environ.get("GOOGLE_REFRESH_TOKEN") or config.google_refresh_token + + if token_path is not None: + token_path.parent.mkdir(parents=True, exist_ok=True) + if token_path.exists(): + with open(token_path, 'r') as token_file: + creds = Credentials.from_authorized_user_info( + json.load(token_file), config.scopes) + + refresh_token = ( + self.refresh_token + or os.environ.get("GOOGLE_REFRESH_TOKEN") + or config.google_refresh_token + ) if not creds and refresh_token and self.credentials_info: client_id = self.credentials_info["installed"]["client_id"] client_secret = self.credentials_info["installed"]["client_secret"] @@ -138,15 +178,14 @@ def _authenticate(self): flow = InstalledAppFlow.from_client_config( self.credentials_info, config.scopes) creds = flow.run_local_server(port=0) - - # Save the credentials for future use - with open(token_path, 'w') as token: - token.write(creds.to_json()) - - # Output refresh token for environment variable setup - if creds.refresh_token: - print("\nNew refresh token obtained. Consider setting this in your environment:") - print(f"GOOGLE_REFRESH_TOKEN={creds.refresh_token}\n") + + if token_path is not None: + with open(token_path, 'w') as token: + token.write(creds.to_json()) + + if creds.refresh_token: + print("\nNew refresh token obtained. Consider setting this in your environment:") + print(f"GOOGLE_REFRESH_TOKEN={creds.refresh_token}\n") # Build and return the Google Contacts service return build('people', 'v1', credentials=creds) diff --git a/mcp_google_contacts_server/google_oauth_routes.py b/mcp_google_contacts_server/google_oauth_routes.py new file mode 100644 index 0000000..f376be7 --- /dev/null +++ b/mcp_google_contacts_server/google_oauth_routes.py @@ -0,0 +1,144 @@ +"""Extra Starlette routes that the FastMCP app needs for the Google auth loop. + +Only `/oauth/google/callback` is exposed — the MCP client never calls this +directly; Google redirects the user's browser here at the end of consent. +""" +import secrets +import urllib.parse +from typing import Any, Dict + +import httpx +from mcp.server.fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import PlainTextResponse, RedirectResponse, Response + +from mcp_google_contacts_server.auth_provider import GoogleOAuthProvider +from mcp_google_contacts_server.config import config +from mcp_google_contacts_server.db import Db + + +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo" + + +def register_routes(mcp: FastMCP, db: Db, provider: GoogleOAuthProvider) -> None: + @mcp.custom_route("/oauth/google/callback", methods=["GET"]) + async def google_callback(request: Request) -> Response: + err = request.query_params.get("error") + state = request.query_params.get("state") + google_code = request.query_params.get("code") + + if not state: + return PlainTextResponse("missing state", status_code=400) + + state_row = db.pop_state(state) + if not state_row: + return PlainTextResponse("state not found or expired", status_code=400) + + mcp_redirect = state_row["mcp_redirect_uri"] + mcp_state = state_row.get("mcp_state") + + if err: + return _redirect_with(mcp_redirect, error=err, state=mcp_state) + + if not google_code: + return _redirect_with(mcp_redirect, error="invalid_request", state=mcp_state) + + # 1. Exchange Google authorization code for Google tokens. + try: + token_resp = await _post_json( + GOOGLE_TOKEN_URL, + data={ + "code": google_code, + "client_id": config.google_web_client_id or "", + "client_secret": config.google_web_client_secret or "", + "redirect_uri": config.google_oauth_redirect_uri or "", + "grant_type": "authorization_code", + }, + ) + except httpx.HTTPError as exc: + return _redirect_with( + mcp_redirect, + error="server_error", + error_description=f"google token exchange failed: {exc}", + state=mcp_state, + ) + + google_refresh_token = token_resp.get("refresh_token") + google_access_token = token_resp.get("access_token") + if not google_refresh_token or not google_access_token: + return _redirect_with( + mcp_redirect, + error="server_error", + error_description="google response missing refresh_token (user must re-consent)", + state=mcp_state, + ) + + # 2. Get the user's email. + try: + userinfo = await _get_json( + GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {google_access_token}"}, + ) + except httpx.HTTPError as exc: + return _redirect_with( + mcp_redirect, + error="server_error", + error_description=f"userinfo failed: {exc}", + state=mcp_state, + ) + + email = userinfo.get("email") + email_verified = userinfo.get("email_verified") + if not email or not email_verified: + return _redirect_with( + mcp_redirect, + error="access_denied", + error_description="google account has no verified email", + state=mcp_state, + ) + + # 3. Persist the Google refresh_token keyed by email. + db.upsert_user(email, google_refresh_token) + + # 4. Mint an authorization code for the MCP client. + our_code = secrets.token_urlsafe(32) + db.put_auth_code( + our_code, + { + "google_email": email, + "mcp_client_id": state_row["mcp_client_id"], + "mcp_redirect_uri": state_row["mcp_redirect_uri"], + "mcp_redirect_uri_explicit": state_row["mcp_redirect_uri_explicit"], + "mcp_code_challenge": state_row["mcp_code_challenge"], + "mcp_scopes": state_row["mcp_scopes"], + "mcp_resource": state_row.get("mcp_resource"), + }, + ttl_seconds=config.auth_code_ttl, + ) + + # 5. Send the browser back to the MCP client. + return _redirect_with(mcp_redirect, code=our_code, state=mcp_state) + + +def _redirect_with(base_url: str, **params: Any) -> RedirectResponse: + parsed = urllib.parse.urlparse(base_url) + existing = urllib.parse.parse_qsl(parsed.query) + existing.extend((k, v) for k, v in params.items() if v is not None) + new_query = urllib.parse.urlencode(existing) + final = urllib.parse.urlunparse(parsed._replace(query=new_query)) + return RedirectResponse(url=final, status_code=302) + + +async def _post_json(url: str, *, data: Dict[str, str]) -> Dict[str, Any]: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.post(url, data=data) + r.raise_for_status() + return r.json() + + +async def _get_json(url: str, *, headers: Dict[str, str]) -> Dict[str, Any]: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(url, headers=headers) + r.raise_for_status() + return r.json() diff --git a/mcp_google_contacts_server/main.py b/mcp_google_contacts_server/main.py index 84467f7..71493a9 100644 --- a/mcp_google_contacts_server/main.py +++ b/mcp_google_contacts_server/main.py @@ -2,100 +2,105 @@ MCP Google Contacts Server: A server that provides Google Contacts functionality through the Machine Conversation Protocol (MCP). """ -import sys -import os import argparse -from typing import Dict, List, Optional +import os from pathlib import Path + +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions from mcp.server.fastmcp import FastMCP -from mcp_google_contacts_server.tools import register_tools, init_service +from mcp_google_contacts_server.auth_provider import GoogleOAuthProvider from mcp_google_contacts_server.config import config +from mcp_google_contacts_server.db import Db +from mcp_google_contacts_server.google_oauth_routes import register_routes +from mcp_google_contacts_server.tools import init_service, register_tools + def parse_args(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="MCP Google Contacts Server" - ) - parser.add_argument( - "--transport", - choices=["stdio", "http"], - default="stdio", - help="Transport protocol to use (default: stdio)" - ) - parser.add_argument( - "--host", - default="localhost", - help="Host for HTTP transport (default: localhost)" - ) - parser.add_argument( - "--port", - type=int, - default=8000, - help="Port for HTTP transport (default: 8000)" - ) - parser.add_argument( - "--client-id", - help="Google OAuth client ID (overrides environment variable)" - ) - parser.add_argument( - "--client-secret", - help="Google OAuth client secret (overrides environment variable)" - ) - parser.add_argument( - "--refresh-token", - help="Google OAuth refresh token (overrides environment variable)" - ) - parser.add_argument( - "--credentials-file", - help="Path to Google OAuth credentials.json file" - ) - + parser = argparse.ArgumentParser(description="MCP Google Contacts Server") + parser.add_argument("--transport", choices=["stdio", "http"], default="stdio") + parser.add_argument("--host", default="localhost") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--client-id", help="Google OAuth client ID (single-mode override)") + parser.add_argument("--client-secret", help="Google OAuth client secret (single-mode override)") + parser.add_argument("--refresh-token", help="Google OAuth refresh token (single-mode override)") + parser.add_argument("--credentials-file", help="Path to Google OAuth credentials.json file") return parser.parse_args() + +def _build_mcp_single() -> FastMCP: + """Legacy mode: one baked-in refresh token shared by all callers.""" + return FastMCP("google-contacts") + + +def _build_mcp_multi() -> FastMCP: + """Per-user Google OAuth broker mode.""" + if not config.oauth_issuer_url: + raise RuntimeError("OAUTH_ISSUER_URL required when AUTH_MODE=multi") + if not config.google_web_client_id or not config.google_web_client_secret: + raise RuntimeError("GOOGLE_WEB_CLIENT_ID / GOOGLE_WEB_CLIENT_SECRET required when AUTH_MODE=multi") + if not config.google_oauth_redirect_uri: + raise RuntimeError("GOOGLE_OAUTH_REDIRECT_URI required when AUTH_MODE=multi") + + db = Db(config.db_path) + provider = GoogleOAuthProvider(db) + mcp = FastMCP( + "google-contacts", + auth_server_provider=provider, + auth=AuthSettings( + issuer_url=config.oauth_issuer_url, + resource_server_url=f"{config.oauth_issuer_url.rstrip('/')}/mcp", + required_scopes=[], + client_registration_options=ClientRegistrationOptions( + enabled=True, + default_scopes=config.scopes, + valid_scopes=config.scopes, + ), + ), + ) + register_routes(mcp, db, provider) + return mcp + + def main(): - """Run the MCP server.""" print("Starting Google Contacts MCP Server...") - args = parse_args() - - # Update config based on arguments + if args.client_id: os.environ["GOOGLE_CLIENT_ID"] = args.client_id if args.client_secret: os.environ["GOOGLE_CLIENT_SECRET"] = args.client_secret if args.refresh_token: os.environ["GOOGLE_REFRESH_TOKEN"] = args.refresh_token - - # Handle credentials file argument + if args.credentials_file: credentials_path = Path(args.credentials_file) if credentials_path.exists(): - # Add the specified credentials file to the beginning of the search paths config.credentials_paths.insert(0, credentials_path) print(f"Using credentials file: {credentials_path}") else: print(f"Warning: Specified credentials file {credentials_path} not found") - - # Initialize FastMCP server - mcp = FastMCP("google-contacts") - - # Register all tools + + if config.auth_mode == "multi": + print("AUTH_MODE=multi: per-user Google OAuth broker enabled") + mcp = _build_mcp_multi() + else: + print("AUTH_MODE=single: legacy single-tenant mode") + mcp = _build_mcp_single() + register_tools(mcp) - - # Initialize service with credentials - service = init_service() - - if not service: - print("Warning: No valid Google credentials found. Authentication will be required.") - print("You can provide credentials using environment variables or command line arguments:") - print(" GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN") - print(" --client-id, --client-secret, --refresh-token, --credentials-file") - - # Run the MCP server with the specified transport + + if config.auth_mode == "single": + service = init_service() + if not service: + print("Warning: No valid Google credentials found. Authentication will be required.") + print("You can provide credentials using environment variables or command line arguments:") + print(" GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN") + print(" --client-id, --client-secret, --refresh-token, --credentials-file") + if args.transport == "stdio": print("Running with stdio transport") - mcp.run(transport='stdio') + mcp.run(transport="stdio") else: print(f"Running with HTTP transport on {args.host}:{args.port}") mcp.settings.host = args.host @@ -103,7 +108,8 @@ def main(): extra_hosts = [h for h in os.environ.get("MCP_ALLOWED_HOSTS", "").split(",") if h] if extra_hosts: mcp.settings.transport_security.allowed_hosts.extend(extra_hosts) - mcp.run(transport='streamable-http') + mcp.run(transport="streamable-http") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/mcp_google_contacts_server/tools.py b/mcp_google_contacts_server/tools.py index 0dd389b..e09f6ca 100644 --- a/mcp_google_contacts_server/tools.py +++ b/mcp_google_contacts_server/tools.py @@ -1,16 +1,27 @@ """MCP tools implementation for Google Contacts.""" import asyncio +import threading +import time +import traceback +from collections import OrderedDict from typing import Dict, List, Optional, Any, Union + +from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.fastmcp import FastMCP -import traceback from mcp_google_contacts_server.google_contacts_service import GoogleContactsService, GoogleContactsError from mcp_google_contacts_server.formatters import format_contact, format_contacts_list, format_directory_people from mcp_google_contacts_server.config import config -# Global service instance +# Global service instance (single-tenant mode only) contacts_service = None +# Per-user service cache (multi-tenant mode) +_SERVICE_CACHE_MAX = 64 +_SERVICE_CACHE_TTL = 55 * 60 # rebuild just before Google's 1h access-token expiry +_service_cache_lock = threading.Lock() +_service_cache: "OrderedDict[str, tuple[GoogleContactsService, float]]" = OrderedDict() + def init_service() -> Optional[GoogleContactsService]: """Initialize and return a Google Contacts service instance. @@ -51,6 +62,65 @@ def init_service() -> Optional[GoogleContactsService]: traceback.print_exc() return None + +def _service_for_email(google_email: str) -> Optional[GoogleContactsService]: + """Build (or reuse) a GoogleContactsService for a specific user in multi-tenant mode. + + Looks up the user's refresh_token in the DB, constructs a per-user service, + and caches it briefly so consecutive tool calls in a session don't repeatedly + round-trip to Google's token endpoint. + """ + now = time.monotonic() + with _service_cache_lock: + entry = _service_cache.get(google_email) + if entry and entry[1] > now: + _service_cache.move_to_end(google_email) + return entry[0] + + # Import here to avoid a circular import at module load time + from mcp_google_contacts_server.db import Db + + db = Db(config.db_path) + user = db.get_user(google_email) + if not user: + return None + + try: + svc = GoogleContactsService.from_tokens( + client_id=config.google_web_client_id, + client_secret=config.google_web_client_secret, + refresh_token=user["google_refresh_token"], + ) + except Exception as e: + print(f"Failed to build service for {google_email}: {e}") + traceback.print_exc() + return None + + db.touch_user(google_email) + + with _service_cache_lock: + _service_cache[google_email] = (svc, now + _SERVICE_CACHE_TTL) + _service_cache.move_to_end(google_email) + while len(_service_cache) > _SERVICE_CACHE_MAX: + _service_cache.popitem(last=False) + return svc + + +def _resolve_service() -> Optional[GoogleContactsService]: + """Return the service appropriate for the current request. + + In single mode: shared global (init_service). + In multi mode: the caller's per-user service via the bearer-token context. + """ + if config.auth_mode == "multi": + tok = get_access_token() + email = getattr(tok, "google_email", None) if tok else None + if not email: + return None + return _service_for_email(email) + return init_service() + + def register_tools(mcp: FastMCP) -> None: """Register all Google Contacts tools with the MCP server. @@ -66,7 +136,7 @@ async def list_contacts(name_filter: Optional[str] = None, max_results: int = 10 name_filter: Optional filter to find contacts by name max_results: Maximum number of results to return (default: 100) """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." @@ -83,7 +153,7 @@ async def get_contact(identifier: str) -> str: Args: identifier: Resource name (people/*) or email address of the contact """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." @@ -104,7 +174,7 @@ async def create_contact(given_name: str, family_name: Optional[str] = None, email: Email address of the contact phone: Phone number of the contact """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." @@ -132,7 +202,7 @@ async def update_contact(resource_name: str, given_name: Optional[str] = None, email: Updated email address phone: Updated phone number """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." @@ -155,7 +225,7 @@ async def delete_contact(resource_name: str) -> str: Args: resource_name: Contact resource name (people/*) to delete """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." @@ -176,7 +246,7 @@ async def search_contacts(query: str, max_results: int = 10) -> str: query: Search term to find in contacts max_results: Maximum number of results to return (default: 10) """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." @@ -216,7 +286,7 @@ async def list_workspace_users(query: Optional[str] = None, max_results: int = 5 query: Optional search term to find specific users (name, email, etc.) max_results: Maximum number of results to return (default: 50) """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." @@ -236,7 +306,7 @@ async def search_directory(query: str, max_results: int = 20) -> str: query: Search term to find specific directory members max_results: Maximum number of results to return (default: 20) """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." @@ -256,7 +326,7 @@ async def get_other_contacts(max_results: int = 50) -> str: Args: max_results: Maximum number of results to return (default: 50) """ - service = init_service() + service = _resolve_service() if not service: return "Error: Google Contacts service is not available. Please check your credentials." From a1c1bef16cd3af3bd888a65cc7df8d3068f39458 Mon Sep 17 00:00:00 2001 From: Forrest Surprenant <57930975+ForrestOfFidum@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:20:00 -0400 Subject: [PATCH 7/7] chore(gitignore): ignore Google OAuth client secret files --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index de11fe9..5f34127 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ token.json credentials.json .env .env.* -!.env.example \ No newline at end of file +!.env.example +# Google OAuth client secrets (never commit) +client_secret_*.json