← back to George Mcp

index.js

593 lines

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

import { execSync } from "node:child_process";
// BASE_URL = origin only. Path is appended in `george()`. Default uses Tailscale-Serve
// path-routed mount so requests go via HTTPS without needing a port number.
const BASE_URL = (process.env.GEORGE_URL || "https://kamatera.tail79cb8e.ts.net").replace(/\/$/, "");
// Direct-port URLs (e.g. http://host:9850) reach George's app at root → no prefix.
// Bare-hostname URLs go through the Tailscale-Serve reverse proxy → "/george" mount.
const BASE_PATH_PREFIX =
  process.env.GEORGE_PATH_PREFIX ??
  (/:\d{2,5}(\/|$)/.test(BASE_URL) ? "" : "/george");
function loadAuthFromKeychain() {
  try {
    const pw = execSync("security find-generic-password -s dw-agents -a admin -w", { encoding: "utf-8" }).trim();
    return Buffer.from(`admin:${pw}`).toString("base64");
  } catch { return null; }
}
const BASIC_AUTH = process.env.GEORGE_BASIC_AUTH || loadAuthFromKeychain();
if (!BASIC_AUTH) {
  console.error("george-mcp: no auth — set GEORGE_BASIC_AUTH or store in Keychain (security add-generic-password -s dw-agents -a admin -w '<pw>')");
  process.exit(1);
}

