Compare commits
1 Commits
db1603b6e5
...
addSeconda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2be350c88 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,12 +1,12 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
# OAuth secrets (inside accountsAndCredentials/, example files are NOT ignored)
|
# OAuth secrets
|
||||||
accountsAndCredentials/credentials.json
|
credentials.json
|
||||||
accountsAndCredentials/accounts.json
|
credentials.*
|
||||||
accountsAndCredentials/token*.json
|
token*.json
|
||||||
accountsAndCredentials/token.json.bak
|
accounts.json
|
||||||
!accountsAndCredentials/*.example.json
|
|
||||||
|
|
||||||
# Runtime output
|
# Runtime output
|
||||||
mailSummaries/
|
summary.json
|
||||||
|
token-secondary.json
|
||||||
|
|||||||
342
README.md
342
README.md
@@ -1,194 +1,272 @@
|
|||||||
<p align="center">
|
# Declawed: A Configurable, Promptable AI Mail Assisty Kitty
|
||||||
<img src="assets/logo.png" alt="deClawed" width="120" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
# deClawed
|
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.
|
||||||
|
|
||||||
**LLM-powered email, meeting, teleconference and event triage and workflow automation via the Model Context Protocol**
|
Ssssimple. Siamsese, if you please.... Eats the blue-plate-crustacean for breakfast.
|
||||||
|
|
||||||
deClawed is an MCP server that connects email inbox to an LLM-capable client (such as Claude Desktop), within an orchestration harness: Agents have access to tools to classify incoming emails, automate routine actions (delete, archive, summarize). The framwork integrates with Google Mail, Sheets and Calendar to keep your high-volume email workflow organized and actionable.
|
Infinitely mod-able. Dead simple. Privacy centric.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started
|
# Scope
|
||||||
|
|
||||||
### Requirements
|
## Current implementation contemplates: Claude Desktop + Local Model Context Protocol Server + Commercial SMTP Server (MX'config’d properly) + your DNS-config’d XXX.YYY
|
||||||
|
|
||||||
| Dependency | Version |
|
---
|
||||||
|------------|---------|
|
|
||||||
| Node.js | 16+ |
|
|
||||||
| npm | 8+ |
|
|
||||||
| Google Cloud Project | With Gmail API enabled |
|
|
||||||
| MCP Client | Claude Desktop (or any MCP-compatible client) |
|
|
||||||
|
|
||||||
### Installation
|
### 1. Install: Node.js
|
||||||
|
|
||||||
|
Node.js v16 or higher must be installed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.sjdev.online/kjannette/deClawed-Assity-Kitty
|
node --version
|
||||||
cd deClawed-Assity-Kitty
|
npm --version
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Google Cloud Setup
|
If not installed, download from [nodejs.org](https://nodejs.org/).
|
||||||
|
|
||||||
1. Create a project at [Google Cloud Console](https://console.cloud.google.com/)
|
### 2. Initialize the Project
|
||||||
2. Enable the **Gmail API** (and optionally **Sheets API** and **Calendar API**)
|
|
||||||
3. Configure OAuth consent screen (External, add your email as a test user)
|
|
||||||
4. Create OAuth credentials (Desktop app) and download `credentials.json`
|
|
||||||
5. Place `credentials.json` in the `accountsAndCredentials/` directory
|
|
||||||
|
|
||||||
### Account Configuration
|
```bash
|
||||||
|
mkdir assistant
|
||||||
|
cd assistant
|
||||||
|
npm init -y
|
||||||
|
```
|
||||||
|
|
||||||
Create `accountsAndCredentials/accounts.json`:
|
### 3. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
```json
|
||||||
{
|
{
|
||||||
"work": {
|
"type": "module",
|
||||||
"label": "you@example.com",
|
"bin": {
|
||||||
"tokenFile": "token.json",
|
"assistant": "./build/index.js"
|
||||||
"spreadsheetId": "YOUR_GOOGLE_SHEET_ID",
|
},
|
||||||
"calendarId": "primary"
|
"scripts": {
|
||||||
}
|
"build": "tsc && chmod 755 build/index.js",
|
||||||
|
"auth": "npm run build && node build/auth.js"
|
||||||
|
},
|
||||||
|
"files": ["build"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authorize Gmail Access
|
#### 4b. Create `tsconfig.json` in the project root
|
||||||
|
|
||||||
```bash
|
```json
|
||||||
npm run auth # Authorize default account
|
{
|
||||||
npm run auth -- secondary # Authorize additional accounts
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "./build",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Follow the browser prompts to complete OAuth. Tokens are saved locally and auto-refresh.
|
### 5. Write the Server Code
|
||||||
|
|
||||||
### Connect to Claude Desktop
|
The server source lives in `src/index.ts`. It registers three tools and one prompt with the MCP server:
|
||||||
|
|
||||||
Add the server to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
- **`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/)
|
||||||
|
2. Sign in with the Google account that owns the target Gmail
|
||||||
|
3. Click the project dropdown (top-left) and select **New Project**
|
||||||
|
4. Name it (e.g., `assistant-mcp`) and click **Create**
|
||||||
|
5. Select the new project from the dropdown
|
||||||
|
|
||||||
|
#### 6b. Enable the Gmail API
|
||||||
|
|
||||||
|
1. Go to **APIs & Services > Library** ([direct link](https://console.cloud.google.com/apis/library))
|
||||||
|
2. Search for **Gmail API**
|
||||||
|
3. Click it, then click **Enable**
|
||||||
|
|
||||||
|
#### 6c. Configure the OAuth Consent Screen
|
||||||
|
|
||||||
|
1. Go to **Google Auth Platform > Branding** (or **APIs & Services > OAuth consent screen**)
|
||||||
|
2. Set user type to **External**, click **Create**
|
||||||
|
3. Fill in app name, support email, and developer contact email
|
||||||
|
4. Save and continue
|
||||||
|
|
||||||
|
#### 6d. Add the Gmail Scope
|
||||||
|
|
||||||
|
1. Go to **Google Auth Platform > Data Access** (or the Scopes page)
|
||||||
|
2. Click **Add or remove scopes**
|
||||||
|
3. Add: `https://www.googleapis.com/auth/gmail.modify`
|
||||||
|
4. Save
|
||||||
|
|
||||||
|
#### 6e. Add Yourself as a Test User
|
||||||
|
|
||||||
|
1. Go to **Google Auth Platform > Audience**
|
||||||
|
2. Add your Gmail address as a test user
|
||||||
|
|
||||||
|
#### 6f. Create OAuth Client Credentials
|
||||||
|
|
||||||
|
1. Go to **Google Auth Platform > Clients** (or **APIs & Services > Credentials**)
|
||||||
|
2. Click **Create Client** (or **+ Create Credentials > OAuth client ID**)
|
||||||
|
3. Application type: **Desktop app**
|
||||||
|
4. Name it anything (e.g., `Assistant MCP Desktop`)
|
||||||
|
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
|
||||||
|
|
||||||
|
Build the project and run the auth script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run auth
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Print a URL -- open it in your browser
|
||||||
|
2. Sign in with your Google account and click **Allow**
|
||||||
|
3. You'll land on a "localhost refused to connect" page (this is normal)
|
||||||
|
4. Copy the **entire URL** from the browser address bar
|
||||||
|
5. Paste it into the terminal prompt
|
||||||
|
6. The script extracts the auth code and saves `token.json`
|
||||||
|
|
||||||
|
You only need to do this once. The token auto-refreshes.
|
||||||
|
|
||||||
|
### 8. Build the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This compiles `src/*.ts` into `build/*.js`.
|
||||||
|
|
||||||
|
### 9. Write the Classification Prompt
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `assistant` server to the `mcpServers` object:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"assistant": {
|
"assistant": {
|
||||||
"command": "/path/to/node",
|
"command": "/ABSOLUTE/PATH/TO/node",
|
||||||
"args": ["/path/to/deClawed-Assity-Kitty/build/index.js"]
|
"args": [
|
||||||
|
"/Users/kjannette/assistant/build/index.js"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart Claude Desktop. The server appears under **Connectors**.
|
Replace `/ABSOLUTE/PATH/TO/node` with the output of `which node`.
|
||||||
|
|
||||||
|
### 11. Restart Claude Desktop
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage
|
## Usage Guide
|
||||||
|
|
||||||
### Basic Workflow
|
### Prompt Loader
|
||||||
|
|
||||||
In Claude Desktop, invoke the email review workflow:
|
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 Prompt: `review_emails`
|
||||||
Review my inbox
|
|
||||||
```
|
|
||||||
|
|
||||||
The server fetches unread emails, classifies them using your configured prompts, and executes the appropriate actions.
|
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.
|
||||||
|
|
||||||
### Classification Categories
|
### `fetch_new_emails` Enrichment
|
||||||
|
|
||||||
| Category | Description | Default Action |
|
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.
|
||||||
|----------|-------------|----------------|
|
|
||||||
| **A** | Acknowledgements, auto-replies | Star and mark read |
|
|
||||||
| **B** | Advancement (interview requests, next steps) | Log + Calendar event (never star) |
|
|
||||||
| **C** | Rejections | Star and mark read |
|
|
||||||
| **D** | Other/uncategorized | Star, mark read, and log for review |
|
|
||||||
|
|
||||||
### Customizing Prompts
|
### Running the Workflow
|
||||||
|
|
||||||
Edit the plain-text files in `src/prompts/` to adjust classification rules and actions:
|
1. Open Claude Desktop
|
||||||
|
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`
|
||||||
|
|
||||||
- `classify-emails.txt` — Defines category criteria
|
### Key Commands
|
||||||
- `take-action-on-emails.txt` — Specifies actions per category
|
|
||||||
|
|
||||||
Changes take effect immediately without rebuilding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP Tools Reference
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `fetch_new_emails` | Fetch unread emails with classification instructions appended |
|
|
||||||
| `delete_emails` | Move specified message IDs to trash |
|
|
||||||
| `star_emails` | Star Category A/C/D messages and mark as read |
|
|
||||||
| `append_to_summary` | Log email metadata to local JSON (auto-purges after 30 days) |
|
|
||||||
| `log_recruiter_contact` | Append or update a row in Google Sheets |
|
|
||||||
| `create_calendar_event` | Create a Google Calendar event (skips past dates) |
|
|
||||||
|
|
||||||
### Example: Fetch Emails
|
|
||||||
|
|
||||||
```
|
|
||||||
fetch_new_emails(account: "work", maxResults: 50)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example: Star and Mark Read (Categories A, C, D only)
|
|
||||||
|
|
||||||
```
|
|
||||||
star_emails(account: "work", messageIds: ["msg-id-1", "msg-id-2"])
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example: Create Calendar Event
|
|
||||||
|
|
||||||
```
|
|
||||||
create_calendar_event(
|
|
||||||
account: "work",
|
|
||||||
title: "[Interview] Acme Corp - Senior Engineer",
|
|
||||||
startDateTime: "2026-09-01T14:00:00-04:00",
|
|
||||||
durationMinutes: 60,
|
|
||||||
location: "https://zoom.us/j/123456789"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CLI Commands
|
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `npm run build` | Compile TypeScript to `build/` |
|
| `npm run build` | Recompile after editing `src/index.ts` |
|
||||||
| `npm run auth` | Authorize the default account |
|
| `npm run auth` | Re-authorize Gmail (only if `token.json` deleted/expired) |
|
||||||
| `npm run auth -- <name>` | Authorize a named account |
|
| Cmd+Q Claude Desktop, reopen | Pick up server changes after a rebuild |
|
||||||
| `npm test` | Run test suite |
|
|
||||||
| `npm run test:watch` | Run tests in watch mode |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
deClawed-Assity-Kitty/
|
assistant/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.ts # Entry point
|
│ ├── index.ts # MCP server source (tools + prompt)
|
||||||
│ ├── McpServer.ts # MCP server instance
|
│ └── auth.ts # One-time OAuth setup script
|
||||||
│ ├── auth.ts # OAuth setup script
|
├── build/
|
||||||
│ ├── loaders/ # Config and prompt loaders
|
│ ├── index.js # Compiled server (Claude Desktop runs this)
|
||||||
│ ├── prompts/ # Classification and action prompts
|
│ └── auth.js # Compiled auth script
|
||||||
│ └── tools/ # MCP tool implementations
|
├── classify-emails.txt # Classification prompt (plain text, edit anytime)
|
||||||
├── accountsAndCredentials/ # OAuth credentials and tokens (gitignored)
|
├── credentials.json # Google OAuth client credentials (from Cloud Console)
|
||||||
├── mailSummaries/ # Local email logs (gitignored)
|
├── token.json # Gmail access/refresh token (auto-generated)
|
||||||
├── test/ # Unit and integration tests
|
├── summary.json # Output file where B/D emails are logged
|
||||||
└── build/ # Compiled output
|
├── package.json # Project config and scripts
|
||||||
|
├── tsconfig.json # TypeScript compiler config
|
||||||
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- All credentials and tokens are stored locally and excluded from version control
|
|
||||||
- OAuth tokens auto-refresh; re-authorization is only needed if revoked
|
|
||||||
- The server runs locally via stdio—no network exposure
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
ISC
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"work": {
|
|
||||||
"label": "",
|
|
||||||
"tokenFile": "token.json",
|
|
||||||
"spreadsheetId": "",
|
|
||||||
"calendarId": "primary"
|
|
||||||
},
|
|
||||||
"secondary": {
|
|
||||||
"label": "",
|
|
||||||
"tokenFile": "token-secondary.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"access_token": "",
|
|
||||||
"refresh_token": "",
|
|
||||||
"scope": "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.events",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expiry_date": 0
|
|
||||||
}
|
|
||||||
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
30
classify-emails.txt
Normal file
30
classify-emails.txt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
You are an expert Executive Assistant AI specialized in email management and productivity. Your goal is to organize, prioritize, and summarize incoming emails to maximize user efficiency.
|
||||||
|
|
||||||
|
I have been applying for jobs, which generates a large volume of responses. Review my inbox and apply the following instructions to all new emails.
|
||||||
|
|
||||||
|
STEP 1: Constraints
|
||||||
|
- Do not make up information.
|
||||||
|
|
||||||
|
STEP 2: CLASSIFY each email into exactly one category.
|
||||||
|
|
||||||
|
Category A - Acknowledgement Only
|
||||||
|
The employer confirms receipt of my application but requires no action from me. Typical language: "We received your application," "Your application is under review," "We will contact you if selected." No next steps are requested.
|
||||||
|
|
||||||
|
Category B - Advancement to Next Step
|
||||||
|
The employer wants to move forward. This includes: invitations to schedule a phone screen or interview, requests to complete an assessment or assignment, requests for additional information or documents, or any communication that requires a response or action from me. If an email both acknowledges receipt AND requests action, classify it as B.
|
||||||
|
|
||||||
|
Category C - Rejection
|
||||||
|
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
||||||
|
|
||||||
|
Category D - Other
|
||||||
|
The email does not fit into categories A, B, or C. This includes non-job-application emails, newsletters, promotional content, or ambiguous messages that do not clearly match another category.
|
||||||
|
|
||||||
|
STEP 3: DELETE emails classified as Category A and Category C.
|
||||||
|
|
||||||
|
STEP 4: SUMMARIZE emails classified as Category B and Category D. For each, include:
|
||||||
|
1. Sender name and email address
|
||||||
|
2. Email subject line
|
||||||
|
3. Date and time received
|
||||||
|
4. Category (B or D)
|
||||||
|
5. Suggested action I should take
|
||||||
|
|
||||||
1466
package-lock.json
generated
1466
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,7 @@
|
|||||||
},
|
},
|
||||||
"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"
|
||||||
@@ -19,13 +17,12 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3"
|
||||||
"vitest": "^4.0.18"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
|
|
||||||
export const server = new McpServer({
|
|
||||||
name: "assistant",
|
|
||||||
version: "1.0.0",
|
|
||||||
});
|
|
||||||
57
src/auth.ts
57
src/auth.ts
@@ -5,47 +5,21 @@ import readline from "readline";
|
|||||||
|
|
||||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||||
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
||||||
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
|
const CREDENTIALS_PATH = path.join(PROJECT_ROOT, "credentials.json");
|
||||||
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
|
const TOKEN_PATH = path.join(PROJECT_ROOT, "token.json");
|
||||||
const ACCOUNTS_PATH = path.join(SECRETS_DIR, "accounts.json");
|
|
||||||
|
|
||||||
const SCOPES = [
|
// Gmail modify scope: read, send, delete, and manage labels
|
||||||
"https://www.googleapis.com/auth/gmail.modify",
|
const SCOPES = ["https://www.googleapis.com/auth/gmail.modify"];
|
||||||
"https://www.googleapis.com/auth/spreadsheets",
|
|
||||||
"https://www.googleapis.com/auth/calendar.events",
|
|
||||||
];
|
|
||||||
|
|
||||||
function resolveTokenPath(accountKey: string): string {
|
|
||||||
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
|
||||||
throw new Error(`Missing ${ACCOUNTS_PATH}. Create accounts.json first.`);
|
|
||||||
}
|
|
||||||
const accounts = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
|
||||||
const acct = accounts[accountKey];
|
|
||||||
if (!acct) {
|
|
||||||
const available = Object.keys(accounts).join(", ");
|
|
||||||
throw new Error(
|
|
||||||
`Unknown account "${accountKey}". Available: ${available}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return path.join(SECRETS_DIR, acct.tokenFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authorize(): Promise<void> {
|
async function authorize(): Promise<void> {
|
||||||
const accountKey = process.argv[2] || "work";
|
|
||||||
const tokenPath = resolveTokenPath(accountKey);
|
|
||||||
|
|
||||||
console.log(`Authorizing account: "${accountKey}"`);
|
|
||||||
console.log(`Token file: ${tokenPath}`);
|
|
||||||
console.log(`Scopes: ${SCOPES.join(", ")}\n`);
|
|
||||||
|
|
||||||
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
||||||
console.error(
|
console.error(
|
||||||
`Missing ${CREDENTIALS_PATH}\n\n` +
|
`Missing ${CREDENTIALS_PATH}\n\n` +
|
||||||
"To create this file:\n" +
|
"To create this file:\n" +
|
||||||
"1. Go to https://console.cloud.google.com/\n" +
|
"1. Go to https://console.cloud.google.com/\n" +
|
||||||
"2. Create a project and enable the Gmail, Sheets, and Calendar APIs\n" +
|
"2. Create a project and enable the Gmail API\n" +
|
||||||
"3. Create OAuth 2.0 credentials (Desktop app type)\n" +
|
"3. Create OAuth 2.0 credentials (Desktop app type)\n" +
|
||||||
"4. Download the JSON and save it as credentials.json in the accountsAndCredentials/ folder\n"
|
"4. Download the JSON and save it as credentials.json in the project root\n"
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -60,18 +34,16 @@ async function authorize(): Promise<void> {
|
|||||||
redirect_uris[0]
|
redirect_uris[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fs.existsSync(tokenPath)) {
|
// Check if we already have a valid token
|
||||||
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
if (fs.existsSync(TOKEN_PATH)) {
|
||||||
|
const token = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
|
||||||
oAuth2Client.setCredentials(token);
|
oAuth2Client.setCredentials(token);
|
||||||
console.log("Token already exists at:", tokenPath);
|
console.log("Token already exists at:", TOKEN_PATH);
|
||||||
console.log(
|
console.log("To re-authorize, delete token.json and run this script again.");
|
||||||
"To re-authorize with new scopes, delete the token file and run again:\n" +
|
|
||||||
` rm ${tokenPath}\n` +
|
|
||||||
` npm run auth -- ${accountKey}`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate auth URL and prompt user
|
||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
access_type: "offline",
|
access_type: "offline",
|
||||||
scope: SCOPES,
|
scope: SCOPES,
|
||||||
@@ -97,6 +69,7 @@ async function authorize(): Promise<void> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract the code whether they pasted the full URL or just the code
|
||||||
let code = rawInput;
|
let code = rawInput;
|
||||||
if (rawInput.includes("code=")) {
|
if (rawInput.includes("code=")) {
|
||||||
const url = new URL(rawInput);
|
const url = new URL(rawInput);
|
||||||
@@ -108,8 +81,8 @@ async function authorize(): Promise<void> {
|
|||||||
const { tokens } = await oAuth2Client.getToken(code);
|
const { tokens } = await oAuth2Client.getToken(code);
|
||||||
oAuth2Client.setCredentials(tokens);
|
oAuth2Client.setCredentials(tokens);
|
||||||
|
|
||||||
fs.writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
|
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2));
|
||||||
console.log("\nToken saved to:", tokenPath);
|
console.log("\nToken saved to:", TOKEN_PATH);
|
||||||
console.log("You can now start the MCP server.");
|
console.log("You can now start the MCP server.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
470
src/index.ts
470
src/index.ts
@@ -1,18 +1,470 @@
|
|||||||
#!/usr/bin/env node
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { server } from "./McpServer.js";
|
import { z } from "zod";
|
||||||
|
import { google, gmail_v1 } from "googleapis";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
// Side-effect imports: each module registers its prompts/tools on the server
|
// ---------------------------------------------------------------------------
|
||||||
import "./prompt-controller-service/prompt-controller-service.js";
|
// Paths — resolved relative to the compiled build/ directory, up one level
|
||||||
import "./tools/tools-email.js";
|
// to the project root where credentials.json, token.json, and summary.json live.
|
||||||
import "./tools/tools-spreadsheet.js";
|
// ---------------------------------------------------------------------------
|
||||||
import "./tools/tools-calendar.js";
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
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 PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
|
||||||
|
|
||||||
const main = async (): Promise<void> => {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Account configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface AccountConfig {
|
||||||
|
label: string;
|
||||||
|
tokenFile: 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 accountSchema = z
|
||||||
|
.enum(VALID_ACCOUNTS)
|
||||||
|
.describe(
|
||||||
|
"Which email account to use: \"work\" (sj@sjdev.co) or \"secondary\" (ken.jannette@gmail.com)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Load classification prompt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function loadClassificationPrompt(): string {
|
||||||
|
if (!fs.existsSync(PROMPT_PATH)) {
|
||||||
|
console.error(`Warning: ${PROMPT_PATH} not found. Classification instructions will be missing.`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return fs.readFileSync(PROMPT_PATH, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gmail auth helper — parameterized by account key
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getGmailClient(account: string): gmail_v1.Gmail {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Persist refreshed tokens automatically
|
||||||
|
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 google.gmail({ version: "v1", auth: oAuth2Client });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 (sj@sjdev.co): classify job application emails (A/B/C/D), delete A+C, summarize B+D.",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const instructions = loadClassificationPrompt();
|
||||||
|
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` +
|
||||||
|
(instructions ||
|
||||||
|
"Review my new emails and classify them by job application category."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prompt: review_secondary_emails (secondary account)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerPrompt(
|
||||||
|
"review_secondary_emails",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Review SECONDARY inbox (ken.jannette@gmail.com): classify job application emails (A/B/C/D), delete A+C, summarize B+D.",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const instructions = loadClassificationPrompt();
|
||||||
|
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` +
|
||||||
|
(instructions ||
|
||||||
|
"Review my new emails and classify them by job application category."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 instructionsBlock = classificationInstructions
|
||||||
|
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS:\n${"=".repeat(60)}\n${classificationInstructions}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
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().toISOString();
|
||||||
|
const newEntries: SummaryEntry[] = entries.map((e) => ({
|
||||||
|
...e,
|
||||||
|
addedAt: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
summary.push(...newEntries);
|
||||||
|
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error appending to summary: ${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);
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
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";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Paths
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
export const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
|
|
||||||
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
|
|
||||||
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
|
|
||||||
const ACCOUNTS_PATH = path.join(SECRETS_DIR, "accounts.json");
|
|
||||||
const PROMPTS_DIR = path.join(PROJECT_ROOT, "src", "prompts");
|
|
||||||
const CLASSIFY_PROMPT_PATH = path.join(PROMPTS_DIR, "classify-emails.txt");
|
|
||||||
const ACTION_PROMPT_PATH = path.join(PROMPTS_DIR, "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(SECRETS_DIR, 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";
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { server } from "../McpServer.js";
|
|
||||||
import {
|
|
||||||
accounts,
|
|
||||||
loadClassificationPrompt,
|
|
||||||
loadActionPrompt,
|
|
||||||
} from "../loaders/prompt-config-loaders.js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Prompt: Classify Emails (Phase 1)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
server.registerPrompt(
|
|
||||||
"classify-emails",
|
|
||||||
{
|
|
||||||
description: "Phase 1: Organize, prioritize, and summarize incoming recruiter emails.",
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
const phase1 = loadClassificationPrompt();
|
|
||||||
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.")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Prompt: Take Action on Emails (Phase 2)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
server.registerPrompt(
|
|
||||||
"take-action-on-emails",
|
|
||||||
{
|
|
||||||
description: "Phase 2: Log Category B recruiters to spreadsheet and create calendar events.",
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
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` +
|
|
||||||
(phase2 || "Process remaining action items for flagged recruiters.")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
You are an expert Executive Assistant AI specialized in email management. Your goal is to organize, prioritize, and triage incoming emails to maximize productivity and ensure nothing important is missed.
|
|
||||||
|
|
||||||
Review the inbox emails provided. Apply the following instructions.
|
|
||||||
|
|
||||||
STEP 1: CONSTRAINTS
|
|
||||||
- NEVER fabricate information. If data is not explicitly present in an email, it does not exist.
|
|
||||||
- When classification is ambiguous, err on the side of higher priority (e.g., classify as Action Required rather than Informational).
|
|
||||||
|
|
||||||
STEP 2: CLASSIFY each email into exactly one category.
|
|
||||||
|
|
||||||
Category A - Action Required
|
|
||||||
Emails that require a response, decision, or task completion from the user. Indicators include:
|
|
||||||
1. Direct questions addressed to the user
|
|
||||||
2. Requests for information, documents, or approvals
|
|
||||||
3. Meeting invitations or scheduling requests
|
|
||||||
4. Deadlines or time-sensitive requests
|
|
||||||
5. Follow-ups on prior conversations that need a reply
|
|
||||||
6. Emails from humans (not automation) explicitly asking for something
|
|
||||||
|
|
||||||
This category does NOT include:
|
|
||||||
- Automated notifications that require only a click (e.g., "verify your email," "reset password")
|
|
||||||
- Subscription confirmations or account alerts
|
|
||||||
|
|
||||||
Category B - Informational
|
|
||||||
Emails that provide useful information but require no immediate action. Examples:
|
|
||||||
1. Project updates or status reports
|
|
||||||
2. FYI messages or CC'd correspondence
|
|
||||||
3. Newsletters or digests the user has opted into
|
|
||||||
4. Industry news or curated content
|
|
||||||
5. Internal announcements (policy changes, team updates, etc.)
|
|
||||||
6. Shared documents or resources for reference
|
|
||||||
|
|
||||||
Category C - Transactional
|
|
||||||
Automated or system-generated emails confirming an action or status. Examples:
|
|
||||||
1. Order confirmations, shipping notifications, delivery updates
|
|
||||||
2. Payment receipts and invoices
|
|
||||||
3. Appointment reminders from services (doctors, salons, etc.)
|
|
||||||
4. Travel itineraries and booking confirmations
|
|
||||||
5. Password resets, login alerts, two-factor codes
|
|
||||||
6. Subscription renewals or billing notices
|
|
||||||
|
|
||||||
Category D - Low Priority
|
|
||||||
Emails that can be safely ignored, archived, or deleted. Examples:
|
|
||||||
1. Marketing and promotional emails
|
|
||||||
2. Sales outreach from unknown senders
|
|
||||||
3. Surveys or feedback requests
|
|
||||||
4. Social media notifications
|
|
||||||
5. Spam or unsolicited bulk mail
|
|
||||||
6. Emails from mailing lists the user rarely engages with
|
|
||||||
|
|
||||||
Category E - Personal
|
|
||||||
Emails from known personal contacts or containing personal (non-work) content. Examples:
|
|
||||||
1. Messages from friends or family
|
|
||||||
2. Personal appointment confirmations
|
|
||||||
3. Social invitations or event coordination
|
|
||||||
4. Non-work correspondence from colleagues
|
|
||||||
|
|
||||||
STEP 3: MARK PROCESSED EMAILS
|
|
||||||
|
|
||||||
Star all emails classified as Category A (Action Required).
|
|
||||||
Do NOT star emails in Categories B, C, D, or E.
|
|
||||||
|
|
||||||
STEP 4: REPORT EMAIL COUNT
|
|
||||||
|
|
||||||
Confirm the total number of emails reviewed in the chat.
|
|
||||||
|
|
||||||
STEP 5: SUMMARIZE PRIORITY EMAILS
|
|
||||||
|
|
||||||
Summarize all Category A (Action Required) and Category E (Personal) emails in the chat, under separate headers.
|
|
||||||
|
|
||||||
For each email, include:
|
|
||||||
- From: sender name and email address
|
|
||||||
- Subject: email subject line
|
|
||||||
- Date: date received
|
|
||||||
- Time: time received (convert to user's local timezone if known, otherwise use original)
|
|
||||||
- Summary: 1-2 sentence summary of the content and what action may be needed
|
|
||||||
|
|
||||||
STEP 6: EXECUTE ACTIONS
|
|
||||||
|
|
||||||
For Category A emails:
|
|
||||||
- If the email contains a meeting request with a specific date/time, create a calendar event using the create_calendar_event tool.
|
|
||||||
- Title: Use the subject line or a clear description of the meeting
|
|
||||||
- Start: Extract date/time from the email (ISO 8601 format)
|
|
||||||
- Duration: Use the duration specified, or default to 60 minutes
|
|
||||||
- Location: Include any meeting link, address, or phone number mentioned
|
|
||||||
- Description: Include relevant context (attendees, agenda, contact info)
|
|
||||||
|
|
||||||
For Category C emails:
|
|
||||||
- If the email contains an appointment or reservation with a specific date/time (e.g., doctor appointment, flight, hotel check-in), create a calendar event.
|
|
||||||
- Title: "[Reminder] " + service/company name + description
|
|
||||||
- Start: Extract date/time from the email
|
|
||||||
- Duration: Use duration if specified, or default to 60 minutes
|
|
||||||
- Location: Include address or relevant details
|
|
||||||
- Description: Include confirmation numbers, contact info, or instructions
|
|
||||||
|
|
||||||
For Category D emails:
|
|
||||||
- Use delete_emails to move these to trash (if configured for auto-cleanup).
|
|
||||||
|
|
||||||
STRICT CONSTRAINTS FOR CALENDAR EVENTS:
|
|
||||||
- Do not fabricate dates, times, links, phone numbers, or any other information.
|
|
||||||
- Before creating an event, verify the proposed date is in the future. If the date has passed, skip calendar creation.
|
|
||||||
- Do not create duplicate events for the same appointment (same date/time and same organizer/service).
|
|
||||||
- Use the same account for calendar events as was used for email fetching.
|
|
||||||
|
|
||||||
STEP 7: LOG SUMMARY
|
|
||||||
|
|
||||||
Use append_to_summary to log all Category A and Category E emails for future reference.
|
|
||||||
@@ -9,27 +9,22 @@ STEP 2: CLASSIFY each email into exactly one category.
|
|||||||
|
|
||||||
Category A - Acknowledgement Only
|
Category A - Acknowledgement Only
|
||||||
The employer confirms receipt of my application but requires no action from me. Typical language: "We received your application," "Your application is under review," "We will contact you if selected." No next steps are requested.
|
The employer confirms receipt of my application but requires no action from me. Typical language: "We received your application," "Your application is under review," "We will contact you if selected." No next steps are requested.
|
||||||
Action you should take: Call star_emails with this message's ID (same account used for fetch_new_emails). That stars the email and marks it read.
|
|
||||||
|
|
||||||
Category B - Advancement to Next Step
|
Category B - Advancement to Next Step
|
||||||
The employer wants to move forward. This includes: invitations to schedule a phone screen or interview, requests to complete an assessment or assignment, requests for additional information or documents, or any communication that requires a response or action from me. If an email both acknowledges receipt AND requests action, classify it as B.
|
The employer wants to move forward. This includes: invitations to schedule a phone screen or interview, requests to complete an assessment or assignment, requests for additional information or documents, or any communication that requires a response or action from me. If an email both acknowledges receipt AND requests action, classify it as B.
|
||||||
Actions you should take: 1) add a calendar event with all pertinent info: a) person I will be meeting with,
|
|
||||||
b) company c) role I will be discussing d) method for joining meeting - ie, telephone? If yes, will the representative all me, and is the number correct (718) 749-8292 c) or, is it Zoom, Google Meet, Webex or another video format, if so, include the link for joining
|
|
||||||
|
|
||||||
NEVER, EVER ADD A STAR TO A CATEGORY B EMAIL. Do not pass Category B message IDs to star_emails.
|
|
||||||
|
|
||||||
Category C - Rejection
|
Category C - Rejection
|
||||||
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
||||||
Action you should take: Call star_emails with this message's ID (same account used for fetch_new_emails). That stars the email and marks it read.
|
|
||||||
|
|
||||||
Category D - Other
|
Category D - Other
|
||||||
The email does not fit into categories A, B, or C. This includes non-job-application emails, newsletters, promotional content, or ambiguous messages that do not clearly match another category.
|
The email does not fit into categories A, B, or C. This includes non-job-application emails, newsletters, promotional content, or ambiguous messages that do not clearly match another category.
|
||||||
Action you should take: Call star_emails with this message's ID (same account used for fetch_new_emails). That stars the email and marks it read.
|
|
||||||
|
|
||||||
STEP 3: SUMMARIZE emails classified as Category B and Category D in the chat in an organized list with headings. Example format for this:
|
STEP 3: DELETE emails classified as Category A and Category C.
|
||||||
|
|
||||||
|
STEP 4: SUMMARIZE emails classified as Category B and Category D. For each, include:
|
||||||
1. Sender name and email address
|
1. Sender name and email address
|
||||||
2. Email subject line
|
2. Email subject line
|
||||||
3. Date and time received
|
3. Date and time received
|
||||||
4. Category (B or D)
|
4. Category (B or D)
|
||||||
5. Suggested action I might take
|
5. Suggested action I should take
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
PHASE 2: TAKE ACTION ON CATEGORY B EMAILS
|
|
||||||
After completing classification (Phase 1), perform the following actions for each email classified as Category B (Advancement to Next Step).
|
|
||||||
|
|
||||||
STEP 1: CREATE CALENDAR EVENTS
|
|
||||||
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
|
||||||
- Recruiter Call Scheduled
|
|
||||||
- Company First Interview
|
|
||||||
- Company Second Interview
|
|
||||||
|
|
||||||
For EACH of the above fields that contains a date/time, create a Google Calendar event using the create_calendar_event tool:
|
|
||||||
- Title format: "[<Recruiter Name> Call] Company - Role" or "[First Interview] Company - Role" or "[Second Interview] Company - Role"
|
|
||||||
- Start date/time: extracted from the email (convert to ISO 8601)
|
|
||||||
- Duration: 30 minutes unless otherwise specified in the email
|
|
||||||
- Location: the video meeting link, phone number, or platform name
|
|
||||||
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
|
||||||
|
|
||||||
STRICT CONSTRAINTS:
|
|
||||||
- Do not make up phone numbers, links, dates, or anything at all. If it does not exist in text within a verifiable email, IT DOES NOT EXIST.
|
|
||||||
- PAST-DATE CHECK: Before creating a calendar event: 1) verify today's date from an external, reliable source; 2) compare the proposed meeting date/time against today's date; 3) if the meeting date has already passed, do NOT create a calendar event for it. Still log it to the spreadsheet, but skip the calendar step. The tool will also enforce this server-side.
|
|
||||||
- As described in more detail below, each new date/time value in a scheduling column = one new calendar event. Never skip this.
|
|
||||||
- Always use the same account for calendar events as was used for email fetching.
|
|
||||||
- Do not create duplicate calendar events for the same meeting (same company representative && same job description | same company && same date time)
|
|
||||||
- If information is not present in the email, leave that field empty (it's OK!) - do not guess or make up information.
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
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. After evaluation, " +
|
|
||||||
"pass Category A, C, and D message IDs to star_emails. Never pass " +
|
|
||||||
"Category B IDs to star_emails. Specify which account to fetch from.",
|
|
||||||
inputSchema: {
|
|
||||||
account: accountSchema,
|
|
||||||
maxResults: z
|
|
||||||
.number()
|
|
||||||
.min(1)
|
|
||||||
.max(200)
|
|
||||||
.describe("Maximum number of unread emails to fetch (1-200)"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// --------------------------------------------------------------------------- //
|
|
||||||
// Tool: star_emails
|
|
||||||
// --------------------------------------------------------------------------- //
|
|
||||||
server.registerTool(
|
|
||||||
"star_emails",
|
|
||||||
{
|
|
||||||
description: "Mark reviewed emails with a star and mark them as read in Gmail. " +
|
|
||||||
"Use this for Category A (acknowledgements), C (rejections), and D (other) " +
|
|
||||||
"messages to signal they have been processed. Never use this for Category B " +
|
|
||||||
"(advancement) emails. Specify which account the emails belong to.",
|
|
||||||
inputSchema: {
|
|
||||||
account: accountSchema,
|
|
||||||
messageIds: z
|
|
||||||
.array(z.string())
|
|
||||||
.describe("Array of Gmail message IDs to star and mark read"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ account, messageIds }) => {
|
|
||||||
try {
|
|
||||||
if (messageIds.length === 0) {
|
|
||||||
return { content: [{ type: "text" as const, text: "No message IDs provided." }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const gmail = getGmailClient(account);
|
|
||||||
|
|
||||||
// Batch modify allows updating up to 1000 messages in a single API call
|
|
||||||
await gmail.users.messages.batchModify({
|
|
||||||
userId: "me",
|
|
||||||
requestBody: {
|
|
||||||
ids: messageIds,
|
|
||||||
addLabelIds: ["STARRED"],
|
|
||||||
removeLabelIds: ["UNREAD"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Successfully starred and marked read ${messageIds.length} email(s).`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `Error starring emails: ${errMsg}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
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}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
130
summary-secondary.json
Normal file
130
summary-secondary.json
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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
167
test/fixtures/mock-emails.ts
vendored
@@ -1,167 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
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();
|
|
||||||
const mockGmailBatchModify = 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,
|
|
||||||
batchModify: mockGmailBatchModify,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
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 2b: Star A, C, and D emails -----
|
|
||||||
describe("star_emails", () => {
|
|
||||||
it("stars and marks read the specified message IDs", async () => {
|
|
||||||
mockGmailBatchModify.mockResolvedValue({});
|
|
||||||
|
|
||||||
const text = await callTool("star_emails", {
|
|
||||||
account: "work",
|
|
||||||
messageIds: ["msg-cat-a-001", "msg-cat-c-001", "msg-cat-d-001"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockGmailBatchModify).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockGmailBatchModify).toHaveBeenCalledWith({
|
|
||||||
userId: "me",
|
|
||||||
requestBody: {
|
|
||||||
ids: ["msg-cat-a-001", "msg-cat-c-001", "msg-cat-d-001"],
|
|
||||||
addLabelIds: ["STARRED"],
|
|
||||||
removeLabelIds: ["UNREAD"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(text).toContain("Successfully starred and marked read 3 email(s).");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns early when no message IDs are provided", async () => {
|
|
||||||
const text = await callTool("star_emails", {
|
|
||||||
account: "work",
|
|
||||||
messageIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockGmailBatchModify).not.toHaveBeenCalled();
|
|
||||||
expect(text).toBe("No message IDs provided.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles Gmail API errors gracefully", async () => {
|
|
||||||
mockGmailBatchModify.mockRejectedValue(new Error("Quota exceeded"));
|
|
||||||
|
|
||||||
const text = await callTool("star_emails", {
|
|
||||||
account: "work",
|
|
||||||
messageIds: ["msg-cat-a-001"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(text).toContain("Error starring emails");
|
|
||||||
expect(text).toContain("Quota exceeded");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----- 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,5 +11,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "test", "vitest.config.ts"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
include: ["test/**/*.test.ts"],
|
|
||||||
globals: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user