← back to George Mcp

index.js

396 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 instead of sending immediately.",
    inputSchema: {
      type: "object",
      properties: {
        to: { type: "string" },
        subject: { type: "string" },
        body: { type: "string", description: "HTML body" },
        cc: { type: "string" },
        bcc: { type: "string" },
        ...ACCOUNT_PROP,
      },
      required: ["to", "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,
    },
  },
];

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_create_draft":
        data = await george("/api/drafts", { method: "POST", body: args });
        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);