blob: 9c531b7f62bdf32d4a7c5e0d8dc4313316c7531f [file]
#!/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);