6 Commits

22 changed files with 3637 additions and 3595 deletions

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ accountsAndCredentials/token.json.bak
# Runtime output
mailSummaries/
// revised Aug 31

View File

@@ -487,4 +487,5 @@ deClawed-Assity-Kitty/
├── vitest.config.ts
├── .gitignore
└── README.md
```
```
// revised Aug 31

View File

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

View File

@@ -1,11 +1,13 @@
{
"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"]
}
}
"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"
]
}
}

View File

@@ -1,7 +1,7 @@
{
"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
}
"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
}

6744
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,31 @@
{
"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",
"test": "vitest run",
"test:watch": "vitest"
},
"files": [
"build"
],
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"googleapis": "^171.4.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}
"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",
"test": "vitest run",
"test:watch": "vitest"
},
"files": [
"build"
],
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"googleapis": "^171.4.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}

View File

@@ -4,3 +4,5 @@ export const server = new McpServer({
name: "assistant",
version: "1.0.0",
});
// revised Aug 31

View File

@@ -117,3 +117,5 @@ authorize().catch((err) => {
console.error("Authorization failed:", err);
process.exit(1);
});
// revised Aug 31

View File

@@ -18,3 +18,5 @@ main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
// revised Aug 31

View File

@@ -3,11 +3,13 @@ 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 __dirname = path.dirname(new URL(import.meta.url).pathname);
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");
@@ -20,130 +22,132 @@ 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;
label: string;
tokenFile: string;
spreadsheetId?: string;
calendarId?: string;
}
export interface AccountsMap {
[key: string]: AccountConfig;
[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"));
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);
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`);
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 ");
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
.join(" or ");
export const accountSchema = z
.enum(VALID_ACCOUNTS)
.describe(`Which email account to use: ${accountDescription}`);
.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");
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");
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
export const loadActionPrompt = (): string =>
loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
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);
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(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]
);
}
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 token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
oAuth2Client.setCredentials(token);
const oAuth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris[0]
);
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}".`);
});
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;
return oAuth2Client;
};
export const getGmailClient = (account: string): gmail_v1.Gmail =>
google.gmail({ version: "v1", auth: getOAuth2Client(account) });
google.gmail({ version: "v1", auth: getOAuth2Client(account) });
export const getSheetsClient = (account: string): sheets_v4.Sheets =>
google.sheets({ version: "v4", auth: getOAuth2Client(account) });
google.sheets({ version: "v4", auth: getOAuth2Client(account) });
export const getCalendarClient = (account: string): calendar_v3.Calendar =>
google.calendar({ version: "v3", auth: getOAuth2Client(account) });
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;
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";
accounts[account]?.calendarId ?? "primary";
// revised Aug 31

View File

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

View File

@@ -11,6 +11,7 @@ The employer confirms receipt of my application but requires no action from me.
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.
This does *not* include Greenhouse "security code" emails, other "confirm identity codes" or "identity-confirmation" type emails, "verify candidate account" type emails,
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."
@@ -19,14 +20,43 @@ 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: STAR REVIEWED EMAILS
- For EVERY single email processed and reviewed in this run (Categories A, B, C, and D), apply the Gmail "STARRED" system label or use your email tool to add a star to the message.
- For EVERY email processed and reviewed in this run that is in Category A, C, and D (NOT B), apply the Gmail "STARRED" system label or use your email tool to add a star to the message.
STEP 4: Confirm, via natural language output in the chat window, the total number of emails fetched for review and successfully starred.
STEP 4: Confirm, via natural language output in the chat window, the total number of emails fetched for review and starred.
STEP 5: SUMMARIZE emails classified as Category B and Category D.
STEP 5: SUMMARIZE emails classified as Category B and Category D, under separate headers, in the chat.
For each, state:
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
STEP 6: **STRICT COMPLIANCE REQUIRED** **DO NOT FORGET***:
Summarize all "Category B" emails in the chat.
Star all reviewed email
Next, perform the following actions for each "Category B" email:
STEP 7: 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.
// revised Aug 31

View File

@@ -1,20 +1,7 @@
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: LOG TO SPREADSHEET
For each Category B email, extract as much of the following information as possible from the email contents and log it using the log_recruiter_contact tool:
A column exists for:
- Recruiter Name
- Recruiter Email
- Recruiter Phone/Tel
- Company/Role the recruiter seeks to fill
- First Contact: the date and time the email was received
- Subsequent Contacts: any follow-up communication with this recruiter about this role
- Recruiter Call Scheduled: if the email contains scheduling information, extract ALL details — date, time, platform (Zoom, MS Teams, Google Meet, etc.), meeting link, phone number to call — and note whether they confirm having your cell number or might not have it
If a row already exists for the same recruiter email + company/role combination (from a previous email), the tool will UPDATE the existing row. Provide all available fields and the tool handles merging.
STEP 2: CREATE CALENDAR EVENTS
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
@@ -27,12 +14,6 @@ For EACH of the above fields that contains a date/time, create a Google Calendar
- 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
STEP 3: COMPANY INTERVIEW TRACKING
When an email indicates advancement beyond the recruiter screen to a company interview:
- Populate the Company First Interview or Company Second Interview field with details
- Those details should include company contact info (interviewer name, email, phone)
- Then immediately create a calendar event for the new interview date/time including interviewer name, email, phone
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.
@@ -40,3 +21,5 @@ STRICT CONSTRAINTS:
- 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.
// revised Aug 31

View File

@@ -109,3 +109,5 @@ server.registerTool(
}
}
);
// revised Aug 31

View File

@@ -358,3 +358,5 @@ server.registerTool(
}
}
);
// revised Aug 31

View File

@@ -177,3 +177,5 @@ server.registerTool(
}
}
);
// revised Aug 31

View File

@@ -165,3 +165,5 @@ export const RAW_BODIES = {
"msg-cat-c-001": catCBody,
"msg-cat-d-001": catDBody,
};
// revised Aug 31

View File

@@ -285,3 +285,5 @@ describe("Phase 1: Email Review Workflow", () => {
});
});
});
// revised Aug 31

View File

@@ -84,3 +84,5 @@ describe("decodeBody", () => {
expect(decodeBody(msg)).toBe(htmlContent);
});
});
// revised Aug 31

View File

@@ -1,15 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "test", "vitest.config.ts"]
}
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"test",
"vitest.config.ts"
]
}

View File

@@ -6,3 +6,5 @@ export default defineConfig({
globals: true,
},
});
// revised Aug 31