Under the hood — the architecture¶
This page explains how the project is built: which pieces exist, who talks to whom, and — above all — where the safeguards are that stop the assistant from acting on its own. It is for anyone who wants to understand or audit the system; to just get started, go to Getting started instead.
One principle runs through everything below:
The tool verifies, the human authorizes, the LLM proposes. The assistant can ask for access; it can never grant it to itself.
The idea in one picture¶
Everything runs on your machine — nothing is hosted in an application cloud.
Your assistant talks to the MCP server; the server goes through a gateway that
checks two things before every call to Google: is the account unlocked? Is the
action allowed by the policy? You, the human, act from the other side — the mag
CLI or the admin interface — to open or close these accesses. The two sides meet on
the same state (locks, grants, policy) without either being able to command the
other.
flowchart TD
subgraph clients [LLM clients]
Desktop[Claude_Desktop]
Cursor[Cursor]
Code[Claude_Code]
end
subgraph entry [Entry door]
MCP[bin_google_mcp]
GW[gateway_api]
GMA[bin_gma]
Admin[admin_4877]
end
subgraph controls [Controls]
Lock[profile_lock]
Pol[policy_check]
Grants[Drive_zone_grants]
Strong[strongauth_TouchID]
end
subgraph exec [Executor_v1]
GWS[gws_CLI]
Creds["credentials.enc_per_alias"]
end
Google[APIs_Google]
Desktop --> MCP
Cursor --> MCP
Code --> MCP
Code -.->|admin_shell| GMA
Admin --> GMA
MCP --> GW
GW --> Lock
GW --> Pol
GMA --> Lock
GMA --> Pol
Pol --> Grants
Strong -.->|unlock_grant_human| GMA
GW -->|executor.py| GWS
GMA --> GWS
GWS --> Creds
GWS --> Google
The detailed sequence diagrams (reading data, connecting an account, repairing
access) are in diagrams/.
Where things live¶
On the workstation, six layers take turns:
| Layer | Artifact | Role |
|---|---|---|
| LLM clients | Claude Desktop, Cursor, Claude Code | Call the MCP (data) or the shell (admin) |
| MCP | bin/google-mcp → gateway/ |
The recommended entry point for Gmail/Drive |
| Human CLI | bin/mag |
Multi-account: add, lock/unlock, grant, policy |
| Human cockpit | admin/server.js 127.0.0.1:4877 |
Local web interface (never exposed beyond loopback) |
| Policy | scripts/policy-check.py |
Checks "default-deny" before any call to Google |
| Google execution | gws + ~/.config/gws-accounts/<alias>/ |
Encrypted tokens + API calls |
Since Phase 2 A, gws is no longer launched all over the place: a local broker
(bin/google-broker,
started automatically, listening on 127.0.0.1:4878) is the only component that
runs it to access data. The gateway only talks to the broker. Credentials stay under
~/.config/gws-accounts/ (the dedicated vault is planned — backlog item features/0003-vault-credentials-hors-perimetre-agent.md).
Two names not to confuse: the repository and the connector¶
You will come across two very similar identifiers. This is not an inconsistency; it is the MCP convention:
| Name | What it refers to | Where you see it |
|---|---|---|
google-mcp-multi-account |
the repository / project (and the name the server announces, mcp_server.py:SERVER_NAME) |
GitHub URL, cloned folder, bin/google-mcp binary, ~/.local/share/google-mcp/ install |
google-multi-account |
the installed connector — the key under mcpServers in the client config |
Claude Desktop → Connectors, claude mcp get |
The repository carries mcp (discoverable, self-describing); the connector is shorter
because it already lives under mcpServers — repeating "mcp" there would be
redundant. Same pattern everywhere: github/github-mcp-server → connector github;
the mcp-server-git package → connector git. The CLI, for its part, is called mag.
The components, one by one¶
The gateway (gateway/)¶
This is the core: it receives calls from the MCP server and lets none of them through without a check.
| Module | Responsibility |
|---|---|
api.py |
The public contract: profiles_list, gmail_*, drive_*, access_request |
profiles.py |
Aliases, locks, profile listing |
executor.py |
RPC client to the broker (no more gws calls here) |
broker_server.py |
The 127.0.0.1:4878 daemon — lock + policy + audit log + gws |
default_policy.py |
The "cautious" policy written at mag add |
mcp_server.py |
The MCP stdio adapter (JSON-RPC, stdlib only) |
errors.py |
GatewayError (locked, policy, alias, …) |
Concretely, a call like gmail_list goes through five steps: it validates the
alias, refuses if the profile is locked (with an elicitation message), passes the
command to the policy-check, logs it, and only then executes. And
access_request never unlocks or grants anything: it returns the exact command
that the human will have to run (mag unlock, mag grant).
The exposed MCP tools¶
Deliberately few and unsurprising — there is no generic "run any command" tool. Each one reads or writes something precise:
| Tool | Effect | Sends mail? |
|---|---|---|
profiles_list |
Lists aliases / lock / policy | — |
gmail_list / gmail_get |
Read | No |
gmail_draft_create |
Draft | No (no send tool) |
gmail_attachment_get |
Read — attachment written to .downloads (ADR-0006) |
— |
drive_list / drive_get |
Read, including the owner | — |
drive_read |
Content as text (Doc → markdown, Sheet → CSV) | — |
drive_create / drive_update |
Create / modify under an authorized zone (ADR-0003) | — |
drive_copy / drive_upload |
Native copy / upload, under a zone | — |
drive_permissions_* |
Read/write sharing (share policy) |
— |
access_request |
Returns a command for the human to run | — |
The mag CLI¶
The human control station. It isolates each account in its own directory
(~/.config/gws-accounts/<alias>/), writes a cautious policy at connection time, and
exposes the human controls (lock, unlock, grant, policy). Before any call to
gws, it checks the lock and then the policy. Its interpreters use absolute
paths (/usr/bin/python3, /usr/bin/swift) — so a fake python3 at the front of
PATH cannot quietly disable the policy.
The policy (policy-check.py)¶
When a policy.json exists, the rule is "default-deny":
| Rule | Behavior |
|---|---|
| Undeclared service | Refused (e.g. chat, meet) |
| Declared service | Fail closed by category (read, send, create, …) |
Drive zonesOnly |
Writes only under authorized folders ∪ temporary grants |
| Corrupted JSON | Refused (fail closed) |
The default preset is deliberately cautious (gateway/default_policy.py): Drive with
no write zone, Gmail with read + drafts without sending, everything else
essentially read-only.
Per-session rights (sessions.py)¶
On top of the policy (the workstation's floor), each conversation carries its
own rights, signed and short-lived (ADR-0007).
A session's identity is not the MCP connection but a token that the human creates
with a signed action (mag session open) and that the assistant presents on every
call — so two conversations on the same connection stay isolated.
| Aspect | Behavior |
|---|---|
| Granularity | Capabilities per service × operation × resource (resource optional: Drive zone, Gmail label…) |
| Granting | mag session unlock\|grant\|grant-capability — always signed (never a silent widening) |
| Effective | account policy ∩ project manifest ∩ session capabilities (∩, fail closed; anti-downgrade if the manifest is tampered with) |
| Lifecycle | TTL + explicit revocation; MCP disconnection purges nothing; registry .sessions/<token>.json |
| Sub-agents | Inheritance ⊆ parent, no autonomous widening, cascading revocation |
| Bootstrap | Without a token: no data access, only the creation request (mag session open) |
The admin interface¶
A small local web UI, designed to stay harmless: it listens only on
127.0.0.1:4877, requires an X-GWSA-Admin header and checks the Origin
(anti-CSRF), acts only through execFile(mag) (never a shell), and has no
application-level authentication — the trust is "we are on the local machine".
Where the secrets are¶
| Item | Location |
|---|---|
| Encrypted OAuth tokens | ~/.config/gws-accounts/<alias>/credentials.enc |
| Master key | macOS Keychain (gws-cli), via gws |
client_secret.json |
~/.config/gws-accounts/ (outside the repository) |
| Policy / grants / lock | Files in the profile folder |
| Audit log | ~/.config/gws-accounts/usage.jsonl |
What each safeguard protects — and its limit¶
The table is deliberately honest in its last column: every control has a condition that bypasses it.
| Control | Where | Against what | Bypassable if… |
|---|---|---|---|
| Default-deny policy | policy-check.py |
Sending, unlisted services, writes outside zones | The agent calls gws outside mag/the gateway |
| Drive zones + grants | policy + session-grants.json |
Writes outside authorized folders | Same, bare gws |
| Per-session rights | sessions.py + broker |
Rights leaking between conversations; unsigned widening | The agent calls gws outside the gateway (cooperative until the vault) |
| Profile lock | .locked / .unlock-until |
Access without your go-ahead | Editing the files, or bare gws |
MCP tools without send |
mcp_server.py |
Sending mail via the MCP | Another client / the shell |
Non-executing access_request |
api.py |
Self-unlocking by the LLM | The human (or an agent) runs the command |
| Touch ID (strongauth) | touchid.swift |
Unlock/grant without physical presence | Forged PATH: no (absolute swift); editable lock files: yes |
| Loopback admin + anti-CSRF | admin/server.js |
A remote website | A malicious local process |
usage.jsonl audit log |
3 write paths | Cooperative audit | GWSA_CLIENT can be forged |
In plain terms (Phase 1): this is the discipline of a cooperative agent (MCP + policy + lock), not a prison. It is not isolation against an agent that has a free shell and access to the credential files — this is stated in black and white in the threat model.
The three paths to know¶
The supported path (data goes through here):
The human path (you open/close accesses):
The path to block on the agent's permissions side:
The countermeasure: deny the agent, in its shell permissions, bare gws calls and
edits to ~/.config/gws-accounts/ — data goes through the MCP only.
Where the product stands¶
| Phase | State | Content |
|---|---|---|
| 1 | Deployed | MCP + gateway + default-deny + docs |
| 2 A | Deployed | Loopback broker: gws only inside the broker |
| Rights/session | In progress — ADR-0007 | Signed token carried per call, fine-grained capabilities per conversation, isolation, audit |
| 2.1 | Planned — backlog item features/0003-vault-credentials-hors-perimetre-agent.md |
Credentials vault out of the agent's reach |
| 3 | Idea — backlog item features/0001-elicitation-signee-strongauth-v2.md |
Elicitation signed by the Secure Enclave |
To go fast¶
The files that matter, and how to check that everything is fine: