Merge pull request #21 from apache/rbowen-api-update

Fix get_thread, add get_source, make API suffix configurable
diff --git a/mcp/ponymail-mcp/README.md b/mcp/ponymail-mcp/README.md
index cab6e8a..405900a 100644
--- a/mcp/ponymail-mcp/README.md
+++ b/mcp/ponymail-mcp/README.md
@@ -9,7 +9,8 @@
 | `list_lists` | Get an overview of all available mailing lists and message counts |
 | `search_list` | Search/browse a mailing list with filters (date, sender, subject, body, query) |
 | `get_email` | Fetch a specific email by ID with full body and attachments |
-| `get_thread` | Fetch the root message of a thread by thread ID |
+| `get_thread` | Fetch a complete email thread (full tree + flat message list). Supports `find_parent` to navigate to thread root from any reply. |
+| `get_source` | Fetch the raw RFC 2822 source of an email (original headers, MIME structure, encoded body) |
 | `get_mbox` | Download mbox-formatted archive data for bulk export |
 | `login` | Authenticate via ASF OAuth to access private mailing lists |
 | `logout` | Clear cached session cookie |
@@ -37,6 +38,7 @@
 | Variable | Default | Description |
 |----------|---------|-------------|
 | `PONYMAIL_BASE_URL` | `https://lists.apache.org` | Base URL of the PonyMail instance |
+| `PONYMAIL_API_SUFFIX` | `.json` | API endpoint suffix and method selector. `.json` (default) = POST with JSON body (native Foal). `.lua` = GET with query params (legacy compat for older deployments). |
 | `PONYMAIL_SESSION_COOKIE` | *(none)* | Manual session cookie override (skips OAuth flow) |
 | `PONYMAIL_RESTRICTED_LISTS` | *(see below)* | Comma-separated patterns to block pre-fetch. Set to `none` to clear pattern blocks. |
 | `PONYMAIL_ALLOWED_LISTS` | *(none)* | Comma-separated opt-in patterns. Lists matching these bypass all blocks. |
@@ -223,4 +225,7 @@
 - "Search the dev@iceberg.apache.org list for messages about partition spec in the last 30 days"
 - "Show me the available mailing lists"
 - "Fetch email with ID xyz..."
+- "Get the full thread for this email, navigating to the root"
+- "Show me the raw source of that email"
+- "What restrictions are currently active?"
 - "Get the mbox archive for dev@httpd.apache.org for 2024-03"
diff --git a/mcp/ponymail-mcp/index.js b/mcp/ponymail-mcp/index.js
index e30093c..9c531b7 100644
--- a/mcp/ponymail-mcp/index.js
+++ b/mcp/ponymail-mcp/index.js
@@ -34,6 +34,10 @@
 
 const BASE_URL = process.env.PONYMAIL_BASE_URL || "https://lists.apache.org";
 
+// API endpoint suffix and method selector. ".json" (default) uses POST with
+// JSON body (native Foal). ".lua" uses GET with query params (legacy compat).
+const API_SUFFIX = process.env.PONYMAIL_API_SUFFIX || ".json";
+
 // Loud, opt-in security notice. When the user has enabled the auto-extract
 // path via PONYMAIL_AUTO_EXTRACT_COOKIE=1, remind them at every startup what
 // they have just granted the MCP process. Goes to stderr so it shows up in
