Add readOnlyHint annotations to all read-only tools
diff --git a/asf-highlights/activity_2026_05_01.md b/asf-highlights/activity_2026_05_01.md
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/asf-highlights/activity_2026_05_01.md
diff --git a/asf-highlights/generate_asf_blog_posts.sh b/asf-highlights/generate_asf_blog_posts.sh
new file mode 100755
index 0000000..5d450df
--- /dev/null
+++ b/asf-highlights/generate_asf_blog_posts.sh
@@ -0,0 +1,170 @@
+#!/bin/bash
+# generate_asf_blog_posts.sh
+#
+# Generates the 4 monthly ASF community blog posts:
+#   1. PMC members elected last month
+#   2. Committers elected last month
+#   3. Releases made last month
+#   4. Project birthdays for the new month
+#
+# Usage:
+#   ./generate_asf_blog_posts.sh              # normal run (uses current date)
+#   ./generate_asf_blog_posts.sh --dry-run    # show what would happen
+#
+# Designed to be run on the 1st of the month (or shortly after).
+
+ASF_HIGHLIGHTS_DIR="$HOME/devel/apache/comdev/comdev/asf-highlights"
+BLOG_DIR="$HOME/devel/apache/comdev-site/source/blog"
+AUTHOR="Rich Bowen"
+
+set -euo pipefail
+
+DRY_RUN=false
+if [[ "${1:-}" == "--dry-run" ]]; then
+    DRY_RUN=true
+fi
+
+# --- Date calculations (macOS date) ---
+LAST_MONTH_NUM=$(date -v-1m +%m)
+LAST_MONTH_YEAR=$(date -v-1m +%Y)
+LAST_MONTH_NAME=$(date -v-1m +%B)
+
+THIS_MONTH_NUM=$(date +%m)
+THIS_MONTH_YEAR=$(date +%Y)
+THIS_MONTH_NAME=$(date +%B)
+
+# Publication date: 1st of this month
+PUB_DATE="${THIS_MONTH_YEAR}-${THIS_MONTH_NUM}-01"
+
+# File prefixes
+LAST_PREFIX="${LAST_MONTH_YEAR}_${LAST_MONTH_NUM}"
+THIS_PREFIX="${THIS_MONTH_YEAR}_${THIS_MONTH_NUM}"
+
+echo "=== ASF Monthly Blog Post Generator ==="
+echo "Reporting on: ${LAST_MONTH_NAME} ${LAST_MONTH_YEAR}"
+echo "Birthdays for: ${THIS_MONTH_NAME} ${THIS_MONTH_YEAR}"
+echo "Publication date: ${PUB_DATE}"
+if $DRY_RUN; then
+    echo "Mode: DRY RUN (no files will be written)"
+fi
+echo ""
+
+if $DRY_RUN; then
+    echo "Would generate:"
+    echo "  ${BLOG_DIR}/${LAST_PREFIX}_pmc_members.md"
+    echo "    title: New PMC members, ${LAST_MONTH_NAME} ${LAST_MONTH_YEAR}"
+    echo "    date: ${PUB_DATE}"
+    echo ""
+    echo "  ${BLOG_DIR}/${LAST_PREFIX}_committers.md"
+    echo "    title: New Committers, ${LAST_MONTH_NAME} ${LAST_MONTH_YEAR}"
+    echo "    date: ${PUB_DATE}"
+    echo ""
+    echo "  ${BLOG_DIR}/${LAST_PREFIX}_releases.md"
+    echo "    title: ASF Releases, ${LAST_MONTH_NAME} ${LAST_MONTH_YEAR}"
+    echo "    date: ${PUB_DATE}"
+    echo ""
+    echo "  ${BLOG_DIR}/${THIS_PREFIX}_birthdays.md"
+    echo "    title: ASF Project Birthdays, ${THIS_MONTH_NAME} ${THIS_MONTH_YEAR}"
+    echo "    date: ${PUB_DATE}"
+    echo ""
+    echo "=== Dry run complete ==="
+    exit 0
+fi
+
+# Helper: prepend frontmatter to a body file and write to blog dir
+write_blog_post() {
+    local title="$1"
+    local date="$2"
+    local output_file="$3"
+    local body_file="$4"
+
+    {
+        echo "---"
+        echo "title: ${title}"
+        echo "date: ${date}"
+        echo "blog_post: true"
+        echo "published_by: ${AUTHOR}"
+        echo 'tags: ["blog"]'
+        echo "---"
+        echo ""
+        cat "$body_file"
+    } > "$output_file"
+
+    echo "  ✓ $(basename "$output_file")"
+}
+
+cd "$ASF_HIGHLIGHTS_DIR"
+
+# asf_activity.py --markdown writes to activity_YYYY_MM_DD.md in CWD.
+# We run each subcommand, read that file, prepend frontmatter, clean up.
+ACTIVITY_OUT="activity_$(date +%Y_%m_%d).md"
+
+# --- 1. PMC Members ---
+echo "→ PMC members..."
+rm -f "$ACTIVITY_OUT"
+uv run asf_activity.py pmc --markdown
+if [ -f "$ACTIVITY_OUT" ]; then
+    write_blog_post \
+        "New PMC members, ${LAST_MONTH_NAME} ${LAST_MONTH_YEAR}" \
+        "$PUB_DATE" \
+        "${BLOG_DIR}/${LAST_PREFIX}_pmc_members.md" \
+        "$ACTIVITY_OUT"
+    rm -f "$ACTIVITY_OUT"
+else
+    echo "  ⚠ No output from asf_activity.py pmc"
+fi
+
+# --- 2. Committers ---
+echo "→ Committers..."
+rm -f "$ACTIVITY_OUT"
+uv run asf_activity.py committers --markdown
+if [ -f "$ACTIVITY_OUT" ]; then
+    write_blog_post \
+        "New Committers, ${LAST_MONTH_NAME} ${LAST_MONTH_YEAR}" \
+        "$PUB_DATE" \
+        "${BLOG_DIR}/${LAST_PREFIX}_committers.md" \
+        "$ACTIVITY_OUT"
+    rm -f "$ACTIVITY_OUT"
+else
+    echo "  ⚠ No output from asf_activity.py committers"
+fi
+
+# --- 3. Releases ---
+echo "→ Releases..."
+rm -f "$ACTIVITY_OUT"
+uv run asf_activity.py releases --markdown
+if [ -f "$ACTIVITY_OUT" ]; then
+    write_blog_post \
+        "ASF Releases, ${LAST_MONTH_NAME} ${LAST_MONTH_YEAR}" \
+        "$PUB_DATE" \
+        "${BLOG_DIR}/${LAST_PREFIX}_releases.md" \
+        "$ACTIVITY_OUT"
+    rm -f "$ACTIVITY_OUT"
+else
+    echo "  ⚠ No output from asf_activity.py releases"
+fi
+
+# --- 4. Project Birthdays ---
+echo "→ Birthdays..."
+uv run project_birthdays.py
+BDAY_MD_FILE=$(ls -t birthdays/apache_birthdays_*.md 2>/dev/null | head -1)
+if [ -n "$BDAY_MD_FILE" ]; then
+    # Strip the H1 title line — frontmatter title replaces it
+    BDAY_TEMP=$(mktemp)
+    sed '1{/^# /d;}' "$BDAY_MD_FILE" > "$BDAY_TEMP"
+    write_blog_post \
+        "ASF Project Birthdays, ${THIS_MONTH_NAME} ${THIS_MONTH_YEAR}" \
+        "$PUB_DATE" \
+        "${BLOG_DIR}/${THIS_PREFIX}_birthdays.md" \
+        "$BDAY_TEMP"
+    rm -f "$BDAY_TEMP"
+else
+    echo "  ⚠ project_birthdays.py produced no output"
+fi
+
+echo ""
+echo "=== Done! Blog posts written to ${BLOG_DIR}/ ==="
+echo "  ${LAST_PREFIX}_pmc_members.md"
+echo "  ${LAST_PREFIX}_committers.md"
+echo "  ${LAST_PREFIX}_releases.md"
+echo "  ${THIS_PREFIX}_birthdays.md"
diff --git a/mcp/apache-projects-mcp/index.js b/mcp/apache-projects-mcp/index.js
index 6b9f432..83b7cb5 100644
--- a/mcp/apache-projects-mcp/index.js
+++ b/mcp/apache-projects-mcp/index.js
@@ -160,6 +160,7 @@
     ),
     limit: z.number().optional().describe("Max results to return (default 50)"),
   },
