15 Commits

Author SHA1 Message Date
KS Jannette
1cff926c15 mo 2026-02-20 11:53:31 -05:00
KS Jannette
809da541dc more 2026-02-20 10:59:01 -05:00
KS Jannette
b44118bd2f more readme stuff 2026-02-18 09:17:11 -05:00
KS Jannette
1d86280e8c more 2026-02-18 08:59:14 -05:00
KS Jannette
a22a43a5ab More 2026-02-18 08:50:24 -05:00
KS Jannette
9d465d5fca more 2026-02-18 08:44:37 -05:00
KS Jannette
5ca368f15e m 2026-02-18 08:32:13 -05:00
KS Jannette
dc8624592d more 2026-02-18 08:23:58 -05:00
KS Jannette
3a3a971feb tune up README 2026-02-18 08:14:10 -05:00
KS Jannette
8fb4fe7ce5 More updates for mail configs 2026-02-18 07:31:02 -05:00
KS Jannette
a0e1b43360 basic unit tests 2026-02-18 07:26:13 -05:00
S Jannette
410ee0656f Merge pull request #5 from kjannette/restructure
Restructure
2026-02-18 07:06:07 -05:00
KS Jannette
94b07531d0 restruct and refact 2026-02-18 07:04:41 -05:00
KS Jannette
1094f9c972 more 2026-02-18 06:58:44 -05:00
S Jannette
68e48b2345 Merge pull request #4 from kjannette/finetune
more
2026-02-18 06:21:31 -05:00
19 changed files with 3117 additions and 1119 deletions

3
.gitignore vendored
View File

@@ -8,5 +8,4 @@ token*.json
accounts.json accounts.json
# Runtime output # Runtime output
summary.json mailSummaries/
summary-secondary.json

437
README.md
View File

