potiuk commented on code in PR #2:
URL: https://github.com/apache/comdev/pull/2#discussion_r3142190573


##########
mcp/ponymail-mcp/index.js:
##########
@@ -0,0 +1,486 @@
+#!/usr/bin/env node
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from 
"@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+import { loadSession, performLogin, clearSession } from "./auth.js";
+import {
+  restrictionFor,
+  restrictionForAddress,
+  restrictionError,
+  listRestrictions,
+} from "./restrictions.js";
+
+const BASE_URL = process.env.PONYMAIL_BASE_URL || "https://lists.apache.org";;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+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 !== "") {

Review Comment:
   **Bug — empty-string params are silently dropped.** The filter `v !== ""` 
here means any flag-style param assigned `""` further down (e.g. `params.quick 
= ""`, `params.emailsOnly = ""` at lines 156–157) never reaches the URL. Fix: 
send `"1"` (or `"true"`) for boolean flags, or relax this filter to `v !== 
undefined && v !== null`.



##########
mcp/ponymail-mcp/index.js:
##########
@@ -0,0 +1,486 @@
+#!/usr/bin/env node
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from 
"@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+import { loadSession, performLogin, clearSession } from "./auth.js";
+import {
+  restrictionFor,
+  restrictionForAddress,
+  restrictionError,
+  listRestrictions,
+} from "./restrictions.js";
+
+const BASE_URL = process.env.PONYMAIL_BASE_URL || "https://lists.apache.org";;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+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 });
+
+  if (!resp.ok) {
+    const body = await resp.text().catch(() => "");
+    throw new Error(`PonyMail API error ${resp.status}: ${body}`);
+  }
+
+  const contentType = resp.headers.get("content-type") || "";
+  if (contentType.includes("application/json")) {
+    return await resp.json();
+  }
+  // mbox endpoint returns text
+  return await resp.text();
+}
+
+function truncate(text, max = 4000) {
+  if (!text || text.length <= max) return text;
+  return text.slice(0, max) + `\n... [truncated, ${text.length - max} more 
chars]`;
+}
+
+// Extract (list, domain) from a PonyMail email record. PonyMail returns
+// `list` as "list@domain" and `list_raw` as "<list.domain>". We try both.
+function extractListDomain(record) {
+  const candidates = [record?.list, record?.list_raw];
+  for (const c of candidates) {
+    if (!c || typeof c !== "string") continue;
+    const stripped = c.replace(/^<|>$/g, "");
+    if (stripped.includes("@")) {
+      const [list, domain] = stripped.split("@", 2);
+      if (list && domain) return { list, domain };
+    }
+    const dot = stripped.indexOf(".");
+    if (dot > 0) {
+      return { list: stripped.slice(0, dot), domain: stripped.slice(dot + 1) };
+    }
+  }
+  return { list: null, domain: null };
+}
+
+// ---------------------------------------------------------------------------
+// Server
+// ---------------------------------------------------------------------------
+
+const server = new McpServer({
+  name: "ponymail",
+  version: "1.0.0",
+});
+
+// --- Tool: list_lists -------------------------------------------------------
+server.tool(
+  "list_lists",
+  "Get an overview of available mailing lists and their message counts. " +
+    "Returns domain → list → count mappings.",
+  {},
+  async () => {
+    const data = await apiFetch("/api/preferences.lua");
+    const lists = data.lists || {};
+    const descriptions = data.descriptions || {};
+
+    const lines = [];
+    for (const [domain, domainLists] of Object.entries(lists)) {
+      lines.push(`## ${domain}`);
+      for (const [listName, count] of Object.entries(domainLists)) {
+        const desc = descriptions[`${listName}@${domain}`] || "";
+        const restricted = restrictionFor(listName, domain);
+        const marker = restricted ? " [RESTRICTED — blocked by server policy]" 
: "";
+        lines.push(`  - ${listName}: ${count} messages${desc ? " — " + desc : 
""}${marker}`);
+      }
+    }
+
+    return {
+      content: [{ type: "text", text: lines.join("\n") || "No lists found." }],
+    };
+  }
+);
+
+// --- Tool: search_list ------------------------------------------------------
+server.tool(
+  "search_list",
+  "Search or browse a mailing list. Returns email summaries, participant 
stats, " +
+    "and thread structure. Use the list prefix (e.g. 'dev') and domain " +
+    "(e.g. 'iceberg.apache.org'). Supports date ranges, search queries, and " +
+    "header filters.",
+  {
+    list: z.string().describe("List prefix, e.g. 'dev', 'user', 'general'. Use 
'*' for all lists in a domain."),

Review Comment:
   **Restriction bypass via `list="*"`.** Promoting `*` as a valid list value 
lets callers aggregate-search a whole domain. `restrictionFor("*", 
"apache.org")` returns null since no `*@` pattern exists, so `list="*", 
domain="apache.org"` would slip past the pattern block. With auth, this could 
surface results from `private@`/`security@` lists in the same domain. PR #3's 
response-level private-flag check catches the most obvious cases, but this is 
worth either explicitly disallowing (`if (list === "*") return 
restrictionError(...)`) or applying the per-result check that #3 adds to 
`search_list`.



##########
mcp/ponymail-mcp/index.js:
##########
@@ -0,0 +1,486 @@
+#!/usr/bin/env node
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from 
"@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+import { loadSession, performLogin, clearSession } from "./auth.js";
+import {
+  restrictionFor,
+  restrictionForAddress,
+  restrictionError,
+  listRestrictions,
+} from "./restrictions.js";
+
+const BASE_URL = process.env.PONYMAIL_BASE_URL || "https://lists.apache.org";;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+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 });
+
+  if (!resp.ok) {
+    const body = await resp.text().catch(() => "");
+    throw new Error(`PonyMail API error ${resp.status}: ${body}`);
+  }
+
+  const contentType = resp.headers.get("content-type") || "";
+  if (contentType.includes("application/json")) {
+    return await resp.json();
+  }
+  // mbox endpoint returns text
+  return await resp.text();
+}
+
+function truncate(text, max = 4000) {
+  if (!text || text.length <= max) return text;
+  return text.slice(0, max) + `\n... [truncated, ${text.length - max} more 
chars]`;
+}
+
+// Extract (list, domain) from a PonyMail email record. PonyMail returns
+// `list` as "list@domain" and `list_raw` as "<list.domain>". We try both.
+function extractListDomain(record) {
+  const candidates = [record?.list, record?.list_raw];
+  for (const c of candidates) {
+    if (!c || typeof c !== "string") continue;
+    const stripped = c.replace(/^<|>$/g, "");
+    if (stripped.includes("@")) {
+      const [list, domain] = stripped.split("@", 2);
+      if (list && domain) return { list, domain };
+    }
+    const dot = stripped.indexOf(".");
+    if (dot > 0) {
+      return { list: stripped.slice(0, dot), domain: stripped.slice(dot + 1) };
+    }
+  }
+  return { list: null, domain: null };
+}
+
+// ---------------------------------------------------------------------------
+// Server
+// ---------------------------------------------------------------------------
+
+const server = new McpServer({
+  name: "ponymail",
+  version: "1.0.0",
+});
+
+// --- Tool: list_lists -------------------------------------------------------
+server.tool(
+  "list_lists",
+  "Get an overview of available mailing lists and their message counts. " +
+    "Returns domain → list → count mappings.",
+  {},
+  async () => {
+    const data = await apiFetch("/api/preferences.lua");
+    const lists = data.lists || {};
+    const descriptions = data.descriptions || {};
+
+    const lines = [];
+    for (const [domain, domainLists] of Object.entries(lists)) {
+      lines.push(`## ${domain}`);
+      for (const [listName, count] of Object.entries(domainLists)) {
+        const desc = descriptions[`${listName}@${domain}`] || "";
+        const restricted = restrictionFor(listName, domain);
+        const marker = restricted ? " [RESTRICTED — blocked by server policy]" 
: "";
+        lines.push(`  - ${listName}: ${count} messages${desc ? " — " + desc : 
""}${marker}`);
+      }
+    }
+
+    return {
+      content: [{ type: "text", text: lines.join("\n") || "No lists found." }],
+    };
+  }
+);
+
+// --- Tool: search_list ------------------------------------------------------
+server.tool(
+  "search_list",
+  "Search or browse a mailing list. Returns email summaries, participant 
stats, " +
+    "and thread structure. Use the list prefix (e.g. 'dev') and domain " +
+    "(e.g. 'iceberg.apache.org'). Supports date ranges, search queries, and " +
+    "header filters.",
+  {
+    list: z.string().describe("List prefix, e.g. 'dev', 'user', 'general'. Use 
'*' for all lists in a domain."),
+    domain: z.string().describe("List domain, e.g. 'iceberg.apache.org', 
'httpd.apache.org'"),
+    query: z.string().optional().describe("Search query (supports wildcards 
and negation with -)"),
+    timespan: z
+      .string()
+      .optional()
+      .describe(
+        "Timespan filter: 'yyyy-mm' for a month, 'lte=Nd' for last N days, " +
+          "'gte=Nd' for older than N days, 'dfr=yyyy-mm-dd dto=yyyy-mm-dd' for 
range"
+      ),
+    from: z.string().optional().describe("Filter by From: header address"),
+    subject: z.string().optional().describe("Filter by Subject: header"),
+    body: z.string().optional().describe("Filter by body text"),
+    quick: z.boolean().optional().describe("If true, return statistics only 
(faster)"),
+    emails_only: z.boolean().optional().describe("If true, return email 
summaries only (skip thread_struct, participants, word cloud)"),
+  },
+  async ({ list, domain, query, timespan, from, subject, body, quick, 
emails_only }) => {
+    const restricted = restrictionFor(list, domain);
+    if (restricted) {
+      return {
+        content: [{ type: "text", text: restrictionError(list, domain, 
restricted) }],
+        isError: true,
+      };
+    }
+
+    const params = {
+      list,
+      domain,
+      q: query,
+      d: timespan,
+      header_from: from,
+      header_subject: subject,
+      header_body: body,
+    };
+    if (quick) params.quick = "";
+    if (emails_only) params.emailsOnly = "";

Review Comment:
   **Dead code — flags never reach PonyMail.** `params.quick = ""` and 
`params.emailsOnly = ""` are filtered out by `apiFetch` (line 23 — `v !== ""`). 
The `quick` and `emails_only` features are silently no-ops today. Fix: assign 
`"1"` instead.



##########
mcp/ponymail-mcp/bookmarklet.html:
##########
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html>
+<head><title>PonyMail Cookie Bookmarklet</title></head>
+<body style="font-family:system-ui;max-width:700px;margin:40px auto;padding:0 
20px">
+<h1>🐴 PonyMail Cookie Extractor</h1>
+<p>Drag this bookmarklet to your bookmarks bar:</p>
+
+<p style="text-align:center;margin:30px 0">
+<a 
href="javascript:void(fetch('/api/preferences.lua',{credentials:'include'}).then(r=>{const
 c=document.cookie;return r.json().then(j=>{const 
m=j.login&&j.login.credentials;if(!m){alert('Not logged in to PonyMail. Log in 
first, then try again.');return}const x=new 
XMLHttpRequest();x.open('GET','/api/preferences.lua',false);x.withCredentials=true;x.send();const
 raw=x.getResponseHeader('X-Request-Cookies')||'';let 
pc='';document.cookie.split(';').forEach(s=>{if(s.trim().startsWith('ponymail='))pc=s.trim()});if(!pc){const
 p=prompt('Logged in as: '+m.fullname+' ('+m.email+')\n\nThe ponymail cookie is 
HttpOnly so I cannot read it directly.\n\nPlease paste the cookie value 
from:\nDevTools → Network → any api/ request → Headers → Cookie\n\nLook for: 
ponymail=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx','');if(p){const 
v=p.startsWith('ponymail=')?p:'ponymail='+p;navigator.clipboard.writeText(v).then(()=>alert('Copied
 to clipboard:\n'+v)).catch(()=>prompt('Copy this:',v))}return}navigator.
 clipboard.writeText(pc).then(()=>alert('Cookie copied to 
clipboard!\n\n'+pc)).catch(()=>prompt('Copy this cookie 
value:',pc))})}).catch(e=>alert('Error: '+e.message)))"

Review Comment:
   **Dead XHR code.** `x.getResponseHeader('X-Request-Cookies')` — that header 
doesn't exist (PonyMail doesn't set it), and even if it did, request headers 
aren't readable from the browser via XHR. The synchronous `XMLHttpRequest` is 
also deprecated. The control flow ends up at the `prompt()` fallback 
regardless, so the entire XHR block is unreachable in practice. The whole 
bookmarklet could be reduced to a `console.log()` of instructions for the user 
— the cookie can't be auto-extracted because it's `HttpOnly`.



##########
mcp/ponymail-mcp/auth.js:
##########
@@ -0,0 +1,264 @@
+/**
+ * auth.js — PonyMail session management
+ *
+ * PonyMail Foal handles OAuth entirely server-side — the auth code from ASF 
OAuth
+ * can only be exchanged by PonyMail's own backend (its redirect_uri is 
registered
+ * with ASF OAuth, not ours). So we can't replicate the OAuth exchange from a 
CLI.
+ *
+ * Instead, this module:
+ * 1. Opens the PonyMail login page in the user's browser
+ * 2. Runs a tiny local server that waits for the user to paste their cookie
+ *    OR watches for the cookie file to appear (if using browser extension)
+ * 3. Caches the session cookie to ~/.ponymail-mcp/session.json
+ *
+ * The simplest reliable flow:
+ * - Open lists.apache.org/oauth.html in the browser
+ * - User logs in (ASF LDAP)
+ * - After login, PonyMail sets a session cookie in the browser
+ * - User copies the cookie value from DevTools (or we provide a bookmarklet)
+ * - We cache it and use it for API requests
+ *
+ * Alternatively, set PONYMAIL_SESSION_COOKIE env var directly.
+ */
+
+import http from "node:http";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { exec } from "node:child_process";
+
+const SESSION_DIR = path.join(os.homedir(), ".ponymail-mcp");
+const SESSION_FILE = path.join(SESSION_DIR, "session.json");
+const CALLBACK_PORT = 39817;
+const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
+
+// ---------------------------------------------------------------------------
+// Session persistence
+// ---------------------------------------------------------------------------
+
+export function loadSession() {
+  try {
+    if (!fs.existsSync(SESSION_FILE)) return null;
+    const data = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8"));
+    if (data.timestamp && Date.now() - data.timestamp > 20 * 60 * 60 * 1000) {
+      console.error("[auth] Cached session expired");
+      return null;
+    }
+    return data.cookie || null;
+  } catch {
+    return null;
+  }
+}
+
+function saveSession(cookie, userInfo = {}) {
+  fs.mkdirSync(SESSION_DIR, { recursive: true });
+  fs.writeFileSync(
+    SESSION_FILE,
+    JSON.stringify({ cookie, timestamp: Date.now(), user: userInfo }, null, 2)
+  );

Review Comment:
   **Sensitive file written with default permissions.** `fs.writeFileSync` 
defaults to ~0644, world-readable on most systems. The cookie is a session 
credential. Pass `{ mode: 0o600 }` to `writeFileSync`, and ideally tighten 
`fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 })` on line 54 too.



##########
mcp/ponymail-mcp/auth.js:
##########
@@ -0,0 +1,264 @@
+/**
+ * auth.js — PonyMail session management
+ *
+ * PonyMail Foal handles OAuth entirely server-side — the auth code from ASF 
OAuth
+ * can only be exchanged by PonyMail's own backend (its redirect_uri is 
registered
+ * with ASF OAuth, not ours). So we can't replicate the OAuth exchange from a 
CLI.
+ *
+ * Instead, this module:
+ * 1. Opens the PonyMail login page in the user's browser
+ * 2. Runs a tiny local server that waits for the user to paste their cookie
+ *    OR watches for the cookie file to appear (if using browser extension)
+ * 3. Caches the session cookie to ~/.ponymail-mcp/session.json
+ *
+ * The simplest reliable flow:
+ * - Open lists.apache.org/oauth.html in the browser
+ * - User logs in (ASF LDAP)
+ * - After login, PonyMail sets a session cookie in the browser
+ * - User copies the cookie value from DevTools (or we provide a bookmarklet)
+ * - We cache it and use it for API requests
+ *
+ * Alternatively, set PONYMAIL_SESSION_COOKIE env var directly.
+ */
+
+import http from "node:http";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { exec } from "node:child_process";
+
+const SESSION_DIR = path.join(os.homedir(), ".ponymail-mcp");
+const SESSION_FILE = path.join(SESSION_DIR, "session.json");
+const CALLBACK_PORT = 39817;
+const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
+
+// ---------------------------------------------------------------------------
+// Session persistence
+// ---------------------------------------------------------------------------
+
+export function loadSession() {
+  try {
+    if (!fs.existsSync(SESSION_FILE)) return null;
+    const data = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8"));
+    if (data.timestamp && Date.now() - data.timestamp > 20 * 60 * 60 * 1000) {
+      console.error("[auth] Cached session expired");
+      return null;
+    }
+    return data.cookie || null;
+  } catch {
+    return null;
+  }
+}
+
+function saveSession(cookie, userInfo = {}) {
+  fs.mkdirSync(SESSION_DIR, { recursive: true });
+  fs.writeFileSync(
+    SESSION_FILE,
+    JSON.stringify({ cookie, timestamp: Date.now(), user: userInfo }, null, 2)
+  );
+  console.error(`[auth] Session saved to ${SESSION_FILE}`);
+}
+
+export function clearSession() {
+  try {
+    if (fs.existsSync(SESSION_FILE)) {
+      fs.unlinkSync(SESSION_FILE);
+      console.error("[auth] Session cleared");
+    }
+  } catch (err) {
+    console.error("[auth] Failed to clear session:", err.message);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Browser helper
+// ---------------------------------------------------------------------------
+
+function openBrowser(url) {
+  const cmd =
+    process.platform === "darwin"
+      ? `open "${url}"`
+      : process.platform === "win32"
+        ? `start "${url}"`
+        : `xdg-open "${url}"`;
+  exec(cmd, (err) => {
+    if (err) console.error("[auth] Could not open browser:", err.message);
+  });
+}
+
+// ---------------------------------------------------------------------------
+// Login flow
+// ---------------------------------------------------------------------------
+
+/**
+ * Perform login by:
+ * 1. Opening PonyMail's login page in the browser
+ * 2. Starting a local HTTP server with a simple form where the user pastes
+ *    their cookie after logging in, OR uses the bookmarklet to auto-fill
+ * 3. Saving the cookie once received
+ *
+ * @param {string} baseUrl - PonyMail base URL
+ * @param {number} [timeoutMs] - Max time to wait (default 3 min)
+ * @returns {Promise<string>} The session cookie string
+ */
+export function performLogin(baseUrl, timeoutMs = LOGIN_TIMEOUT_MS) {
+  return new Promise((resolve, reject) => {
+    let server;
+    let settled = false;
+
+    function settle(err, result) {
+      if (settled) return;
+      settled = true;
+      clearTimeout(timer);
+      if (server) {
+        try { server.close(); } catch {}
+      }
+      if (err) reject(err);
+      else resolve(result);
+    }
+
+    const timer = setTimeout(() => {
+      settle(new Error(
+        `Login timed out after ${timeoutMs / 1000}s. Call login again to 
retry.`
+      ));
+    }, timeoutMs);
+
+    server = http.createServer(async (req, res) => {
+      // Serve the cookie-paste form
+      if (req.method === "GET") {
+        res.writeHead(200, { "Content-Type": "text/html" });
+        res.end(loginPage(baseUrl));
+        return;
+      }
+
+      // Receive the pasted cookie
+      if (req.method === "POST" && req.url === "/save") {
+        let body = "";
+        for await (const chunk of req) body += chunk;
+        const params = new URLSearchParams(body);
+        const cookie = (params.get("cookie") || "").trim();
+
+        if (!cookie) {
+          res.writeHead(400, { "Content-Type": "text/html" });
+          res.end(resultPage(false, "No cookie value provided. Please try 
again."));
+          return;
+        }
+
+        // Validate the cookie by calling preferences endpoint
+        try {
+          const testUrl = new URL("/api/preferences.lua", baseUrl);
+          const testResp = await fetch(testUrl.toString(), {
+            headers: { Accept: "application/json", Cookie: cookie },
+          });
+          const testData = await testResp.json();
+
+          if (testData.login && testData.login.credentials) {
+            const name = testData.login.credentials.fullname || "Unknown";
+            const email = testData.login.credentials.email || "";
+            saveSession(cookie, { fullname: name, email });
+            res.writeHead(200, { "Content-Type": "text/html" });
+            res.end(resultPage(true, `Authenticated as ${name} (${email})`));
+            settle(null, cookie);
+          } else {
+            res.writeHead(200, { "Content-Type": "text/html" });
+            res.end(resultPage(false,
+              "Cookie was accepted but no login credentials found. " +
+              "Make sure you copied the full cookie string. Try again."
+            ));
+          }
+        } catch (err) {
+          res.writeHead(500, { "Content-Type": "text/html" });
+          res.end(resultPage(false, `Validation failed: ${err.message}`));
+        }
+        return;
+      }
+
+      res.writeHead(404);
+      res.end("Not found");
+    });
+
+    server.on("error", (err) => {
+      settle(new Error(`Could not start callback server on port 
${CALLBACK_PORT}: ${err.message}`));
+    });
+
+    server.listen(CALLBACK_PORT, () => {

Review Comment:
   **Binds to all interfaces.** Without an explicit host, Node's 
`server.listen(port)` binds to `0.0.0.0` — for the 3-minute login window the 
cookie-paste form is reachable from the LAN, and any service that can reach 
this machine can POST a cookie that gets cached. Fix: 
`server.listen(CALLBACK_PORT, "127.0.0.1", ...)`.



##########
mcp/ponymail-mcp/auth.js:
##########
@@ -0,0 +1,264 @@
+/**
+ * auth.js — PonyMail session management
+ *
+ * PonyMail Foal handles OAuth entirely server-side — the auth code from ASF 
OAuth
+ * can only be exchanged by PonyMail's own backend (its redirect_uri is 
registered
+ * with ASF OAuth, not ours). So we can't replicate the OAuth exchange from a 
CLI.
+ *
+ * Instead, this module:
+ * 1. Opens the PonyMail login page in the user's browser
+ * 2. Runs a tiny local server that waits for the user to paste their cookie
+ *    OR watches for the cookie file to appear (if using browser extension)
+ * 3. Caches the session cookie to ~/.ponymail-mcp/session.json
+ *
+ * The simplest reliable flow:
+ * - Open lists.apache.org/oauth.html in the browser
+ * - User logs in (ASF LDAP)
+ * - After login, PonyMail sets a session cookie in the browser
+ * - User copies the cookie value from DevTools (or we provide a bookmarklet)
+ * - We cache it and use it for API requests
+ *
+ * Alternatively, set PONYMAIL_SESSION_COOKIE env var directly.
+ */
+
+import http from "node:http";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { exec } from "node:child_process";
+
+const SESSION_DIR = path.join(os.homedir(), ".ponymail-mcp");
+const SESSION_FILE = path.join(SESSION_DIR, "session.json");
+const CALLBACK_PORT = 39817;
+const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
+
+// ---------------------------------------------------------------------------
+// Session persistence
+// ---------------------------------------------------------------------------
+
+export function loadSession() {
+  try {
+    if (!fs.existsSync(SESSION_FILE)) return null;
+    const data = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8"));
+    if (data.timestamp && Date.now() - data.timestamp > 20 * 60 * 60 * 1000) {
+      console.error("[auth] Cached session expired");
+      return null;
+    }
+    return data.cookie || null;
+  } catch {
+    return null;
+  }
+}
+
+function saveSession(cookie, userInfo = {}) {
+  fs.mkdirSync(SESSION_DIR, { recursive: true });
+  fs.writeFileSync(
+    SESSION_FILE,
+    JSON.stringify({ cookie, timestamp: Date.now(), user: userInfo }, null, 2)
+  );
+  console.error(`[auth] Session saved to ${SESSION_FILE}`);
+}
+
+export function clearSession() {
+  try {
+    if (fs.existsSync(SESSION_FILE)) {
+      fs.unlinkSync(SESSION_FILE);
+      console.error("[auth] Session cleared");
+    }
+  } catch (err) {
+    console.error("[auth] Failed to clear session:", err.message);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Browser helper
+// ---------------------------------------------------------------------------
+
+function openBrowser(url) {
+  const cmd =
+    process.platform === "darwin"
+      ? `open "${url}"`
+      : process.platform === "win32"
+        ? `start "${url}"`

Review Comment:
   **Broken on Windows.** `start "${url}"` — the Windows `start` command treats 
the first quoted argument as the **window title**, not the URL. With a single 
quoted arg the URL is read as a title and nothing opens. Fix: `start "" 
"${url}"` (empty title, then URL). The `darwin` and Linux branches are correct.



##########
mcp/ponymail-mcp/package.json:
##########
@@ -0,0 +1,13 @@
+{
+  "name": "ponymail-mcp",
+  "version": "1.0.0",
+  "description": "MCP server for accessing Apache PonyMail mailing list 
archives",
+  "type": "module",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js"
+  },

Review Comment:
   **Missing `engines.node`.** This package uses top-level `await` 
(`index.js:486`), `node:test` (added in #5), and built-in `fetch` — Node 18+ at 
minimum, 20+ recommended. Without `engines.node` declared, contributors on 
older Node will get confusing failures. Add e.g. `"engines": { "node": ">=20" 
}` next to `scripts`.



##########
.gitignore:
##########
@@ -1,3 +1,5 @@
 asf-highlights/birthdays/
 project-activity/DATA/
 project-activity/REPORTS/
+node_modules/
+package-lock.json

Review Comment:
   **`package-lock.json` should be committed for an application.** Conventional 
for libraries to ignore it, but this is a runnable application that 
contributors and CI will `npm install`. Without a lockfile, installs are 
non-reproducible across machines and CVE scanning has nothing to scan against. 
Recommend dropping this line and committing `package-lock.json` (PR #5 adds CI 
that would benefit immediately).



##########
mcp/ponymail-mcp/index.js:
##########
@@ -0,0 +1,486 @@
+#!/usr/bin/env node
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from 
"@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+import { loadSession, performLogin, clearSession } from "./auth.js";
+import {
+  restrictionFor,
+  restrictionForAddress,
+  restrictionError,
+  listRestrictions,
+} from "./restrictions.js";
+
+const BASE_URL = process.env.PONYMAIL_BASE_URL || "https://lists.apache.org";;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+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 });
+
+  if (!resp.ok) {
+    const body = await resp.text().catch(() => "");
+    throw new Error(`PonyMail API error ${resp.status}: ${body}`);
+  }
+
+  const contentType = resp.headers.get("content-type") || "";
+  if (contentType.includes("application/json")) {
+    return await resp.json();
+  }
+  // mbox endpoint returns text
+  return await resp.text();
+}
+
+function truncate(text, max = 4000) {
+  if (!text || text.length <= max) return text;
+  return text.slice(0, max) + `\n... [truncated, ${text.length - max} more 
chars]`;
+}
+
+// Extract (list, domain) from a PonyMail email record. PonyMail returns
+// `list` as "list@domain" and `list_raw` as "<list.domain>". We try both.
+function extractListDomain(record) {
+  const candidates = [record?.list, record?.list_raw];
+  for (const c of candidates) {
+    if (!c || typeof c !== "string") continue;
+    const stripped = c.replace(/^<|>$/g, "");
+    if (stripped.includes("@")) {
+      const [list, domain] = stripped.split("@", 2);
+      if (list && domain) return { list, domain };
+    }
+    const dot = stripped.indexOf(".");
+    if (dot > 0) {
+      return { list: stripped.slice(0, dot), domain: stripped.slice(dot + 1) };
+    }
+  }
+  return { list: null, domain: null };
+}
+
+// ---------------------------------------------------------------------------
+// Server
+// ---------------------------------------------------------------------------
+
+const server = new McpServer({
+  name: "ponymail",
+  version: "1.0.0",
+});
+
+// --- Tool: list_lists -------------------------------------------------------
+server.tool(
+  "list_lists",
+  "Get an overview of available mailing lists and their message counts. " +
+    "Returns domain → list → count mappings.",
+  {},
+  async () => {
+    const data = await apiFetch("/api/preferences.lua");
+    const lists = data.lists || {};
+    const descriptions = data.descriptions || {};
+
+    const lines = [];
+    for (const [domain, domainLists] of Object.entries(lists)) {
+      lines.push(`## ${domain}`);
+      for (const [listName, count] of Object.entries(domainLists)) {
+        const desc = descriptions[`${listName}@${domain}`] || "";
+        const restricted = restrictionFor(listName, domain);
+        const marker = restricted ? " [RESTRICTED — blocked by server policy]" 
: "";
+        lines.push(`  - ${listName}: ${count} messages${desc ? " — " + desc : 
""}${marker}`);
+      }
+    }
+
+    return {
+      content: [{ type: "text", text: lines.join("\n") || "No lists found." }],
+    };
+  }
+);
+
+// --- Tool: search_list ------------------------------------------------------
+server.tool(
+  "search_list",
+  "Search or browse a mailing list. Returns email summaries, participant 
stats, " +
+    "and thread structure. Use the list prefix (e.g. 'dev') and domain " +
+    "(e.g. 'iceberg.apache.org'). Supports date ranges, search queries, and " +
+    "header filters.",
+  {
+    list: z.string().describe("List prefix, e.g. 'dev', 'user', 'general'. Use 
'*' for all lists in a domain."),
+    domain: z.string().describe("List domain, e.g. 'iceberg.apache.org', 
'httpd.apache.org'"),
+    query: z.string().optional().describe("Search query (supports wildcards 
and negation with -)"),
+    timespan: z
+      .string()
+      .optional()
+      .describe(
+        "Timespan filter: 'yyyy-mm' for a month, 'lte=Nd' for last N days, " +
+          "'gte=Nd' for older than N days, 'dfr=yyyy-mm-dd dto=yyyy-mm-dd' for 
range"
+      ),
+    from: z.string().optional().describe("Filter by From: header address"),
+    subject: z.string().optional().describe("Filter by Subject: header"),
+    body: z.string().optional().describe("Filter by body text"),
+    quick: z.boolean().optional().describe("If true, return statistics only 
(faster)"),
+    emails_only: z.boolean().optional().describe("If true, return email 
summaries only (skip thread_struct, participants, word cloud)"),
+  },
+  async ({ list, domain, query, timespan, from, subject, body, quick, 
emails_only }) => {
+    const restricted = restrictionFor(list, domain);
+    if (restricted) {
+      return {
+        content: [{ type: "text", text: restrictionError(list, domain, 
restricted) }],
+        isError: true,
+      };
+    }
+
+    const params = {
+      list,
+      domain,
+      q: query,
+      d: timespan,
+      header_from: from,
+      header_subject: subject,
+      header_body: body,
+    };
+    if (quick) params.quick = "";
+    if (emails_only) params.emailsOnly = "";
+
+    const data = await apiFetch("/api/stats.lua", params);
+
+    // Build a readable summary
+    const lines = [];
+    lines.push(`# ${data.list || list + "@" + domain}`);
+    lines.push(`Hits: ${data.hits ?? "N/A"} | Threads: ${data.no_threads ?? 
"N/A"}`);
+    if (data.firstYear) lines.push(`Archive range: ${data.firstYear} – 
${data.lastYear}`);
+    lines.push("");
+
+    // Participants
+    if (data.participants && Object.keys(data.participants).length > 0) {
+      lines.push("## Top Participants");
+      const parts = Array.isArray(data.participants)
+        ? data.participants
+        : Object.values(data.participants);
+      for (const p of parts.slice(0, 15)) {
+        lines.push(`  - ${p.name} (${p.email}): ${p.count} messages`);
+      }
+      lines.push("");
+    }
+
+    // Emails
+    if (data.emails) {
+      lines.push("## Emails");
+      const emails = Array.isArray(data.emails)
+        ? data.emails
+        : Object.values(data.emails);
+      for (const e of emails.slice(0, 30)) {
+        const date = e.date || new Date((e.epoch || 0) * 
1000).toISOString().slice(0, 10);
+        lines.push(`- **${e.subject}**`);
+        lines.push(`  From: ${e.from} | Date: ${date} | ID: ${e.id || e.mid}`);
+      }
+      lines.push("");
+      if (emails.length > 30) {
+        lines.push(`... and ${emails.length - 30} more emails`);
+      }
+    }
+
+    return {
+      content: [{ type: "text", text: lines.join("\n") }],
+    };
+  }
+);
+
+// --- Tool: get_email --------------------------------------------------------
+server.tool(
+  "get_email",
+  "Fetch a specific email by its ID or Message-ID header. Returns full body, " 
+
+    "headers, and attachment info.",
+  {
+    id: z.string().describe("The email ID (mid) or Message-ID header value"),
+  },
+  async ({ id }) => {
+    const data = await apiFetch("/api/email.lua", { id });
+
+    const { list, domain } = extractListDomain(data);
+    const restricted = list && domain ? restrictionFor(list, domain) : null;
+    if (restricted) {
+      return {
+        content: [{ type: "text", text: restrictionError(list, domain, 
restricted) }],
+        isError: true,
+      };
+    }
+
+    const lines = [];
+    lines.push(`# ${data.subject || "(no subject)"}`);
+    lines.push(`From: ${data.from}`);
+    lines.push(`Date: ${data.date} (epoch: ${data.epoch})`);
+    lines.push(`List: ${data.list || data.list_raw}`);
+    lines.push(`Message-ID: ${data["message-id"]}`);
+    lines.push(`Thread ID: ${data.tid}`);
+    if (data["in-reply-to"]) lines.push(`In-Reply-To: ${data["in-reply-to"]}`);
+    if (data.references) lines.push(`References: ${data.references}`);
+    lines.push(`Private: ${data.private}`);
+    lines.push("");
+    lines.push("## Body");
+    lines.push(truncate(data.body, 8000));
+
+    if (data.attachments && Object.keys(data.attachments).length > 0) {
+      lines.push("");
+      lines.push("## Attachments");
+      for (const [hash, att] of Object.entries(data.attachments)) {
+        lines.push(`  - ${att.filename || hash} (${att.content_type}, 
${att.size} bytes)`);
+      }
+    }
+
+    return {
+      content: [{ type: "text", text: lines.join("\n") }],
+    };
+  }
+);
+
+// --- Tool: get_thread -------------------------------------------------------
+server.tool(
+  "get_thread",
+  "Fetch all emails in a thread. Provide the thread ID (tid) from a search 
result " +

Review Comment:
   **Tool description doesn't match the implementation.** This says "Returns 
the thread as a flat list of email summaries" but the body of the handler only 
fetches the root email (`apiFetch("/api/email.lua", { id })` at line 273) and 
prints it (`truncate(root.body, 4000)` at line 292). Children are never 
fetched. The inline comment on lines 269–271 even acknowledges this as the 
intended next step. Either implement child fetching from `root.thread_struct`, 
or rewrite the description to match ("Fetch the root message of a thread by 
thread ID"). LLMs that call this tool expecting full threads will be misled.



##########
mcp/ponymail-mcp/auth.js:
##########
@@ -0,0 +1,264 @@
+/**
+ * auth.js — PonyMail session management
+ *
+ * PonyMail Foal handles OAuth entirely server-side — the auth code from ASF 
OAuth
+ * can only be exchanged by PonyMail's own backend (its redirect_uri is 
registered
+ * with ASF OAuth, not ours). So we can't replicate the OAuth exchange from a 
CLI.
+ *
+ * Instead, this module:
+ * 1. Opens the PonyMail login page in the user's browser
+ * 2. Runs a tiny local server that waits for the user to paste their cookie
+ *    OR watches for the cookie file to appear (if using browser extension)
+ * 3. Caches the session cookie to ~/.ponymail-mcp/session.json
+ *
+ * The simplest reliable flow:
+ * - Open lists.apache.org/oauth.html in the browser
+ * - User logs in (ASF LDAP)
+ * - After login, PonyMail sets a session cookie in the browser
+ * - User copies the cookie value from DevTools (or we provide a bookmarklet)
+ * - We cache it and use it for API requests
+ *
+ * Alternatively, set PONYMAIL_SESSION_COOKIE env var directly.
+ */
+
+import http from "node:http";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { exec } from "node:child_process";
+
+const SESSION_DIR = path.join(os.homedir(), ".ponymail-mcp");
+const SESSION_FILE = path.join(SESSION_DIR, "session.json");
+const CALLBACK_PORT = 39817;
+const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
+
+// ---------------------------------------------------------------------------
+// Session persistence
+// ---------------------------------------------------------------------------
+
+export function loadSession() {
+  try {
+    if (!fs.existsSync(SESSION_FILE)) return null;
+    const data = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8"));
+    if (data.timestamp && Date.now() - data.timestamp > 20 * 60 * 60 * 1000) {
+      console.error("[auth] Cached session expired");
+      return null;
+    }
+    return data.cookie || null;
+  } catch {
+    return null;
+  }
+}
+
+function saveSession(cookie, userInfo = {}) {
+  fs.mkdirSync(SESSION_DIR, { recursive: true });
+  fs.writeFileSync(
+    SESSION_FILE,
+    JSON.stringify({ cookie, timestamp: Date.now(), user: userInfo }, null, 2)
+  );
+  console.error(`[auth] Session saved to ${SESSION_FILE}`);
+}
+
+export function clearSession() {
+  try {
+    if (fs.existsSync(SESSION_FILE)) {
+      fs.unlinkSync(SESSION_FILE);
+      console.error("[auth] Session cleared");
+    }
+  } catch (err) {
+    console.error("[auth] Failed to clear session:", err.message);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Browser helper
+// ---------------------------------------------------------------------------
+
+function openBrowser(url) {
+  const cmd =
+    process.platform === "darwin"
+      ? `open "${url}"`
+      : process.platform === "win32"
+        ? `start "${url}"`
+        : `xdg-open "${url}"`;
+  exec(cmd, (err) => {
+    if (err) console.error("[auth] Could not open browser:", err.message);
+  });
+}
+
+// ---------------------------------------------------------------------------
+// Login flow
+// ---------------------------------------------------------------------------
+
+/**
+ * Perform login by:
+ * 1. Opening PonyMail's login page in the browser
+ * 2. Starting a local HTTP server with a simple form where the user pastes
+ *    their cookie after logging in, OR uses the bookmarklet to auto-fill
+ * 3. Saving the cookie once received
+ *
+ * @param {string} baseUrl - PonyMail base URL
+ * @param {number} [timeoutMs] - Max time to wait (default 3 min)
+ * @returns {Promise<string>} The session cookie string
+ */
+export function performLogin(baseUrl, timeoutMs = LOGIN_TIMEOUT_MS) {
+  return new Promise((resolve, reject) => {
+    let server;
+    let settled = false;
+
+    function settle(err, result) {
+      if (settled) return;
+      settled = true;
+      clearTimeout(timer);
+      if (server) {
+        try { server.close(); } catch {}
+      }
+      if (err) reject(err);
+      else resolve(result);
+    }
+
+    const timer = setTimeout(() => {
+      settle(new Error(
+        `Login timed out after ${timeoutMs / 1000}s. Call login again to 
retry.`
+      ));
+    }, timeoutMs);
+
+    server = http.createServer(async (req, res) => {
+      // Serve the cookie-paste form
+      if (req.method === "GET") {
+        res.writeHead(200, { "Content-Type": "text/html" });
+        res.end(loginPage(baseUrl));
+        return;
+      }
+
+      // Receive the pasted cookie
+      if (req.method === "POST" && req.url === "/save") {
+        let body = "";
+        for await (const chunk of req) body += chunk;

Review Comment:
   **No body-size cap on the cookie-paste endpoint.** A misbehaving (or 
malicious) client during the 3-minute login window can grow `body` unbounded. 
Trivial DoS, but cheap to bound: track the running length and `req.destroy()` 
past, say, 16 KB.



##########
mcp/ponymail-mcp/auth.js:
##########
@@ -0,0 +1,264 @@
+/**
+ * auth.js — PonyMail session management
+ *
+ * PonyMail Foal handles OAuth entirely server-side — the auth code from ASF 
OAuth
+ * can only be exchanged by PonyMail's own backend (its redirect_uri is 
registered
+ * with ASF OAuth, not ours). So we can't replicate the OAuth exchange from a 
CLI.
+ *
+ * Instead, this module:
+ * 1. Opens the PonyMail login page in the user's browser
+ * 2. Runs a tiny local server that waits for the user to paste their cookie
+ *    OR watches for the cookie file to appear (if using browser extension)
+ * 3. Caches the session cookie to ~/.ponymail-mcp/session.json
+ *
+ * The simplest reliable flow:
+ * - Open lists.apache.org/oauth.html in the browser
+ * - User logs in (ASF LDAP)
+ * - After login, PonyMail sets a session cookie in the browser
+ * - User copies the cookie value from DevTools (or we provide a bookmarklet)
+ * - We cache it and use it for API requests
+ *
+ * Alternatively, set PONYMAIL_SESSION_COOKIE env var directly.
+ */
+
+import http from "node:http";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { exec } from "node:child_process";
+
+const SESSION_DIR = path.join(os.homedir(), ".ponymail-mcp");
+const SESSION_FILE = path.join(SESSION_DIR, "session.json");
+const CALLBACK_PORT = 39817;
+const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
+
+// ---------------------------------------------------------------------------
+// Session persistence
+// ---------------------------------------------------------------------------
+
+export function loadSession() {
+  try {
+    if (!fs.existsSync(SESSION_FILE)) return null;
+    const data = JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8"));
+    if (data.timestamp && Date.now() - data.timestamp > 20 * 60 * 60 * 1000) {
+      console.error("[auth] Cached session expired");

Review Comment:
   **Log spam.** `loadSession()` is called from `apiFetch` on every API request 
(`index.js:34`). Once a session expires, every subsequent tool call writes this 
line to stderr. If you want to keep the breadcrumb, log it once on first 
detection (e.g. delete the file or set a flag) rather than per call.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to