rbowen commented on code in PR #2:
URL: https://github.com/apache/comdev/pull/2#discussion_r3139643335
##########
mcp/ponymail-mcp/index.js:
##########
@@ -0,0 +1,341 @@
+#!/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";
+
+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]`;
+}
+
+// ---------------------------------------------------------------------------
+// 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}`] || "";
+ lines.push(` - ${listName}: ${count} messages${desc ? " — " + desc :
""}`);
+ }
+ }
+
+ 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 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 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 " +
+ "or email. Returns the thread as a flat list of email summaries.",
+ {
+ id: z.string().describe("The thread ID (tid)"),
+ list: z.string().describe("List prefix, e.g. 'dev'"),
+ domain: z.string().describe("List domain, e.g. 'iceberg.apache.org'"),
Review Comment:
Removed
--
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]