From 6a2643f590856491a071477d5e9d981af13d40ad Mon Sep 17 00:00:00 2001 From: Major Date: Thu, 30 Jul 2026 23:04:27 +0000 Subject: [PATCH] docs(skills): document bounded Gmail message retrieval PR #1978 (MAJ-344) made gmail_get_message(format="full") return a normalized, size-bounded message instead of the raw Gmail payload. The resources_gmail skill still described the old raw shape, so agents had no way to know the body arrives decoded or that truncation is reported. - describe the two-step read: gmail_list_messages for IDs, then gmail_get_message with format="metadata" for headers or the default format="full" for content - document the normalized full response: selected headers, decoded text at body.value.body.text, truncated / originalChars, and bounded attachment metadata with no base64 payloads - state that base64-decoding the text, or persisting the response to Read/grep/parse it back, is unnecessary - note that only format="full" is normalized; metadata/minimal/raw and Gmail error envelopes pass through unchanged - reserve gmail_invoke for what the typed tools don't cover (complete threads) and document that its query values are arrays, e.g. {"format":["metadata"]} - correct the TypeScript client example, which is a thin wrapper over the raw Gmail API and is NOT subject to the MCP tool's normalization The documented JSON example was generated from the real normalizer output, so it matches field-for-field. Built on major.build Co-Authored-By: Major --- .../shared/skills/resources_gmail/SKILL.md | 86 +++++++++++++++++-- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/plugins/shared/skills/resources_gmail/SKILL.md b/plugins/shared/skills/resources_gmail/SKILL.md index 9a86055..9034fd9 100644 --- a/plugins/shared/skills/resources_gmail/SKILL.md +++ b/plugins/shared/skills/resources_gmail/SKILL.md @@ -39,11 +39,79 @@ Gmail requires OAuth authentication before use. ## MCP Tools -- `mcp__resources__gmail_list_messages` — Search and list emails. Args: `resourceId`, `q?` (Gmail search syntax), `maxResults?`, `pageToken?`. **Returns only message IDs and thread IDs** — always follow up with `gmail_get_message` for content. -- `mcp__resources__gmail_get_message` — Get a specific email by ID with full content. Args: `resourceId`, `messageId`, `format?` (default: "full") +- `mcp__resources__gmail_list_messages` — Search and list emails. Args: `resourceId`, `q?` (Gmail search syntax), `maxResults?`, `pageToken?`. **Returns only message IDs and thread IDs** — always follow up with `gmail_get_message` for headers or content. +- `mcp__resources__gmail_get_message` — Get one message by ID. Args: `resourceId`, `messageId`, `format?` (default: `"full"`). Use `format="metadata"` when you only need headers, and the default `format="full"` for message content — it returns a **normalized, bounded** response (see below). Never reach for `gmail_invoke` to read an ordinary message. - `mcp__resources__gmail_send_message` — Send a plain-text email. Args: `resourceId`, `to`, `subject`, `body`, `cc?`, `bcc?`. Requires the `readwrite` scope preset. - `mcp__resources__gmail_list_labels` — List all Gmail labels (inbox, sent, custom labels). Args: `resourceId` -- `mcp__resources__gmail_invoke` — Make any HTTP request to the Gmail API v1 (for operations not covered by other tools). Args: `resourceId`, `method`, `path`, `query?`, `body?`, `timeoutMs?` +- `mcp__resources__gmail_invoke` — Escape hatch for Gmail API v1 operations the typed tools don't cover (complete threads, drafts, label modification, attachment bytes). Args: `resourceId`, `method`, `path`, `query?`, `body?`, `timeoutMs?`. Returns the **unmodified** Gmail response, so it is not bounded — see the fallback section below. + +### Reading messages + +Standard flow — two steps, no raw MIME, no manual decoding: + +1. `gmail_list_messages` → message IDs. +2. `gmail_get_message` per ID → `format="metadata"` for headers only, or the default `format="full"` for content. + +`format="full"` is normalized into an agent-friendly, size-bounded object at `body.value`: + +| Field (full path in the tool result) | Meaning | +| --- | --- | +| `body.value.headers` | Selected headers only: `from`, `to`, `cc`, `bcc`, `subject`, `date`, `reply-to`, `in-reply-to`, `message-id`. Each is `{ name, value }`. | +| `body.value.body.text` | The **already-decoded** message text. Prefers `text/plain`, falling back to `text/html`. | +| `body.value.body.mimeType` | Which of the two the text came from (`text/plain` or `text/html`). | +| `body.value.body.truncated` | `true` when the text was cut to fit the budget. | +| `body.value.body.originalChars` | Character count of the full decoded text before truncation, so you can tell how much you're missing. | +| `body.value.attachments` | Bounded metadata only — `filename`, `mimeType`, `size`, `attachmentId`. **Never base64 payload bytes.** | +| `body.value.attachmentsTruncated` | `true` when attachments were dropped to fit the budget. | + +(`body.value` is the message; `body.value.body` is its decoded content — the outer `body` is the HTTP response envelope.) + +The text is capped at 16,000 characters and the whole formatted response at roughly 20KB; when the response would still be too large, the body text is trimmed further and then attachments, labels, and headers are shed. Read the `truncated` / `originalChars` / `attachmentsTruncated` flags rather than assuming you got everything. + +```jsonc +// gmail_get_message(resourceId, messageId, format: "full") → +{ + "kind": "api", + "status": 200, + "body": { + "kind": "json", + "value": { + "id": "m1", + "threadId": "t1", + "labelIds": ["INBOX", "UNREAD"], + "snippet": "hi", + "headers": [ + { "name": "From", "value": "a@b.com" }, + { "name": "Subject", "value": "Hello" } + ], + "body": { "mimeType": "text/plain", "text": "Hello", "truncated": false, "originalChars": 5 }, + "attachments": [ + { "filename": "a.pdf", "mimeType": "application/pdf", "size": 10, "attachmentId": "att1" } + ], + "attachmentsTruncated": false + } + } +} +``` + +Because the text arrives decoded, **do not** base64-decode it, and do not write the response to a file to `Read`/`grep`/parse it back — read `body.value.body.text` directly from the tool result. + +Only `format="full"` is normalized. `metadata`, `minimal`, and `raw` pass through as the upstream Gmail shape, so `metadata` headers stay under `body.value.payload.headers` and `raw` still returns base64 (`raw` is rarely what you want — prefer `full`). Gmail error responses and non-2xx statuses are passed through untouched, so keep checking `status`. + +### When to fall back to `gmail_invoke` + +Reserve it for operations the typed tools don't cover — most commonly fetching a **complete thread**: + +```jsonc +gmail_invoke({ + resourceId, + method: "GET", + path: "users/me/threads/THREAD_ID", + query: { "format": ["metadata"] } // values are ARRAYS of strings +}) +``` + +`query` is `map[string][]string`: every value must be an array, so it's `{"format": ["metadata"]}`, never `{"format": "metadata"}`. Since `gmail_invoke` returns the raw Gmail response, a thread fetched with `format="full"` carries base64 MIME parts for every message and can be enormous — prefer `format=["metadata"]` to enumerate the thread, then `gmail_get_message` per message ID for bounded content. ## TypeScript Client @@ -61,9 +129,13 @@ if (result.ok && result.result.status === 200 && result.result.body.kind === "js const messages = result.result.body.value.messages; } -// Get a specific message +// Get a specific message. The client is a thin wrapper over the Gmail API, so this +// returns the RAW Gmail shape (base64 MIME parts under body.value.payload) — the +// normalization and size bounds described above apply to the gmail_get_message MCP +// tool, not to gmailClient.invoke. In app code, decode payload parts yourself, or +// request format=metadata when headers are enough. const msgResult = await gmailClient.invoke("GET", "users/me/messages/MSG_ID", "get-message", { - query: { format: "full" }, + query: { format: "metadata" }, }); ``` @@ -71,8 +143,8 @@ const msgResult = await gmailClient.invoke("GET", "users/me/messages/MSG_ID", "g - **All paths are relative to `https://gmail.googleapis.com/gmail/v1/`** — e.g. use `users/me/messages`, not the full URL. - **Gmail search syntax**: `from:user@example.com`, `subject:meeting`, `after:2024/01/01`, `is:unread`, `has:attachment`, `label:INBOX`. Combine with spaces (AND) or `OR`. -- **Message format options**: `full` (headers + parsed body), `metadata` (headers only), `minimal` (IDs only), `raw` (RFC 2822 encoded). -- **Two-step read pattern**: `list_messages` returns only IDs → use `get_message` to fetch full content. +- **Message format options**: `full` (default — normalized, bounded, decoded text), `metadata` (headers only), `minimal` (IDs only), `raw` (RFC 2822, base64). Only `full` is normalized. +- **Two-step read pattern**: `list_messages` returns only IDs → `get_message` with `format="metadata"` for headers or the default `format="full"` for content. Use `gmail_invoke` only for what the typed tools don't cover, e.g. complete threads. - **Pagination**: Check `nextPageToken` in the response and pass it as `pageToken` to get the next page. - Response structure: `{ kind: "api", status: number, body: { kind: "json", value: {...} } }` - **Scope presets**: "readonly" (read/search only) or "readwrite" (read/search + send). Send operations fail with 403 on readonly.