Skip to main content
Glama
knowledgeislands

mcp-gmail

README.md
# mcp-gmail

[![CI](https://github.com/knowledgeislands/mcp-gmail/actions/workflows/ci.yml/badge.svg)](https://github.com/knowledgeislands/mcp-gmail/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/@knowledgeislands/mcp-gmail.svg)](https://www.npmjs.com/package/@knowledgeislands/mcp-gmail) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)

An MCP (Model Context Protocol) server that connects Claude with Gmail through the Google Gmail API.

## Features

- **Search and triage** — Gmail-query syntax at message + thread granularity, batch-relabel up to 1000 messages in a single API call.
- **Label management** — list/create/rename/delete user labels; toggle read/unread/archive/trash via sugar tools so callers don't have to know system-label ids.
- **Recoverable trash** — `messages.trash` / `threads.trash` only. Permanent deletion (`messages.delete` / `threads.delete`) is deliberately not exposed.
- **Drafts-only outbound** — compose plain text + HTML (with `multipart/alternative` fallback), attachments with filename/MIME-type overrides, reply + reply-all (self-dedupe via cached profile). **Never sends mail** — the user reviews drafts in Gmail and clicks Send.
- **Strict input schemas** — every tool registers a Zod schema; `tools/list` reports proper JSON Schema and honest MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`).

**Quality:** 420 tests at 100% coverage; CI also boots the built server over stdio MCP and asserts the wire-level tool surface on every commit ([`bun run ki:test:smoke`](#development)).

## Quick Start

1. **Install dependencies**: `bun install`.
2. **Set up Google Cloud credentials** — see [Google Cloud Console Setup](#google-cloud-console-setup).
3. **Configure environment** — copy `.env.example` to `.env.development` and add your Google OAuth credentials.
4. **Build**: `bun run build`.
5. **Configure Claude Desktop** with `dist/mcp-server/index.js` and your `MCP_GMAIL_CLIENT_ID`/`MCP_GMAIL_CLIENT_SECRET` (see [Configuration](#configuration)).
6. **Start the auth server**: `bun run ki:server:auth:dev` (separate process; handles OAuth on `localhost:3334`).
7. **Authenticate** — call the `gmail_auth_start` tool in your MCP client, follow the URL, sign in. Tokens land at `~/.mcp-gmail-tokens.json` (mode `0600`). (`gmail_auth_start` is annotated `WRITE_REMOTE` because it persists tokens, so it registers at `MCP_GMAIL_ACCESS_LEVEL=write` or higher; the default `read`-only configuration hides it along with every other mutating tool.)

## Example Conversations

Concrete asks you might make of Claude with this server connected.

**Triage by sender:**

> "Find all unread emails from `notifications@github.com` from the last 30 days and archive them."

Claude uses [`gmail_messages_search`](#message) with `from:notifications@github.com is:unread newer_than:30d` to collect ids, then [`gmail_messages_batch_modify`](#message) (single round-trip, up to 1000 ids per call) to drop the `INBOX` label from the batch.

**Draft a contextual reply:**

> "Find the meeting invite from Alice yesterday and draft a reply confirming I'll be there at 2pm."

Claude uses `gmail_messages_search` to locate the thread, `gmail_message_get` to read the headers, then [`gmail_draft_create`](#draft) with `replyToMessageId` set — the draft inherits `In-Reply-To`, the References chain, the threadId, and a `Re:` subject. Pass `replyAll: true` and Claude auto-populates To (= original From + To) and Cc (= original Cc); your authenticated address is dropped from both so you don't email yourself.

**Find what needs attention:**

> "Show me unread threads with attachments from this week."

Claude calls [`gmail_threads_search`](#thread) with `is:unread has:attachment newer_than:7d` and returns subject, from, snippet, label ids, and attachment counts per thread — fast structured output, not free-form text.

**Bulk relabel:**

> "Move every message labelled `newsletter` from before 2026 to my `reading-list/archive` label and drop `newsletter`."

Claude resolves both label ids via [`gmail_labels_list`](#label), searches with `label:newsletter before:2026/01/01`, and applies the swap in a single `gmail_messages_batch_modify({addLabelIds, removeLabelIds})` call.

Filtering by a label whose name contains spaces (e.g. `Matters/Criminal - False Allegations`) needs care: Gmail's `q` operator only matches the hyphenated form (`label:Matters/Criminal---False-Allegations`), and an unquoted name with spaces silently matches nothing. Pass the exact label id(s) via the `labelIds` parameter on `gmail_messages_search` / `gmail_threads_search` (most reliable), or quote the name in the query (`label:"Matters/Criminal - False Allegations"`) — the server rewrites quoted names to the form Gmail expects.

## Installation

### Prerequisites

- [Bun](https://bun.sh) 1.3+ for the dev loop
- Node.js 22.0.0 or higher to run the compiled `dist/`
- A Google account for Cloud Console access

```bash
bun install
```

## Google Cloud Console Setup

### 1. Create a project

1. Open the [Google Cloud Console](https://console.cloud.google.com).
2. Project dropdown → **New Project**.
3. Name it (e.g. `mcp-gmail`) → **Create**.

### 2. Enable the Gmail API

1. **APIs & Services → Library**.
2. Search for **Gmail API** → **Enable**.

### 3. Configure the OAuth consent screen

For brand-new projects, Google gates this behind a one-time wizard. If you see **"Google Auth Platform not configured yet"** with a **Get Started** button, follow 3a. Otherwise jump to 3b.

#### 3a. First-time setup

1. **APIs & Services → OAuth consent screen** → **Get Started**.
2. **App Information**: app name, your support email → **Next**.
3. **Audience**: **External** → **Next**.
4. **Contact Information**: your email → **Next**.
5. Agree to the user-data policy → **Continue** → **Create**.

#### 3b. Publish the app

1. **OAuth consent screen → Audience**.
2. **Publishing status** → **Publish App** → **Confirm**. (Avoids the 7-day refresh-token expiry of "Testing" mode. The app stays unverified — fine for personal use; you'll see a one-time "advanced → continue" warning during sign-in.)

#### 3c. Configure data access (scopes)

**This step is mandatory.** If a scope isn't pre-declared here, Google silently drops it from consent, and Gmail API calls return 403 even after a "successful" sign-in.

1. **OAuth consent screen → Data Access** → **Add or remove scopes**.
2. Tick `https://www.googleapis.com/auth/gmail.modify` → **Update** → **Save**.

After changing scopes here, **delete the token file (default `~/.mcp-gmail-tokens.json`) and re-run the `gmail_auth_start` tool** so the consent screen prompts again with the new scope set.

### 4. Create OAuth credentials

1. **APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID**.
2. Application type: **Web application**.
3. Name: anything (e.g. `mcp-gmail`).
4. **Authorized redirect URIs** → add `http://localhost:3334/auth/callback` (must match `MCP_GMAIL_REDIRECT_URI`).
5. **Create**, then copy the **Client ID** and **Client Secret**.

## Configuration

### Environment Variables

| Name | Required | Default | Purpose |
| --- | --- | --- | --- |
| `MCP_GMAIL_CLIENT_ID` | yes | — | OAuth 2.0 Client ID (`xxxx.apps.googleusercontent.com`). |
| `MCP_GMAIL_CLIENT_SECRET` | yes | — | OAuth 2.0 Client Secret. |
| `MCP_GMAIL_REDIRECT_URI` | no | `http://localhost:3334/auth/callback` | Must match the URI registered in Google Cloud. |
| `MCP_GMAIL_SCOPES` | no | `https://www.googleapis.com/auth/gmail.modify` | Space-separated OAuth scopes. |
| `MCP_GMAIL_AUTH_PORT` | no | `3334` | Port the auth server listens on. Must match the redirect URI port. |
| `MCP_GMAIL_TOKEN_PATH` | no | `~/.mcp-gmail-tokens.json` | Token file location. Override to keep multiple accounts side-by-side. |
| `MCP_GMAIL_ACCESS_LEVEL` | no | `read` | Maximum tool access level to register. † |
| `MCP_GMAIL_DOWNLOAD_PATH` | no | `~/Downloads` | Directory where attachment downloads are written. |
| `MCP_GMAIL_INLINE_ATTACHMENT_MAX_BYTES` | no | `262144` (256 KiB) | Cap on inline-returned attachment bytes. ‡ |
| `MCP_GMAIL_AUDIT_LOG` | no | `writes` | Audit-log scope. § |
| `MCP_GMAIL_AUDIT_LOG_PATH` | no | `~/.local/state/mcp-gmail/audit.jsonl` | Path to the JSONL audit log. |
| `MCP_GMAIL_AUDIT_LOG_MAX_BYTES` | no | `10485760` (10 MiB) | Size-based rotation threshold in bytes. Set to `0` to disable rotation. |
| `MCP_GMAIL_AUDIT_LOG_KEEP` | no | `5` | Number of rotated audit-log files to retain. |
| `NODE_ENV` | no | — | Affects which `.env*` files hydrate config. ¶ |

† One of: `read` (default — read-only tools only, least privilege), `write` (adds non-destructive mutations like `gmail_draft_create`, `gmail_label_create`, `gmail_auth_start`), `destructive` (adds delete tools). Levels nest. Each tool's level is derived from its MCP annotations (`readOnlyHint: true` → `read`; `destructiveHint: true` → `destructive`; explicit `readOnlyHint: false` AND `destructiveHint: false` → `write`; missing annotations → `destructive` fail-safe); a tool registers when its derived level ≤ the configured level. Unknown values abort startup.

‡ Larger attachments must be saved via the download tool.

§ One of `off`, `writes` (record only non-read tool calls), `all` (record every invocation).

¶ On load the server reads, from the package root and highest precedence first, `.env.local`, then `.env.${NODE_ENV}` (when `NODE_ENV` is set), then `.env`; a var already in the environment (e.g. your MCP client's `env` block) always wins over a file. The `server:*:dev`/`ki:server:mcp:inspect` scripts set `development`, so `.env.development` is also picked up there.

### Claude Desktop Configuration

Run `bun run build` first so `dist/mcp-server/index.js` exists, then add to your Claude Desktop config:

```json
{
  "mcpServers": {
    "mcp-gmail": {
      "command": "node",
      "args": ["/path/to/mcp-gmail/dist/mcp-server/index.js"],
      "env": {
        "MCP_GMAIL_CLIENT_ID": "your-client-id",
        "MCP_GMAIL_CLIENT_SECRET": "your-client-secret"
      }
    }
  }
}
```

A starter is in [`claude-config-sample.json`](./claude-config-sample.json).

### Running From Source (Dev)

```bash
cp .env.example .env.development
# edit .env.development with your Google OAuth credentials, then:
bun run ki:server:mcp:dev    # MCP server
bun run ki:server:auth:dev   # OAuth server on :3334
```

## Authentication

OAuth runs out-of-band via the standalone auth server:

1. Start `bun run ki:server:auth:dev` (listens on `http://localhost:3334`).
2. In your MCP client, call the `gmail_auth_start` tool — it returns a sign-in URL.
3. Open the URL, sign in with the Google account you want to access, grant the requested scope.
4. Tokens (including refresh token) are persisted to `~/.mcp-gmail-tokens.json` (override with `MCP_GMAIL_TOKEN_PATH`).
5. The MCP server reads that file and refreshes tokens transparently when they expire.

To force re-authentication (or if the refresh token is revoked), delete the token file and call `gmail_auth_start` again.

**Scope troubleshooting.** If Gmail API calls return 403 after a successful sign-in, inspect the `scope` field in the token file — Google only grants scopes that are pre-declared on the **OAuth consent screen → Data Access** tab (see [step 3c](#3c-configure-data-access-scopes)). If `gmail.modify` is missing from `scope`, add it to Data Access, delete the token file, and re-authenticate.

## Available Tools

32 tools across six areas, all prefixed `gmail_`. Each tool's access level (`read`, `write`, or `destructive`) is derived from its MCP annotations (`readOnlyHint` / `destructiveHint`), not its name, so the access-level gate (`MCP_GMAIL_ACCESS_LEVEL`) decides at boot which to register. Default `MCP_GMAIL_ACCESS_LEVEL=read` exposes only the 12 read tools; `write` adds non-destructive mutations (draft/label-create/relabel/trash/auth); `destructive` enables all 32. Default OAuth scope: `https://www.googleapis.com/auth/gmail.modify`.

### auth

| Tool                | Level   | Purpose                                                          |
| ------------------- | ------- | ---------------------------------------------------------------- |
| `gmail_about`       | `read`  | Server version, scopes, token store path.                        |
| `gmail_auth_start`  | `write` | Returns the URL to start Google OAuth consent.[^auth-server]     |
| `gmail_auth_status` | `read`  | Whether a token is persisted + scope/expiry metadata.[^no-token] |

### label

| Tool                 | Level         | Purpose                                                     |
| -------------------- | ------------- | ----------------------------------------------------------- |
| `gmail_labels_list`  | `read`        | List all system + user labels with `id` and `name`.         |
| `gmail_label_create` | `write`       | Create a user label.                                        |
| `gmail_label_update` | `write`       | Rename a user label.[^system-labels]                        |
| `gmail_label_delete` | `destructive` | Delete a user label.[^system-labels] [^label-delete-effect] |

### message

| Tool                          | Level   | Purpose                                                                     |
| ----------------------------- | ------- | --------------------------------------------------------------------------- |
| `gmail_messages_search`       | `read`  | Gmail-query search at message granularity.[^paginated]                      |
| `gmail_message_get`           | `read`  | Full message: headers, body, labels, attachments.[^html-strip][^msg-format] |
| `gmail_message_raw`           | `read`  | Write the raw RFC 2822 message to `outputPath` (e.g. `.eml`).[^raw-no-body] |
| `gmail_message_label`         | `write` | Add label ids to a message.                                                 |
| `gmail_message_unlabel`       | `write` | Remove label ids from a message.                                            |
| `gmail_message_mark_read`     | `write` | Remove the `UNREAD` label.[^sugar]                                          |
| `gmail_message_mark_unread`   | `write` | Add the `UNREAD` label.[^sugar]                                             |
| `gmail_message_archive`       | `write` | Remove the `INBOX` label.[^sugar]                                           |
| `gmail_message_trash`         | `write` | Move to Trash via `messages.trash`.[^trash]                                 |
| `gmail_messages_batch_modify` | `write` | Add/remove labels on up to 1000 messages in one call.[^batch-modify]        |

### attachment

| Tool                        | Level  | Purpose                                                                     |
| --------------------------- | ------ | --------------------------------------------------------------------------- |
| `gmail_attachment_get`      | `read` | Download an attachment, to disk via `outputPath` or inline.[^attach-inline] |
| `gmail_attachment_metadata` | `read` | Get filename, MIME type, size without downloading bytes.[^attach-metadata]  |

### thread

| Tool                       | Level   | Purpose                                                                |
| -------------------------- | ------- | ---------------------------------------------------------------------- |
| `gmail_threads_search`     | `read`  | Gmail-query search at thread granularity.[^paginated] [^thread-shape]  |
| `gmail_thread_get`         | `read`  | Full thread: every message with headers, body, label ids, attachments. |
| `gmail_thread_label`       | `write` | Add label ids to every message in a thread.                            |
| `gmail_thread_unlabel`     | `write` | Remove label ids from every message in a thread.                       |
| `gmail_thread_mark_read`   | `write` | Remove the `UNREAD` label from every message in the thread.[^sugar]    |
| `gmail_thread_mark_unread` | `write` | Add the `UNREAD` label to every message in the thread.[^sugar]         |
| `gmail_thread_archive`     | `write` | Remove the `INBOX` label from every message in the thread.[^sugar]     |
| `gmail_thread_trash`       | `write` | Move every message in the thread to Trash via `threads.trash`.[^trash] |

### draft

| Tool                 | Level         | Purpose                                                                     |
| -------------------- | ------------- | --------------------------------------------------------------------------- |
| `gmail_draft_create` | `write`       | Create a Gmail draft (saved, never sent).[^draft-shape]                     |
| `gmail_draft_update` | `write`       | Replace an existing draft's contents (same fields as `gmail_draft_create`). |
| `gmail_drafts_list`  | `read`        | List drafts with headers + snippet; optional `query` filter.[^paginated]    |
| `gmail_draft_get`    | `read`        | Get a draft's full headers, body, label ids, and attachment refs.           |
| `gmail_draft_delete` | `destructive` | Permanently delete a draft (does not go to Trash).                          |

This server deliberately exposes draft creation but no sending tool. The user reviews drafts in Gmail and clicks Send — Claude never directly delivers mail. The OAuth scope technically permits sending; the MCP surface does not.

[^auth-server]: The auth server must be running on `:3334`.

[^no-token]: Never returns access or refresh token values.

[^system-labels]: System labels (INBOX, SENT, etc.) cannot be renamed or deleted; Gmail rejects the request.

[^label-delete-effect]: Gmail removes the label from every message that had it; the messages themselves are untouched.

[^paginated]: Returns `{<items>, nextPageToken?}`. Pass `nextPageToken` back as `pageToken` to fetch the next page; it's omitted on the last page.

[^html-strip]: If the message has no `text/plain` part, the HTML body is stripped and returned instead.

[^msg-format]: `format` defaults to `'full'`. Pass `'metadata'` to skip the body (headers + labels only, with `body` and `attachments` empty) — cheaper when the caller doesn't need content.

[^raw-no-body]: Returns `{messageId, path, sizeBytes}`. The body never travels through the response, so this is safe for messages with large attachments. Subject/date aren't returned — with `format=raw` Gmail does not break out headers (use `gmail_message_get`).

[^attach-inline]: With `outputPath`, writes the decoded bytes and returns `{messageId, path, sizeBytes}`. Without it, returns `{filename, mimeType, data}` (base64url) — suitable for small attachments only.

[^attach-metadata]: Backed by `messages.get(format=full)` — fetches the message part tree without downloading the attachment bytes. Returns `{messageId, attachmentId, filename, mimeType, sizeBytes}`.

[^thread-shape]: Each thread carries `id`, `snippet`, `messageCount`, latest-message headers, and the union of label ids across all messages.

[^draft-shape]: Plain-text body via `bodyText`, optional rich body via `bodyHtml` (both → `multipart/alternative` so plain-text clients still render). Attachments accept either a bare path or `{path, filename?, mimeType?}` to override either field. With `replyToMessageId` we wire `In-Reply-To`, extend `References`, prepend `Re:` to Subject, and tie the draft to the right thread. With `replyAll: true` (requires `replyToMessageId`), `to` (= original From + To) and `cc` (= original Cc) auto-populate, with the authenticated account removed; caller-supplied `to` / `cc` win.

[^sugar]: Sugar over `messages.modify` / `threads.modify` so callers don't have to know the magic system-label id.

[^trash]: Recoverable for ~30 days from Gmail's Trash UI. Permanent deletion (`messages.delete` / `threads.delete`) is intentionally not exposed.

[^batch-modify]: Backed by Gmail `messages.batchModify`. At least one of `addLabelIds` or `removeLabelIds` is required. Returns `{count, messageIds, addLabelIds, removeLabelIds}` echoing the operation; Gmail returns 204 No Content on success.

## Security Model

- Secrets (`MCP_GMAIL_CLIENT_SECRET`) come from env vars only; never committed. `.env*` files are gitignored except `.env*.example` templates.
- OAuth tokens live at `MCP_GMAIL_TOKEN_PATH` (default `~/.mcp-gmail-tokens.json`), mode `0600`.
- Token writes are **atomic** — temp file + `rename()`. A crash mid-write cannot corrupt the token file.
- Token values are **never** logged or returned by any MCP tool. The `gmail_auth_status` tool exposes presence flags and metadata only.
- The auth server binds to `localhost:3334` only and accepts a single OAuth callback at a time; CSRF state entries expire after 10 minutes.
- If the token file is lost, revoked, or you want to switch Google accounts, delete the file and re-authenticate.

## Troubleshooting

**Port 3334 already in use.** Another auth-server process is bound to the port. Free it:

```bash
bunx kill-port 3334
```

**Gmail API returns 403 after a successful sign-in.** The OAuth consent screen didn't pre-declare the scope, so Google silently dropped it. Inspect `~/.mcp-gmail-tokens.json` and check the `scope` field; if `gmail.modify` is missing, add it via **OAuth consent screen → Data Access** ([step 3c](#3c-configure-data-access-scopes)), delete the token file, and re-run the `gmail_auth_start` tool.

**Token revoked or refresh fails.** Delete the token file and re-authenticate:

```bash
rm ~/.mcp-gmail-tokens.json
# then call the `gmail_auth_start` tool again
```

**Claude Desktop shows no tools / "Cannot find module".** The built server isn't where the config points. Rebuild and verify:

```bash
bun run build
ls dist/mcp-server/index.js
```

Then restart Claude Desktop. The `args` path in the Claude config must point at the compiled `dist/mcp-server/index.js`, not the TS source.

**`bun run ki:test:smoke` fails with "tool surface mismatch".** You've added or removed a tool but the smoke test's expected list is out of sync. Update both [`scripts/smoke.ts`](./scripts/smoke.ts) (`EXPECTED_TOOLS`) and the matching list in `src/tool-registration.test.ts`.

**Refresh token expires every 7 days.** Your OAuth consent screen is in **Testing** mode. Switch to **Published** under **OAuth consent screen → Audience** ([step 3b](#3b-publish-the-app)) — the app stays unverified for personal use; you'll see a one-time "advanced → continue" warning during sign-in.

## Directory Structure

```text
├── claude-config-sample.json   # Example Claude Desktop config
├── .github/workflows/ci.yml    # Lint, typecheck, test:coverage, smoke
├── package.json
├── tsconfig.json               # Base TS config
├── tsconfig.build.json         # Build config (emits to dist/)
├── .env.example                # Template for GMAIL_* env vars
├── scripts/
│   └── smoke.ts                # Wire-level tool-surface smoke test (bun run ki:test:smoke)
├── src/
│   ├── config/index.ts         # loadConfig(env?) → Config (no env read at import)
│   ├── auth-server/index.ts    # Standalone OAuth server (port 3334)
│   ├── mcp-server/index.ts     # MCP server entry — loadConfig() + registers every tool
│   ├── tools/                  # Thin tool defs grouped by resource; call into main/
│   │   ├── auth/               # about, authenticate, check-auth-status
│   │   ├── labels/             # label_list/create/update/delete
│   │   ├── messages/           # message_* (search, get, label, sugar wrappers, batch_modify)
│   │   ├── attachments/        # attachment_get + attachment_get_metadata
│   │   ├── threads/            # thread_* (search, get, label, sugar wrappers)
│   │   └── drafts/             # draft_create/update/list/get/delete
│   ├── main/                   # Real implementation (config injected as first arg)
│   │   ├── auth/               # OAuth2Client + token refresh + atomic token persistence
│   │   ├── gmail-client/       # Gmail payload parsing (headers, body, attachments)
│   │   ├── auth-info/          # about / authenticate / auth-status handlers
│   │   └── {labels,messages,threads,drafts,attachments}/  # one function per tool
│   └── utils/                  # MIME builder, paths, result envelopes, access-level, audit-log, annotations
└── dist/                       # Build output (gitignored, created by `bun run build`)
    └── mcp-server/index.js     # Compiled entry point used by Claude Desktop
```

## Development

```bash
bun run ki:server:mcp:dev      # bun --watch, MCP server
bun run ki:server:auth:dev     # bun --watch, OAuth server
bun run ki:server:mcp:start    # build then run from dist/ under node
bun run ki:server:auth:start   # build then run auth server from dist/ under node
bun run ki:server:mcp:inspect  # MCP Inspector against TS source
bun run test                # vitest (use `bun run test`, not `bun test`)
bun run test:coverage       # vitest + 100% threshold enforced
bun run ki:test:smoke          # build + boot server over stdio MCP, assert wire-level tool surface
bun run ki:lint:types          # tsc --noEmit
bun run ki:lint:check          # Biome
bun run ki:lint:fix            # Biome auto-fix (--unsafe)
bun run ki:lint:md             # prettier + markdownlint for *.md
```

## Extending the Server

Add a new tool by registering it in the appropriate module under [`src/tools/<resource>/`](./src/tools/) and re-exporting from [`src/tools/index.ts`](./src/tools/index.ts). Follow the existing pattern:

1. Pick a resource module (or create a new one) and name the tool `gmail_<resource>_<action>` (snake_case; plural resource for collection ops). Set `annotations` to one of the presets in [`src/utils/annotations.ts`](./src/utils/annotations.ts) (`READ_ONLY_REMOTE`, `WRITE_REMOTE`, `WRITE_IDEMPOTENT_REMOTE`, `DESTRUCTIVE_REMOTE`) — the access-level gate in [`src/utils/access-level.ts`](./src/utils/access-level.ts) maps the annotation to `read` / `write` / `destructive` and decides whether to register the tool under the current `MCP_GMAIL_ACCESS_LEVEL` value, and the audit log uses the derived level as the `level` field.
2. Validate inputs with a Zod schema; mark optional fields explicitly.
3. Set MCP annotations honestly via the constants in [`src/utils/annotations.ts`](./src/utils/annotations.ts) (`READ_ONLY_REMOTE`, `WRITE_IDEMPOTENT_REMOTE`, `DESTRUCTIVE_REMOTE`, `WRITE_REMOTE`).
4. Return successes via `jsonResult(...)` and failures via `errorResult('verbing', err)` so the client gets `isError: true` with a recognisable message.
5. Update `EXPECTED_TOOLS` in [`scripts/smoke.ts`](./scripts/smoke.ts) **and** the matching list in [`src/tool-registration.test.ts`](./src/tool-registration.test.ts) so both `bun run ki:test:smoke` and the unit suite stay in sync.

TDQS

B3.1/5.0

Scored across 3 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: server info, authentication initiation, and auth status checking. No overlap in functionality exists.

Naming Consistency2/5

Tool names lack a consistent pattern: 'about' is a preposition, 'authenticate' is a verb, and 'check-auth-status' uses hyphens. Mixing conventions reduces predictability.

Tool Count1/5

With only 3 tools, all related to authentication and server info, the server is far too narrow for a Gmail integration. No email operations are offered.

Completeness1/5

The tool surface misses core Gmail functionality like reading, sending, or managing emails and labels. The server is severely incomplete for its domain.

Maintenance

ActivityActive
ResponsivenessSyncing