@@ -1,272 +1,367 @@
# Declawed: A Configurable, Promptable AI Mail Assisty Kitty <p align="center">
<img src="assets/logo.png" alt="Declawed" width="120" />
</p>
A local Model Context Protocol (MCP) server and LLM integration platform. Built to connect to LLM APIs - be itClaude Desktop, *others*... or locally hosted models. Executes your prompts to manage your mail, while you... watch Fellini films, solve climate change, sip Mai Tais, or.... whatever. # Declawed: your kuddly assisty-kitty
Ssssimple. Siamsese, if you please.... Eats the blue-plate-crustacean for breakfast. # Inbox brimming with stank-ass algorithmically-generated turds?
Infinitely mod-able. Dead simple. Privacy centric. # (Cat) nip that sh*t in the bud!!!
### Declawed is a configurable, prompt purrr-fectable LLM mail management assisty-kitty.
### Puuurfect for tedious daily sorts/classification/responses
### Cuts down on the frustartion of feeling like you'll never get your paws on that catnip-stuffed mouse dangling from the string.
# Privacy
# More than the blue-plate crustacean
A local Model Context Protocol (MCP) server integrates with an LLM API you choose -- both of which offer greater ops and privacy control and transparency.
# Avoid flakey (molty?) black box installs.
Billed as QUICK AND EASY! ...loaded with more Trojans than an Olympic Village.
... Silent processes: data harvest phone-homes, behavioral analytic reporting, telemetry backdoors - all the nasties crawling the dark detritus. You know lobsters eat poop, right?
# Declawed: **actually** easy
## Stop drilling byzantine menus in mail, scehduling and sheets pltforms to 1. configure filters that don't even work 2. change every three months
## Short, declarative prompts are teh equivelent of wrapping slop, bulk-mail's feet in tin foil and tossing it in the bathtub.
### Prompts are discerning, unlike filters. For exaple, they keep the gold and bin the junk in forums/feeds where solid-gold insights are only 10% of the signal/noise.
### With refinement, prompting runs auto: cleaning that stank litter(in)box before you get a whiff.
### Morning greets you with a fresh, spring-meadow aroma of opportunities and insights -- not an avalanche of turds -- like its f*cxing 1998 again.
# Kitty connex are simple
Assisty-kitty easily interfaces with mail and calendar and other app APIs (about any other service you want to plug in) ... it keeps things moving so you can go you chase your tail or take a 19-hour nap in a sunbeam.
--- ---
# Scope # Scope and Architecture
## Current implementation contemplates: Claude Desktop + Custom, Local Model Context Protocol Server + Commercial SMTP Server (MX'configd properly) + your DNS-configd XXX.YYY ## Current implementation requires building/using:
-- Claude Desktop (as UI - proprietary UI coming soonish)
-- Building/Connecting a Local Model Context Protocol Server
-- A DNS-configd domain, with MX records pointing to:
-- A commercial or self-hosted SMTP Serve
--- ## Coming soonish:
-- Our own custom UI
-- Suppost for wiring up local LLMs.
### 1. Install: Node.js # Setup
Node.js v16 or higher must be installed. ### 1. Install Node.js (v16+ required).
```bash ```bash
node --version node --version
npm --version npm --version
``` ```
If not installed, download from [nodejs.org](https://nodejs.org/). If not installed, grab it from [nodejs.org](https://nodejs.org/).
### 2. Initialize the Project ### 2. Clone and Install
```bash ```bash
mkdir assistant git clone https://github.com/kjannette/deClawed-Assisty-Kitty.git
cd assistant cd deClawed-Assity-Kitty
npm init -y npm install
``` ```
### 3. Install Dependencies ### 3. Set Up Google Cloud Credentials
```bash #### 3a. Create a Google Cloud Project
npm install @modelcontextprotocol/sdk zod@3 googleapis
npm install -D @types/node typescript
```
Create the source directory and entry file:
```bash
mkdir src
touch src/index.ts
```
### 4. Configure the Project
#### 4a. Update `package.json`
Set the module type, binary entry, and build scripts:
```json
{
"type": "module",
"bin": {
"assistant": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js",
"auth": "npm run build && node build/auth.js"
},
"files": ["build"]
}
```
#### 4b. Create `tsconfig.json` in the project root
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
```
### 5. Write the Server Code
The server source lives in `src/index.ts`. It registers three tools and one prompt with the MCP server:
- **`fetch_new_emails`** -- fetches unread Gmail messages
- **`delete_emails`** -- trashes messages by ID
- **`append_to_summary`** -- logs classified emails to `summary.json`
- **`review_emails`** (prompt) -- feeds Claude the classification instructions
The auth helper lives in `src/auth.ts`, used only for the one-time OAuth setup.
### 6. Set Up Google Cloud Credentials
#### 6a. Create a Google Cloud Project
1. Go to [Google Cloud Console](https://console.cloud.google.com/) 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Sign in with the Google account that owns the target Gmail 2. Sign in with the Google account that owns the target Gmail
3. Click the project dropdown (top-left) and select **New Project** 3. Click the project dropdown (top-left) > **New Project**
4. Name it (e.g., `assistant-mcp`) and click **Create** 4. Name it (e.g., `assistant-mcp`) and click **Create**
5. Select the new project from the dropdown 5. Select the new project from the dropdown
#### 6b. Enable the Gmail API #### 3b. Enable APIs
1. Go to **APIs & Services > Library** ([direct link](https://console.cloud.google.com/apis/library)) In **APIs & Services > Library**, enable:
2. Search for **Gmail API** - **Gmail API**
3. Click it, then click **Enable** - **Google Sheets API** (if mapping mail to sheets)
- **Google Calendar API** (if using calendar event creation, etc.s)
#### 6c. Configure the OAuth Consent Screen #### 3c. Configure the OAuth Consent Screen
1. Go to **Google Auth Platform > Branding** (or **APIs & Services > OAuth consent screen**) 1. Go to **Google Auth Platform > Branding** (or **APIs & Services > OAuth consent screen**)
2. Set user type to **External**, click **Create** 2. Set user type to **External**, click **Create**
3. Fill in app name, support email, and developer contact email 3. Fill in app name, support email, and developer contact email
4. Save and continue 4. Save and continue
#### 6d. Add the Gmail Scope #### 3d. Add OAuth Scopes
1. Go to **Google Auth Platform > Data Access** (or the Scopes page) In **Google Auth Platform > Data Access**, add:
2. Click **Add or remove scopes** - `https://www.googleapis.com/auth/gmail.modify`
3. Add: `https://www.googleapis.com/auth/gmail.modify` - `https://www.googleapis.com/auth/spreadsheets` (for Sheets integration)
4. Save - `https://www.googleapis.com/auth/calendar.events` (for Calendar integration)
#### 6e. Add Yourself as a Test User #### 3e. Add Yourself as a Test User
1. Go to **Google Auth Platform > Audience** Go to **Google Auth Platform > Audience** and add each Gmail address you'll use.
2. Add your Gmail address as a test user
#### 6f. Create OAuth Client Credentials #### 3f. Create OAuth Client Credentials
1. Go to **Google Auth Platform > Clients** (or **APIs & Services > Credentials**) 1. Go to **Google Auth Platform > Clients** (or **APIs & Services > Credentials**)
2. Click **Create Client** (or **+ Create Credentials > OAuth client ID**) 2. Click **Create Client** > Application type: **Desktop app**
3. Application type: **Desktop app** 3. **Download the JSON**, rename it to `credentials.json`
4. Name it anything (e.g., `Assistant MCP Desktop`) 4. Place it in the project root
5. Click **Create**
6. **Download the JSON** file
7. Rename it to `credentials.json`
8. Move it to the project root: `/Users/kjannette/assistant/credentials.json`
### 7. Authorize Your Gmail Account ### 4. Configure Accounts
Build the project and run the auth script: Create file `accounts.json` in the project root. Each key is an account alias with its own token file and optional Sheets/Calendar config:
```bash ```json
npm run auth {
"work": {
"label": "you@yourdomain.com",
"tokenFile": "token.json",
"spreadsheetId": "YOUR_GOOGLE_SHEET_ID",
"calendarId": "primary"
},
"secondary": {
"label": "you@gmail.com",
"tokenFile": "token-secondary.json"
}
}
``` ```
This will: - **`label`** -- display name (typically the email address)
- **`tokenFile`** -- per-account OAuth token (auto-generated during auth)
- **`spreadsheetId`** -- Google Sheets ID for recruiter contact logging (optional)
- **`calendarId`** -- Google Calendar ID for event creation (optional, `"primary"` uses the default calendar)
### 5. Authorize Gmail Accounts
Build and run the auth script for each account:
```bash
npm run auth # authorizes the "work" account
npm run auth -- secondary # authorizes the "secondary" account
```
Each run will:
1. Print a URL -- open it in your browser 1. Print a URL -- open it in your browser
2. Sign in with your Google account and click **Allow** 2. Sign in and click **Allow**
3. You'll land on a "localhost refused to connect" page (this is normal) 3. You'll land on a "localhost refused to connect" page (normal)
4. Copy the **entire URL** from the browser address bar 4. Copy the **entire URL** from the address bar and paste it back into the terminal
5. Paste it into the terminal prompt 5. The script saves the token file (e.g., `token.json` or `token-secondary.json`)
6. The script extracts the auth code and saves `token.json`
You only need to do this once. The token auto-refreshes. You only need to do this once per account. Tokens auto-refresh.
### 8. Build the Server ### 6. Write the Prompts
Two plain-text prompt files in `src/prompts/` control the workflow:
| File | Phase | Purpose |
|------|-------|---------|
| `src/prompts/classify-emails.txt` | 1 -- Classification | Defines categories A/B/C/D and how to sort emails |
| `src/prompts/take-action-on-emails.txt` | 2 -- Action | Tells the LLM what to do with each category (delete, log, schedule, etc.) |
**Tips:**
- Use clear, explicit category definitions with example language
- Handle ambiguous cases (e.g., "If an email both acknowledges receipt AND requests action, classify as B")
- Prompt files are loaded at runtime -- edit them anytime, no rebuild required
### 7. Build
```bash ```bash
npm run build npm run build
``` ```
This compiles `src/*.ts` into `build/*.js`. Compiles `src/**/*.ts` into `build/`.
### 9. Write the Classification Prompt ## 8. Configure Claude Desktop
Create a file called `classify-emails.txt` in the project root. This file contains the plain-text instructions that tell Claude how to classify your emails. Edit your Claude Desktop config:
**Tips for writing the prompt:**
- Use clear, explicit category definitions with example language for each
- Handle ambiguous cases (e.g., "If an email both acknowledges receipt AND requests action, classify it as B")
- Define the exact actions to take for each category (delete, summarize, etc.)
- Specify what fields to include in summaries
- Keep it in plain text -- no JSON or special formatting needed
- The file is loaded at runtime, so you can edit it without rebuilding the server
**File location:** Must be at the project root as `classify-emails.txt`.
### 10. Configure Claude Desktop
Edit the Claude Desktop config file:
```bash ```bash
code ~/Library/Application\ Support/Claude/claude_desktop_config.json code ~/Library/Application\ Support/Claude/claude_desktop_config.json
``` ```
Add the `assistant` server to the `mcpServers` object: Add the server:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"assistant": { "assistant": {
"command": "/ABSOLUTE/PATH/TO/node", "command": "/ABSOLUTE/PATH/TO/node",
"args": [ "args": [
"/Users/kjannette/assistant/build/index.js" "/ABSOLUTE/PATH/TO/deClawed-Assity-Kitty/build/index.js"
] ]
} }
}
}
```
Replace paths with the output of `which node` and your actual project location.
## 9. Add/Configure API Keys, Credentials, and Other Secrets
Secret files are **gitignored** -- they never leave your machine. Example templates are provided so you know what shape each file needs to be in.
#### 9a. `credentials.json`
This file holds your Google OAuth client credentials. **You do not write this by hand** -- it is downloaded from the Google Cloud Console (see Step 3f above). Copy the example and then replace it with the real download:
```bash
cp credentials.example.json credentials.json
# Now replace credentials.json with the file downloaded from Google Cloud Console.
```
The structure looks like this (the example file ships with empty values):
```json
{
"installed": {
"client_id": "",
"project_id": "",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "",
"redirect_uris": ["http://localhost"]
} }
} }
``` ```
Replace `/ABSOLUTE/PATH/TO/node` with the output of `which node`. #### 9b. `accounts.json`
### 11. Restart Claude Desktop Defines each Gmail account the server manages. Copy the example and fill in your values:
Fully quit Claude Desktop (**Cmd+Q**, not just close the window) and reopen it. The `assistant` server should now appear under **Connectors** in the chat input. ```bash
cp accounts.example.json accounts.json
```
| Field | What to put here |
|-------|-----------------|
| `label` | The email address for this account (display only) |
| `tokenFile` | Filename for this account's OAuth token (e.g., `token.json`) |
| `spreadsheetId` | The ID from your Google Sheet URL: `docs.google.com/spreadsheets/d/<THIS_PART>/edit` (optional) |
| `calendarId` | `"primary"` for your default calendar, or a specific calendar ID (optional) |
Add as many accounts as you need. Each key (e.g., `"work"`, `"secondary"`) becomes the account name used in tool calls and auth commands.
#### 9c. `token*.json` (auto-generated)
Token files are **created automatically** when you run `npm run auth` (Step 5). You do not need to create or edit them manually. A `token.example.json` is provided for reference only -- it shows the structure but the values are populated by the OAuth flow.
#### Summary of secret files
| Example template | Actual file (gitignored) | How to create |
|-----------------|--------------------------|---------------|
| `credentials.example.json` | `credentials.json` | Download from Google Cloud Console |
| `accounts.example.json` | `accounts.json` | Copy example, fill in your email/sheet/calendar IDs |
| `token.example.json` | `token.json`, `token-secondary.json`, etc. | Auto-generated by `npm run auth` |
### 10. Restart Claude Desktop
Fully quit (**Cmd+Q**, not just close the window) and reopen. The `assistant` server should appear under **Connectors**.
--- ---
## Usage Guide ## MCP Tools
### Prompt Loader | Tool | Description |
|------|-------------|
| `fetch_new_emails` | Fetches unread emails for a given account. Classification and action prompts are automatically appended to the response. |
| `delete_emails` | Moves emails to trash by Gmail message ID. Used for categories A (acknowledgements/junk/”THANKS!”/offers) and C (rejections). |
| `append_to_summary` | Logs classified emails to per-account summary files in `mailSummaries/`. Entries older than 30 days are auto-purged. |
| `log_contact` | Logs or updates contact info in a Google Sheet. Merges rows by email + role. |
| `create_calendar_event` | Creates a Google Calendar event for scheduled calls/meetings. Includes meeting links, attendees and their contact info. |
The server reads `classify-emails.txt` from the project root at runtime. To change classification behavior, edit that file directly -- no rebuild required. The updated instructions take effect on the next tool call. ## MCP Prompts (built in as of now-ish - add your own)
### BETTER YET - DO A PR OR FORK
### MCP Prompt: `review_emails` | Prompt | Account | Description |
|--------|---------|-------------|
| `review_emails` | work | Loads the classification + action prompts for the work inbox |
| `review_secondary_emails` | secondary | Same workflow, but for a second email account inbox |
A registered MCP prompt available in Claude Desktop's Connectors menu. When invoked, it feeds Claude the full contents of `classify-emails.txt` as a user message, giving Claude all the classification criteria before it calls any tools. This is the recommended way to trigger the workflow -- it ensures Claude has the complete instructions every time. Invoke these from Claude Desktop's Connectors menu, or just type "Review my inbox" / "Review my secondary inbox."
### `fetch_new_emails` Enrichment ---
Every time `fetch_new_emails` is called, the classification instructions from `classify-emails.txt` are appended to the response alongside the email data. This means Claude always sees the rules with the data, even if the `review_emails` prompt was not explicitly invoked. Belt and suspenders. ## Two-Phase Workflow
### Running the Workflow **Phase 1 -- Classify**
1. Open Claude Desktop The LLM calls `fetch_new_emails`, which returns email data with the classification instructions from `classify-emails.txt` appended. Each email is sorted into:
2. Type: **"Review my inbox"** (or invoke the `review_emails` prompt from Connectors)
3. Claude will:
- Call `fetch_new_emails` to retrieve unread messages
- Classify each email as A, B, C, or D using the prompt instructions
- Call `delete_emails` for categories A and C
- Call `append_to_summary` for categories B and D
4. Results are displayed in the chat and saved to `summary.json`
### Key Commands | Category | Meaning | Action |
|----------|---------|--------|
| **A** | Acknowledgement / auto-reply | Delete |
| **B** | Advancement to next step | Summarize + log |
| **C** | Rejection | Delete |
| **D** | Other / uncategorized | Summarize + log |
**Phase 2 -- Act**
Using `take-action-on-emails.txt`, the LLM:
- Calls `delete_emails` for A + C
- Calls `append_to_summary` for B + D
- Calls `log_recruiter_contact` to track contacts in Sheets (if configured)
- Calls `create_calendar_event` for any scheduled interviews/calls (if configured)
---
## Key Commands
| Command | Purpose | | Command | Purpose |
|---------|---------| |---------|---------|
| `npm run build` | Recompile after editing `src/index.ts` | | `npm run build` | Recompile after editing source files |
| `npm run auth` | Re-authorize Gmail (only if `token.json` deleted/expired) | | `npm run auth` | Authorize the default (work) account |
| Cmd+Q Claude Desktop, reopen | Pick up server changes after a rebuild | | `npm run auth -- secondary` | Authorize the secondary account |
| `npm test` | Run the test suite |
| `npm run test:watch` | Run tests in watch mode |
--- ---
## Project Structure ## Project Structure
``` ```
assistant/ deClawed-Assity-Kitty/
├── src/ ├── src/
│ ├── index.ts # MCP server source (tools + prompt) │ ├── index.ts # Entry point -- imports modules, starts server
── auth.ts # One-time OAuth setup script ── McpServer.ts # MCP server instance
├── build/ │ ├── auth.ts # Multi-account OAuth setup script
│ ├── index.js # Compiled server (Claude Desktop runs this) │ ├── loaders/
│ └── auth.js # Compiled auth script │ └── prompt-config-loaders.ts # Account, prompt, and OAuth client loaders
├── classify-emails.txt # Classification prompt (plain text, edit anytime) │ ├── prompt-controller-service/
├── credentials.json # Google OAuth client credentials (from Cloud Console) └── prompt-controller-service.ts # MCP prompt registration
├── token.json # Gmail access/refresh token (auto-generated) │ ├── prompts/
├── summary.json # Output file where B/D emails are logged │ │ ├── classify-emails.txt # Phase 1: classification instructions
├── package.json # Project config and scripts │ │ └── take-action-on-emails.txt # Phase 2: action instructions
├── tsconfig.json # TypeScript compiler config │ └── tools/
└── README.md # This file │ ├── tools-email.ts # fetch, delete, append_to_summary
│ ├── tools-calendar.ts # create_calendar_event
│ └── tools-spreadsheet.ts # log_recruiter_contact
├── test/
│ ├── fixtures/
│ │ └── mock-emails.ts # Mock Gmail API responses
│ ├── integration/
│ │ └── email-workflow.test.ts # Integration tests
│ └── unit/
│ └── email-parsing.test.ts # Unit tests for parsing helpers
├── mailSummaries/ # Per-account summary output (auto-generated)
├── build/ # Compiled JS (auto-generated)
├── accounts.json # Multi-account configuration
├── credentials.json # Google OAuth client credentials
├── token*.json # Per-account OAuth tokens (auto-generated)
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── README.md
``` ```

