← back to George Mcp
george-mcp: drafts are no longer a terminal success — TK-11231
accb909dd31fb26f6effe5c82658d18c920ec42e · 2026-09-10 07:56:08 -0700 · Steve Abrams
Three causal failures in the record, three fixes (DTD-4 verdict, 2026-09-04):
1. FALSE COMPLETION SIGNALLING (the deepest cause). create_draft returned a
plain success, so assistants reported the task done and tickets recorded
'waiting on the vendor' for 17 days while the ask had never left Drafts.
Draft results now carry status=awaiting_owner_send, delivered=false,
recipient_has_not_received_this=true and an explicit instruction not to
close a ticket or record waiting-on-the-other-party.
2. DUPLICATE COMPOSITION. The same vendor ask was drafted twice three times
over (Command, Maya Romanoff, Newmor) because an agent could not see or
revise its own prior draft. Adds gmail_list_drafts (with age_days and an
agent_composed flag) and gmail_update_draft (revise in place), and makes
create_draft query-before-write: it REFUSES when unsent drafts to that
recipient already exist, handing them back with their draftIds.
3. Dedupe FAILS CLOSED. Caught by live test: a Gmail per-minute quota error
made the check a silent no-op and a duplicate vendor draft was created
reporting success. An unverifiable check now refuses instead of guessing.
Also: cache the expensive drafts.list id-join (60s TTL) since calling it per
invocation is what exhausted the quota, and invalidate it on create/update —
without that a draft created seconds earlier came back with draftId:null,
i.e. un-updatable, reopening the duplicate hole.
Verified end-to-end over real stdio JSON-RPC against live George: refusal
fires (isError), update replaces in place leaving exactly one artifact, and
every test draft created was deleted (counts back to 136/41 baseline).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTxE4MgnygQ9EY2rPZvtcK
Files touched
Diff
commit accb909dd31fb26f6effe5c82658d18c920ec42e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 10 07:56:08 2026 -0700
george-mcp: drafts are no longer a terminal success — TK-11231
Three causal failures in the record, three fixes (DTD-4 verdict, 2026-09-04):
1. FALSE COMPLETION SIGNALLING (the deepest cause). create_draft returned a
plain success, so assistants reported the task done and tickets recorded
'waiting on the vendor' for 17 days while the ask had never left Drafts.
Draft results now carry status=awaiting_owner_send, delivered=false,
recipient_has_not_received_this=true and an explicit instruction not to
close a ticket or record waiting-on-the-other-party.
2. DUPLICATE COMPOSITION. The same vendor ask was drafted twice three times
over (Command, Maya Romanoff, Newmor) because an agent could not see or
revise its own prior draft. Adds gmail_list_drafts (with age_days and an
agent_composed flag) and gmail_update_draft (revise in place), and makes
create_draft query-before-write: it REFUSES when unsent drafts to that
recipient already exist, handing them back with their draftIds.
3. Dedupe FAILS CLOSED. Caught by live test: a Gmail per-minute quota error
made the check a silent no-op and a duplicate vendor draft was created
reporting success. An unverifiable check now refuses instead of guessing.
Also: cache the expensive drafts.list id-join (60s TTL) since calling it per
invocation is what exhausted the quota, and invalidate it on create/update —
without that a draft created seconds earlier came back with draftId:null,
i.e. un-updatable, reopening the duplicate hole.
Verified end-to-end over real stdio JSON-RPC against live George: refusal
fires (isError), update replaces in place leaving exactly one artifact, and
every test draft created was deleted (counts back to 136/41 baseline).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTxE4MgnygQ9EY2rPZvtcK
---
index.js | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 189 insertions(+), 3 deletions(-)
diff --git a/index.js b/index.js
index 0e237f0..e9da4da 100755
--- a/index.js
+++ b/index.js
@@ -245,7 +245,15 @@ const TOOLS = [
},
{
name: "gmail_create_draft",
- description: "Create a Gmail draft instead of sending immediately.",
+ 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: {
@@ -254,12 +262,63 @@ const TOOLS = [
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:
@@ -272,6 +331,66 @@ const TOOLS = [
},
];
+// ─── 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: {} } }
@@ -371,9 +490,76 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
});
break;
}
- case "gmail_create_draft":
- data = await george("/api/drafts", { method: "POST", body: args });
+ 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 {
+ const dupes = await george("/api/search", {
+ query: { q: `in:draft to:${args.to}`, maxResults: 10, account },
+ });
+ existing = dupes.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;
← 67b3cb6 chore: lint, refactor, v0.2.0 (session close)
·
back to George Mcp
·
george-mcp: retry once on a Gmail quota error before fail-cl 4cdbff5 →