@@ -63,24 +67,37 @@
 // ---------------------------------------------------------------------------
 
 async function apiFetch(path, params = {}) {
-  const url = new URL(path, BASE_URL);
-  for (const [k, v] of Object.entries(params)) {
-    if (v !== undefined && v !== null && v !== "") {
-      url.searchParams.set(k, String(v));
-    }
-  }
-
   // Build headers — include session cookie if available
   const headers = { Accept: "application/json" };
-
-  // Priority: env var > cached session file
   const envCookie = process.env.PONYMAIL_SESSION_COOKIE;
   const sessionCookie = envCookie || loadSession();
   if (sessionCookie) {
     headers.Cookie = sessionCookie;
   }
 
-  const resp = await fetch(url.toString(), { headers });
+  let resp;
+
+  if (API_SUFFIX === ".json") {
+    // .json endpoints expect POST with JSON body
+    const url = new URL(path, BASE_URL);
+    headers["Content-Type"] = "application/json";
+    const body = {};
+    for (const [k, v] of Object.entries(params)) {
+      if (v !== undefined && v !== null && v !== "") {
+        body[k] = v;
+      }
+    }
+    resp = await fetch(url.toString(), { method: "POST", headers, body: JSON.stringify(body) });
+  } else {
+    // .lua endpoints use GET with query parameters
+    const url = new URL(path, BASE_URL);
+    for (const [k, v] of Object.entries(params)) {
+      if (v !== undefined && v !== null && v !== "") {
+        url.searchParams.set(k, String(v));
+      }
+    }
+    resp = await fetch(url.toString(), { headers });
+  }
 
   if (!resp.ok) {
     const body = await resp.text().catch(() => "");
@@ -136,7 +153,7 @@
   {},
   { readOnlyHint: true },
   async () => {
-    const data = await apiFetch("/api/preferences.lua");
+    const data = await apiFetch(`/api/preferences${API_SUFFIX}`);
     const lists = data.lists || {};
     const descriptions = data.descriptions || {};
 
@@ -224,7 +241,7 @@
     if (quick) params.quick = "1";
     if (emails_only) params.emailsOnly = "1";
 
-    const data = await apiFetch("/api/stats.lua", params);
+    const data = await apiFetch(`/api/stats${API_SUFFIX}`, params);
 
     // Defence-in-depth: PonyMail tags private lists with `private: true`.
     // Block unless the caller has opted in via PONYMAIL_ALLOWED_LISTS.
@@ -322,7 +339,7 @@
   },
   { readOnlyHint: true },
   async ({ id }) => {
-    const data = await apiFetch("/api/email.lua", { id });
+    const data = await apiFetch(`/api/email${API_SUFFIX}`, { id });
 
     const { list, domain } = extractListDomain(data);
     const restricted = list && domain ? restrictionFor(list, domain) : null;
@@ -370,16 +387,19 @@
 // --- Tool: get_thread -------------------------------------------------------
 server.tool(
   "get_thread",
-  "Fetch all emails in a thread. Provide the thread ID (tid) from a search result " +
-    "or email. Returns the thread as a flat list of email summaries.",
+  "Fetch all emails in a thread. Provide the email ID (mid/permalink) from a " +
+    "search result or email. Returns the full threaded tree and a flat list of " +
+    "all emails in the conversation. Optionally use find_parent to navigate up " +
+    "to the thread root from any message in the thread.",
   {
-    id: z.string().describe("The thread ID (tid)"),
-    list: z.string().describe("List prefix, e.g. 'dev', 'user'"),
-    domain: z.string().describe("List domain, e.g. 'iceberg.apache.org'"),
+    id: z.string().describe("The email ID (mid/permalink) — the thread root or any message in the thread"),
+    list: z.string().optional().describe("List prefix for restriction checks, e.g. 'dev', 'user'"),
+    domain: z.string().optional().describe("List domain for restriction checks, e.g. 'iceberg.apache.org'"),
+    find_parent: z.boolean().optional().describe("If true, navigate up to the thread root before fetching the full thread"),
   },
   { readOnlyHint: true },
-  async ({ id, list, domain }) => {
-    const restrictedUp = restrictionFor(list, domain);
+  async ({ id, list, domain, find_parent }) => {
+    const restrictedUp = list && domain ? restrictionFor(list, domain) : null;
     if (restrictedUp) {
       return {
         content: [{ type: "text", text: restrictionError(list, domain, restrictedUp) }],
@@ -387,14 +407,12 @@
       };
     }
 
-    // Use stats endpoint scoped to a very wide range and filter by tid
-    // PonyMail doesn't have a dedicated thread endpoint, but we can fetch
-    // the root email which contains thread_struct, then fetch each child.
-    const root = await apiFetch("/api/email.lua", { id });
+    // Use the dedicated thread endpoint which returns the full tree + flat emails
+    const params = { id };
+    if (find_parent) params.find_parent = "true";
+    const data = await apiFetch(`/api/thread${API_SUFFIX}`, params);
 
-    // Defence-in-depth: if the returned email belongs to a restricted list
-    // (e.g. caller passed a mismatched list/domain), block anyway.
-    const { list: rList, domain: rDomain } = extractListDomain(root);
+    const { list: rList, domain: rDomain } = extractListDomain(data?.thread || {});
     const restrictedAfter = rList && rDomain ? restrictionFor(rList, rDomain) : null;
     if (restrictedAfter) {
       return {
@@ -402,20 +420,40 @@
         isError: true,
       };
     }
-    if (isPrivateBlocked(rList || list, rDomain || domain, root?.private)) {
+    if (isPrivateBlocked(rList || list, rDomain || domain, data?.thread?.private)) {
       return {
         content: [{ type: "text", text: privateError(rList || list, rDomain || domain) }],
         isError: true,
       };
     }
 
+    const thread = data?.thread || {};
+    const emails = data?.emails || [];
+
     const lines = [];
-    lines.push(`# Thread: ${root.subject || "(no subject)"}`);
-    lines.push(`Root From: ${root.from} | Date: ${root.date}`);
-    lines.push(`List: ${root.list || root.list_raw}`);
+    lines.push(`# Thread: ${thread.subject || "(no subject)"}`);
+    lines.push(`Root From: ${thread.from || "(unknown)"} | Date: ${thread.date || new Date((thread.epoch || 0) * 1000).toISOString().slice(0, 10)}`);
+    lines.push(`List: ${thread.list || thread.list_raw || "(unknown)"}`);
+    lines.push(`Messages in thread: ${emails.length}`);
     lines.push("");
-    lines.push("## Root Message");
-    lines.push(truncate(root.body, 4000));
+
+    // Render flat email list for easy consumption
+    lines.push("## Messages");
+    for (const e of emails) {
+      const date = e.date || new Date((e.epoch || 0) * 1000).toISOString().slice(0, 10);
+      const indent = e.id === thread.id ? "" : "  "; // indent replies
+      lines.push(`${indent}### ${e.subject || "(no subject)"}`);
+      lines.push(`${indent}From: ${e.from || "(unknown)"} | Date: ${date} | ID: ${e.id || e.mid || "(no id)"}`);
+      if (e["in-reply-to"]) lines.push(`${indent}In-Reply-To: ${e["in-reply-to"]}`);
+      lines.push(`${indent}${truncate(e.body || e.body_short || "(no body)", 2000)}`);
+      lines.push("");
+    }
+
+    // If no emails were returned (maybe API gave just the tree), show root body
+    if (emails.length === 0 && thread.body) {
+      lines.push("## Root Message");
+      lines.push(truncate(thread.body, 4000));
+    }
 
     return {
       content: [{ type: "text", text: lines.join("\n") }],
@@ -423,6 +461,67 @@
   }
 );
 
+// --- Tool: get_source -------------------------------------------------------
+server.tool(
+  "get_source",
+  "Fetch the raw RFC 2822 source of an email (original headers + body). " +
+    "Useful for debugging email parsing issues, verifying headers, or " +
+    "extracting information not available in the parsed view.",
+  {
+    id: z.string().describe("The email ID (mid/permalink) or Message-ID header value"),
+  },
+  { readOnlyHint: true },
+  async ({ id }) => {
+    // First fetch the email metadata to check restrictions
+    const email = await apiFetch(`/api/email${API_SUFFIX}`, { id });
+
+    const { list, domain } = extractListDomain(email);
+    const restricted = list && domain ? restrictionFor(list, domain) : null;
+    if (restricted) {
+      return {
+        content: [{ type: "text", text: restrictionError(list, domain, restricted) }],
+        isError: true,
+      };
+    }
+    if (isPrivateBlocked(list, domain, email?.private)) {
+      return {
+        content: [{ type: "text", text: privateError(list, domain) }],
+        isError: true,
+      };
+    }
+
+    // Fetch the raw source
+    const url = new URL(`/api/source${API_SUFFIX}`, BASE_URL);
+    const headers = { Accept: "text/plain" };
+    const envCookie = process.env.PONYMAIL_SESSION_COOKIE;
+    const sessionCookie = envCookie || loadSession();
+    if (sessionCookie) headers.Cookie = sessionCookie;
+
+    let resp;
+    if (API_SUFFIX === ".json") {
+      headers["Content-Type"] = "application/json";
+      resp = await fetch(url.toString(), {
+        method: "POST",
+        headers,
+        body: JSON.stringify({ id }),
+      });
+    } else {
+      url.searchParams.set("id", id);
+      resp = await fetch(url.toString(), { headers });
+    }
+
+    if (!resp.ok) {
+      const body = await resp.text().catch(() => "");
+      throw new Error(`PonyMail API error ${resp.status}: ${body}`);
+    }
+    const source = await resp.text();
+
+    return {
+      content: [{ type: "text", text: truncate(source, 12000) }],
+    };
+  }
+);
+
 // --- Tool: get_mbox ---------------------------------------------------------
 server.tool(
   "get_mbox",
@@ -453,7 +552,7 @@
     // marks this list as private. Skipped when the list is allow-listed.
     if (lp && dp && !allowedFor(lp, dp)) {
       try {
-        const meta = await apiFetch("/api/stats.lua", { list: lp, domain: dp, quick: "1" });
+        const meta = await apiFetch(`/api/stats${API_SUFFIX}`, { list: lp, domain: dp, quick: "1" });
         if (isPrivateBlocked(lp, dp, meta?.private)) {
           return {
             content: [{ type: "text", text: privateError(lp, dp) }],
@@ -474,7 +573,7 @@
       header_subject: subject,
     };
 
-    const data = await apiFetch("/api/mbox.lua", params);
+    const data = await apiFetch(`/api/mbox${API_SUFFIX}`, params);
     const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
 
     return {
@@ -575,10 +674,19 @@
 
     // Validate the cookie
     try {
-      const url = new URL("/api/preferences.lua", BASE_URL);
-      const resp = await fetch(url.toString(), {
-        headers: { Accept: "application/json", Cookie: sessionCookie },
-      });
+      const url = new URL(`/api/preferences${API_SUFFIX}`, BASE_URL);
+      const headers = { Accept: "application/json", Cookie: sessionCookie };
+      let resp;
+      if (API_SUFFIX === ".json") {
+        headers["Content-Type"] = "application/json";
+        resp = await fetch(url.toString(), {
+          method: "POST",
+          headers,
+          body: JSON.stringify({}),
+        });
+      } else {
+        resp = await fetch(url.toString(), { headers });
+      }
       const data = await resp.json();
 
       if (data.login && data.login.credentials) {