- Python 97.6%
- Vim Snippet 0.9%
- Dockerfile 0.8%
- Shell 0.7%
- README.md is the single reference: status, tools (trash via labels allowed and documented), access, Google Cloud, settings, git deploy, backups, scheduled tasks, tests, troubleshooting, open items - AGENTS.md holds agent rules; CLAUDE.md includes it - Remove SETUP.md, CALENDARS.md, HANDOVER.md, docs/mcp-server-reference.md - .env.example: CALDAV_URL empty by default so calendars stay off - Point Caddyfile.snippet and authorize.py at the README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| authorize.py | ||
| Caddyfile.snippet | ||
| calendars.py | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| mailbox | ||
| mailboxes.py | ||
| README.md | ||
| requirements.txt | ||
| server.py | ||
bogacai-mcp
A small MCP server on celen that Claude uses as a custom connector named bogacai-mcp. It lets Claude search, read, draft, send and label mail in several Gmail mailboxes, and (once switched on) read and add to the Nextcloud calendars. Built 9 October 2026 instead of using ready-made connectors: Claude's own Gmail connector holds one Google account at a time, and this way no third party holds the mailbox access.
This README is the single reference for the server. Celen-wide facts (Caddy, ports table,
backups) live in server-docs/celen-server-documentation.md, which points back here. Rules for
agents working in this repo are in AGENTS.md.
Claude (Anthropic's cloud)
-> https://mcp.celen.cloud Caddy on celen (native process, TLS)
-> 127.0.0.1:8811 container gmail-mcp, port 8000 inside
-> Gmail API, using a stored approval per mailbox
-> Nextcloud CalDAV (calendar tools, off until CALDAV_URL is set)
| Thing | Value |
|---|---|
| Address given to Claude | https://mcp.celen.cloud/mcp |
| Connector name in Claude | bogacai-mcp |
| Google Cloud app | bogacai-mail, External, unverified on purpose |
| Server folder | ~/gmail-mcp on celen (note: not mcp) |
| Container | gmail-mcp, built locally from python:3.13-slim |
| Source | Forgejo bogac/mcp, Mac checkout ~/dev/forgejo/mcp |
| Mailboxes | ada = bogachancelen.ai@gmail.com, vera = veracelen.ai@gmail.com, both approved 9 October 2026 |
Allowed sign-ins (ALLOWED_EMAILS) |
the same two addresses |
1. Status
Checked on celen on 28 October 2026:
- Running and healthy.
~/gmail-mcpis a git checkout ofbogac/mcp, deployed bygit pullsince 28 October 2026 (before that, files copied in by hand on 9 October 2026) - Mail tools on, sending on, calendars off (
CALDAV_URLnot set) .envmode 600, tokens mode 600- Searches answered in both mailboxes on 23 October 2026
- The pre-git folder is kept as
~/gmail-mcp-old-2026-10-28for rollback (open item 10)
2. Tools
Mail (always on)
Every tool except list_accounts takes the mailbox name, so it is always explicit which mailbox
is used. The server tells Claude to ask when the mailbox was not named.
| Tool | What it does | Changes anything? |
|---|---|---|
list_accounts |
Mailbox names and the address behind each | No |
search_emails |
Gmail search syntax (from:, is:unread, newer_than:7d). Sender, recipients, subject, date, labels, preview. 10 results by default, 50 at most |
No |
read_email |
One full message: headers, text, attachment names, types and sizes. Text beyond 40,000 characters is cut and flagged | No |
read_thread |
Every message in a conversation, oldest first | No |
create_draft |
Saves a plain-text draft, optionally as a reply that stays in its conversation | Adds a draft |
send_email |
Sends at once. To, cc, bcc, plain text, optionally a reply. Refused when ALLOW_SEND=false |
Sends mail |
list_labels |
The mailbox's labels, user labels first | No |
modify_labels |
Adds or removes existing labels on one message, by name or id | Changes labels; see below |
What modify_labels can do, because it accepts any existing label, system labels included:
- remove
INBOXarchives; removeUNREADmarks read; addSTARREDstars - add
TRASHmoves the message to Trash, and addSPAMmarks it as spam. Google's labels guide lists both as labels that "can be manually applied" throughmessages.modify, which is the call this tool makes. Gmail empties Trash after 30 days. The Ada weekday brief relies on this. Read from the code and Google's docs; not yet tried on a real message
What the server cannot do: delete mail permanently (the Google permission gmail.modify does not
allow it), open or send attachments, send formatted mail, or keep any mail (nothing is stored
after a request). Searching does not mark mail read (checked on ada, 9 October 2026).
Calendars (off until CALDAV_URL is set)
| Tool | What it does | Changes anything? |
|---|---|---|
list_calendars |
Calendars and task lists, and which ones may be changed | No |
query_events |
Events between two dates, repeating events expanded, local time. At most 62 days per call | No |
query_tasks |
Open tasks in one list, optionally only those due by a date | No |
create_task |
New task. Refused without notes, because every task needs a first step | Adds a task |
update_task |
Changes an open task's title, notes or due date. Refuses completed or cancelled tasks | Edits a task |
create_event |
New event, 30 minutes if no end is given | Adds an event |
Only calendars named in CALDAV_WRITE_CALENDARS can be changed; everything else, including the
MBS subscription, is read-only. No tool completes, reopens or deletes anything.
Claude asks before using a tool that changes something, unless that tool is set to "always allow" in Claude. The server itself does not ask.
3. How access works
Two independent locks. Keep them apart when reasoning about access.
- Who may connect. Claude signs in through Google. FastMCP's
OAuthProxysits in front;AllowlistVerifier(aGoogleTokenVerifiersubclass) accepts a token only if Google says the address is verified, the address is inALLOWED_EMAILS, and the token was issued to this Google client. Results are cached for 300 seconds.AuthMiddlewarerepeats the allowlist check on every tool call. Only Claude's callbackshttps://claude.ai/api/mcp/auth_callbackandhttps://claude.com/api/mcp/auth_callbackcan complete a sign-in (override withALLOWED_REDIRECT_URIS), so another MCP client cannot use the gate. The server issues its own tokens to Claude; a raw Google token sent to/mcpis refused. - Which mailboxes exist. A mailbox exists only after a one-time approval on the server
(section 7). The approval is stored encrypted in
data/tokens/<name>.token. Being onALLOWED_EMAILSdoes not make an account a mailbox, and a mailbox does not need to be onALLOWED_EMAILS.
Every allowed sign-in can use every mailbox. There is no per-mailbox permission.
Three paths answer without sign-in and show nothing private: / (home page), /privacy, and
/healthz (returns a static ok). Google will not publish an External app without the first two,
which is why the server serves them.
4. Google Cloud
One project, one app, one OAuth client, all at https://console.cloud.google.com.
| Item | Value |
|---|---|
| API enabled | Gmail API, not "Gmail MCP API" (a separate Google product this does not use) |
| App name | bogacai-mail, audience External |
| Client type | Web application |
| Redirect URIs | https://mcp.celen.cloud/auth/callback (Claude's sign-in, lock 1) and http://localhost:8765/ (mailbox approval, lock 2; the trailing slash matters) |
| Scopes | openid, https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/gmail.modify |
| Branding | home page https://mcp.celen.cloud/, privacy policy https://mcp.celen.cloud/privacy, authorized domain celen.cloud |
| Publishing status | Must be In production. While "Testing", Google cancels every approval after seven days |
The status has not been read off the Audience page. It is confirmed by behavior: both mailboxes were approved on 9 October 2026 and both answered a search on 23 October 2026, 14 days later.
The app is unverified on purpose. Google's help says a personal-use app under 100 users needs no verification; the cost is a "Google hasn't verified this app" warning at each approval. Google keeps showing "Your app requires verification". Do not submit it.
Every approval and sign-in makes Google send a "Security alert" or "You shared some Google Account data" email to that account. Expected.
5. Files and settings
In this repo
| File | Role |
|---|---|
server.py |
Sign-in gate, the mail tools, the calendar tools (registered only when CALDAV_URL is set), the public pages |
mailboxes.py |
Encrypted mailbox store: one Fernet-encrypted JSON file per mailbox, mode 600, reloaded when its modification time changes |
authorize.py |
One-time mailbox approval, run through ./mailbox |
calendars.py |
Nextcloud CalDAV client for the calendar tools |
mailbox |
Wrapper: docker compose run --rm -p 127.0.0.1:8765:8765 gmail-mcp python authorize.py "$@" |
Dockerfile, docker-compose.yml, requirements.txt |
Build and run. Every Python dependency pinned (fastmcp==4.1.0, which uses httpx2, not httpx) |
.env.example |
Every setting, blank. The real .env exists only on the server |
Caddyfile.snippet |
The mcp.celen.cloud block, with the optional IP restriction commented out |
.gitignore, .dockerignore |
Keep .env, data/ and __pycache__/ out of git; .dockerignore also keeps *.md and .git/ out of the image |
On the server, in ~/gmail-mcp
The repo checkout plus two things that never leave the server: .env, and data/ (bind-mounted
to /data) with data/tokens/<name>.token per mailbox and data/fastmcp/ for Claude's encrypted
sign-in state. .env and data/ only work together: data/ is encrypted with keys from .env.
Backups (checked 28 October 2026): the nightly system-config tar (backup_system_config.sh)
holds .env and docker-compose.yml. Nothing backs up data/. Code is in Forgejo.
Settings (.env)
After any change: docker compose up -d --force-recreate. A plain restart does not re-read
env_file.
| Setting | What it controls |
|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
The Google client (secret) |
ALLOWED_EMAILS |
Who may connect, commas between |
BASE_URL |
https://mcp.celen.cloud |
JWT_SIGNING_KEY |
Secret. Signs Claude's sessions and keys data/fastmcp. Changing it signs Claude out everywhere |
TOKEN_ENCRYPTION_KEY |
Secret. Decrypts data/tokens. Changing or losing it means approving every mailbox again |
ALLOW_SEND |
true or false |
HOST_PORT |
Loopback port Caddy forwards to, 8811 |
PUID, PGID |
User the container runs as, 1000:1000 |
APP_NAME, CONTACT_EMAIL |
Shown on the home and privacy pages. Empty, so the pages say "Private mail connector" |
CALDAV_URL |
Nextcloud calendar home. Any non-empty value switches the calendar tools on |
CALDAV_USER, CALDAV_PASSWORD |
Nextcloud user and an app password (secret), never the real password |
CALDAV_TZ |
Default Australia/Melbourne |
CALDAV_READ_CALENDARS |
Optional allowlist of display names; empty means every calendar |
CALDAV_WRITE_CALENDARS |
The only calendars that can be changed, display names, case-insensitive |
LOG_LEVEL, AUTHORIZE_PORT |
Optional, defaults INFO and 8765 |
./mailbox --gen-secrets prints a fresh JWT_SIGNING_KEY and TOKEN_ENCRYPTION_KEY.
6. Deploying
Edit on the Mac, push to Forgejo, pull on celen. Never edit code on the server.
[Mac] cd ~/dev/forgejo/mcp && git push
[server] cd ~/gmail-mcp && git pull --ff-only && docker compose up -d --build
[server] sleep 10 && docker compose ps && docker compose logs --tail 20
Check after every deploy:
git log -1 --format=%hon the server matches the Mac- the log shows
Allowed sign-ins: ...,Mailboxes: ada, vera,Sending is on|offandCalendars: on|off. AConfiguration problem: ...line names the setting to fix curl http://127.0.0.1:8811/healthzprintsok. The server needs a few seconds to start; acurlin the same second gets "connection reset"- if the tool list changed, start a fresh Claude session; if the new tools still do not show, disconnect and reconnect the connector in Claude's settings
The server pulls with a read-only Forgejo deploy key, ~/.ssh/mcp_deploy, through the
forgejo-mcp host alias in ~/.ssh/config (same pattern as forgejo-manec).
Fresh install (rebuild from nothing)
- DNS: an IONOS A record for
mcp.celen.cloudpointing at celen's public IP (new subdomains default to the wrong one). - Google Cloud as in section 4, then Audience > Publish app.
- On celen:
Fill[server] git clone forgejo-mcp:bogac/mcp.git ~/gmail-mcp && cd ~/gmail-mcp [server] mkdir data && cp .env.example .env && chmod 600 .env # create data/ yourself so root does not own it [server] docker compose build && ./mailbox --gen-secrets.env: the two generated secrets, the Google client id and secret,ALLOWED_EMAILS. docker compose up -d, then the checks above.- Add the
Caddyfile.snippetblock to/etc/caddy/Caddyfile,sudo systemctl reload caddy, andcurl https://mcp.celen.cloud/healthzfrom anywhere. - Approve each mailbox (section 7).
- In Claude: Settings > Connectors > Add custom connector, name
bogacai-mcp, addresshttps://mcp.celen.cloud/mcp. Sign in with an address fromALLOWED_EMAILS, then ask Claude to list the Gmail accounts.
The connector lives in your Claude account, not on the Mac, so it works from any device.
7. Day to day (server, in ~/gmail-mcp)
| Task | Command |
|---|---|
| Logs: one line per tool call (tool, mailbox, signed-in address) and per refused sign-in; never content | docker compose logs -f |
| Health | docker compose ps should say "healthy" (checked every 60 seconds) |
| List mailboxes | ./mailbox --list |
| Add a mailbox | see below |
| Rename a mailbox | mv data/tokens/<old>.token data/tokens/<new>.token; no restart, no re-approval |
| Remove a mailbox | ./mailbox --remove <name>, then remove the app at myaccount.google.com/connections in that account |
| Change who may connect | edit ALLOWED_EMAILS, then docker compose up -d --force-recreate |
| Turn sending off or on | edit ALLOW_SEND, then docker compose up -d --force-recreate |
| Sign Claude out everywhere | ./mailbox --gen-secrets, paste only the new JWT_SIGNING_KEY, then docker compose up -d --force-recreate |
Adding a mailbox. The approval needs a browser and the server has none, so the Mac's browser is borrowed through a tunnel:
[Mac] ssh -L 8765:127.0.0.1:8765 celen
[server] cd ~/gmail-mcp && ./mailbox <name> # in that same session
Open the printed link on the Mac, pick the Gmail account, click Advanced on the unverified-app
warning, continue, allow. The terminal prints Stored mailbox '<name>' for <address>, and the
server picks it up on the next request. Names are lowercase letters, digits, - and _. A
university or company Google account may block unverified apps ("Access blocked"); such a
mailbox cannot be added this way.
8. Calendars: switching them on
About 15 minutes. Not done yet.
- App password. Nextcloud: Settings, Security, Devices & sessions, "Create new app
password", name it
mcp-calendars. Store it in Vaultwarden. Revoking it later switches calendar access off. - Settings. Add the calendar block from
.env.exampleto~/gmail-mcp/.env:CALDAV_URLwith your Nextcloud user name,CALDAV_USER,CALDAV_PASSWORD, andCALDAV_WRITE_CALENDARS(for exampleADOD,Boğaç, matching the display names in Nextcloud). - Recreate.
docker compose up -d --force-recreate; the log must sayCalendars: on, writable: .... - Hourly subscription refresh. Nextcloud refreshes subscribed calendars (MBS) once a day by
default:
[server] docker exec -u www-data nextcloud-app-1 php occ config:app:set dav calendarSubscriptionRefreshRate --value "PT1H" - First real run. Ask Claude to list the calendars with bogacai-mcp, then query a week of events and check the MBS meetings appear with titles.
Nextcloud calendar URLs use the URI slug, not the display name. List the slugs with
docker exec -u www-data nextcloud-app-1 php occ dav:list-calendars bogac.
9. Hardening
- Caddy IP restriction. The commented
@outsidersrule inCaddyfile.snippetlimits/mcpto160.79.104.0/21, the range Anthropic publishes for its outbound calls. The sign-in pages must stay open because your own browser visits them. Not enabled on celen (checked 28 October 2026). To try it: copy the rule into the live block,sudo systemctl reload caddy, ask Claude to list the accounts; if that fails, take it out again. - Container. Read-only root,
/tmpin memory, all capabilities dropped,no-new-privileges, non-root, port on loopback only, FastMCP update check off. Stateless HTTP transport, so restarts do not break Claude's session; Claude's sign-in survives a restart (tested 9 October 2026).
10. Security: what protects what
| If this happens | What protects you | The limit |
|---|---|---|
| A stranger finds the address | Only /, /privacy and /healthz answer without sign-in |
The address is public |
| Someone signs in with their own Google account | Refused on every request | None found in testing |
| Another program tries to use the gate | Only Claude's callback addresses complete a sign-in | None found in testing |
Someone copies data/ |
Encrypted, useless without .env |
A backup holding both is as sensitive as the server |
| The server is taken over | Nothing: whoever has .env and data/ can use both mailboxes |
Remove the app at myaccount.google.com/connections in each account; that cancels the approvals at once |
| An email carries text written to steer an assistant | Claude asks before a changing action; ALLOW_SEND=false removes sending |
Claude can still be misled, and can trash mail through modify_labels (recoverable for 30 days) |
| A flaw in the program | Hardened container (section 9) | It can still reach data/ and Google |
The mail passes through Claude, so Anthropic's systems see what you ask about. True of any mail connector.
11. Scheduled tasks that use this connector
A scheduled task keeps the connector list it had when it was created, and Cowork's edit screen has no connector setting. Connector tools in scheduled runs can arrive deferred and need one tool search to load (anthropics/claude-ai-mcp #999). State read from the trigger API on 28 October 2026:
| Task | ID | Enabled | Connector attached? | Instructions |
|---|---|---|---|---|
| Ada weekday brief | trig_016Qz9B7WBmmvD4GPUig7Agq |
Yes, weekdays 07:00 Melbourne | No | Say to use bogacai-mcp, mailbox ada, and trash handled mail by adding TRASH |
| Vera weekday brief | trig_01XiWXh5rGR9CnB982jsjeH6 |
No (since 24 October 2026) | No | Predate the server; name no connector |
| Ada weekday brief (cloud) | trig_01KfpqpSe7RLQnZdr2XrLrix |
Yes, but no schedule | Yes | Placeholder, "not configured yet" |
The other Ada and Vera tasks (Sunday briefs, Friday wraps, meeting capture) are disabled and have no connector.
12. Tests
The suites used before deployment are lost (they lived in a previous agent's scratch space). What
they covered, so they can be rebuilt in a tests/ folder:
- Tools, 29 checks: FastMCP's in-memory
Clientagainstcreate_server(None), withmailboxes.gmailpatched to return a Gmail client ongoogleapiclient.http.HttpMockSequence. Encrypted store round-trip, mode 600, wrong key refused; search parsing; Turkish text in windows-1254; HTML-only bodies; truncation at 40,000 characters; threads; replies keepingthreadId,In-Reply-ToandReferences; draft versus send;ALLOW_SEND=false; label resolution; bad mailbox names; Gmail 404; revoked approval (RefreshError). - Sign-in gate, 28 checks: the real app under uvicorn, a fake Google token endpoint, and
Google's tokeninfo and userinfo stubbed through
httpx2.MockTransportpassed tobuild_auth()(its keyword arguments exist only for this). Full flow: client registration,/authorize, consent with CSRF token,/auth/callback,/tokenwith PKCE,/mcp. No token, garbage token, raw Google token, listed, unlisted and unverified addresses, a token from another Google client, a non-Claude redirect, the public pages, and encrypted login state on disk. - Calendars, 23 checks (15 October 2026): against Radicale 3.8.3. Repeating and all-day events, Turkish names, open and completed tasks, the due filter, create and update, the 30-minute default, every refusal. Never run against real Nextcloud; the open question is how Nextcloud presents the MBS subscription.
Exercised against real Gmail: sign-in, list_accounts, search_emails in both mailboxes.
Not yet: read_email, read_thread, drafting, sending, label changes including TRASH.
13. Troubleshooting
| What you see | Cause | Fix |
|---|---|---|
Claude answers with the wrong mailbox or no ada/vera |
Claude's built-in Gmail connector is on in that chat and was used instead | Switch it off in the chat's connector menu |
| Claude asks to connect again | Signed in with an unlisted address, or JWT_SIGNING_KEY changed |
Connect again with an allowed address |
| "Google no longer accepts the stored approval for mailbox X" | Approval cancelled: "Testing" status, password change, six months unused, or app removed | ./mailbox X again |
| "No mailbox named X. Available: ..." | Wrong name | Use a listed name |
| "Sending is switched off on this server" | ALLOW_SEND=false |
Change it, or draft instead |
| "Mailbox X cannot be decrypted" | TOKEN_ENCRYPTION_KEY changed |
Restore the old key, or approve again |
docker compose ps says "Restarting" |
A setting is missing or wrong | docker compose logs --tail 20, look for Configuration problem: |
curl "connection reset" right after up -d |
Still starting | Wait 10 seconds |
| TLS error right after a Caddy change | Certificate still being issued | Wait a minute, then check DNS |
Google Error 400: redirect_uri_mismatch |
Client lacks exactly http://localhost:8765/ |
Fix the redirect list, wait a few minutes |
| "Port 8765 is busy" | Previous approval's socket not released | Wait a minute |
| Google's Publish button is grey | Home or privacy link missing on Branding | Fill in section 4's links |
Search preview shows ' |
Gmail returns snippet HTML-escaped and it is passed through |
Not fixed yet: html.unescape in parse_message |
| Scheduled run: "bogacai-mcp isn't available in this session" | Task created before the connector existed | Section 11 |
Interactive run: list_accounts blocked as "Exfil Scouting" |
Claude's auto-approval classifier saw an unattended call returning addresses | Authorize the call in the task's own instructions; do not route around it |
| Calendars: "Nextcloud refused the calendar list (401)" | Wrong user or app password | Section 8, steps 1 and 2 |
| Calendars: "Could not reach Nextcloud: ConnectTimeout" | Container cannot reach celen.cloud through the router |
Add extra_hosts: ["celen.cloud:host-gateway"] under gmail-mcp: in docker-compose.yml, commit, deploy |
| MBS meetings have no titles | Outlook feed published as free/busy only | Republish from Outlook with full details |
| MBS a day behind | Hourly refresh not set | Section 8, step 4 |
14. Design decisions
- Separate locks for connecting and for mailboxes, so a mailbox never needs to be a login.
- No permanent delete.
gmail.modifycannot do it. Trashing throughmodify_labelsis allowed on purpose (decided 28 October 2026): it is recoverable for 30 days and the Ada brief uses it. ALLOW_SENDswitch: one setting turns sending off for every mailbox; drafting stays on.- The server serves its own home and privacy pages, because Google requires both links on a domain you own before publishing.
- Unverified Google app in production. Do not submit it for verification.
- Stateless HTTP transport, so restarts do not break Claude's session.
- House pattern on celen: loopback port, Caddy terminates TLS, one Compose network per
service, secrets only in
.env. - Calendars are opt-in and write only to named calendars, with no complete, reopen or delete.
15. Open items
As of 28 October 2026, most important first.
- Ada weekday brief has no connector. It runs every weekday but cannot reach bogacai-mcp. Recreate it with the connector attached (section 11), then retire the old trigger and the cloud placeholder.
- Vera weekday brief. Disabled; its instructions predate the server. Rewrite with the connector attached, or delete.
- Live tests. Try
read_email,read_thread, a draft, a send to yourself, and a label change includingTRASHon a message of your own (section 12). - Back up
data/. Not in any backup (section 5). Losing it costs two mailbox approvals and a fresh Claude sign-in, no mail. - Secrets in Vaultwarden. Confirm
GOOGLE_CLIENT_SECRET,JWT_SIGNING_KEYandTOKEN_ENCRYPTION_KEYare stored there. - Uptime Kuma monitor for
https://mcp.celen.cloud/healthz. None exists. - Caddy IP restriction. Try it (section 9).
'in search previews.html.unescapeinparse_message(section 13).- Rebuild the tests in a
tests/folder (section 12). - Delete
~/gmail-mcp-old-2026-10-28once a later deploy bygit pullhas worked. - Calendars. Switch on when wanted (section 8).
- Sending.
ALLOW_SENDistrue. Decide whether it stays on.
16. Decisions not made
- WhatsApp. Asked for, then dropped on 14 October 2026. If it returns: the official route needs a second number not on WhatsApp (Meta's Cloud API, 24-hour free-text window, templates otherwise); the unofficial route on his own number risks a ban and must not share a container with mail. He does not use Zapier.