async function george(path, { method = "GET", query, body } = {}) {
  // Build URL by concatenation so the path-prefix is preserved.
  const fullPath = BASE_PATH_PREFIX + (path.startsWith("/") ? path : "/" + path);
  const url = new URL(BASE_URL + fullPath);
  if (query) {
    for (const [k, v] of Object.entries(query)) {
      if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
    }
  }
  const res = await fetch(url, {
    method,
    headers: {
      Authorization: `Basic ${BASIC_AUTH}`,
      ...(body ? { "Content-Type": "application/json" } : {}),
      // Human-approval token for George's external-send guard. Sent only when configured,
      // so removing GEORGE_EXTERNAL_SEND_TOKEN from the env re-gates external sends.
      ...(process.env.GEORGE_EXTERNAL_SEND_TOKEN
        ? { "X-Send-Approval": process.env.GEORGE_EXTERNAL_SEND_TOKEN }
        : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  let data;
  try {
    data = JSON.parse(text);
  } catch {
    data = { raw: text };
  }
  if (!res.ok) {
    const err = new Error(
      `George ${method} ${path} → ${res.status} ${res.statusText}: ${
        data.error || text.slice(0, 200)
      }`
    );
    err.status = res.status;
    throw err;
  }
  return data;
}

// Fetch raw binary bytes (attachments) from George. The JSON `george()` helper
// mangles binary via res.text()/JSON.parse, so attachments use this instead.
async function georgeRaw(path, { query } = {}) {
  const fullPath = BASE_PATH_PREFIX + (path.startsWith("/") ? path : "/" + path);
  const url = new URL(BASE_URL + fullPath);
  if (query) {
    for (const [k, v] of Object.entries(query)) {
      if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
    }
  }
  const res = await fetch(url, { headers: { Authorization: `Basic ${BASIC_AUTH}` } });
  if (!res.ok) {
    throw new Error(`George GET ${fullPath} → ${res.status} : ${(await res.text()).slice(0, 200)}`);
  }
  return Buffer.from(await res.arrayBuffer());
}

// George serves five Gmail accounts. Every tool below accepts an optional
// `account` param; omitted => steve-office. Keys match George's resolveAccount().
const ACCOUNTS = ["steve-office", "info", "steve-personal", "stevesclaude", "agentabrams"];
const ACCOUNT_PROP = {
  account: {
    type: "string",
    enum: ACCOUNTS,
    description:
      "Which Gmail account to act on. Default 'steve-office' (steve@designerwallcoverings.com). " +
      "Others: 'info' (info@designerwallcoverings.com), 'steve-personal' (steveabramsdesigns@gmail.com), " +
      "'agentabrams' (theagentabrams@gmail.com), 'stevesclaude'.",
  },
};

const TOOLS = [
  {
    name: "gmail_health",
    description:
      "Check that George (the DW Gmail HTTP agent) is reachable. Returns uptime + scopes. No auth needed on this endpoint.",
    inputSchema: { type: "object", properties: {}, additionalProperties: false },
  },
  {
    name: "gmail_list_messages",
    description:
      "List Gmail messages matching a Gmail search query (same syntax as the Gmail web UI: in:sent, after:YYYY/MM/DD, subject:, has:attachment, etc.). Returns id, threadId, subject, from, to, date, snippet for each match.",
    inputSchema: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description:
            'Gmail search query, e.g. \'in:sent subject:invoice after:2026/04/01\'',
        },
        maxResults: {
          type: "integer",
          minimum: 1,
          maximum: 100,
          default: 25,
        },
        labelIds: {
          type: "string",
          description: "Comma-separated label IDs to restrict the search.",
        },
        pageToken: { type: "string" },
        ...ACCOUNT_PROP,
      },
      additionalProperties: false,
    },
  },
  {
    name: "gmail_search",
    description:
      "Lightweight search alias — same as gmail_list_messages but returns a flatter shape (id, subject, from, date, snippet). Good for quick lookups.",
    inputSchema: {
      type: "object",
      properties: {
        query: { type: "string" },
        maxResults: {
          type: "integer",
          minimum: 1,
          maximum: 100,
          default: 10,
        },
        ...ACCOUNT_PROP,
      },
      required: ["query"],
      additionalProperties: false,
    },
  },
  {
    name: "gmail_get_message",
    description:
      "Fetch a single Gmail message by id (full body + headers + attachment metadata).",
    inputSchema: {
      type: "object",
      properties: { id: { type: "string" }, ...ACCOUNT_PROP },
      required: ["id"],
      additionalProperties: false,
    },
  },
  {
    name: "gmail_get_attachment",
    description:
      "Download a Gmail attachment (matched by filename substring) from a message and save it under /tmp for local processing. Returns the saved path + byte size + mimeType.",
    inputSchema: {
      type: "object",
      properties: {
        id: { type: "string", description: "Gmail message id" },
        filename: { type: "string", description: "Attachment filename or substring to match" },
        save: { type: "string", description: "Absolute path under /tmp/ to write the file to" },
        ...ACCOUNT_PROP,
      },
      required: ["id", "filename", "save"],
      additionalProperties: false,
    },
  },
  {
    name: "gmail_list_labels",
    description: "List all Gmail labels on the connected account.",
    inputSchema: {
      type: "object",
      properties: { ...ACCOUNT_PROP },
      additionalProperties: false,
    },
  },
  {
    name: "gmail_send",
    description:
      "Send an email from one of George's DW Gmail accounts (steve-office / steve@designerwallcoverings.com by default — pass `account` to send from another). Body is HTML. To reply IN-THREAD to a previous George-sent message, pass `replyToMessageId` (Gmail message id) — George will resolve the thread, set In-Reply-To/References, and prefix Re: as needed. `subject` becomes optional in that case.",
    inputSchema: {
      type: "object",
      properties: {
        to: { type: "string" },
        subject: { type: "string" },
        body: { type: "string", description: "HTML body" },
        cc: { type: "string" },
        bcc: { type: "string" },
        replyToMessageId: {
          type: "string",
          description: "Gmail message id of the prior message to reply to. When set, Claude is replying in-thread; subject + In-Reply-To/References + threadId are auto-resolved.",
        },
        threadId: {
          type: "string",
          description: "Gmail thread id. Use to land the message in a known thread when you don't want George to look it up.",
        },
        inReplyTo: {
          type: "string",
          description: "RFC Message-Id header value (with angle brackets) of the message being replied to. Bypasses replyToMessageId resolution.",
        },
        ...ACCOUNT_PROP,
      },
      required: ["to", "body"],
      additionalProperties: false,
    },
  },
  {
    name: "gmail_send_attachment",
    description:
      "Send an email WITH one or more file attachments from a DW Gmail account (steve-office / steve@designerwallcoverings.com by default — pass account:'info' to send from info@). Body is HTML. `attachments` are LOCAL filesystem paths — the bridge reads each file, base64-encodes it, and posts to George's /api/send-with-attachment. Use this instead of gmail_send whenever a file must be attached.",
    inputSchema: {
      type: "object",
      properties: {
        to: { type: "string" },
        subject: { type: "string" },
        body: { type: "string", description: "HTML body" },
        cc: { type: "string" },
        bcc: { type: "string" },
        attachments: {
          type: "array",
          description: "Local filesystem paths to attach.",
          items: { type: "string" },
        },
        ...ACCOUNT_PROP,
      },
      required: ["to", "subject", "body", "attachments"],
      additionalProperties: false,
    },
  },
  {
    name: "gmail_create_draft",
    description:
      "Create a Gmail draft. A DRAFT IS NOT A SENT EMAIL — nobody receives it and no task is " +
      "completed by creating one. It parks text in Steve's mailbox until Steve personally sends it. " +
      "Never report a task as done, and never mark a ticket resolved or waiting-on-the-other-party, " +
      "because a draft was created. " +
      "DEDUPE IS ENFORCED: this tool first searches for existing drafts to the same recipient and " +
      "REFUSES if it finds any, returning them to you. Revise the existing draft with " +
      "gmail_update_draft instead of composing a second one; pass acknowledge_existing:true only " +
      "when a genuinely separate email to that recipient is intended.",
    inputSchema: {
      type: "object",
      properties: {
        to: { type: "string" },
        subject: { type: "string" },
        body: { type: "string", description: "HTML body" },
        cc: { type: "string" },
        bcc: { type: "string" },
        acknowledge_existing: {
          type: "boolean",
          description:
            "Set true ONLY after reviewing the existing drafts this tool returned to you and " +
            "deciding a genuinely separate email is warranted. Prefer gmail_update_draft.",
        },
        ...ACCOUNT_PROP,
      },
      required: ["to", "subject", "body"],
      additionalProperties: false,
    },
  },
  {
    name: "gmail_list_drafts",
    description:
      "List unsent drafts, newest first, with each draft's AGE IN DAYS and whether it was " +
      "agent-composed (agent drafts carry a 'From job:' banner). Use this BEFORE composing any " +
      "email so you revise your own prior work instead of piling a duplicate beside it, and to " +
      "see what is sitting unsent. Optional `query` narrows with Gmail operators, " +
      "e.g. 'to:vendor@example.com' or 'older_than:7d'.",
    inputSchema: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description: "Extra Gmail search terms ANDed with in:draft (e.g. \"to:info@vahallan.com\").",
        },
        maxResults: { type: "number", description: "Default 25." },
        ...ACCOUNT_PROP,
      },
      additionalProperties: false,
    },
  },
  {
    name: "gmail_update_draft",
    description:
      "Revise an EXISTING draft in place (replaces its recipient/subject/body). Use this instead of " +
      "creating a second draft when one already exists for the same ask — that duplicate-composition " +
      "pattern is what produced the unsent-draft backlog. Takes the DRAFT id (looks like " +
      "'r-5804208907435628955'), which gmail_list_drafts returns as `draftId`. The overwritten " +
      "to/subject/date come back as `previous` so a bad edit is recoverable. " +
      "Updating still does NOT send: the result stays awaiting_owner_send.",
    inputSchema: {
      type: "object",
      properties: {
        draftId: { type: "string", description: "The DRAFT id (e.g. 'r-5804…'), not the message id." },
        to: { type: "string" },
        subject: { type: "string" },
        body: { type: "string", description: "HTML body" },
        cc: { type: "string" },
        bcc: { type: "string" },
        ...ACCOUNT_PROP,
      },
      required: ["draftId", "subject", "body"],
      additionalProperties: false,
    },
  },
  {
    name: "gmail_profile",
    description:
      "Return profile info (email address, total messages, total threads) for the connected account.",
    inputSchema: {
      type: "object",
      properties: { ...ACCOUNT_PROP },
      additionalProperties: false,
    },
  },
];

