blob: 681046a91ab4bfc6cc546d56c20067f655b62589 [file]
#!/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'"),
},
async ({ id, list, domain }) => {
// 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 });
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("");
lines.push("## Root Message");
lines.push(truncate(root.body, 4000));
return {
content: [{ type: "text", text: lines.join("\n") }],
};
}
);
// --- Tool: get_mbox ---------------------------------------------------------
server.tool(
"get_mbox",
"Download mbox-formatted archive data for a list and time range. " +
"Useful for bulk export or offline analysis.",
{
list: z.string().describe("Full list address, e.g. 'dev@iceberg.apache.org'"),
date: z.string().describe("Month in yyyy-mm format, e.g. '2024-06'"),
from: z.string().optional().describe("Filter by sender email"),
subject: z.string().optional().describe("Filter by subject words"),
},
async ({ list, date, from: fromAddr, subject }) => {
const params = {
list,
date,
header_from: fromAddr,
header_subject: subject,
};
const data = await apiFetch("/api/mbox.lua", params);
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return {
content: [{ type: "text", text: truncate(text, 10000) }],
};
}
);
// --- Tool: login ------------------------------------------------------------
server.tool(
"login",
"Authenticate with Apache's OAuth system to access private mailing lists. " +
"Opens a browser window for ASF LDAP login. The session cookie is cached " +
"to ~/.ponymail-mcp/session.json and reused for subsequent requests.",
{},
async () => {
// Check if already logged in
const existing = loadSession();
if (existing) {
return {
content: [
{
type: "text",
text: "Already authenticated (cached session found). Use `logout` first to re-authenticate, or `auth_status` to check details.",
},
],
};
}
try {
const cookie = await performLogin(BASE_URL);
return {
content: [
{
type: "text",
text: `✅ Successfully authenticated! Session cookie cached.\nCookie: ${cookie.split("=")[0]}=...\nPrivate list access is now enabled.`,
},
],
};
} catch (err) {
return {
content: [
{ type: "text", text: `❌ Authentication failed: ${err.message}` },
],
};
}
}
);
// --- Tool: logout -----------------------------------------------------------
server.tool(
"logout",
"Clear the cached PonyMail session cookie. After this, only public lists will be accessible.",
{},
async () => {
clearSession();
return {
content: [{ type: "text", text: "Session cleared. Only public lists are now accessible." }],
};
}
);
// --- Tool: auth_status ------------------------------------------------------
server.tool(
"auth_status",
"Check whether an authenticated session exists and display session info.",
{},
async () => {
const cookie = process.env.PONYMAIL_SESSION_COOKIE || loadSession();
const source = process.env.PONYMAIL_SESSION_COOKIE ? "environment variable" : "cached session file";
const status = cookie
? `✅ Authenticated (via ${source})\nCookie: ${cookie.split("=")[0]}=...`
: "❌ Not authenticated. Use the `login` tool to authenticate, or set PONYMAIL_SESSION_COOKIE env var.";
return {
content: [{ type: "text", text: status }],
};
}
);
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
const transport = new StdioServerTransport();
await server.connect(transport);