mcp-google-multi 5.2.0-alpha.4 → 5.2.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,168 +1,61 @@
1
1
  # mcp-google-multi
2
2
 
3
- A **local** [MCP](https://modelcontextprotocol.io) server that gives Claude Code (and any MCP client) access to your **Google Workspace** — Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console (plus optional Slides, Forms, Chat, and Workspace Admin) across **multiple Google accounts** at once.
3
+ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar, Sheets, Docs, Slides, Forms, Contacts, Tasks, Chat, Meet, Classroom, Vault, Admin and more **every OAuth-reachable Workspace API method** as a tool, across **multiple Google accounts** at once, from Claude Code or any MCP client.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/mcp-google-multi?label=npm&color=cb3837)](https://www.npmjs.com/package/mcp-google-multi)
6
6
 
7
- > Open-source and **funded by [IdeaCrafters](https://github.com/IdeaCraftersHQ)** — the studio that pays for its development and upkeep.
7
+ - 🧰 **Exhaustive** — 871 tools across 28 services + an escape hatch for anything else [COVERAGE.md](./COVERAGE.md)
8
+ - 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
9
+ - 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
8
10
 
9
- - 🔑 **Multi-account** — drive any number of your Google accounts from one server, each by a short alias.
10
- - 🔒 **Secure by default** — refresh tokens **encrypted at rest** (AES-256-GCM); **writes are deny-by-default**; no telemetry — it talks only to Google.
11
- - 📦 **npm-first** — install and run with `npx`; everything configured through env vars.
12
- - 🧰 **Exhaustive coverage** — every OAuth-reachable Workspace API method is a named tool: **871 tools across 28 services** (182 hand-curated + 689 generated from Google's API Discovery), plus an escape hatch for anything else → full list in **[COVERAGE.md](./COVERAGE.md)**.
11
+ ## Quick setup
13
12
 
14
- > v5 is **local + user-OAuth only**. Service accounts and hosting (and the APIs they unlock) are on the [roadmap](https://github.com/bakissation/mcp-google-multi/milestones). **Upgrading from v4?** Jump to [Upgrading](#upgrading-from-v4).
13
+ You don't need to know anything about MCP or OAuth five steps, all copy-paste:
15
14
 
16
- ## Quick start
15
+ 1. **Install [Node.js](https://nodejs.org) 20 or newer**, then install the server:
17
16
 
18
- You need **Node 20+**, a Google Cloud OAuth client (~2 min — [setup below](#google-cloud-setup)), and a random 32-byte key.
17
+ ```bash
18
+ npm install -g mcp-google-multi
19
+ ```
19
20
 
20
- ```bash
21
- # 1) install
22
- npm i -g mcp-google-multi
21
+ 2. **Create your (free) Google app** so the server can sign in as you — one-time, ~2 minutes: follow [Google Cloud setup](./docs/google-cloud-setup.md). You come back with a **Client ID** and **Client Secret**.
23
22
 
24
- # 2) put your config + creds in the environment (see "Configuration").
25
- # Easiest for a quick try — export them, or drop a .env in your working dir:
26
- # GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_ACCOUNTS, MASTER_KEY, GOOGLE_PROFILE
23
+ 3. **Create a file named `.env`** in the folder you'll run from, and fill in your values:
27
24
 
28
- # 3) authenticate each account (opens a browser; one-time per account)
29
- mcp-google-multi auth --account work
30
- mcp-google-multi auth --account personal
25
+ ```bash
26
+ GOOGLE_CLIENT_ID=paste-your-client-id
27
+ GOOGLE_CLIENT_SECRET=paste-your-client-secret
28
+ # name each Google account with a short alias:
29
+ GOOGLE_ACCOUNTS=work:you@company.com,personal:you@gmail.com
30
+ # encryption key for stored tokens — generate one with: openssl rand -base64 32
31
+ MASTER_KEY=paste-the-generated-key
32
+ ```
31
33
 
32
- # 4) register with Claude Code
33
- claude mcp add google-multi -s user -- npx -y mcp-google-multi
34
- ```
34
+ 4. **Sign in each account** (a browser window opens; approve the permissions):
35
35
 
36
- Restart your MCP client and the tools appear. Tokens are written **encrypted** to `~/.config/mcp-google-multi/tokens/` (override with `TOKEN_STORE_PATH`) — useless to anyone without your `MASTER_KEY`.
36
+ ```bash
37
+ mcp-google-multi auth --account work
38
+ mcp-google-multi auth --account personal
39
+ ```
37
40
 
38
- Generate a key: `openssl rand -base64 32`.
41
+ 5. **Connect it to Claude Code** (any MCP client works the same way):
39
42
 
40
- ## Recommended: keep secrets in a vault (Infisical)
43
+ ```bash
44
+ claude mcp add google-multi -s user -- npx -y mcp-google-multi
45
+ ```
41
46
 
42
- A plaintext `.env` is fine to try it out, but for a daily driver, **don't leave `GOOGLE_CLIENT_SECRET` + `MASTER_KEY` on disk** — inject them at launch from a secrets manager. The server just reads `process.env` (it has no idea where the values come from), so wrap it with [Infisical](https://infisical.com):
47
+ Restart your client and the tools appear. Check everything with `mcp-google-multi config check`.
43
48
 
44
- ```bash
45
- #!/usr/bin/env bash
46
- # ~/.local/bin/mcp-google-multi-run — chmod +x, then register this as the MCP command
47
- set -euo pipefail
48
- export INFISICAL_TOKEN="$(infisical login --method=universal-auth \
49
- --client-id "$YOUR_CLIENT_ID" --client-secret "$YOUR_CLIENT_SECRET" --plain --silent)"
50
- exec infisical run --projectId <project> --env prod --path /mcp-google-multi \
51
- -- npx -y mcp-google-multi
52
- ```
49
+ **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Secrets in a vault](./docs/secrets.md) · [Upgrading from v4](./docs/upgrading-v4.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
53
50
 
54
- ```bash
55
- claude mcp add google-multi -s user -- ~/.local/bin/mcp-google-multi-run
56
- ```
57
-
58
- Now the only thing on disk is the **encrypted** token store. Pass the token via the `INFISICAL_TOKEN` env var (as above), **not** a `--token` flag, so it never shows up in `ps`. (Any secrets manager works — Doppler, Vault, 1Password CLI, etc. — the pattern is the same.)
59
-
60
- ## Configuration
61
-
62
- | Env var | Required | Description |
63
- |---|---|---|
64
- | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | ✓ | OAuth **Desktop** client from Google Cloud |
65
- | `GOOGLE_ACCOUNTS` | ✓ | `alias:email,…` — e.g. `work:you@co.com,personal:you@gmail.com` |
66
- | `MASTER_KEY` | ✓ | base64 32-byte key that encrypts the token store (`openssl rand -base64 32`) |
67
- | `GOOGLE_PROFILE` | — | write policy: `read-only` (default) · `safe-writes` · `full-writes` |
68
- | `GOOGLE_READ_ONLY` | — | `true` = hard kill-switch for all writes |
69
- | `GOOGLE_WRITE_ALLOW` / `GOOGLE_WRITE_DENY` | — | glob overrides, e.g. `calendar:*`, `*:delete*` |
70
- | `GOOGLE_OPTIONAL_SCOPES` | — | extra bundles: `slides`, `forms`, `chat` |
71
- | `GOOGLE_ADMIN_ACCOUNTS` | — | aliases granted Workspace-admin scopes (the account's own super-admin OAuth) |
72
- | `GOOGLE_TOOLSETS` | — | `all` (default) or a CSV filter of: `gmail`, `drive`, `calendar`, `sheets`, `docs`, `contacts`, `searchconsole`, `tasks`, `meet`, `slides`, `forms`, `chat`, `admin` |
73
- | `TOKEN_STORE_PATH` | — | override the encrypted token dir (default: `~/.config/mcp-google-multi/tokens`) |
74
- | `DISCOVERY_CACHE_PATH` | — | override the Discovery-doc cache dir (default: `~/.config/mcp-google-multi/discovery`) |
75
- | `GOOGLE_TRIM` | — | `off` (or `0`/`false`/`no`) disables compact JSON serialization of tool responses |
76
-
77
- Inspect the resolved setup any time: `mcp-google-multi config check`.
78
-
79
- ## Discover-first tools (tiny idle context)
80
-
81
- The server does **not** dump ~170 tool schemas into your model's context. At boot, `tools/list` exposes only one small `{service}_discover` tool per service (plus nothing else). Calling e.g. `drive_discover` returns that service's operation catalog (name, one-line summary, arguments, read/write class), reveals the operational tools, and emits `notifications/tools/list_changed` so the client re-fetches the list. An optional `query` argument filters the catalog.
82
-
83
- Hidden is a listing concept, not a security boundary: operational tools stay callable at all times (existing prompts that call tools directly keep working), and write-control + OAuth scopes remain the real enforcement. Use `GOOGLE_TOOLSETS` to switch entire services off — it is a filter only: listing `slides`/`forms`/`chat`/`admin` does not enable them without their `GOOGLE_OPTIONAL_SCOPES` / `GOOGLE_ADMIN_ACCOUNTS` gates.
84
-
85
- ## Multi-account: fan out one call across accounts
86
-
87
- Every read tool's `account` argument also accepts `"*"` (all configured accounts) or a CSV subset (`"work,personal"`). The server runs the call once per account (bounded concurrency) and returns per-account results — one account failing never hides the others:
88
-
89
- ```jsonc
90
- { "results": [
91
- { "account": "work", "ok": true, "data": { /* … */ } },
92
- { "account": "personal", "ok": false, "error": { "error": "auth_required", /* … */ } }
93
- ],
94
- "partial": true }
95
- ```
96
-
97
- Fan-out is **read-only by design** (write tools take exactly one account), and the three read tools that save files to disk (`drive_download`, `drive_export`, `gmail_download_attachment`) are excluded so parallel accounts can't clobber the same path. `account_list` shows what's configured: alias, email, token health (`ok` / `expired_refreshable` / `needs_reauth` / `missing` / `decrypt_error`), and granted-vs-configured scopes — without ever touching token values.
98
-
99
- `drive_transfer` copies or moves a file between two of your accounts: server-side share+copy when possible (the temporary read grant on the source is revoked right after; the copy gets a clean name, never "Copy of"), download+upload as the fallback. `move: true` trashes the source after a successful copy — that part is **delete-gated by write-control**, so `safe-writes` can copy but never move. Comments, revision history, and permissions don't transfer; if the fallback has to change the format (Drawings export as PNG), the result is flagged `lossy` and a requested move keeps the source intact. Native files over Google's 10MB export cap and binaries over 1GB can't take the fallback path.
100
-
101
- ## Escape hatch: any Workspace REST method
102
-
103
- Two eager tools cover everything the curated set doesn't: `google_api_search` finds any method in Google's API Discovery index (including APIs with no dedicated tools here, like Drive Activity), and `google_api_call` invokes a method by its Discovery id (`drive.revisions.list`, `slides.presentations.create`, …) with path/query params and a JSON body. Calls run through your account's OAuth client and the **same write-control policy** as named tools: the read/create/update/delete class is derived from the method's HTTP verb and name (POST deletes like `batchDelete`/`clear` count as deletes), and policy globs/`GOOGLE_TOOLSETS` match the same service names as named tools (`people` counts as `contacts`, `admin_*` as `admin`; `driveactivity`/`drivelabels`/`groupssettings` have no named service and are always available).
104
-
105
- Discovery documents are fetched from Google on first use and cached on disk for 7 days (`DISCOVERY_CACHE_PATH`, default `~/.config/mcp-google-multi/discovery`); a stale cache is used when offline.
106
-
107
- ## Lean responses by default
108
-
109
- Tool responses are serialized compactly (no pretty-print token tax; set `GOOGLE_TRIM=off` to restore pretty JSON), and the fat readers ship sensible caps with per-call escape valves. The caps are per-call controls (`full` / `maxChars`) and are NOT affected by `GOOGLE_TRIM`:
110
-
111
- - `drive_read` returns up to `maxChars` characters (default 100k) with `truncated`/`totalChars`/`offset` for paging — this also bounds Google Doc exports, which can reach 10MB. (Non-Google-native files over 2MB are still rejected with `too_large`, not paged.)
112
- - `gmail_read` / `gmail_read_thread` cap each message body at 50k chars (`bodyTruncated` + `bodyTotalChars` flags); pass `full: true` for the whole body.
113
- - `calendar_list_events` / `calendar_list_instances` trim descriptions to ~300 chars and drop empty/audit fields (`created`/`updated`) in list view; `calendar_get_event` always returns the full event.
114
-
115
- ## Write-control (deny-by-default)
116
-
117
- Reads are never gated. **Every create/update/delete is off until you opt in** — pick a profile:
118
-
119
- | `GOOGLE_PROFILE` | Allows |
120
- |---|---|
121
- | `read-only` (default) | reads only |
122
- | `safe-writes` | create + update (deletes still blocked) |
123
- | `full-writes` | everything |
124
-
125
- `GOOGLE_READ_ONLY=true` overrides all. For fine control: `GOOGLE_WRITE_ALLOW="calendar:*, sheets:update*"` and `GOOGLE_WRITE_DENY="*:delete*"` (deny wins). `mcp-google-multi config check` prints the resolved policy and exactly which tools are enabled.
126
-
127
- ## What's covered
128
-
129
- Every OAuth-reachable Workspace API method is a named tool — 871 across 28 services. Core services (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Search Console, Tasks, Meet) are on by default; Slides, Forms, Chat, Classroom, Vault, Keep, Apps Script, Cloud Search, Drive Labels and more enable via `GOOGLE_OPTIONAL_SCOPES` bundles, and Workspace Admin APIs via `GOOGLE_ADMIN_ACCOUNTS`. Curated tools give shaped, token-lean responses for everyday operations; generated tools (from Google's API Discovery documents) cover the long tail with the same write-control and account fan-out. **Full per-tool list → [COVERAGE.md](./COVERAGE.md).** Every tool takes an `account` argument matching one of your aliases.
130
-
131
- ## Google Cloud setup
132
-
133
- 1. [Google Cloud Console](https://console.cloud.google.com) → create or select a project.
134
- 2. Enable the APIs you'll use: Gmail, Drive, Calendar, Sheets, Docs, People, Search Console, Tasks, Meet (+ Slides / Forms / Chat / Admin SDK if you enable those bundles).
135
- 3. **APIs & Services → Credentials → Create Credentials → OAuth client ID → Desktop app**.
136
- 4. Add the redirect URI `http://localhost:4242/oauth2callback`.
137
- 5. Copy the **Client ID** + **Client Secret** into your environment.
138
-
139
- ## Upgrading from v4
140
-
141
- v5 is a breaking change, but the migration is a one-time, ~2-minute step:
142
-
143
- 1. Update: `npm i -g mcp-google-multi@latest` (or update your client config).
144
- 2. Add **`MASTER_KEY`** to your environment (`openssl rand -base64 32`) — now required.
145
- 3. Encrypt existing tokens: `mcp-google-multi migrate-tokens` (reads your old `tokens/<alias>/token.json` and encrypts them) — or just re-auth each account.
146
- 4. Writes are now **deny-by-default** — set `GOOGLE_PROFILE=safe-writes` (or `full-writes`) to keep writing. (`GOOGLE_ALLOW_ADMIN_WRITES` is gone — replaced by write-control profiles.)
147
-
148
- ## Security
149
-
150
- Your OAuth, your machine. Refresh tokens are **AES-256-GCM encrypted at rest** (decryptable only with your `MASTER_KEY`), writes are deny-by-default, and the server has **no telemetry** — it connects only to Google's APIs. Found a vulnerability? Report it privately — see [SECURITY.md](./SECURITY.md), never a public issue.
151
-
152
- ## Roadmap
153
-
154
- Maintainer-led. Direction is tracked publicly as **[GitHub Milestones](https://github.com/bakissation/mcp-google-multi/milestones)** (exhaustive API coverage → service accounts + hosting in v6). Not accepting unsolicited feature PRs; bug reports are welcome.
155
-
156
- ## Contributing
157
-
158
- See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Code of Conduct](./CODE_OF_CONDUCT.md). Security issues go to [SECURITY.md](./SECURITY.md), never a public issue.
159
-
160
- ## Credits
51
+ ## Maintainer & credits
161
52
 
162
53
  Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.xyz) · [@bakissation](https://github.com/bakissation). [Read the case study →](https://berkati.xyz/case-studies/mcp-google-multi/)
163
54
 
164
55
  Development is **funded by [IdeaCrafters](https://ideacrafters.com)** ([@IdeaCraftersHQ](https://github.com/IdeaCraftersHQ)) — the studio that pays for this OSS to exist.
165
56
 
57
+ Thanks to contributors [@obatried](https://github.com/obatried), [@trevor-commits](https://github.com/trevor-commits), and [@mjreddy](https://github.com/mjreddy). The project is maintainer-led (roadmap on [Milestones](https://github.com/bakissation/mcp-google-multi/milestones); bug reports welcome, feature PRs by prior agreement — see [CONTRIBUTING.md](./CONTRIBUTING.md)). Security reports go to [SECURITY.md](./SECURITY.md), never a public issue.
58
+
166
59
  ## License
167
60
 
168
61
  [MIT](./LICENSE)
@@ -1,4 +1,26 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ export declare function extractPlainTextInRange(body: any, start: number, end: number): string;
3
+ export type HeadingLevel = 'title' | 1 | 2 | 3 | 4 | 5 | 6;
4
+ export interface HeadingEntry {
5
+ i: number;
6
+ level: HeadingLevel;
7
+ text: string;
8
+ startIndex: number;
9
+ endIndex: number;
10
+ }
11
+ export declare function buildHeadingIndex(body: any): HeadingEntry[];
12
+ export type HeadingResolution = {
13
+ match: HeadingEntry;
14
+ } | {
15
+ error: 'not_found' | 'ambiguous';
16
+ candidates: HeadingEntry[];
17
+ };
18
+ export declare function resolveHeading(headings: HeadingEntry[], query: string): HeadingResolution;
19
+ export declare function findTab(tabs: any[] | undefined, tabId: string): any | undefined;
20
+ export declare function listTabs(tabs: any[] | undefined): {
21
+ tabId: string;
22
+ title: string;
23
+ }[];
2
24
  export declare function registerDocsTools(server: ToolRegistry): void;
3
25
  export declare function buildParagraphStyle(input: {
4
26
  namedStyleType?: string;
@@ -4,29 +4,129 @@ import { google } from 'googleapis';
4
4
  import { ACCOUNTS } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError } from './_errors.js';
7
+ import { capText } from '../trim.js';
7
8
  const accountEnum = z.enum(ACCOUNTS);
8
9
  function extractPlainText(body) {
10
+ return extractPlainTextInRange(body, 0, Infinity);
11
+ }
12
+ export function extractPlainTextInRange(body, start, end) {
9
13
  if (!body?.content)
10
14
  return '';
11
15
  let text = '';
12
16
  for (const element of body.content) {
17
+ const es = element.startIndex ?? 0;
18
+ const ee = element.endIndex ?? Infinity;
19
+ if (ee <= start || es >= end)
20
+ continue;
13
21
  if (element.paragraph) {
14
22
  for (const pe of element.paragraph.elements ?? []) {
15
- if (pe.textRun?.content) {
16
- text += pe.textRun.content;
17
- }
23
+ const run = pe.textRun?.content;
24
+ if (!run)
25
+ continue;
26
+ const ps = pe.startIndex ?? es;
27
+ const from = Math.max(0, start - ps);
28
+ const to = Math.min(run.length, end - ps);
29
+ if (to > from)
30
+ text += run.slice(from, to);
18
31
  }
19
32
  }
20
33
  else if (element.table) {
21
34
  for (const row of element.table.tableRows ?? []) {
22
35
  for (const cell of row.tableCells ?? []) {
23
- text += extractPlainText(cell);
36
+ text += extractPlainTextInRange(cell, start, end);
24
37
  }
25
38
  }
26
39
  }
27
40
  }
28
41
  return text;
29
42
  }
43
+ const HEADING_RANKS = {
44
+ TITLE: 0,
45
+ HEADING_1: 1,
46
+ HEADING_2: 2,
47
+ HEADING_3: 3,
48
+ HEADING_4: 4,
49
+ HEADING_5: 5,
50
+ HEADING_6: 6,
51
+ };
52
+ export function buildHeadingIndex(body) {
53
+ const found = [];
54
+ let docEnd = 0;
55
+ const walk = (b) => {
56
+ if (!b?.content)
57
+ return;
58
+ for (const element of b.content) {
59
+ if (typeof element.endIndex === 'number' && element.endIndex > docEnd)
60
+ docEnd = element.endIndex;
61
+ if (element.paragraph) {
62
+ const rank = HEADING_RANKS[element.paragraph.paragraphStyle?.namedStyleType ?? ''];
63
+ if (rank !== undefined) {
64
+ let text = '';
65
+ for (const pe of element.paragraph.elements ?? []) {
66
+ if (pe.textRun?.content)
67
+ text += pe.textRun.content;
68
+ }
69
+ found.push({ rank, text: text.trim(), startIndex: element.startIndex ?? 0 });
70
+ }
71
+ }
72
+ else if (element.table) {
73
+ for (const row of element.table.tableRows ?? []) {
74
+ for (const cell of row.tableCells ?? [])
75
+ walk(cell);
76
+ }
77
+ }
78
+ }
79
+ };
80
+ walk(body);
81
+ return found.map((h, i) => {
82
+ let endIndex = docEnd;
83
+ for (let j = i + 1; j < found.length; j++) {
84
+ if (found[j].rank <= h.rank) {
85
+ endIndex = found[j].startIndex;
86
+ break;
87
+ }
88
+ }
89
+ return {
90
+ i,
91
+ level: (h.rank === 0 ? 'title' : h.rank),
92
+ text: h.text,
93
+ startIndex: h.startIndex,
94
+ endIndex,
95
+ };
96
+ });
97
+ }
98
+ export function resolveHeading(headings, query) {
99
+ const q = query.trim().toLowerCase();
100
+ const exact = headings.filter((h) => h.text.toLowerCase() === q);
101
+ if (exact.length === 1)
102
+ return { match: exact[0] };
103
+ if (exact.length > 1)
104
+ return { error: 'ambiguous', candidates: exact };
105
+ const partial = headings.filter((h) => h.text.toLowerCase().includes(q));
106
+ if (partial.length === 1)
107
+ return { match: partial[0] };
108
+ if (partial.length > 1)
109
+ return { error: 'ambiguous', candidates: partial };
110
+ return { error: 'not_found', candidates: headings };
111
+ }
112
+ export function findTab(tabs, tabId) {
113
+ for (const tab of tabs ?? []) {
114
+ if (tab.tabProperties?.tabId === tabId)
115
+ return tab;
116
+ const nested = findTab(tab.childTabs, tabId);
117
+ if (nested)
118
+ return nested;
119
+ }
120
+ return undefined;
121
+ }
122
+ export function listTabs(tabs) {
123
+ const out = [];
124
+ for (const tab of tabs ?? []) {
125
+ out.push({ tabId: tab.tabProperties?.tabId ?? '', title: tab.tabProperties?.title ?? '' });
126
+ out.push(...listTabs(tab.childTabs));
127
+ }
128
+ return out;
129
+ }
30
130
  export function registerDocsTools(server) {
31
131
  server.registerTool('docs_create', {
32
132
  description: 'Create a new Google Docs document',
@@ -90,22 +190,100 @@ export function registerDocsTools(server) {
90
190
  }
91
191
  });
92
192
  server.registerTool('docs_read', {
93
- description: 'Read document content as plain text',
193
+ description: 'Read document content as plain text with a heading index (returns up to maxChars characters per call). Pass heading or headingIndex to read one section, tabId to read a specific tab.',
94
194
  inputSchema: {
95
195
  account: accountEnum.describe('Google account alias'),
96
196
  documentId: z.string().describe('Google Docs document ID'),
197
+ heading: z.string().optional()
198
+ .describe('Read only the section under this heading (case-insensitive; exact match first, then substring)'),
199
+ headingIndex: z.number().int().min(0).optional()
200
+ .describe('Read only the section at this position in the headings array (the `i` field)'),
201
+ tabId: z.string().optional()
202
+ .describe('Read a specific tab instead of the first tab'),
203
+ maxChars: z.number().min(1).max(2_000_000).default(50_000).optional()
204
+ .describe('Max characters of text to return (default: 50000)'),
205
+ offset: z.number().min(0).default(0).optional()
206
+ .describe('Character offset to continue a truncated read'),
97
207
  },
98
- }, async ({ account, documentId }) => {
208
+ }, async ({ account, documentId, heading, headingIndex, tabId, maxChars, offset }) => {
99
209
  try {
210
+ if (heading !== undefined && headingIndex !== undefined) {
211
+ return {
212
+ content: [{ type: 'text', text: JSON.stringify({
213
+ error: 'invalid_params',
214
+ message: 'Provide either heading or headingIndex, not both',
215
+ }, null, 2) }],
216
+ isError: true,
217
+ };
218
+ }
100
219
  const auth = await getClient(account);
101
220
  const docs = google.docs({ version: 'v1', auth });
102
- const res = await docs.documents.get({ documentId });
103
- const text = extractPlainText(res.data.body);
221
+ const res = await docs.documents.get({
222
+ documentId,
223
+ ...(tabId !== undefined ? { includeTabsContent: true } : {}),
224
+ });
225
+ let body = res.data.body;
226
+ if (tabId !== undefined) {
227
+ const tab = findTab(res.data.tabs, tabId);
228
+ if (!tab) {
229
+ return {
230
+ content: [{ type: 'text', text: JSON.stringify({
231
+ error: 'not_found',
232
+ message: `Tab "${tabId}" not found in document ${documentId}`,
233
+ availableTabs: listTabs(res.data.tabs),
234
+ }, null, 2) }],
235
+ isError: true,
236
+ };
237
+ }
238
+ body = tab.documentTab?.body;
239
+ }
240
+ const headings = buildHeadingIndex(body);
241
+ let section;
242
+ if (headingIndex !== undefined) {
243
+ section = headings[headingIndex];
244
+ if (!section) {
245
+ return {
246
+ content: [{ type: 'text', text: JSON.stringify({
247
+ error: 'invalid_params',
248
+ message: `headingIndex ${headingIndex} is out of range (document has ${headings.length} headings)`,
249
+ headings: headings.map(({ i, level, text }) => ({ i, level, text })),
250
+ }, null, 2) }],
251
+ isError: true,
252
+ };
253
+ }
254
+ }
255
+ else if (heading !== undefined) {
256
+ const resolved = resolveHeading(headings, heading);
257
+ if ('error' in resolved) {
258
+ return {
259
+ content: [{ type: 'text', text: JSON.stringify({
260
+ error: resolved.error === 'ambiguous' ? 'ambiguous_heading' : 'not_found',
261
+ message: resolved.error === 'ambiguous'
262
+ ? `Heading "${heading}" matches multiple headings; use headingIndex to disambiguate`
263
+ : `Heading "${heading}" not found in document ${documentId}`,
264
+ candidates: resolved.candidates.map(({ i, level, text }) => ({ i, level, text })),
265
+ }, null, 2) }],
266
+ isError: true,
267
+ };
268
+ }
269
+ section = resolved.match;
270
+ }
271
+ const text = section
272
+ ? extractPlainTextInRange(body, section.startIndex, section.endIndex)
273
+ : extractPlainText(body);
274
+ const from = offset ?? 0;
275
+ const capped = capText(text, maxChars ?? 50_000, from);
104
276
  return {
105
277
  content: [{ type: 'text', text: JSON.stringify({
106
278
  documentId: res.data.documentId,
107
279
  title: res.data.title,
108
- text,
280
+ ...(tabId !== undefined ? { tabId } : {}),
281
+ headings: headings.map(({ i, level, text: t }) => ({ i, level, text: t })),
282
+ ...(section ? { section: { i: section.i, level: section.level, text: section.text } } : {}),
283
+ text: capped.text,
284
+ ...(capped.truncated || from > 0
285
+ ? { truncated: capped.truncated, totalChars: capped.totalChars, offset: from }
286
+ : {}),
109
287
  }, null, 2) }],
110
288
  };
111
289
  }
@@ -14,6 +14,20 @@ export declare function encodeAddressHeader(value: string): string;
14
14
  * Normalize bare `\n` or `\r` to CRLF.
15
15
  */
16
16
  export declare function normalizeBodyLineEndings(body: string): string;
17
+ /**
18
+ * Best-effort HTML→plain-text for reading HTML-only emails. Regex-based on
19
+ * purpose — no HTML parser dependency, and mail HTML is flat enough for it.
20
+ */
21
+ export declare function htmlToText(html: string): string;
22
+ /**
23
+ * RFC 5322 threading: In-Reply-To/References must carry the parent's real
24
+ * Message-ID header, not the Gmail API id. Falls back to the API id when the
25
+ * header is missing so replies still thread inside Gmail.
26
+ */
27
+ export declare function buildReplyHeaders(fallbackId: string, parentMessageIdHeader: string, parentReferences: string): {
28
+ inReplyTo: string;
29
+ references: string;
30
+ };
17
31
  /**
18
32
  * Build a multipart/alternative body so HTML-capable clients render the rich
19
33
  * version and plain clients fall back. Returns the header value AND the body.
@@ -63,6 +63,95 @@ export function encodeAddressHeader(value) {
63
63
  export function normalizeBodyLineEndings(body) {
64
64
  return body.replace(/\r\n|\r|\n/g, '\r\n');
65
65
  }
66
+ const NAMED_ENTITIES = {
67
+ amp: '&',
68
+ lt: '<',
69
+ gt: '>',
70
+ quot: '"',
71
+ apos: "'",
72
+ nbsp: ' ',
73
+ };
74
+ function codePointToChar(code, fallback) {
75
+ if (Number.isNaN(code) || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
76
+ return fallback;
77
+ return String.fromCodePoint(code);
78
+ }
79
+ function decodeEntities(text) {
80
+ return text.replace(/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z][a-zA-Z0-9]*));/g, (match, dec, hex, named) => {
81
+ if (dec)
82
+ return codePointToChar(parseInt(dec, 10), match);
83
+ if (hex)
84
+ return codePointToChar(parseInt(hex, 16), match);
85
+ return NAMED_ENTITIES[named.toLowerCase()] ?? match;
86
+ });
87
+ }
88
+ // Fixpoint tag strip: one pass can reassemble a tag from a removed span's
89
+ // edges (<scr<b>ipt> -> <script>).
90
+ function stripTags(input, replacement = '') {
91
+ let out = input;
92
+ for (let previous = ''; previous !== out;) {
93
+ previous = out;
94
+ out = out.replace(/<[^>]*>/g, replacement);
95
+ }
96
+ return out;
97
+ }
98
+ /**
99
+ * Best-effort HTML→plain-text for reading HTML-only emails. Regex-based on
100
+ * purpose — no HTML parser dependency, and mail HTML is flat enough for it.
101
+ */
102
+ export function htmlToText(html) {
103
+ // Repeat until stable: single-pass removal can leave behind sequences
104
+ // reassembled from the removed span's edges (<scr<script>ipt>).
105
+ let text = html;
106
+ for (let previous = ''; previous !== text;) {
107
+ previous = text;
108
+ text = text
109
+ // --!> is a valid comment terminator per WHATWG
110
+ .replace(/<!--[\s\S]*?--!?>/g, '')
111
+ // an unterminated comment consumes the rest of the document per spec
112
+ .replace(/<!--[\s\S]*$/, '')
113
+ .replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
114
+ }
115
+ // All comment content is gone; drop any stray bare delimiters too.
116
+ text = text.replace(/<!--|--!?>/g, '');
117
+ // Source whitespace (incl. newlines) is insignificant in HTML; real line
118
+ // structure is reintroduced from block tags below.
119
+ text = text.replace(/\s+/g, ' ');
120
+ text = text.replace(/<a\b[^>]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a\s*>/gi, (_match, dq, sq, inner) => {
121
+ const href = (dq ?? sq ?? '').trim();
122
+ const innerText = stripTags(inner, ' ').replace(/\s+/g, ' ').trim();
123
+ const redundant = href === '' ||
124
+ href.startsWith('#') ||
125
+ href === innerText ||
126
+ href === `mailto:${innerText}`;
127
+ return redundant ? inner : `${inner} (${href})`;
128
+ });
129
+ text = text
130
+ .replace(/<(?:br|hr)\b[^>]*>/gi, '\n')
131
+ .replace(/<\/t[dh]\s*>/gi, ' ')
132
+ .replace(/<(?:p|h[1-6])\b[^>]*>/gi, '\n')
133
+ .replace(/<\/(?:p|div|h[1-6]|li|tr|table|ul|ol|blockquote|pre|section|article|header|footer|address|figure|dl|dt|dd)\s*>/gi, '\n');
134
+ text = stripTags(text);
135
+ return decodeEntities(text)
136
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
137
+ .replace(/\n{3,}/g, '\n\n')
138
+ .trim();
139
+ }
140
+ /**
141
+ * RFC 5322 threading: In-Reply-To/References must carry the parent's real
142
+ * Message-ID header, not the Gmail API id. Falls back to the API id when the
143
+ * header is missing so replies still thread inside Gmail.
144
+ */
145
+ export function buildReplyHeaders(fallbackId, parentMessageIdHeader, parentReferences) {
146
+ const parentId = parentMessageIdHeader.trim();
147
+ if (parentId === '')
148
+ return { inReplyTo: fallbackId, references: fallbackId };
149
+ const refs = parentReferences.trim().replace(/\s+/g, ' ');
150
+ return {
151
+ inReplyTo: parentId,
152
+ references: refs === '' ? parentId : `${refs} ${parentId}`,
153
+ };
154
+ }
66
155
  /**
67
156
  * RFC 2046 §5.1.1 boundary token: 1-70 chars from a restricted set, no trailing space.
68
157
  * randomBytes hex output is only [0-9a-f], all of which are bcharsnospace.
@@ -1,4 +1,6 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
2
  import type { GmailMessageFull } from '../types.js';
3
- export declare function parseMessage(msg: any, bodyCap?: number): GmailMessageFull;
3
+ export declare function parseMessage(msg: any, bodyCap?: number, opts?: {
4
+ rawHtml?: boolean;
5
+ }): GmailMessageFull;
4
6
  export declare function registerGmailTools(server: ToolRegistry): void;
@@ -4,7 +4,7 @@ import { google } from 'googleapis';
4
4
  import { ACCOUNTS } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError } from './_errors.js';
7
- import { buildMultipartAlternative, encodeAddressHeader, encodeHeaderValue, normalizeBodyLineEndings } from './gmail-mime.js';
7
+ import { buildMultipartAlternative, buildReplyHeaders, encodeAddressHeader, encodeHeaderValue, htmlToText, normalizeBodyLineEndings } from './gmail-mime.js';
8
8
  import { sliceClean } from '../trim.js';
9
9
  import * as path from 'path';
10
10
  import * as fs from 'fs';
@@ -12,38 +12,50 @@ const accountEnum = z.enum(ACCOUNTS);
12
12
  function getHeader(headers, name) {
13
13
  return headers?.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
14
14
  }
15
- function decodeBody(payload) {
16
- if (payload.body?.data) {
17
- return Buffer.from(payload.body.data, 'base64url').toString('utf-8');
15
+ function collectTextParts(part, plain, html, topLevel = false) {
16
+ if (!part)
17
+ return;
18
+ const isAttachment = Boolean(part.filename) || Boolean(part.body?.attachmentId);
19
+ if (!isAttachment && part.body?.data) {
20
+ const decoded = Buffer.from(part.body.data, 'base64url').toString('utf-8');
21
+ if (part.mimeType === 'text/html')
22
+ html.push(decoded);
23
+ // Simple (non-multipart) messages can carry any mimeType at the top level;
24
+ // returning their raw body preserves pre-collect behavior.
25
+ else if (part.mimeType === 'text/plain' || !part.mimeType || topLevel)
26
+ plain.push(decoded);
18
27
  }
19
- if (payload.parts) {
20
- for (const part of payload.parts) {
21
- if (part.mimeType === 'text/plain' && part.body?.data) {
22
- return Buffer.from(part.body.data, 'base64url').toString('utf-8');
23
- }
24
- }
25
- for (const part of payload.parts) {
26
- if (part.mimeType === 'text/html' && part.body?.data) {
27
- return Buffer.from(part.body.data, 'base64url').toString('utf-8');
28
- }
29
- }
30
- for (const part of payload.parts) {
31
- if (part.parts) {
32
- const result = decodeBody(part);
33
- if (result)
34
- return result;
35
- }
36
- }
28
+ for (const child of part.parts ?? []) {
29
+ collectTextParts(child, plain, html);
37
30
  }
38
- return '';
31
+ }
32
+ function decodeBody(payload, rawHtml = false) {
33
+ const plain = [];
34
+ const html = [];
35
+ collectTextParts(payload, plain, html, true);
36
+ // Concatenating every plain leaf keeps forwarded/mixed messages whole;
37
+ // any plain content beats HTML because alternatives duplicate the same body.
38
+ if (plain.length > 0)
39
+ return { body: plain.join('\n\n'), bodyOrigin: 'text/plain' };
40
+ if (html.length > 0) {
41
+ const joined = html.join('\n\n');
42
+ return { body: rawHtml ? joined : htmlToText(joined), bodyOrigin: 'text/html' };
43
+ }
44
+ return { body: '' };
39
45
  }
40
46
  function getAttachments(payload) {
41
47
  const attachments = [];
42
48
  if (payload.filename && payload.body?.attachmentId) {
49
+ const disposition = getHeader(payload.headers, 'Content-Disposition');
50
+ const inline = disposition.toLowerCase().startsWith('inline')
51
+ || getHeader(payload.headers, 'Content-ID') !== '';
43
52
  attachments.push({
44
53
  filename: payload.filename,
45
54
  attachmentId: payload.body.attachmentId,
46
55
  mimeType: payload.mimeType ?? 'application/octet-stream',
56
+ ...(typeof payload.body.size === 'number' ? { sizeBytes: payload.body.size } : {}),
57
+ ...(payload.partId ? { partId: payload.partId } : {}),
58
+ ...(inline ? { inline: true } : {}),
47
59
  });
48
60
  }
49
61
  if (payload.parts) {
@@ -53,11 +65,30 @@ function getAttachments(payload) {
53
65
  }
54
66
  return attachments;
55
67
  }
68
+ async function resolveReplyHeaders(gmail, replyToMessageId) {
69
+ try {
70
+ const meta = await gmail.users.messages.get({
71
+ userId: 'me',
72
+ id: replyToMessageId,
73
+ format: 'metadata',
74
+ metadataHeaders: ['Message-ID', 'References'],
75
+ });
76
+ const headers = meta.data.payload?.headers;
77
+ return buildReplyHeaders(replyToMessageId, getHeader(headers, 'Message-ID'), getHeader(headers, 'References'));
78
+ }
79
+ catch {
80
+ // Degrade to the API id (pre-lookup behavior) rather than blocking the send.
81
+ return { inReplyTo: replyToMessageId, references: replyToMessageId };
82
+ }
83
+ }
56
84
  const BODY_CAP_CHARS = 50_000;
57
- export function parseMessage(msg, bodyCap) {
85
+ export function parseMessage(msg, bodyCap, opts) {
58
86
  const headers = msg.payload?.headers ?? [];
59
- const body = decodeBody(msg.payload);
87
+ const { body, bodyOrigin } = decodeBody(msg.payload, opts?.rawHtml === true);
60
88
  const capped = bodyCap !== undefined && body.length > bodyCap;
89
+ const messageIdHeader = getHeader(headers, 'Message-ID');
90
+ const inReplyTo = getHeader(headers, 'In-Reply-To');
91
+ const references = getHeader(headers, 'References');
61
92
  return {
62
93
  id: msg.id ?? '',
63
94
  threadId: msg.threadId ?? '',
@@ -67,7 +98,13 @@ export function parseMessage(msg, bodyCap) {
67
98
  cc: getHeader(headers, 'Cc'),
68
99
  date: getHeader(headers, 'Date'),
69
100
  body: capped ? sliceClean(body, bodyCap) : body,
101
+ ...(bodyOrigin ? { bodyOrigin } : {}),
70
102
  ...(capped ? { bodyTruncated: true, bodyTotalChars: body.length } : {}),
103
+ ...(messageIdHeader ? { messageIdHeader } : {}),
104
+ ...(inReplyTo ? { inReplyTo } : {}),
105
+ ...(references ? { references } : {}),
106
+ ...(msg.labelIds ? { labelIds: msg.labelIds } : {}),
107
+ ...(msg.internalDate ? { internalDate: msg.internalDate } : {}),
71
108
  attachments: getAttachments(msg.payload),
72
109
  };
73
110
  }
@@ -91,21 +128,28 @@ export function registerGmailTools(server) {
91
128
  });
92
129
  const messages = listRes.data.messages ?? [];
93
130
  const results = [];
94
- for (const m of messages) {
95
- const detail = await gmail.users.messages.get({
131
+ // Bounded parallelism: chunked Promise.all keeps order and avoids
132
+ // hammering the per-user quota with an unbounded fan-out.
133
+ const CHUNK_SIZE = 10;
134
+ for (let i = 0; i < messages.length; i += CHUNK_SIZE) {
135
+ const details = await Promise.all(messages.slice(i, i + CHUNK_SIZE).map((m) => gmail.users.messages.get({
96
136
  userId: 'me',
97
137
  id: m.id,
98
138
  format: 'metadata',
99
- metadataHeaders: ['From', 'Subject', 'Date'],
100
- });
101
- results.push({
102
- id: detail.data.id ?? '',
103
- threadId: detail.data.threadId ?? '',
104
- subject: getHeader(detail.data.payload?.headers, 'Subject'),
105
- from: getHeader(detail.data.payload?.headers, 'From'),
106
- date: getHeader(detail.data.payload?.headers, 'Date'),
107
- snippet: detail.data.snippet ?? '',
108
- });
139
+ metadataHeaders: ['From', 'To', 'Subject', 'Date'],
140
+ })));
141
+ for (const detail of details) {
142
+ results.push({
143
+ id: detail.data.id ?? '',
144
+ threadId: detail.data.threadId ?? '',
145
+ subject: getHeader(detail.data.payload?.headers, 'Subject'),
146
+ from: getHeader(detail.data.payload?.headers, 'From'),
147
+ to: getHeader(detail.data.payload?.headers, 'To'),
148
+ date: getHeader(detail.data.payload?.headers, 'Date'),
149
+ snippet: detail.data.snippet ?? '',
150
+ labelIds: detail.data.labelIds ?? [],
151
+ });
152
+ }
109
153
  }
110
154
  return {
111
155
  content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
@@ -121,8 +165,10 @@ export function registerGmailTools(server) {
121
165
  account: accountEnum.describe('Google account alias'),
122
166
  messageId: z.string().describe('Gmail message ID'),
123
167
  full: coerceBoolean.optional().describe('Return the entire body without the character cap'),
168
+ rawHtml: coerceBoolean.optional()
169
+ .describe('Return the HTML body unconverted instead of the plain-text rendering (HTML-only messages)'),
124
170
  },
125
- }, async ({ account, messageId, full }) => {
171
+ }, async ({ account, messageId, full, rawHtml }) => {
126
172
  try {
127
173
  const auth = await getClient(account);
128
174
  const gmail = google.gmail({ version: 'v1', auth });
@@ -131,7 +177,7 @@ export function registerGmailTools(server) {
131
177
  id: messageId,
132
178
  format: 'full',
133
179
  });
134
- const result = parseMessage(res.data, full ? undefined : BODY_CAP_CHARS);
180
+ const result = parseMessage(res.data, full ? undefined : BODY_CAP_CHARS, { rawHtml });
135
181
  return {
136
182
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
137
183
  };
@@ -141,22 +187,46 @@ export function registerGmailTools(server) {
141
187
  }
142
188
  });
143
189
  server.registerTool('gmail_read_thread', {
144
- description: 'Read all messages in a Gmail thread (bodies capped at 50k chars each unless full=true)',
190
+ description: 'Read all messages in a Gmail thread (bodies capped at 50k chars each unless full=true). mode=summary returns headers + snippets only.',
145
191
  inputSchema: {
146
192
  account: accountEnum.describe('Google account alias'),
147
193
  threadId: z.string().describe('Gmail thread ID'),
148
194
  full: coerceBoolean.optional().describe('Return entire bodies without the character cap'),
195
+ rawHtml: coerceBoolean.optional()
196
+ .describe('Return HTML bodies unconverted instead of the plain-text rendering (HTML-only messages)'),
197
+ mode: z.enum(['full', 'summary']).default('full')
198
+ .describe('full = complete bodies; summary = per-message headers and snippet only'),
149
199
  },
150
- }, async ({ account, threadId, full }) => {
200
+ }, async ({ account, threadId, full, rawHtml, mode }) => {
151
201
  try {
152
202
  const auth = await getClient(account);
153
203
  const gmail = google.gmail({ version: 'v1', auth });
204
+ if (mode === 'summary') {
205
+ const res = await gmail.users.threads.get({
206
+ userId: 'me',
207
+ id: threadId,
208
+ format: 'metadata',
209
+ metadataHeaders: ['From', 'To', 'Subject', 'Date', 'Message-ID'],
210
+ });
211
+ const summaries = (res.data.messages ?? []).map((m) => ({
212
+ id: m.id ?? '',
213
+ from: getHeader(m.payload?.headers, 'From'),
214
+ to: getHeader(m.payload?.headers, 'To'),
215
+ subject: getHeader(m.payload?.headers, 'Subject'),
216
+ date: getHeader(m.payload?.headers, 'Date'),
217
+ snippet: m.snippet ?? '',
218
+ labelIds: m.labelIds ?? [],
219
+ }));
220
+ return {
221
+ content: [{ type: 'text', text: JSON.stringify(summaries, null, 2) }],
222
+ };
223
+ }
154
224
  const res = await gmail.users.threads.get({
155
225
  userId: 'me',
156
226
  id: threadId,
157
227
  format: 'full',
158
228
  });
159
- const messages = (res.data.messages ?? []).map((m) => parseMessage(m, full ? undefined : BODY_CAP_CHARS));
229
+ const messages = (res.data.messages ?? []).map((m) => parseMessage(m, full ? undefined : BODY_CAP_CHARS, { rawHtml }));
160
230
  return {
161
231
  content: [{ type: 'text', text: JSON.stringify(messages, null, 2) }],
162
232
  };
@@ -205,8 +275,9 @@ export function registerGmailTools(server) {
205
275
  if (cc)
206
276
  headers.push(`Cc: ${encodeAddressHeader(cc)}`);
207
277
  if (replyToMessageId) {
208
- headers.push(`In-Reply-To: ${replyToMessageId}`);
209
- headers.push(`References: ${replyToMessageId}`);
278
+ const { inReplyTo, references } = await resolveReplyHeaders(gmail, replyToMessageId);
279
+ headers.push(`In-Reply-To: ${inReplyTo}`);
280
+ headers.push(`References: ${references}`);
210
281
  }
211
282
  const rawMessage = [...headers, '', bodyText].join('\r\n');
212
283
  const encoded = Buffer.from(rawMessage, 'utf-8').toString('base64url');
@@ -272,10 +343,12 @@ export function registerGmailTools(server) {
272
343
  htmlBody: z.string().optional()
273
344
  .describe('Optional HTML body. When set, drafts as multipart/alternative so HTML-capable clients render the rich version. Use bare tags only: <p>, <a>, <br>, <strong>, <em>, <ul><li>.'),
274
345
  cc: z.string().optional().describe('CC recipients, comma-separated'),
346
+ replyToMessageId: z.string().optional()
347
+ .describe('Message ID to reply to (sets In-Reply-To and References headers)'),
275
348
  replyToThreadId: z.string().optional()
276
349
  .describe('Thread ID to associate the draft with'),
277
350
  },
278
- }, async ({ account, to, subject, body, htmlBody, cc, replyToThreadId }) => {
351
+ }, async ({ account, to, subject, body, htmlBody, cc, replyToMessageId, replyToThreadId }) => {
279
352
  try {
280
353
  const auth = await getClient(account);
281
354
  const gmail = google.gmail({ version: 'v1', auth });
@@ -299,6 +372,11 @@ export function registerGmailTools(server) {
299
372
  }
300
373
  if (cc)
301
374
  headers.push(`Cc: ${encodeAddressHeader(cc)}`);
375
+ if (replyToMessageId) {
376
+ const { inReplyTo, references } = await resolveReplyHeaders(gmail, replyToMessageId);
377
+ headers.push(`In-Reply-To: ${inReplyTo}`);
378
+ headers.push(`References: ${references}`);
379
+ }
302
380
  const rawMessage = [...headers, '', bodyText].join('\r\n');
303
381
  const encoded = Buffer.from(rawMessage, 'utf-8').toString('base64url');
304
382
  const draftParams = {
package/dist/types.d.ts CHANGED
@@ -10,13 +10,18 @@ export interface GmailMessageHeader {
10
10
  threadId: string;
11
11
  subject: string;
12
12
  from: string;
13
+ to: string;
13
14
  date: string;
14
15
  snippet: string;
16
+ labelIds: string[];
15
17
  }
16
18
  export interface GmailAttachment {
17
19
  filename: string;
18
20
  attachmentId: string;
19
21
  mimeType: string;
22
+ sizeBytes?: number;
23
+ partId?: string;
24
+ inline?: boolean;
20
25
  }
21
26
  export interface GmailMessageFull {
22
27
  id: string;
@@ -27,8 +32,14 @@ export interface GmailMessageFull {
27
32
  cc: string;
28
33
  date: string;
29
34
  body: string;
35
+ bodyOrigin?: 'text/plain' | 'text/html';
30
36
  bodyTruncated?: boolean;
31
37
  bodyTotalChars?: number;
38
+ messageIdHeader?: string;
39
+ inReplyTo?: string;
40
+ references?: string;
41
+ labelIds?: string[];
42
+ internalDate?: string;
32
43
  attachments: GmailAttachment[];
33
44
  }
34
45
  export interface DriveFile {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "5.2.0-alpha.4",
3
+ "version": "5.2.0-alpha.6",
4
4
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
5
5
  "type": "module",
6
6
  "license": "MIT",