first meow
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# OAuth secrets
|
||||||
|
credentials.json
|
||||||
|
credentials.*
|
||||||
|
token*.json
|
||||||
|
accounts.json
|
||||||
|
|
||||||
|
# Runtime output
|
||||||
|
summary.json
|
||||||
270
README.md
Normal file
270
README.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# Declawed (A Mail Bot)
|
||||||
|
|
||||||
|
A local Model Context Protocol (MCP) server connecing LLM platform APIs (ie Claude Desktop), or locally hosted models to your mail account.
|
||||||
|
Ssimple.
|
||||||
|
No Surprises, unlike the blue-plate-special-crustacean of the day.
|
||||||
|
Automates review, classification, and action -- response, deletion, archiving -- for inbox items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope
|
||||||
|
|
||||||
|
## Current implementaiton contemplates: Claude Desktop + Local Custom Model Context Protocol Server + Goog-MX'ed SMTP for your Registered, DNS'd XXX.YYY domain
|
||||||
|
(highly config'able- more to follow)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. Install: Node.js
|
||||||
|
|
||||||
|
Node.js v16 or higher must be installed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
If not installed, download from [nodejs.org](https://nodejs.org/).
|
||||||
|
|
||||||
|
### 2. Initialize the Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir assistant
|
||||||
|
cd assistant
|
||||||
|
npm init -y
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"assistant": "./build/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && chmod 755 build/index.js",
|
||||||
|
"auth": "npm run build && node build/auth.js"
|
||||||
|
},
|
||||||
|
"files": ["build"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4b. Create `tsconfig.json` in the project root
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "./build",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Write the Server Code
|
||||||
|
|
||||||
|
The server source lives in `src/index.ts`. It registers three tools and one prompt with the MCP server:
|
||||||
|
|
||||||
|
- **`fetch_new_emails`** -- fetches unread Gmail messages
|
||||||
|
- **`delete_emails`** -- trashes messages by ID
|
||||||
|
- **`append_to_summary`** -- logs classified emails to `summary.json`
|
||||||
|
- **`review_emails`** (prompt) -- feeds Claude the classification instructions
|
||||||
|
|
||||||
|
The auth helper lives in `src/auth.ts`, used only for the one-time OAuth setup.
|
||||||
|
|
||||||
|
### 6. Set Up Google Cloud Credentials
|
||||||
|
|
||||||
|
#### 6a. Create a Google Cloud Project
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
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
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"assistant": {
|
||||||
|
"command": "/ABSOLUTE/PATH/TO/node",
|
||||||
|
"args": [
|
||||||
|
"/Users/kjannette/assistant/build/index.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 Guide
|
||||||
|
|
||||||
|
### Prompt Loader
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### `fetch_new_emails` Enrichment
|
||||||
|
|
||||||
|
Every time `fetch_new_emails` is called, the classification instructions from `classify-emails.txt` are appended to the response alongside the email data. This means Claude always sees the rules with the data, even if the `review_emails` prompt was not explicitly invoked. Belt and suspenders.
|
||||||
|
|
||||||
|
### Running the Workflow
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
### Key Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `npm run build` | Recompile after editing `src/index.ts` |
|
||||||
|
| `npm run auth` | Re-authorize Gmail (only if `token.json` deleted/expired) |
|
||||||
|
| Cmd+Q Claude Desktop, reopen | Pick up server changes after a rebuild |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
assistant/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # MCP server source (tools + prompt)
|
||||||
|
│ └── auth.ts # One-time OAuth setup script
|
||||||
|
├── build/
|
||||||
|
│ ├── index.js # Compiled server (Claude Desktop runs this)
|
||||||
|
│ └── auth.js # Compiled auth script
|
||||||
|
├── classify-emails.txt # Classification prompt (plain text, edit anytime)
|
||||||
|
├── credentials.json # Google OAuth client credentials (from Cloud Console)
|
||||||
|
├── token.json # Gmail access/refresh token (auto-generated)
|
||||||
|
├── summary.json # Output file where B/D emails are logged
|
||||||
|
├── package.json # Project config and scripts
|
||||||
|
├── tsconfig.json # TypeScript compiler config
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
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
|
||||||
|
|
||||||
1922
package-lock.json
generated
Normal file
1922
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "assistant",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP server for Gmail job application management",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"assistant": "./build/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && chmod 755 build/index.js",
|
||||||
|
"auth": "npm run build && node build/auth.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"build"
|
||||||
|
],
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
|
"googleapis": "^171.4.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/auth.ts
Normal file
92
src/auth.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { google } from "googleapis";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import readline from "readline";
|
||||||
|
|
||||||
|
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 TOKEN_PATH = path.join(PROJECT_ROOT, "token.json");
|
||||||
|
|
||||||
|
// Gmail modify scope: read, send, delete, and manage labels
|
||||||
|
const SCOPES = ["https://www.googleapis.com/auth/gmail.modify"];
|
||||||
|
|
||||||
|
async function authorize(): Promise<void> {
|
||||||
|
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
||||||
|
console.error(
|
||||||
|
`Missing ${CREDENTIALS_PATH}\n\n` +
|
||||||
|
"To create this file:\n" +
|
||||||
|
"1. Go to https://console.cloud.google.com/\n" +
|
||||||
|
"2. Create a project and enable the Gmail API\n" +
|
||||||
|
"3. Create OAuth 2.0 credentials (Desktop app type)\n" +
|
||||||
|
"4. Download the JSON and save it as credentials.json in the project root\n"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if we already have a valid token
|
||||||
|
if (fs.existsSync(TOKEN_PATH)) {
|
||||||
|
const token = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
|
||||||
|
oAuth2Client.setCredentials(token);
|
||||||
|
console.log("Token already exists at:", TOKEN_PATH);
|
||||||
|
console.log("To re-authorize, delete token.json and run this script again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate auth URL and prompt user
|
||||||
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
|
access_type: "offline",
|
||||||
|
scope: SCOPES,
|
||||||
|
prompt: "consent",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Authorize this app by visiting this URL:\n");
|
||||||
|
console.log(authUrl);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawInput = await new Promise<string>((resolve) => {
|
||||||
|
rl.question(
|
||||||
|
"Paste the FULL URL from your browser address bar (or just the code): ",
|
||||||
|
(answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract the code whether they pasted the full URL or just the code
|
||||||
|
let code = rawInput;
|
||||||
|
if (rawInput.includes("code=")) {
|
||||||
|
const url = new URL(rawInput);
|
||||||
|
code = url.searchParams.get("code") ?? rawInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nExtracted code: ${code.substring(0, 10)}...`);
|
||||||
|
|
||||||
|
const { tokens } = await oAuth2Client.getToken(code);
|
||||||
|
oAuth2Client.setCredentials(tokens);
|
||||||
|
|
||||||
|
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2));
|
||||||
|
console.log("\nToken saved to:", TOKEN_PATH);
|
||||||
|
console.log("You can now start the MCP server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
authorize().catch((err) => {
|
||||||
|
console.error("Authorization failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
389
src/index.ts
Normal file
389
src/index.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { google, gmail_v1 } from "googleapis";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Paths — resolved relative to the compiled build/ directory, up one level
|
||||||
|
// to the project root where credentials.json, token.json, and summary.json live.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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 TOKEN_PATH = path.join(PROJECT_ROOT, "token.json");
|
||||||
|
const SUMMARY_PATH = path.join(PROJECT_ROOT, "summary.json");
|
||||||
|
const PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getGmailClient(): gmail_v1.Gmail {
|
||||||
|
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(TOKEN_PATH)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing token.json at ${TOKEN_PATH}. 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(TOKEN_PATH, "utf-8"));
|
||||||
|
oAuth2Client.setCredentials(token);
|
||||||
|
|
||||||
|
// Persist refreshed tokens automatically
|
||||||
|
oAuth2Client.on("tokens", (newTokens) => {
|
||||||
|
const current = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
|
||||||
|
fs.writeFileSync(
|
||||||
|
TOKEN_PATH,
|
||||||
|
JSON.stringify({ ...current, ...newTokens }, null, 2)
|
||||||
|
);
|
||||||
|
console.error("Token refreshed and saved.");
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerPrompt(
|
||||||
|
"review_emails",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Review inbox, 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: 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 the Gmail inbox. Returns sender, date, " +
|
||||||
|
"subject, message ID, and body text for each message. The message IDs " +
|
||||||
|
"can be passed to delete_emails later.",
|
||||||
|
inputSchema: {
|
||||||
|
maxResults: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.describe("Maximum number of unread emails to fetch (1-100)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ maxResults }) => {
|
||||||
|
try {
|
||||||
|
const gmail = getGmailClient();
|
||||||
|
|
||||||
|
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.",
|
||||||
|
inputSchema: {
|
||||||
|
messageIds: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe("Array of Gmail message IDs to move to trash"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ messageIds }) => {
|
||||||
|
try {
|
||||||
|
const gmail = getGmailClient();
|
||||||
|
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.",
|
||||||
|
inputSchema: {
|
||||||
|
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 ({ entries }) => {
|
||||||
|
try {
|
||||||
|
// Load existing summary or start fresh
|
||||||
|
let summary: SummaryEntry[] = [];
|
||||||
|
if (fs.existsSync(SUMMARY_PATH)) {
|
||||||
|
summary = JSON.parse(fs.readFileSync(SUMMARY_PATH, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const newEntries: SummaryEntry[] = entries.map((e) => ({
|
||||||
|
...e,
|
||||||
|
addedAt: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
summary.push(...newEntries);
|
||||||
|
fs.writeFileSync(SUMMARY_PATH, 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: ${SUMMARY_PATH}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} 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();
|
||||||
|
await server.connect(transport);
|
||||||
|
console.error("Assistant MCP Server running on stdio");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Fatal error in main():", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
30
src/prompts/classify-emails.txt
Normal file
30
src/prompts/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
|
||||||
|
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "./build",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user