[object Object]

← 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 →