+  { readOnlyHint: true },
   async ({ query, limit }) => {
     const committees = await getData("committees");
     const max = limit || 50;
@@ -229,6 +230,7 @@
       "Committee ID (e.g. 'iceberg', 'httpd', 'spark')"
     ),
   },
+  { readOnlyHint: true },
   async ({ id }) => {
     const committees = await getData("committees");
     const c = committees.find(
@@ -294,6 +296,7 @@
     ),
     limit: z.number().optional().describe("Max results (default 20)"),
   },
+  { readOnlyHint: true },
   async ({ query, limit }) => {
     const people = await getData("people");
     const names = await getData("people_name");
@@ -346,6 +349,7 @@
   {
     id: z.string().describe("Apache ID (e.g. 'rbowen', 'jmclean')"),
   },
+  { readOnlyHint: true },
   async ({ id }) => {
     const people = await getData("people");
     const names = await getData("people_name");
@@ -392,6 +396,7 @@
   {
     query: z.string().optional().describe("Filter by name or description"),
   },
+  { readOnlyHint: true },
   async ({ query }) => {
     const podlings = await getData("podlings");
 
@@ -443,6 +448,7 @@
       "Project ID (e.g. 'iceberg', 'spark', 'httpd')"
     ),
   },
+  { readOnlyHint: true },
   async ({ project }) => {
     const releases = await getData("releases");
     const key = project.toLowerCase();
@@ -503,6 +509,7 @@
       "Group name, e.g. 'iceberg' (committers) or 'iceberg-pmc' (PMC members)"
     ),
   },
