| #!/usr/bin/env node |
| |
| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| import { z } from "zod"; |
| import { loadSession, performLogin, clearSession, autoExtractEnabled } from "./auth.js"; |
| import { |
| restrictionFor, |
| restrictionForAddress, |
| restrictionError, |
| isPrivateBlocked, |
| privateError, |
| listRestrictions, |
| listAllowed, |
| allowedFor, |
| } from "./restrictions.js"; |
| |
| 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 |
| // MCP client logs without polluting the JSON-RPC stdout channel. |
| if (autoExtractEnabled()) { |
| console.error( |
| "\n" + |
| "########################################################################\n" + |
| "# PonyMail MCP — AUTO COOKIE EXTRACTION IS ENABLED #\n" + |
| "# #\n" + |
| "# PONYMAIL_AUTO_EXTRACT_COOKIE=1 is set. On `login`, this server will: #\n" + |
| "# 1. Read the local Chrome cookie database #\n" + |
| "# (contains session cookies for EVERY site you use in Chrome). #\n" + |
| "# 2. Access the macOS Keychain entry 'Chrome Safe Storage' to #\n" + |
| "# decrypt cookie values. macOS may prompt you to approve this. #\n" + |
| "# #\n" + |
| "# Only enable this if the MCP server is running under additional #\n" + |
| "# isolation you trust (e.g. a sandbox / hardened launcher such as #\n" + |
| "# Apache Magpie). Otherwise unset PONYMAIL_AUTO_EXTRACT_COOKIE and use #\n" + |
| "# the paste-from-DevTools fallback instead. #\n" + |
| "########################################################################\n" |
| ); |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Helpers |
| // --------------------------------------------------------------------------- |
| |
| async function apiFetch(path, params = {}) { |
| // Build headers — include session cookie if available |
| const headers = { Accept: "application/json" }; |
| const envCookie = process.env.PONYMAIL_SESSION_COOKIE; |
| const sessionCookie = envCookie || loadSession(); |
| if (sessionCookie) { |
| headers.Cookie = sessionCookie; |
| } |
| |
| 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(() => ""); |
| 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.", |
| {}, |
| { readOnlyHint: true }, |
| async () => { |
| const data = await apiFetch(`/api/preferences${API_SUFFIX}`); |
| 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 allowed = allowedFor(listName, domain); |
| const restricted = restrictionFor(listName, domain); |
| let marker = ""; |
| if (allowed) { |
| marker = " [ALLOW-LISTED — opted in via PONYMAIL_ALLOWED_LISTS]"; |
| } else if (restricted) { |
| marker = " [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)"), |
| limit: z |
| .number() |
| .int() |
| .min(1) |
| .max(200) |
| .optional() |
| .describe("Maximum email summaries to return in the rendered output (default 30, max 200). The backend always returns the full hit set; this only caps how many are formatted into the response."), |
| offset: z |
| .number() |
| .int() |
| .min(0) |
| .optional() |
| .describe("Number of email summaries to skip before rendering (default 0). Combine with `limit` to page through a large result set without re-querying the backend."), |
| }, |
| { readOnlyHint: true }, |
| async ({ list, domain, query, timespan, from, subject, body, quick, emails_only, limit, offset }) => { |
| const pageLimit = limit ?? 30; |
| const pageOffset = offset ?? 0; |
| 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 = "1"; |
| if (emails_only) params.emailsOnly = "1"; |
| |
| 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. |
| if (isPrivateBlocked(list, domain, data?.private)) { |
| return { |
| content: [{ type: "text", text: privateError(list, domain) }], |
| isError: true, |
| }; |
| } |
| |
| // Also scan individual emails — a cross-listed message can carry its |
| // origin list's `private: true` even when the queried list isn't private. |
| const emailsForCheck = data?.emails |
| ? Array.isArray(data.emails) |
| ? data.emails |
| : Object.values(data.emails) |
| : []; |
| for (const e of emailsForCheck) { |
| if (!e?.private) continue; |
| const { list: eList, domain: eDomain } = extractListDomain(e); |
| if (isPrivateBlocked(eList || list, eDomain || domain, e.private)) { |
| return { |
| content: [ |
| { type: "text", text: privateError(eList || list, eDomain || domain) }, |
| ], |
| isError: true, |
| }; |
| } |
| } |
| |
| // 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 — `quick` mode is stats-only: PonyMail's quick response carries |
| // email objects without subject/from/id (issue #9), so skip the section |
| // entirely. Use `emails_only` to get email summaries. Fields are also |
| // guarded below so a sparse entry never renders as "undefined". |
| if (!quick && data.emails) { |
| lines.push("## Emails"); |
| const emails = Array.isArray(data.emails) |
| ? data.emails |
| : Object.values(data.emails); |
| const total = emails.length; |
| const start = Math.min(pageOffset, total); |
| const end = Math.min(start + pageLimit, total); |
| const windowed = emails.slice(start, end); |
| for (const e of windowed) { |
| const date = e.date || new Date((e.epoch || 0) * 1000).toISOString().slice(0, 10); |
| lines.push(`- **${e.subject || "(no subject)"}**`); |
| lines.push(` From: ${e.from || "(unknown sender)"} | Date: ${date} | ID: ${e.id || e.mid || "(no id)"}`); |
| } |
| lines.push(""); |
| if (total === 0) { |
| // no-op; the empty "## Emails" header is enough signal |
| } else if (windowed.length === 0) { |
| lines.push(`Showing 0 of ${total} (offset ${pageOffset} is past the end).`); |
| } else { |
| lines.push(`Showing ${start + 1}-${end} of ${total}.`); |
| if (end < total) { |
| const nextOffset = end; |
| lines.push(`... ${total - end} more emails. Re-query with offset=${nextOffset} to continue.`); |
| } |
| } |
| } |
| |
| 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"), |
| }, |
| { readOnlyHint: true }, |
| async ({ id }) => { |
| const data = await apiFetch(`/api/email${API_SUFFIX}`, { 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, |
| }; |
| } |
| if (isPrivateBlocked(list, domain, data?.private)) { |
| return { |
| content: [{ type: "text", text: privateError(list, domain) }], |
| 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 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 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, find_parent }) => { |
| const restrictedUp = list && domain ? restrictionFor(list, domain) : null; |
| if (restrictedUp) { |
| return { |
| content: [{ type: "text", text: restrictionError(list, domain, restrictedUp) }], |
| isError: true, |
| }; |
| } |
| |
| // 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); |
| |
| const { list: rList, domain: rDomain } = extractListDomain(data?.thread || {}); |
| const restrictedAfter = rList && rDomain ? restrictionFor(rList, rDomain) : null; |
| if (restrictedAfter) { |
| return { |
| content: [{ type: "text", text: restrictionError(rList, rDomain, restrictedAfter) }], |
| isError: true, |
| }; |
| } |
| 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: ${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(""); |
| |
| // 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") }], |
| }; |
| } |
| ); |
| |
| // --- 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", |
| "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"), |
| }, |
| { readOnlyHint: true }, |
| async ({ list, date, from: fromAddr, subject }) => { |
| const at = list.indexOf("@"); |
| const lp = at >= 0 ? list.slice(0, at) : list; |
| const dp = at >= 0 ? list.slice(at + 1) : ""; |
| |
| const restricted = restrictionForAddress(list); |
| if (restricted) { |
| return { |
| content: [{ type: "text", text: restrictionError(lp, dp, restricted) }], |
| isError: true, |
| }; |
| } |
| |
| // Pre-flight check: mbox returns raw text so we can't inspect a `private` |
| // flag on the response. Query stats.lua first to learn whether PonyMail |
| // 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${API_SUFFIX}`, { list: lp, domain: dp, quick: "1" }); |
| if (isPrivateBlocked(lp, dp, meta?.private)) { |
| return { |
| content: [{ type: "text", text: privateError(lp, dp) }], |
| isError: true, |
| }; |
| } |
| } catch { |
| // If the metadata probe fails, fall through — the mbox fetch itself |
| // will surface a meaningful error, and pattern-based restrictions |
| // already covered the well-known private list names. |
| } |
| } |
| |
| const params = { |
| list, |
| date, |
| header_from: fromAddr, |
| header_subject: subject, |
| }; |
| |
| const data = await apiFetch(`/api/mbox${API_SUFFIX}`, 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 to access private mailing lists. Opens a local helper page " + |
| "where you paste the ponymail session cookie copied from DevTools on " + |
| "lists.apache.org. Only needed for private/restricted lists — public " + |
| "lists work without auth.\n\n" + |
| "OPTIONAL: if the MCP server was started with the env var " + |
| "PONYMAIL_AUTO_EXTRACT_COOKIE=1, this tool will FIRST try to read the " + |
| "cookie out of the local Chrome cookie store (decrypting via macOS " + |
| "Keychain) and only fall back to the paste form if that fails. That " + |
| "path grants the MCP server broad access to your Chrome cookies and " + |
| "Keychain — only enable it if the MCP is running under additional " + |
| "isolation you trust (e.g. Apache Magpie or a similar sandbox).", |
| {}, |
| async () => { |
| try { |
| const result = await performLogin(BASE_URL); |
| const who = result.user |
| ? ` as ${result.user.fullname}${result.user.email ? ` (${result.user.email})` : ""}` |
| : ""; |
| const sourceLabel = |
| result.source === "browser" |
| ? "Cookie auto-extracted from local Chrome cookie store (opt-in via PONYMAIL_AUTO_EXTRACT_COOKIE)." |
| : "Cookie received from the paste form."; |
| return { |
| content: [ |
| { |
| type: "text", |
| text: |
| `✅ Successfully authenticated${who}.\n\n` + |
| `${sourceLabel}\n` + |
| "Session cached. You can now access private mailing lists. " + |
| "Session expires after ~20 hours.\n" + |
| "Use `auth_status` to check, `logout` to clear.", |
| }, |
| ], |
| }; |
| } catch (err) { |
| return { |
| content: [ |
| { |
| type: "text", |
| text: `❌ Login failed: ${err.message}\n\nTry again, or set PONYMAIL_SESSION_COOKIE env var manually.`, |
| }, |
| ], |
| }; |
| } |
| } |
| ); |
| |
| // --- Tool: logout ----------------------------------------------------------- |
| server.tool( |
| "logout", |
| "Clear the cached PonyMail session cookie. After logout, only public lists " + |
| "will be accessible.", |
| {}, |
| async () => { |
| clearSession(); |
| return { |
| content: [ |
| { type: "text", text: "Session cleared. Only public lists are accessible now." }, |
| ], |
| }; |
| } |
| ); |
| |
| // --- Tool: auth_status ------------------------------------------------------ |
| server.tool( |
| "auth_status", |
| "Check current authentication status. Shows whether a session cookie is " + |
| "cached and if it's still valid.", |
| {}, |
| { readOnlyHint: true }, |
| async () => { |
| const envCookie = process.env.PONYMAIL_SESSION_COOKIE; |
| const sessionCookie = envCookie || loadSession(); |
| |
| if (!sessionCookie) { |
| return { |
| content: [ |
| { |
| type: "text", |
| text: "❌ Not authenticated. No session cookie found.\n\n" + |
| "Use `login` to authenticate, or set PONYMAIL_SESSION_COOKIE env var.", |
| }, |
| ], |
| }; |
| } |
| |
| // Validate the cookie |
| try { |
| 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) { |
| const creds = data.login.credentials; |
| return { |
| content: [ |
| { |
| type: "text", |
| text: `✅ Authenticated as: ${creds.fullname || "Unknown"} (${creds.email || "N/A"})\n\n` + |
| `Source: ${envCookie ? "environment variable" : "cached session file"}\n` + |
| "Session is valid.", |
| }, |
| ], |
| }; |
| } else { |
| return { |
| content: [ |
| { |
| type: "text", |
| text: "⚠️ Session cookie found but no login credentials returned.\n" + |
| "The session may have expired. Use `login` to re-authenticate.", |
| }, |
| ], |
| }; |
| } |
| } catch (err) { |
| return { |
| content: [ |
| { |
| type: "text", |
| text: `⚠️ Could not validate session: ${err.message}\n` + |
| "Use `login` to re-authenticate.", |
| }, |
| ], |
| }; |
| } |
| } |
| ); |
| |
| // --- Tool: list_restrictions ------------------------------------------------ |
| server.tool( |
| "list_restrictions", |
| "Show the mailing list policy: pattern blocks, the response-level private " + |
| "flag block, and any allow-listed opt-ins. Patterns are 'prefix@' " + |
| "(across all domains), '@domain' (whole domain), or exact 'prefix@domain'. " + |
| "Configured via PONYMAIL_RESTRICTED_LISTS and PONYMAIL_ALLOWED_LISTS.", |
| {}, |
| { readOnlyHint: true }, |
| async () => { |
| const patterns = listRestrictions(); |
| const allowed = listAllowed(); |
| const lines = []; |
| |
| lines.push("## Default block policy"); |
| lines.push( |
| "Any list PonyMail marks as `private: true` is blocked, even if it " + |
| "doesn't match a pattern below. Allow-listed lists bypass both this " + |
| "check and the pattern blocks." |
| ); |
| lines.push(""); |
| |
| if (patterns.length === 0) { |
| lines.push("## Restricted list patterns"); |
| lines.push("(none)"); |
| } else { |
| lines.push("## Restricted list patterns"); |
| for (const p of patterns) lines.push(` - ${p}`); |
| } |
| lines.push(""); |
| lines.push( |
| "Override patterns with PONYMAIL_RESTRICTED_LISTS (comma-separated). " + |
| 'Set to "none" to clear all pattern blocks.' |
| ); |
| lines.push(""); |
| |
| lines.push("## Allow-listed (opt-in) patterns"); |
| if (allowed.length === 0) { |
| lines.push("(none — all private lists are blocked)"); |
| } else { |
| for (const p of allowed) lines.push(` - ${p}`); |
| } |
| lines.push(""); |
| lines.push( |
| "Add allow-listed lists via PONYMAIL_ALLOWED_LISTS (comma-separated)." |
| ); |
| |
| return { content: [{ type: "text", text: lines.join("\n") }] }; |
| } |
| ); |
| |
| // --------------------------------------------------------------------------- |
| // Start |
| // --------------------------------------------------------------------------- |
| |
| const transport = new StdioServerTransport(); |
| await server.connect(transport); |