// Dedupe adds a Gmail search to every create, and Gmail's per-minute "Total Query Cost"
// quota is bursty — a blast of drafts to different recipients can trip it. Since the check
// now fails CLOSED, an un-retried blip would hard-block legitimate drafting. Retry once.
async function searchDraftsTo(to, account) {
  const q = `in:draft to:${to}`;
  try {
    return await george("/api/search", { query: { q, maxResults: 10, account } });
  } catch (e) {
    if (!/quota|rate limit|429|userRateLimit/i.test(e.message)) throw e;
    await new Promise((r) => setTimeout(r, 2500));
    return await george("/api/search", { query: { q, maxResults: 10, account } });
  }
}

// ─── Draft return-contract helpers (TK-11231) ───────────────────────────────
// A draft is NOT a delivered outcome. The deepest cause of the unsent-draft
// backlog was that creating a draft returned a plain success, so assistants
// reported the task done and tickets recorded "waiting on the vendor" for
// weeks while the ask had never left Drafts. These make the non-terminal
// state explicit in the payload the model actually reads back.
function asAwaitingSend(data, verb) {
  return {
    ...data,
    status: "awaiting_owner_send",
    delivered: false,
    recipient_has_not_received_this: true,
    next_action: `Steve must open and send this draft. Until he does, nothing has reached the recipient.`,
    warning:
      `Draft ${verb} — NOT SENT. Do not report this task complete, do not close or resolve a ticket, ` +
      `and do not record the state as "waiting on the other party": they have not been contacted. ` +
      `Surface the draft to Steve as an action he still owes.`,
  };
}