12
accounts.example.json Normal file
View File

@@ -0,0 +1,12 @@
{
"work": {
"label": "",
"tokenFile": "token.json",
"spreadsheetId": "",
"calendarId": "primary"
},
"secondary": {
"label": "",
"tokenFile": "token-secondary.json"
}
}

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

1456
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,9 @@
}, },
"scripts": { "scripts": {
"build": "tsc && chmod 755 build/index.js", "build": "tsc && chmod 755 build/index.js",
"auth": "npm run build && node build/auth.js" "auth": "npm run build && node build/auth.js",
"test": "vitest run",
"test:watch": "vitest"
}, },
"files": [ "files": [
"build" "build"
@@ -23,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^4.0.18"
} }
} }

6
src/McpServer.ts Normal file
View File

@@ -0,0 +1,6 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export const server = new McpServer({
name: "assistant",
version: "1.0.0",
});

View File

@@ -1,815 +1,18 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; #!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { server } from "./McpServer.js";
import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis";
import { OAuth2Client } from "google-auth-library";
import fs from "fs";
import path from "path";
// --------------------------------------------------------------------------- // Side-effect imports: each module registers its prompts/tools on the server
// Paths — resolved relative to the compiled build/ directory, up one level import "./prompt-controller-service/prompt-controller-service.js";
// to the project root where credentials.json, token.json, and summary.json live. import "./tools/tools-email.js";
// --------------------------------------------------------------------------- import "./tools/tools-spreadsheet.js";
const __dirname = path.dirname(new URL(import.meta.url).pathname); import "./tools/tools-calendar.js";
const PROJECT_ROOT = path.resolve(__dirname, "..");
const CREDENTIALS_PATH = path.join(PROJECT_ROOT, "credentials.json");
const ACCOUNTS_PATH = path.join(PROJECT_ROOT, "accounts.json");
const CLASSIFY_PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
const ACTION_PROMPT_PATH = path.join(
PROJECT_ROOT, "src", "prompts", "take-action-on-emails.txt"
);
// --------------------------------------------------------------------------- const main = async (): Promise<void> => {
// Account configuration
// ---------------------------------------------------------------------------
interface AccountConfig {
label: string;
tokenFile: string;
spreadsheetId?: string;
calendarId?: string;
}
interface AccountsMap {
[key: string]: AccountConfig;
}
function loadAccounts(): AccountsMap {
if (!fs.existsSync(ACCOUNTS_PATH)) {
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
}
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
}
function getTokenPath(account: string): string {
const accounts = loadAccounts();
const acct = accounts[account];
if (!acct) {
const available = Object.keys(accounts).join(", ");
throw new Error(
`Unknown account "${account}". Available accounts: ${available}`
);
}
return path.join(PROJECT_ROOT, acct.tokenFile);
}
function getSummaryPath(account: string): string {
if (account === "work") {
return path.join(PROJECT_ROOT, "summary.json");
}
return path.join(PROJECT_ROOT, `summary-${account}.json`);
}
const VALID_ACCOUNTS = ["work", "secondary"] as const;
const accounts = loadAccounts();
const accountDescription = VALID_ACCOUNTS
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
.join(" or ");
const accountSchema = z
.enum(VALID_ACCOUNTS)
.describe(`Which email account to use: ${accountDescription}`);
// ---------------------------------------------------------------------------
// Prompt loaders
// ---------------------------------------------------------------------------
function loadPromptFile(filePath: string, label: string): string {
if (!fs.existsSync(filePath)) {
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
return "";
}
return fs.readFileSync(filePath, "utf-8");
}
function loadClassificationPrompt(): string {
return loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
}
function loadActionPrompt(): string {
return loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
}
// ---------------------------------------------------------------------------
// Auth helpers — generic OAuth2 client, then service-specific factories
// ---------------------------------------------------------------------------
function getOAuth2Client(account: string): OAuth2Client {
const tokenPath = getTokenPath(account);
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error(
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
);
}
if (!fs.existsSync(tokenPath)) {
throw new Error(
`Missing token file at ${tokenPath} for account "${account}". Run "npm run auth" first.`
);
}
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
const { client_id, client_secret, redirect_uris } =
credentials.installed || credentials.web;
const oAuth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris[0]
);
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
oAuth2Client.setCredentials(token);
oAuth2Client.on("tokens", (newTokens) => {
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
fs.writeFileSync(
tokenPath,
JSON.stringify({ ...current, ...newTokens }, null, 2)
);
console.error(`Token refreshed and saved for account "${account}".`);
});
return oAuth2Client;
}
function getGmailClient(account: string): gmail_v1.Gmail {
return google.gmail({ version: "v1", auth: getOAuth2Client(account) });
}
function getSheetsClient(account: string): sheets_v4.Sheets {
return google.sheets({ version: "v4", auth: getOAuth2Client(account) });
}
function getCalendarClient(account: string): calendar_v3.Calendar {
return google.calendar({ version: "v3", auth: getOAuth2Client(account) });
}
function getSpreadsheetId(account: string): string {
const acct = accounts[account];
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
throw new Error(
`No spreadsheetId configured for account "${account}" in accounts.json.`
);
}
return acct.spreadsheetId;
}
function getCalendarId(account: string): string {
return accounts[account]?.calendarId ?? "primary";
}
// ---------------------------------------------------------------------------
// Email parsing helpers
// ---------------------------------------------------------------------------
function getHeader(
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
name: string
): string {
if (!headers) return "";
const header = headers.find(
(h) => h.name?.toLowerCase() === name.toLowerCase()
);
return header?.value ?? "";
}
function decodeBody(message: gmail_v1.Schema$Message): string {
const parts = message.payload?.parts;
let encoded = "";
if (parts) {
// Multipart message — prefer text/plain
const textPart = parts.find((p) => p.mimeType === "text/plain");
encoded = textPart?.body?.data ?? "";
// Fallback to text/html if no plain text
if (!encoded) {
const htmlPart = parts.find((p) => p.mimeType === "text/html");
encoded = htmlPart?.body?.data ?? "";
}
} else {
// Single-part message
encoded = message.payload?.body?.data ?? "";
}
if (!encoded) {
return message.snippet ?? "";
}
return Buffer.from(encoded, "base64url").toString("utf-8");
}
// ---------------------------------------------------------------------------
// MCP Server
// ---------------------------------------------------------------------------
const server = new McpServer({
name: "assistant",
version: "1.0.0",
});
// ---------------------------------------------------------------------------
// Prompt: review_emails (work account)
// ---------------------------------------------------------------------------
server.registerPrompt(
"review_emails",
{
description:
`Review WORK inbox (${accounts.work?.label ?? "work"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
},
() => {
const phase1 = loadClassificationPrompt();
const phase2 = loadActionPrompt();
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text:
`ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
(phase1 || "Review my new emails and classify them by job application category.") +
(phase2 ? `\n\n${phase2}` : ""),
},
},
],
};
}
);
// ---------------------------------------------------------------------------
// Prompt: review_secondary_emails (secondary account)
// ---------------------------------------------------------------------------
server.registerPrompt(
"review_secondary_emails",
{
description:
`Review SECONDARY inbox (${accounts.secondary?.label ?? "secondary"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
},
() => {
const phase1 = loadClassificationPrompt();
const phase2 = loadActionPrompt();
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text:
`ACCOUNT: Use account = "secondary" for ALL tool calls in this session.\n\n` +
(phase1 || "Review my new emails and classify them by job application category.") +
(phase2 ? `\n\n${phase2}` : ""),
},
},
],
};
}
);
// ---------------------------------------------------------------------------
// Tool: fetch_new_emails
// ---------------------------------------------------------------------------
server.registerTool(
"fetch_new_emails",
{
description:
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
"subject, message ID, and body text for each message. The message IDs " +
"can be passed to delete_emails later. Specify which account to fetch from.",
inputSchema: {
account: accountSchema,
maxResults: z
.number()
.min(1)
.max(100)
.describe("Maximum number of unread emails to fetch (1-100)"),
},
},
async ({ account, maxResults }) => {
try {
const gmail = getGmailClient(account);
const listResponse = await gmail.users.messages.list({
userId: "me",
q: "is:unread",
maxResults,
});
const messageIds = listResponse.data.messages ?? [];
if (messageIds.length === 0) {
return {
content: [{ type: "text" as const, text: "No unread emails found." }],
};
}
const emails: string[] = [];
for (const msg of messageIds) {
const detail = await gmail.users.messages.get({
userId: "me",
id: msg.id!,
format: "full",
});
const headers = detail.data.payload?.headers;
const from = getHeader(headers, "From");
const subject = getHeader(headers, "Subject");
const date = getHeader(headers, "Date");
const body = decodeBody(detail.data);
// Truncate body to avoid overwhelming the context window
const truncatedBody =
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
emails.push(
[
`MESSAGE_ID: ${msg.id}`,
`FROM: ${from}`,
`DATE: ${date}`,
`SUBJECT: ${subject}`,
`BODY:\n${truncatedBody}`,
].join("\n")
);
}
const classificationInstructions = loadClassificationPrompt();
const actionInstructions = loadActionPrompt();
const instructionsBlock =
(classificationInstructions
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
: "") +
(actionInstructions
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
: "");
return {
content: [
{
type: "text" as const,
text:
`Found ${emails.length} unread email(s):\n\n` +
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
instructionsBlock,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error fetching emails: ${errMsg}`,
},
],
};
}
}
);
// ---------------------------------------------------------------------------
// Tool: delete_emails
// ---------------------------------------------------------------------------
server.registerTool(
"delete_emails",
{
description:
"Move emails to trash by their Gmail message IDs. Use this for " +
"category A (acknowledgements) and category C (rejections) emails. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
messageIds: z
.array(z.string())
.describe("Array of Gmail message IDs to move to trash"),
},
},
async ({ account, messageIds }) => {
try {
const gmail = getGmailClient(account);
const results: string[] = [];
for (const id of messageIds) {
try {
await gmail.users.messages.trash({
userId: "me",
id,
});
results.push(`Trashed: ${id}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
results.push(`Failed to trash ${id}: ${errMsg}`);
}
}
return {
content: [
{
type: "text" as const,
text: `Delete results:\n${results.join("\n")}`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error deleting emails: ${errMsg}`,
},
],
};
}
}
);
// ---------------------------------------------------------------------------
// Tool: append_to_summary
// ---------------------------------------------------------------------------
interface SummaryEntry {
senderName: string;
senderEmail: string;
dateReceived: string;
subject: string;
category: "B" | "D";
addedAt: string;
}
server.registerTool(
"append_to_summary",
{
description:
"Append classified email entries to the local summary file. Use this " +
"for category B (advancement to next step) and category D (other) emails. " +
"Each entry records the sender, date, subject, and category. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
entries: z
.array(
z.object({
senderName: z.string().describe("Name of the sender"),
senderEmail: z.string().describe("Email address of the sender"),
dateReceived: z
.string()
.describe("Date and time the email was received"),
subject: z.string().describe("Email subject line"),
category: z
.enum(["B", "D"])
.describe(
"Category: B = advancement to next step, D = other/uncategorized"
),
})
)
.describe("Array of email summary entries to append"),
},
},
async ({ account, entries }) => {
try {
const summaryPath = getSummaryPath(account);
// Load existing summary or start fresh
let summary: SummaryEntry[] = [];
if (fs.existsSync(summaryPath)) {
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
}
const now = new Date();
const nowIso = now.toISOString();
const newEntries: SummaryEntry[] = entries.map((e) => ({
...e,
addedAt: nowIso,
}));
summary.push(...newEntries);
// Purge entries older than 30 days
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const cutoff = now.getTime() - RETENTION_MS;
const beforePurge = summary.length;
summary = summary.filter(
(entry) => new Date(entry.addedAt).getTime() >= cutoff
);
const purged = beforePurge - summary.length;
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
const purgeNote = purged > 0
? `\nPurged ${purged} entry/entries older than 30 days.`
: "";
return {
content: [
{
type: "text" as const,
text:
`Appended ${newEntries.length} entry/entries to summary.\n` +
`Total entries in summary: ${summary.length}\n` +
`Summary file: ${summaryPath}` +
purgeNote,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error appending to summary: ${errMsg}`,
},
],
};
}
}
);
// ---------------------------------------------------------------------------
// Sheet column layout for "Recruiter Communication Log and Call Schedule"
// ---------------------------------------------------------------------------
const SHEET_COLUMNS = [
"Recruiter Name", // A
"Recruiter Email", // B
"Recruiter Tel", // C
"Company/Role", // D
"First Contact", // E
"Subsequent Contact(s)", // F
"Recruiter Call Scheduled", // G
"Company Contact Info", // H
"Company First Interview", // I
"Company Second Interview", // J
];
const SHEET_RANGE = "Sheet1";
// ---------------------------------------------------------------------------
// Tool: log_recruiter_contact
// ---------------------------------------------------------------------------
server.registerTool(
"log_recruiter_contact",
{
description:
"Log or update recruiter contact info in the tracking spreadsheet. " +
"If a row already exists for the same recruiterEmail + companyRole, it " +
"updates the existing row (merging non-empty fields). Otherwise appends " +
"a new row. Use for Category B emails after classification.",
inputSchema: {
account: accountSchema,
recruiterName: z.string().describe("Recruiter's full name"),
recruiterEmail: z.string().describe("Recruiter's email address"),
recruiterTel: z
.string()
.optional()
.describe("Recruiter's phone number, if mentioned in the email"),
companyRole: z
.string()
.describe("Company name and role the recruiter seeks to fill"),
firstContact: z
.string()
.describe("Date/time of first contact (from the email's Date header)"),
subsequentContacts: z
.string()
.optional()
.describe("Any follow-up contact context mentioned in the email"),
recruiterCallScheduled: z
.string()
.optional()
.describe(
"Scheduled call details: date, time, platform (Zoom/Teams), " +
"meeting link or phone, and whether they have your cell number"
),
companyContactInfo: z
.string()
.optional()
.describe("Company interviewer name/email/tel if advancing to company interview"),
companyFirstInterview: z
.string()
.optional()
.describe("Company first interview: date, time, platform, link/phone, cell number note"),
companySecondInterview: z
.string()
.optional()
.describe("Company second interview: date, time, platform, link/phone, cell number note"),
},
},
async ({
account,
recruiterName,
recruiterEmail,
recruiterTel,
companyRole,
firstContact,
subsequentContacts,
recruiterCallScheduled,
companyContactInfo,
companyFirstInterview,
companySecondInterview,
}) => {
try {
const sheets = getSheetsClient(account);
const spreadsheetId = getSpreadsheetId(account);
const incomingRow = [
recruiterName,
recruiterEmail,
recruiterTel ?? "",
companyRole,
firstContact,
subsequentContacts ?? "",
recruiterCallScheduled ?? "",
companyContactInfo ?? "",
companyFirstInterview ?? "",
companySecondInterview ?? "",
];
// Read existing data to check for a matching row
const existing = await sheets.spreadsheets.values.get({
spreadsheetId,
range: SHEET_RANGE,
});
const rows = existing.data.values ?? [];
// Find row matching recruiterEmail (col B) + companyRole (col D)
const emailNorm = recruiterEmail.toLowerCase().trim();
const roleNorm = companyRole.toLowerCase().trim();
let matchIdx = -1;
for (let i = 0; i < rows.length; i++) {
const rowEmail = (rows[i][1] ?? "").toString().toLowerCase().trim();
const rowRole = (rows[i][3] ?? "").toString().toLowerCase().trim();
if (rowEmail === emailNorm && rowRole === roleNorm) {
matchIdx = i;
break;
}
}
if (matchIdx >= 0) {
// Merge: keep existing values where incoming is empty
const existingRow = rows[matchIdx];
const merged = incomingRow.map((val, col) => {
if (col === 5 && val && existingRow[col]) {
// Subsequent Contacts: append rather than overwrite
return `${existingRow[col]}; ${val}`;
}
return val || existingRow[col] || "";
});
const rowNum = matchIdx + 1; // 1-indexed
await sheets.spreadsheets.values.update({
spreadsheetId,
range: `${SHEET_RANGE}!A${rowNum}:J${rowNum}`,
valueInputOption: "USER_ENTERED",
requestBody: { values: [merged] },
});
return {
content: [
{
type: "text" as const,
text: `Updated existing row ${rowNum} for ${recruiterName} / ${companyRole}.`,
},
],
};
}
// No match — append new row
await sheets.spreadsheets.values.append({
spreadsheetId,
range: SHEET_RANGE,
valueInputOption: "USER_ENTERED",
requestBody: { values: [incomingRow] },
});
return {
content: [
{
type: "text" as const,
text: `Appended new row for ${recruiterName} / ${companyRole}.`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error logging recruiter contact: ${errMsg}`,
},
],
};
}
}
);
// ---------------------------------------------------------------------------
// Tool: create_calendar_event
// ---------------------------------------------------------------------------
server.registerTool(
"create_calendar_event",
{
description:
"Create a Google Calendar event. Use immediately after logging a " +
"recruiter call, company first interview, or company second interview " +
"to the spreadsheet. One calendar event per scheduling column populated.",
inputSchema: {
account: accountSchema,
title: z
.string()
.describe(
"Event title, e.g. '[Recruiter Call] Acme Corp - Sr Engineer' " +
"or '[Interview] Acme Corp - Sr Engineer'"
),
startDateTime: z
.string()
.describe("Event start in ISO 8601 format, e.g. 2026-02-20T14:00:00-06:00"),
durationMinutes: z
.number()
.min(5)
.max(480)
.optional()
.describe("Duration in minutes (default 60)"),
description: z
.string()
.optional()
.describe(
"Event description: recruiter/company contact info, notes, " +
"whether they have your cell number, etc."
),
location: z
.string()
.optional()
.describe("Video meeting link (Zoom/Teams URL) or phone number"),
},
},
async ({ account, title, startDateTime, durationMinutes, description, location }) => {
try {
const start = new Date(startDateTime);
if (start.getTime() < Date.now()) {
return {
content: [
{
type: "text" as const,
text:
`Skipped calendar event "${title}" — the proposed date ` +
`(${start.toISOString()}) is in the past.`,
},
],
};
}
const calendar = getCalendarClient(account);
const calendarId = getCalendarId(account);
const end = new Date(start.getTime() + (durationMinutes ?? 60) * 60_000);
const event = await calendar.events.insert({
calendarId,
requestBody: {
summary: title,
start: {
dateTime: start.toISOString(),
},
end: {
dateTime: end.toISOString(),
},
description: description ?? "",
location: location ?? "",
},
});
return {
content: [
{
type: "text" as const,
text:
`Created calendar event: "${title}"\n` +
`Start: ${start.toISOString()}\n` +
`End: ${end.toISOString()}\n` +
`Event ID: ${event.data.id}\n` +
`Link: ${event.data.htmlLink}`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error creating calendar event: ${errMsg}`,
},
],
};
}
}
);
// ---------------------------------------------------------------------------
// Start server
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
console.error("Assistant MCP Server running on stdio"); console.error("Assistant MCP Server running on stdio");
} };
main().catch((error) => { main().catch((error) => {
console.error("Fatal error in main():", error); console.error("Fatal error in main():", error);

View File

@@ -0,0 +1,149 @@
import { z } from "zod";
import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis";
import { OAuth2Client } from "google-auth-library";
import fs from "fs";
import path from "path";
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const __dirname = path.dirname(new URL(import.meta.url).pathname);
export const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
const CREDENTIALS_PATH = path.join(PROJECT_ROOT, "credentials.json");
const ACCOUNTS_PATH = path.join(PROJECT_ROOT, "accounts.json");
const CLASSIFY_PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
const ACTION_PROMPT_PATH = path.join(
PROJECT_ROOT, "src", "prompts", "take-action-on-emails.txt"
);
// ---------------------------------------------------------------------------
// Account configuration
// ---------------------------------------------------------------------------
export interface AccountConfig {
label: string;
tokenFile: string;
spreadsheetId?: string;
calendarId?: string;
}
export interface AccountsMap {
[key: string]: AccountConfig;
}
export const loadAccounts = (): AccountsMap => {
if (!fs.existsSync(ACCOUNTS_PATH)) {
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
}
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
};
export const getTokenPath = (account: string): string => {
const accts = loadAccounts();
const acct = accts[account];
if (!acct) {
const available = Object.keys(accts).join(", ");
throw new Error(
`Unknown account "${account}". Available accounts: ${available}`
);
}
return path.join(PROJECT_ROOT, acct.tokenFile);
};
export const getSummaryPath = (account: string): string => {
if (account === "work") {
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
}
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
};
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
export const accounts = loadAccounts();
const accountDescription = VALID_ACCOUNTS
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
.join(" or ");
export const accountSchema = z
.enum(VALID_ACCOUNTS)
.describe(`Which email account to use: ${accountDescription}`);
// ---------------------------------------------------------------------------
// Prompt loaders
// ---------------------------------------------------------------------------
const loadPromptFile = (filePath: string, label: string): string => {
if (!fs.existsSync(filePath)) {
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
return "";
}
return fs.readFileSync(filePath, "utf-8");
};
export const loadClassificationPrompt = (): string =>
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
export const loadActionPrompt = (): string =>
loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
// ---------------------------------------------------------------------------
// Auth helpers — generic OAuth2 client, then service-specific factories
// ---------------------------------------------------------------------------
export const getOAuth2Client = (account: string): OAuth2Client => {
const tokenPath = getTokenPath(account);
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error(
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
);
}
if (!fs.existsSync(tokenPath)) {
throw new Error(
`Missing token file at ${tokenPath} for account "${account}". Run "npm run auth" first.`
);
}
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
const { client_id, client_secret, redirect_uris } =
credentials.installed || credentials.web;
const oAuth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris[0]
);
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
oAuth2Client.setCredentials(token);
oAuth2Client.on("tokens", (newTokens) => {
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
fs.writeFileSync(
tokenPath,
JSON.stringify({ ...current, ...newTokens }, null, 2)
);
console.error(`Token refreshed and saved for account "${account}".`);
});
return oAuth2Client;
};
export const getGmailClient = (account: string): gmail_v1.Gmail =>
google.gmail({ version: "v1", auth: getOAuth2Client(account) });
export const getSheetsClient = (account: string): sheets_v4.Sheets =>
google.sheets({ version: "v4", auth: getOAuth2Client(account) });
export const getCalendarClient = (account: string): calendar_v3.Calendar =>
google.calendar({ version: "v3", auth: getOAuth2Client(account) });
export const getSpreadsheetId = (account: string): string => {
const acct = accounts[account];
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
throw new Error(
`No spreadsheetId configured for account "${account}" in accounts.json.`
);
}
return acct.spreadsheetId;
};
export const getCalendarId = (account: string): string =>
accounts[account]?.calendarId ?? "primary";

View File

@@ -0,0 +1,64 @@
import { server } from "../McpServer.js";
import {
accounts,
loadClassificationPrompt,
loadActionPrompt,
} from "../loaders/prompt-config-loaders.js";
// ---------------------------------------------------------------------------
// Prompt: review_emails (work account)
// ---------------------------------------------------------------------------
server.registerPrompt(
"review_emails",
{
description:
`Review WORK inbox (${accounts.work?.label ?? "work"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
},
() => {
const phase1 = loadClassificationPrompt();
const phase2 = loadActionPrompt();
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text:
`ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
(phase1 || "Review my new emails and classify them by job application category.") +
(phase2 ? `\n\n${phase2}` : ""),
},
},
],
};
}
);
// ---------------------------------------------------------------------------
// Prompt: review_secondary_emails (secondary account)
// ---------------------------------------------------------------------------
server.registerPrompt(
"review_secondary_emails",
{
description:
`Review SECONDARY inbox (${accounts.secondary?.label ?? "secondary"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
},
() => {
const phase1 = loadClassificationPrompt();
const phase2 = loadActionPrompt();
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text:
`ACCOUNT: Use account = "secondary" for ALL tool calls in this session.\n\n` +
(phase1 || "Review my new emails and classify them by job application category.") +
(phase2 ? `\n\n${phase2}` : ""),
},
},
],
};
}
);

111
src/tools/tools-calendar.ts Normal file
View File

@@ -0,0 +1,111 @@
import { z } from "zod";
import { server } from "../McpServer.js";
import {
accountSchema,
getCalendarClient,
getCalendarId,
} from "../loaders/prompt-config-loaders.js";
// ---------------------------------------------------------------------------
// Tool: create_calendar_event
// ---------------------------------------------------------------------------
server.registerTool(
"create_calendar_event",
{
description:
"Create a Google Calendar event. Use immediately after logging a " +
"recruiter call, company first interview, or company second interview " +
"to the spreadsheet. One calendar event per scheduling column populated.",
inputSchema: {
account: accountSchema,
title: z
.string()
.describe(
"Event title, e.g. '[Recruiter Call] Acme Corp - Sr Engineer' " +
"or '[Interview] Acme Corp - Sr Engineer'"
),
startDateTime: z
.string()
.describe("Event start in ISO 8601 format, e.g. 2026-02-20T14:00:00-06:00"),
durationMinutes: z
.number()
.min(5)
.max(480)
.optional()
.describe("Duration in minutes (default 60)"),
description: z
.string()
.optional()
.describe(
"Event description: recruiter/company contact info, notes, " +
"whether they have your cell number, etc."
),
location: z
.string()
.optional()
.describe("Video meeting link (Zoom/Teams URL) or phone number"),
},
},
async ({ account, title, startDateTime, durationMinutes, description, location }) => {
try {
const start = new Date(startDateTime);
if (start.getTime() < Date.now()) {
return {
content: [
{
type: "text" as const,
text:
`Skipped calendar event "${title}" — the proposed date ` +
`(${start.toISOString()}) is in the past.`,
},
],
};
}
const calendar = getCalendarClient(account);
const calendarId = getCalendarId(account);
const end = new Date(start.getTime() + (durationMinutes ?? 60) * 60_000);
const event = await calendar.events.insert({
calendarId,
requestBody: {
summary: title,
start: {
dateTime: start.toISOString(),
},
end: {
dateTime: end.toISOString(),
},
description: description ?? "",
location: location ?? "",
},
});
return {
content: [
{
type: "text" as const,
text:
`Created calendar event: "${title}"\n` +
`Start: ${start.toISOString()}\n` +
`End: ${end.toISOString()}\n` +
`Event ID: ${event.data.id}\n` +
`Link: ${event.data.htmlLink}`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error creating calendar event: ${errMsg}`,
},
],
};
}
}
);

305
src/tools/tools-email.ts Normal file
View File

@@ -0,0 +1,305 @@
import { z } from "zod";
import { gmail_v1 } from "googleapis";
import fs from "fs";
import { server } from "../McpServer.js";
import {
accountSchema,
getGmailClient,
getSummaryPath,
loadClassificationPrompt,
loadActionPrompt,
} from "../loaders/prompt-config-loaders.js";
// ---------------------------------------------------------------------------
// Email parsing helpers
// ---------------------------------------------------------------------------
export const getHeader = (
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
name: string
): string => {
if (!headers) return "";
const header = headers.find(
(h) => h.name?.toLowerCase() === name.toLowerCase()
);
return header?.value ?? "";
};
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
const parts = message.payload?.parts;
let encoded = "";
if (parts) {
const textPart = parts.find((p) => p.mimeType === "text/plain");
encoded = textPart?.body?.data ?? "";
if (!encoded) {
const htmlPart = parts.find((p) => p.mimeType === "text/html");
encoded = htmlPart?.body?.data ?? "";
}
} else {
encoded = message.payload?.body?.data ?? "";
}
if (!encoded) {
return message.snippet ?? "";
}
return Buffer.from(encoded, "base64url").toString("utf-8");
};
// ---------------------------------------------------------------------------
// Tool: fetch_new_emails
// ---------------------------------------------------------------------------
server.registerTool(
"fetch_new_emails",
{
description:
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
"subject, message ID, and body text for each message. The message IDs " +
"can be passed to delete_emails later. Specify which account to fetch from.",
inputSchema: {
account: accountSchema,
maxResults: z
.number()
.min(1)
.max(100)
.describe("Maximum number of unread emails to fetch (1-100)"),
},
},
async ({ account, maxResults }) => {
try {
const gmail = getGmailClient(account);
const listResponse = await gmail.users.messages.list({
userId: "me",
q: "is:unread",
maxResults,
});
const messageIds = listResponse.data.messages ?? [];
if (messageIds.length === 0) {
return {
content: [{ type: "text" as const, text: "No unread emails found." }],
};
}
const emails: string[] = [];
for (const msg of messageIds) {
const detail = await gmail.users.messages.get({
userId: "me",
id: msg.id!,
format: "full",
});
const headers = detail.data.payload?.headers;
const from = getHeader(headers, "From");
const subject = getHeader(headers, "Subject");
const date = getHeader(headers, "Date");
const body = decodeBody(detail.data);
const truncatedBody =
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
emails.push(
[
`MESSAGE_ID: ${msg.id}`,
`FROM: ${from}`,
`DATE: ${date}`,
`SUBJECT: ${subject}`,
`BODY:\n${truncatedBody}`,
].join("\n")
);
}
const classificationInstructions = loadClassificationPrompt();
const actionInstructions = loadActionPrompt();
const instructionsBlock =
(classificationInstructions
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
: "") +
(actionInstructions
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
: "");
return {
content: [
{
type: "text" as const,
text:
`Found ${emails.length} unread email(s):\n\n` +
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
instructionsBlock,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error fetching emails: ${errMsg}`,
},
],
};
}
}
);
// ---------------------------------------------------------------------------
// Tool: delete_emails
// ---------------------------------------------------------------------------
server.registerTool(
"delete_emails",
{
description:
"Move emails to trash by their Gmail message IDs. Use this for " +
"category A (acknowledgements) and category C (rejections) emails. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
messageIds: z
.array(z.string())
.describe("Array of Gmail message IDs to move to trash"),
},
},
async ({ account, messageIds }) => {
try {
const gmail = getGmailClient(account);
const results: string[] = [];
for (const id of messageIds) {
try {
await gmail.users.messages.trash({
userId: "me",
id,
});
results.push(`Trashed: ${id}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
results.push(`Failed to trash ${id}: ${errMsg}`);
}
}
return {
content: [
{
type: "text" as const,
text: `Delete results:\n${results.join("\n")}`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error deleting emails: ${errMsg}`,
},
],
};
}
}
);
// ---------------------------------------------------------------------------
// Tool: append_to_summary
// ---------------------------------------------------------------------------
interface SummaryEntry {
senderName: string;
senderEmail: string;
dateReceived: string;
subject: string;
category: "B" | "D";
addedAt: string;
}
server.registerTool(
"append_to_summary",
{
description:
"Append classified email entries to the local summary file. Use this " +
"for category B (advancement to next step) and category D (other) emails. " +
"Each entry records the sender, date, subject, and category. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
entries: z
.array(
z.object({
senderName: z.string().describe("Name of the sender"),
senderEmail: z.string().describe("Email address of the sender"),
dateReceived: z
.string()
.describe("Date and time the email was received"),
subject: z.string().describe("Email subject line"),
category: z
.enum(["B", "D"])
.describe(
"Category: B = advancement to next step, D = other/uncategorized"
),
})
)
.describe("Array of email summary entries to append"),
},
},
async ({ account, entries }) => {
try {
const summaryPath = getSummaryPath(account);
let summary: SummaryEntry[] = [];
if (fs.existsSync(summaryPath)) {
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
}
const now = new Date();
const nowIso = now.toISOString();
const newEntries: SummaryEntry[] = entries.map((e) => ({
...e,
addedAt: nowIso,
}));
summary.push(...newEntries);
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const cutoff = now.getTime() - RETENTION_MS;
const beforePurge = summary.length;
summary = summary.filter(
(entry) => new Date(entry.addedAt).getTime() >= cutoff
);
const purged = beforePurge - summary.length;
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
const purgeNote = purged > 0
? `\nPurged ${purged} entry/entries older than 30 days.`
: "";
return {
content: [
{
type: "text" as const,
text:
`Appended ${newEntries.length} entry/entries to summary.\n` +
`Total entries in summary: ${summary.length}\n` +
`Summary file: ${summaryPath}` +
purgeNote,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error appending to summary: ${errMsg}`,
},
],
};
}
}
);

View File

@@ -0,0 +1,179 @@
import { z } from "zod";
import { server } from "../McpServer.js";
import {
accountSchema,
getSheetsClient,
getSpreadsheetId,
} from "../loaders/prompt-config-loaders.js";
// ---------------------------------------------------------------------------
// Sheet column layout for "Recruiter Communication Log and Call Schedule"
// ---------------------------------------------------------------------------
const SHEET_COLUMNS = [
"Recruiter Name", // A
"Recruiter Email", // B
"Recruiter Tel", // C
"Company/Role", // D
"First Contact", // E
"Subsequent Contact(s)", // F
"Recruiter Call Scheduled", // G
"Company Contact Info", // H
"Company First Interview", // I
"Company Second Interview", // J
];
const SHEET_RANGE = "Sheet1";
// ---------------------------------------------------------------------------
// Tool: log_recruiter_contact
// ---------------------------------------------------------------------------
server.registerTool(
"log_recruiter_contact",
{
description:
"Log or update recruiter contact info in the tracking spreadsheet. " +
"If a row already exists for the same recruiterEmail + companyRole, it " +
"updates the existing row (merging non-empty fields). Otherwise appends " +
"a new row. Use for Category B emails after classification.",
inputSchema: {
account: accountSchema,
recruiterName: z.string().describe("Recruiter's full name"),
recruiterEmail: z.string().describe("Recruiter's email address"),
recruiterTel: z
.string()
.optional()
.describe("Recruiter's phone number, if mentioned in the email"),
companyRole: z
.string()
.describe("Company name and role the recruiter seeks to fill"),
firstContact: z
.string()
.describe("Date/time of first contact (from the email's Date header)"),
subsequentContacts: z
.string()
.optional()
.describe("Any follow-up contact context mentioned in the email"),
recruiterCallScheduled: z
.string()
.optional()
.describe(
"Scheduled call details: date, time, platform (Zoom/Teams), " +
"meeting link or phone, and whether they have your cell number"
),
companyContactInfo: z
.string()
.optional()
.describe("Company interviewer name/email/tel if advancing to company interview"),
companyFirstInterview: z
.string()
.optional()
.describe("Company first interview: date, time, platform, link/phone, cell number note"),
companySecondInterview: z
.string()
.optional()
.describe("Company second interview: date, time, platform, link/phone, cell number note"),
},
},
async ({
account,
recruiterName,
recruiterEmail,
recruiterTel,
companyRole,
firstContact,
subsequentContacts,
recruiterCallScheduled,
companyContactInfo,
companyFirstInterview,
companySecondInterview,
}) => {
try {
const sheets = getSheetsClient(account);
const spreadsheetId = getSpreadsheetId(account);
const incomingRow = [
recruiterName,
recruiterEmail,
recruiterTel ?? "",
companyRole,
firstContact,
subsequentContacts ?? "",
recruiterCallScheduled ?? "",
companyContactInfo ?? "",
companyFirstInterview ?? "",
companySecondInterview ?? "",
];
const existing = await sheets.spreadsheets.values.get({
spreadsheetId,
range: SHEET_RANGE,
});
const rows = existing.data.values ?? [];
const emailNorm = recruiterEmail.toLowerCase().trim();
const roleNorm = companyRole.toLowerCase().trim();
let matchIdx = -1;
for (let i = 0; i < rows.length; i++) {
const rowEmail = (rows[i][1] ?? "").toString().toLowerCase().trim();
const rowRole = (rows[i][3] ?? "").toString().toLowerCase().trim();
if (rowEmail === emailNorm && rowRole === roleNorm) {
matchIdx = i;
break;
}
}
if (matchIdx >= 0) {
const existingRow = rows[matchIdx];
const merged = incomingRow.map((val, col) => {
if (col === 5 && val && existingRow[col]) {
return `${existingRow[col]}; ${val}`;
}
return val || existingRow[col] || "";
});
const rowNum = matchIdx + 1;
await sheets.spreadsheets.values.update({
spreadsheetId,
range: `${SHEET_RANGE}!A${rowNum}:J${rowNum}`,
valueInputOption: "USER_ENTERED",
requestBody: { values: [merged] },
});
return {
content: [
{
type: "text" as const,
text: `Updated existing row ${rowNum} for ${recruiterName} / ${companyRole}.`,
},
],
};
}
await sheets.spreadsheets.values.append({
spreadsheetId,
range: SHEET_RANGE,
valueInputOption: "USER_ENTERED",
requestBody: { values: [incomingRow] },
});
return {
content: [
{
type: "text" as const,
text: `Appended new row for ${recruiterName} / ${companyRole}.`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error logging recruiter contact: ${errMsg}`,
},
],
};
}
}
);

View File

@@ -1,130 +0,0 @@
[
{
"senderName": "Yelena Nikolov",
"senderEmail": "Yelena.Nikolov@meridianlink.com",
"dateReceived": "Tue, 17 Feb 2026 13:57:42 +0000",
"subject": "Interview next steps with MeridianLink",
"category": "B",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "LDEAskHR",
"senderEmail": "LDEAskHR@epiqglobal.com",
"dateReceived": "Fri, 13 Feb 2026 17:12:26 +0000",
"subject": "Immediate Action Requested - Background check",
"category": "B",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Talent Experience Team",
"senderEmail": "talentsupport@mbopartners.com",
"dateReceived": "Fri, 13 Feb 2026 17:03:27 +0000",
"subject": "[URGENT ACTION NEEDED!] FINAL NOTICE - Security Training - OVERDUE",
"category": "B",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Talent Experience Team",
"senderEmail": "talentsupport@mbopartners.com",
"dateReceived": "Fri, 13 Feb 2026 17:01:12 +0000",
"subject": "[ACTION NEEDED!] KPMG Ethics & Integrity Training for KPMG's Contingent Worker Training - OVERDUE",
"category": "B",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Penny from Rocket Money",
"senderEmail": "support@rocketmoney.com",
"dateReceived": "Tue, 17 Feb 2026 14:00:20 +0000",
"subject": "Re: [External] New Cell Number -m Cannot 2FA to My Account",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Mondaq Newsletters",
"senderEmail": "newsletters@webiis01.mondaq.com",
"dateReceived": "Tue, 17 Feb 2026 13:37:53 +0000",
"subject": "Mondaq Personalized News Alert",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "MoneyLion Partner",
"senderEmail": "partners@iemail.moneylion.com",
"dateReceived": "Tue, 17 Feb 2026 04:00:40 +0000",
"subject": "3 days left to view your personalized offers.",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "JobCopilot",
"senderEmail": "noreply@jobcopilot.com",
"dateReceived": "Mon, 16 Feb 2026 23:33:56 +0000",
"subject": "JobCopilot has applied to 50 jobs",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "no-reply@equifax.com",
"senderEmail": "no-reply@equifax.com",
"dateReceived": "Mon, 16 Feb 2026 21:01:48 +0000",
"subject": "New Equifax Credit Report Available",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Call-On-Doc",
"senderEmail": "no-reply@callondoc.com",
"dateReceived": "Mon, 16 Feb 2026 20:46:24 +0000",
"subject": "CallonDoc - Your Login Email has Been Changed.",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Bank of America",
"senderEmail": "customerservice@ealerts.bankofamerica.com",
"dateReceived": "Mon, 16 Feb 2026 09:55:20 -0600",
"subject": "Kenneth S Jannette sent you $150.00",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Concora Credit",
"senderEmail": "donotreply_concoracredit@mail.concoracredit.com",
"dateReceived": "Sat, 14 Feb 2026 16:01:10 -0600",
"subject": "Your Statement Is Ready To View",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "MBO Talent Experience & Support Team",
"senderEmail": "talentsupport@mbopartners.com",
"dateReceived": "Fri, 13 Feb 2026 10:44:08 -0600",
"subject": "KPMG Standard Work Hours and Overtime Policy",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Noun Project",
"senderEmail": "yourfriends@thenounproject.com",
"dateReceived": "Fri, 13 Feb 2026 01:00:53 +0000",
"subject": "Your Noun Project Invoice",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "The Termly Team",
"senderEmail": "termlyservices@email.termly.io",
"dateReceived": "Thu, 12 Feb 2026 20:16:59 +0000",
"subject": "Upcoming IAB TCF v2.3 update: disclosedVendors support",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
},
{
"senderName": "Brave Search API",
"senderEmail": "search-api@brave.com",
"dateReceived": "Thu, 12 Feb 2026 19:57:43 +0000",
"subject": "New ToS & plans for the Brave Search API",
"category": "D",
"addedAt": "2026-02-17T14:10:30.911Z"
}
]

167
test/fixtures/mock-emails.ts vendored Normal file
View File

@@ -0,0 +1,167 @@
/**
* Canned Gmail API responses representing one email from each category.
* Body text is base64url-encoded to match the real Gmail API format.
*/
const toBase64Url = (text: string): string =>
Buffer.from(text, "utf-8").toString("base64url");
// Category A — Acknowledgement only
const catABody = [
"Dear Steven,",
"",
"Thank you for your interest in the Senior Software Engineer position at Acme Corp.",
"We have received your application and it is currently under review.",
"We will contact you if your qualifications match our needs.",
"",
"Best regards,",
"Acme Talent Acquisition",
].join("\n");
// Category B — Advancement to next step
const catBBody = [
"Hi Steven,",
"",
"Thanks for applying to the Full Stack Engineer role at Globex Corp.",
"We'd love to schedule a phone screen to discuss your background.",
"Are you available this Thursday at 2:00 PM CST?",
"Please join via Zoom: https://zoom.us/j/123456789",
"",
"Looking forward to connecting,",
"Jane Smith",
"Senior Recruiter, Globex Corp",
"jane.smith@globexcorp.com",
"(555) 867-5309",
].join("\n");
// Category C — Rejection
const catCBody = [
"Dear Steven,",
"",
"Thank you for taking the time to interview for the Platform Engineer position at Initech.",
"After careful consideration, we have decided to pursue other candidates",
"whose experience more closely aligns with our current needs.",
"",
"We wish you the best in your job search.",
"",
"Regards,",
"Initech Recruiting Team",
].join("\n");
// Category D — Other / non-job
const catDBody = [
"Your Tailscale subscription payment of $6.00 was unsuccessful.",
"Please update your payment method at https://login.tailscale.com/billing.",
"",
"— Tailscale Billing",
].join("\n");
const makeHeaders = (from: string, subject: string, date: string) => [
{ name: "From", value: from },
{ name: "Subject", value: subject },
{ name: "Date", value: date },
{ name: "To", value: "steven@sjdev.co" },
];
export const MOCK_MESSAGE_LIST = {
data: {
messages: [
{ id: "msg-cat-a-001" },
{ id: "msg-cat-b-001" },
{ id: "msg-cat-c-001" },
{ id: "msg-cat-d-001" },
],
},
};
export const MOCK_MESSAGES: Record<string, { data: any }> = {
"msg-cat-a-001": {
data: {
id: "msg-cat-a-001",
snippet: "Thank you for your interest in the Senior Software Engineer position...",
payload: {
headers: makeHeaders(
"Acme Talent <talent@acmecorp.com>",
"Application Received — Senior Software Engineer",
"Mon, 17 Feb 2026 10:00:00 +0000"
),
mimeType: "multipart/alternative",
parts: [
{
mimeType: "text/plain",
body: { data: toBase64Url(catABody) },
},
{
mimeType: "text/html",
body: { data: toBase64Url(`<p>${catABody}</p>`) },
},
],
},
},
},
"msg-cat-b-001": {
data: {
id: "msg-cat-b-001",
snippet: "We'd love to schedule a phone screen...",
payload: {
headers: makeHeaders(
"Jane Smith <jane.smith@globexcorp.com>",
"Phone Screen — Full Stack Engineer at Globex Corp",
"Tue, 18 Feb 2026 14:30:00 +0000"
),
mimeType: "multipart/alternative",
parts: [
{
mimeType: "text/plain",
body: { data: toBase64Url(catBBody) },
},
],
},
},
},
"msg-cat-c-001": {
data: {
id: "msg-cat-c-001",
snippet: "We have decided to pursue other candidates...",
payload: {
headers: makeHeaders(
"Initech Recruiting <recruiting@initech.com>",
"Update on Your Application — Platform Engineer",
"Wed, 19 Feb 2026 09:15:00 +0000"
),
mimeType: "text/plain",
body: { data: toBase64Url(catCBody) },
},
},
},
"msg-cat-d-001": {
data: {
id: "msg-cat-d-001",
snippet: "Your Tailscale subscription payment of $6.00 was unsuccessful.",
payload: {
headers: makeHeaders(
"Tailscale Billing <billing@tailscale.com>",
"$6.00 payment to Tailscale US Inc. was unsuccessful",
"Thu, 20 Feb 2026 17:00:00 +0000"
),
mimeType: "text/plain",
body: { data: toBase64Url(catDBody) },
},
},
},
};
export const MOCK_EMPTY_LIST = {
data: { messages: undefined },
};
// Raw body text for assertion comparisons
export const RAW_BODIES = {
"msg-cat-a-001": catABody,
"msg-cat-b-001": catBBody,
"msg-cat-c-001": catCBody,
"msg-cat-d-001": catDBody,
};

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
import { z } from "zod";
import fs from "fs";
import os from "os";
import path from "path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { MOCK_MESSAGE_LIST, MOCK_MESSAGES, MOCK_EMPTY_LIST } from "../fixtures/mock-emails.js";
// -----------------------------------------------------------------------
// Mock the loaders module so no real credentials/tokens/APIs are needed.
// vi.mock is hoisted — runs before any module imports.
// -----------------------------------------------------------------------
const mockGmailList = vi.fn();
const mockGmailGet = vi.fn();
const mockGmailTrash = vi.fn();
let tmpDir: string;
vi.mock("../../src/loaders/prompt-config-loaders.js", () => {
const testAccounts = {
work: { label: "test@test.com", tokenFile: "token.json" },
secondary: { label: "test2@test.com", tokenFile: "token-secondary.json" },
};
return {
PROJECT_ROOT: "/tmp/test-project",
VALID_ACCOUNTS: ["work", "secondary"] as const,
accounts: testAccounts,
accountSchema: z
.enum(["work", "secondary"])
.describe("test account"),
loadAccounts: () => testAccounts,
getTokenPath: () => "/tmp/fake-token.json",
getSummaryPath: (account: string) => {
const base = tmpDir || os.tmpdir();
return account === "work"
? path.join(base, "mailSummaries", "summary.json")
: path.join(base, "mailSummaries", `summary-${account}.json`);
},
loadClassificationPrompt: () => "CLASSIFY EACH EMAIL AS A, B, C, OR D.",
loadActionPrompt: () => "PHASE 2 INSTRUCTIONS HERE.",
getGmailClient: () => ({
users: {
messages: {
list: mockGmailList,
get: mockGmailGet,
trash: mockGmailTrash,
},
},
}),
getSheetsClient: vi.fn(),
getCalendarClient: vi.fn(),
getSpreadsheetId: vi.fn(),
getCalendarId: () => "primary",
getOAuth2Client: vi.fn(),
};
});
// Now import the server and tool modules (they'll use the mocked loaders)
const { server } = await import("../../src/McpServer.js");
await import("../../src/tools/tools-email.js");
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
let client: Client;
async function connectClient(): Promise<void> {
client = new Client({ name: "test-client", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
}
async function callTool(name: string, args: Record<string, unknown>) {
const result = await client.callTool({ name, arguments: args });
const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? "";
return text;
}
// -----------------------------------------------------------------------
// Test suite
// -----------------------------------------------------------------------
describe("Phase 1: Email Review Workflow", () => {
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-test-"));
fs.mkdirSync(path.join(tmpDir, "mailSummaries"), { recursive: true });
await connectClient();
});
afterAll(async () => {
await client?.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
beforeEach(() => {
vi.clearAllMocks();
});
// ----- Step 1: Fetch new emails -----
describe("fetch_new_emails", () => {
it("returns formatted emails with classification instructions", async () => {
mockGmailList.mockResolvedValue(MOCK_MESSAGE_LIST);
mockGmailGet.mockImplementation(({ id }: { id: string }) =>
Promise.resolve(MOCK_MESSAGES[id])
);
const text = await callTool("fetch_new_emails", {
account: "work",
maxResults: 10,
});
expect(text).toContain("Found 4 unread email(s)");
// All 4 messages present
expect(text).toContain("msg-cat-a-001");
expect(text).toContain("msg-cat-b-001");
expect(text).toContain("msg-cat-c-001");
expect(text).toContain("msg-cat-d-001");
// Headers extracted correctly
expect(text).toContain("FROM: Acme Talent <talent@acmecorp.com>");
expect(text).toContain("SUBJECT: Phone Screen — Full Stack Engineer at Globex Corp");
expect(text).toContain("FROM: Initech Recruiting <recruiting@initech.com>");
// Body content decoded
expect(text).toContain("We have received your application");
expect(text).toContain("schedule a phone screen");
expect(text).toContain("decided to pursue other candidates");
expect(text).toContain("payment of $6.00 was unsuccessful");
// Classification instructions appended
expect(text).toContain("CLASSIFICATION INSTRUCTIONS (PHASE 1)");
expect(text).toContain("CLASSIFY EACH EMAIL AS A, B, C, OR D.");
// Phase 2 instructions also appended
expect(text).toContain("ACTION INSTRUCTIONS (PHASE 2)");
});
it("handles an empty inbox", async () => {
mockGmailList.mockResolvedValue(MOCK_EMPTY_LIST);
const text = await callTool("fetch_new_emails", {
account: "work",
maxResults: 10,
});
expect(text).toBe("No unread emails found.");
});
it("handles Gmail API errors gracefully", async () => {
mockGmailList.mockRejectedValue(new Error("Token expired"));
const text = await callTool("fetch_new_emails", {
account: "work",
maxResults: 10,
});
expect(text).toContain("Error fetching emails");
expect(text).toContain("Token expired");
});
});
// ----- Step 2: Delete A and C emails -----
describe("delete_emails", () => {
it("trashes the specified message IDs", async () => {
mockGmailTrash.mockResolvedValue({});
const text = await callTool("delete_emails", {
account: "work",
messageIds: ["msg-cat-a-001", "msg-cat-c-001"],
});
expect(mockGmailTrash).toHaveBeenCalledTimes(2);
expect(mockGmailTrash).toHaveBeenCalledWith({ userId: "me", id: "msg-cat-a-001" });
expect(mockGmailTrash).toHaveBeenCalledWith({ userId: "me", id: "msg-cat-c-001" });
expect(text).toContain("Trashed: msg-cat-a-001");
expect(text).toContain("Trashed: msg-cat-c-001");
});
it("reports partial failures without aborting", async () => {
mockGmailTrash
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error("Not Found"));
const text = await callTool("delete_emails", {
account: "work",
messageIds: ["msg-cat-a-001", "msg-bad-id"],
});
expect(text).toContain("Trashed: msg-cat-a-001");
expect(text).toContain("Failed to trash msg-bad-id: Not Found");
});
it("handles auth/client errors", async () => {
// Make getGmailClient itself throw by having list throw before any trash
mockGmailTrash.mockRejectedValue(new Error("Auth failed"));
const text = await callTool("delete_emails", {
account: "work",
messageIds: ["msg-cat-a-001"],
});
expect(text).toContain("Failed to trash msg-cat-a-001: Auth failed");
});
});
// ----- Step 3: Summarize B and D -----
describe("append_to_summary", () => {
it("creates a summary file and appends entries", async () => {
const entries = [
{
senderName: "Jane Smith",
senderEmail: "jane.smith@globexcorp.com",
dateReceived: "Tue, 18 Feb 2026 14:30:00 +0000",
subject: "Phone Screen — Full Stack Engineer at Globex Corp",
category: "B",
},
{
senderName: "Tailscale Billing",
senderEmail: "billing@tailscale.com",
dateReceived: "Thu, 20 Feb 2026 17:00:00 +0000",
subject: "$6.00 payment to Tailscale US Inc. was unsuccessful",
category: "D",
},
];
const text = await callTool("append_to_summary", {
account: "work",
entries,
});
expect(text).toContain("Appended 2 entry/entries to summary.");
expect(text).toContain("Total entries in summary: 2");
const summaryPath = path.join(tmpDir, "mailSummaries", "summary.json");
const written = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
expect(written).toHaveLength(2);
expect(written[0].senderName).toBe("Jane Smith");
expect(written[0].category).toBe("B");
expect(written[1].category).toBe("D");
expect(written[0].addedAt).toBeDefined();
});
it("purges entries older than 30 days", async () => {
const summaryPath = path.join(tmpDir, "mailSummaries", "summary.json");
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
// Seed with an old entry
fs.writeFileSync(
summaryPath,
JSON.stringify([
{
senderName: "Old Entry",
senderEmail: "old@example.com",
dateReceived: "2025-01-01",
subject: "Ancient email",
category: "D",
addedAt: oldDate,
},
])
);
const text = await callTool("append_to_summary", {
account: "work",
entries: [
{
senderName: "New Entry",
senderEmail: "new@example.com",
dateReceived: "2026-02-18",
subject: "Fresh email",
category: "B",
},
],
});
expect(text).toContain("Purged 1 entry/entries older than 30 days.");
expect(text).toContain("Total entries in summary: 1");
const written = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
expect(written).toHaveLength(1);
expect(written[0].senderName).toBe("New Entry");
});
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { getHeader, decodeBody } from "../../src/tools/tools-email.js";
import { MOCK_MESSAGES, RAW_BODIES } from "../fixtures/mock-emails.js";
describe("getHeader", () => {
const headers = [
{ name: "From", value: "Jane <jane@example.com>" },
{ name: "Subject", value: "Hello World" },
{ name: "Date", value: "Mon, 17 Feb 2026 10:00:00 +0000" },
];
it("extracts a header by name (case-insensitive)", () => {
expect(getHeader(headers, "from")).toBe("Jane <jane@example.com>");
expect(getHeader(headers, "FROM")).toBe("Jane <jane@example.com>");
expect(getHeader(headers, "From")).toBe("Jane <jane@example.com>");
});
it("returns empty string for missing header", () => {
expect(getHeader(headers, "Cc")).toBe("");
});
it("returns empty string for undefined headers array", () => {
expect(getHeader(undefined, "From")).toBe("");
});
it("returns empty string for empty headers array", () => {
expect(getHeader([], "From")).toBe("");
});
});
describe("decodeBody", () => {
it("decodes a multipart message (text/plain preferred)", () => {
const msg = MOCK_MESSAGES["msg-cat-a-001"].data;
const body = decodeBody(msg);
expect(body).toBe(RAW_BODIES["msg-cat-a-001"]);
expect(body).toContain("We have received your application");
});
it("decodes a multipart message with only text/plain part", () => {
const msg = MOCK_MESSAGES["msg-cat-b-001"].data;
const body = decodeBody(msg);
expect(body).toBe(RAW_BODIES["msg-cat-b-001"]);
expect(body).toContain("schedule a phone screen");
});
it("decodes a single-part message (no parts array)", () => {
const msg = MOCK_MESSAGES["msg-cat-c-001"].data;
const body = decodeBody(msg);
expect(body).toBe(RAW_BODIES["msg-cat-c-001"]);
expect(body).toContain("decided to pursue other candidates");
});
it("falls back to snippet when body data is empty", () => {
const msg = {
snippet: "This is a snippet fallback",
payload: { body: { data: "" } },
};
expect(decodeBody(msg)).toBe("This is a snippet fallback");
});
it("falls back to snippet when payload has no body data at all", () => {
const msg = {
snippet: "Snippet only",
payload: {},
};
expect(decodeBody(msg)).toBe("Snippet only");
});
it("returns empty string when no body and no snippet", () => {
const msg = { payload: {} };
expect(decodeBody(msg)).toBe("");
});
it("falls back to text/html when text/plain is missing in multipart", () => {
const htmlContent = "<p>Hello from HTML</p>";
const encoded = Buffer.from(htmlContent).toString("base64url");
const msg = {
payload: {
parts: [
{ mimeType: "text/html", body: { data: encoded } },
],
},
};
expect(decodeBody(msg)).toBe(htmlContent);
});
});

View File

@@ -11,5 +11,5 @@
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules"] "exclude": ["node_modules", "test", "vitest.config.ts"]
} }

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
globals: true,
},
});