← 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);