// drafts.list is the ONLY source of DRAFT ids (search returns message ids) but it is an
// expensive Gmail call — calling it per-tool-invocation exhausted the account's
// "Units per minute" quota during testing, which silently disabled dedupe. Cache it.
const DRAFT_ID_TTL_MS = 60_000;
const draftIdCache = new Map(); // account -> { at, map }
async function draftIdMap(account) {
  const key = account || "steve-office";
  const hit = draftIdCache.get(key);
  if (hit && Date.now() - hit.at < DRAFT_ID_TTL_MS) return hit.map;
  const raw = await george("/api/drafts", { query: { maxResults: 250, account } });
  const map = {};
  for (const d of raw || []) if (d?.message?.id) map[d.message.id] = d.id;
  draftIdCache.set(key, { at: Date.now(), map });
  return map;
}
// Any create/update changes the draft set, so the cached id map is immediately stale.
// Observed live: a draft created seconds earlier came back from gmail_list_drafts with
// draftId:null, which made it un-updatable — i.e. the duplicate hole reopens.
function invalidateDraftIds(account) {
  draftIdCache.delete(account || "steve-office");
}

function enrichDraft(m, byMessageId = {}) {
  const parsed = m.date ? Date.parse(m.date) : NaN;
  const ageDays = Number.isNaN(parsed)
    ? null
    : Math.floor((Date.now() - parsed) / 86400000);
  return {
    draftId: byMessageId[m.id] || null,
    messageId: m.id,
    subject: m.subject,
    from: m.from,
    date: m.date,
    age_days: ageDays,
    // George stamps agent-composed mail with a "From job:" banner (withSourceFooter).
    agent_composed: /From job:/i.test(m.snippet || ""),
    snippet: (m.snippet || "").slice(0, 160),
  };
}