+  { readOnlyHint: true },
   async ({ group }) => {
     const groups = await getData("groups");
     const names = await getData("people_name");
@@ -562,6 +569,7 @@
       "Project name or keyword to search repos (e.g. 'iceberg', 'kafka')"
     ),
   },
+  { readOnlyHint: true },
   async ({ project }) => {
     const repos = await getData("repositories");
 
@@ -610,6 +618,7 @@
     query: z.string().describe("Search keyword"),
     limit: z.number().optional().describe("Max results (default 30)"),
   },
+  { readOnlyHint: true },
   async ({ query, limit }) => {
     const max = limit || 30;
     const committees = await getData("committees");
@@ -688,6 +697,7 @@
   "Get summary statistics about the ASF: total committees, podlings, people, " +
     "members, groups, and repositories.",
   {},
+  { readOnlyHint: true },
   async () => {
     const committees = await getData("committees");
     const podlings = await getData("podlings");
diff --git a/mcp/ponymail-mcp/index.js b/mcp/ponymail-mcp/index.js
index b1fe782..e30093c 100644
--- a/mcp/ponymail-mcp/index.js
+++ b/mcp/ponymail-mcp/index.js
@@ -134,6 +134,7 @@
   "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.lua");
     const lists = data.lists || {};
@@ -199,6 +200,7 @@
       .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;
@@ -318,6 +320,7 @@
   {
     id: z.string().describe("The email ID (mid) or Message-ID header value"),
   },
+  { readOnlyHint: true },
   async ({ id }) => {
     const data = await apiFetch("/api/email.lua", { id });
 
@@ -374,6 +377,7 @@
     list: z.string().describe("List prefix, e.g. 'dev', 'user'"),
     domain: z.string().describe("List domain, e.g. 'iceberg.apache.org'"),
   },
+  { readOnlyHint: true },
   async ({ id, list, domain }) => {
     const restrictedUp = restrictionFor(list, domain);
     if (restrictedUp) {
@@ -430,6 +434,7 @@
     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;
@@ -551,6 +556,7 @@
   "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();
@@ -620,6 +626,7 @@
     "(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();