Fix get_thread, add get_source, make API suffix configurable The original MCP was built from an outdated and inaccurate version of the PonyMail API documentation. With accurate docs now available (see apache/incubator-ponymail-foal#313), update the MCP to match the actual Foal API: - get_thread: Use the dedicated /api/thread endpoint instead of fetching a single email via /api/email and hoping for the best. Now returns the full threaded tree + flat email array. Add find_parent parameter to navigate to thread root from any message. - get_source: New tool — fetches raw RFC 2822 email source via /api/source. Includes restriction/privacy checks. - API suffix: New PONYMAIL_API_SUFFIX env var (default ".lua"). Set to ".json" for native Foal deployments without the legacy compat layer. All endpoint paths now use the configurable suffix.
diff --git a/mcp/ponymail-mcp/index.js b/mcp/ponymail-mcp/index.js index e30093c..5159a68 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. The legacy compat layer uses ".lua" (default), +// while native Foal deployments use ".json". +const API_SUFFIX = process.env.PONYMAIL_API_SUFFIX || ".lua"; + // 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 @@ -136,7 +140,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 +228,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 +326,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 +374,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 +394,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 +407,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 +448,56 @@ } ); +// --- 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); + url.searchParams.set("id", id); + const headers = { Accept: "text/plain" }; + const envCookie = process.env.PONYMAIL_SESSION_COOKIE; + const sessionCookie = envCookie || loadSession(); + if (sessionCookie) headers.Cookie = sessionCookie; + + const 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 +528,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 +549,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,7 +650,7 @@ // Validate the cookie try { - const url = new URL("/api/preferences.lua", BASE_URL); + const url = new URL(`/api/preferences${API_SUFFIX}`, BASE_URL); const resp = await fetch(url.toString(), { headers: { Accept: "application/json", Cookie: sessionCookie }, });