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();