const server = new Server(
  { name: "george", version: "0.1.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args = {} } = req.params;
  // account flows to George as ?account= on GETs and inside the body on POSTs
  // (George's resolveAccount() reads req.query.account || req.body.account).
  const account = args.account;
  try {
    let data;
    switch (name) {
      case "gmail_health":
        data = await george("/api/health");
        break;
      case "gmail_list_messages":
        data = await george("/api/messages", {
          query: {
            q: args.query,
            maxResults: args.maxResults ?? 25,
            labelIds: args.labelIds,
            pageToken: args.pageToken,
            account,
          },
        });
        break;
      case "gmail_search":
        data = await george("/api/search", {
          query: { q: args.query, maxResults: args.maxResults ?? 10, account },
        });
        break;
      case "gmail_get_message":
        data = await george(`/api/messages/${encodeURIComponent(args.id)}`, {
          query: { account },
        });
        break;
      case "gmail_get_attachment": {
        // George may be REMOTE (Kamatera), so we cannot ask it to save server-side.
        // Resolve the attachmentId from the message, fetch raw bytes, write LOCALLY.
        const msg = await george(`/api/messages/${encodeURIComponent(args.id)}`, { query: { account } });
        const atts = msg.attachments || [];
        const want = String(args.filename || "").toLowerCase();
        const match = atts.find((a) => (a.filename || "").toLowerCase().includes(want));
        if (!match) {
          throw new Error(
            `No attachment matching "${args.filename}" on message ${args.id}. Available: ${atts.map((a) => a.filename).join(", ") || "(none)"}`
          );
        }
        // Account-aware attachment route (mirrors /api/messages/:id?account=…).
        const buf = await georgeRaw(
          `/api/messages/${encodeURIComponent(args.id)}/attachments/${encodeURIComponent(match.attachmentId)}`,
          { query: { account } }
        );
        const fs = await import("node:fs");
        fs.writeFileSync(args.save, buf);
        data = { saved: args.save, bytes: buf.length, mimeType: match.mimeType, filename: match.filename };
        break;
      }
      case "gmail_list_labels":
        data = await george("/api/labels", { query: { account } });
        break;
      case "gmail_send":
        data = await george("/api/send", { method: "POST", body: args });
        break;
      case "gmail_send_attachment": {
        const fs = await import("node:fs");
        const path = await import("node:path");
        const MIME = {
          ".pdf": "application/pdf",
          ".doc": "application/msword",
          ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
          ".xls": "application/vnd.ms-excel",
          ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
          ".png": "image/png",
          ".jpg": "image/jpeg",
          ".jpeg": "image/jpeg",
          ".gif": "image/gif",
          ".txt": "text/plain",
          ".csv": "text/csv",
          ".zip": "application/zip",
        };
        const paths = (Array.isArray(args.attachments) ? args.attachments : [args.attachments]).filter(Boolean);
        const attachments = paths.map((p) => {
          const ext = path.extname(p).toLowerCase();
          return {
            filename: path.basename(p),
            content_base64: fs.readFileSync(p).toString("base64"),
            mime_type: MIME[ext] || "application/octet-stream",
          };
        });
        data = await george("/api/send-with-attachment", {
          method: "POST",
          body: { to: args.to, cc: args.cc, bcc: args.bcc, subject: args.subject, body: args.body, account, attachments },
        });
        break;
      }
      case "gmail_list_drafts": {
        const q = ["in:draft", args.query].filter(Boolean).join(" ");
        const res = await george("/api/search", {
          query: { q, maxResults: args.maxResults ?? 25, account },
        });
        // gmail_update_draft needs the DRAFT id, which search does not return — join on message id.
        let byMessageId = {};
        let joinNote;
        try {
          byMessageId = await draftIdMap(account);
        } catch (e) {
          joinNote = `draftId unavailable (${e.message.slice(0, 120)}) — subjects/ages below are still accurate; re-run for ids.`;
        }
        data = {
          total_drafts_matching: res.total,
          drafts: (res.messages || []).map((m) => enrichDraft(m, byMessageId)),
          reminder: "Drafts are UNSENT. Age is how long a written answer has gone undelivered.",
          ...(joinNote ? { note: joinNote } : {}),
        };
        break;
      }
      case "gmail_update_draft": {
        const updated = await george(`/api/drafts/${encodeURIComponent(args.draftId)}`, {
          method: "PUT",
          body: args,
        });
        invalidateDraftIds(account);
        data = asAwaitingSend(updated, "updated");
        break;
      }
      case "gmail_create_draft": {
        // QUERY-BEFORE-WRITE. Composing without checking is exactly how the same vendor ask
        // got drafted twice (Command, Maya Romanoff, Newmor) — TK-11231.
        if (!args.acknowledge_existing && args.to) {
          let existing;
          try {
            existing = (await searchDraftsTo(args.to, account)).messages || [];
          } catch (e) {
            // FAIL CLOSED. Swallowing this is what silently re-opens the duplicate hole:
            // during testing a Gmail per-minute quota error made the check a no-op and a
            // duplicate vendor draft was created reporting plain success.
            throw new Error(
              `REFUSED — could not verify whether a draft to ${args.to} already exists ` +
                `(duplicate check failed: ${e.message.slice(0, 160)}). ` +
                `Refusing rather than risk composing a duplicate. ` +
                `Retry shortly, or pass acknowledge_existing:true if you have checked by hand.`
            );
          }
          if (existing.length) {
            let byMessageId = {};
            try { byMessageId = await draftIdMap(account); } catch { /* ids are a convenience here */ }
            throw new Error(
              `REFUSED — ${existing.length} unsent draft(s) already addressed to ${args.to}. ` +
                `Creating another would duplicate work that is already sitting unsent.\n` +
                JSON.stringify(existing.map((m) => enrichDraft(m, byMessageId)), null, 2) +
                `\n\nDo ONE of:\n` +
                `  1. Revise the existing draft: gmail_update_draft { draftId: "<draftId above>", … }\n` +
                `  2. If a genuinely separate email is intended, re-call gmail_create_draft with ` +
                `acknowledge_existing: true.`
            );
          }
        }
        const createdDraft = await george("/api/drafts", { method: "POST", body: args });
        invalidateDraftIds(account);
        data = asAwaitingSend(createdDraft, "created");
        break;
      }
      case "gmail_profile":
        data = await george("/api/profile", { query: { account } });
        break;
      default:
        throw new Error(`Unknown tool: ${name}`);
    }
    return {
      content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
    };
  } catch (e) {
    return {
      isError: true,
      content: [{ type: "text", text: e.message }],
    };
  }
});

const transport = new StdioServerTransport();
await server.connect(transport);