first meow

This commit is contained in:
KS Jannette
2026-02-17 08:30:08 -05:00
commit 77ac2feb98
9 changed files with 2787 additions and 0 deletions

92
src/auth.ts Normal file
View 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
View 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);
});

View 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