This is an automated email from the ASF dual-hosted git repository.

rbowen pushed a commit to branch rbowen-apache-projects-mcp
in repository https://gitbox.apache.org/repos/asf/comdev.git

commit 60c8cb528df098760fe6756fa45b899b7534ebc3
Author: Rich Bowen <[email protected]>
AuthorDate: Sat Apr 18 08:08:16 2026 -0400

    Add initial code
---
 index.js     | 560 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json |  13 ++
 2 files changed, 573 insertions(+)

diff --git a/index.js b/index.js
new file mode 100644
index 0000000..de96175
--- /dev/null
+++ b/index.js
@@ -0,0 +1,560 @@
+#!/usr/bin/env node
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from 
"@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+
+const BASE_URL = "https://projects.apache.org/json";;
+
+// ---------------------------------------------------------------------------
+// Data cache — fetched on first use, refreshed every 6 hours
+// ---------------------------------------------------------------------------
+
+const cache = {};
+const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
+
+const DATA_SOURCES = {
+  committees:   `${BASE_URL}/foundation/committees.json`,
+  people:       `${BASE_URL}/foundation/people.json`,
+  people_name:  `${BASE_URL}/foundation/people_name.json`,
+  releases:     `${BASE_URL}/foundation/releases.json`,
+  groups:       `${BASE_URL}/foundation/groups.json`,
+  podlings:     `${BASE_URL}/foundation/podlings.json`,
+  repositories: `${BASE_URL}/foundation/repositories.json`,
+};
+
+async function getData(key) {
+  const now = Date.now();
+  if (cache[key] && (now - cache[key].ts) < CACHE_TTL) {
+    return cache[key].data;
+  }
+  const url = DATA_SOURCES[key];
+  if (!url) throw new Error(`Unknown data source: ${key}`);
+
+  const resp = await fetch(url, { headers: { Accept: "application/json" } });
+  if (!resp.ok) {
+    throw new Error(`Failed to fetch ${key}: HTTP ${resp.status}`);
+  }
+  const data = await resp.json();
+  cache[key] = { data, ts: now };
+  return data;
+}
+
+// Warm all caches
+async function warmCache() {
+  const results = {};
+  for (const key of Object.keys(DATA_SOURCES)) {
+    try {
+      results[key] = await getData(key);
+    } catch (e) {
+      // Non-fatal — tool will retry on demand
+    }
+  }
+  return results;
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function matchesQuery(text, query) {
+  if (!query) return true;
+  const lower = query.toLowerCase();
+  return text.toLowerCase().includes(lower);
+}
+
+function truncateList(items, max = 50) {
+  if (items.length <= max) return { items, truncated: false };
+  return { items: items.slice(0, max), truncated: true, total: items.length };
+}
+
+// ---------------------------------------------------------------------------
+// Server
+// ---------------------------------------------------------------------------
+
+const server = new McpServer({
+  name: "apache-projects",
+  version: "1.0.0",
+});
+
+// --- Tool: list_committees --------------------------------------------------
+server.tool(
+  "list_committees",
+  "List Apache project committees (PMCs). Optionally filter by name or 
keyword. " +
+    "Returns committee ID, name, short description, chair, and established 
date.",
+  {
+    query: z.string().optional().describe(
+      "Search query to filter by name, description, or charter text"
+    ),
+    limit: z.number().optional().describe("Max results to return (default 
50)"),
+  },
+  async ({ query, limit }) => {
+    const committees = await getData("committees");
+    const max = limit || 50;
+
+    let results = committees;
+    if (query) {
+      results = committees.filter(
+        (c) =>
+          matchesQuery(c.name || "", query) ||
+          matchesQuery(c.shortdesc || "", query) ||
+          matchesQuery(c.charter || "", query) ||
+          matchesQuery(c.id || "", query)
+      );
+    }
+
+    const { items, truncated, total } = truncateList(results, max);
+    const lines = [];
+    if (query) {
+      lines.push(`## Committees matching "${query}" (${results.length} 
found)`);
+    } else {
+      lines.push(`## Apache Committees (${committees.length} total)`);
+    }
+    lines.push("");
+
+    for (const c of items) {
+      lines.push(
+        `- **${c.name}** (${c.id}) — ${c.shortdesc || "no description"}`
+      );
+      lines.push(`  Chair: ${c.chair} | Est: ${c.established} | ${c.homepage 
|| ""}`);
+    }
+
+    if (truncated) {
+      lines.push(`\n... showing ${max} of ${total} results. Use a query to 
narrow down.`);
+    }
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: get_committee ----------------------------------------------------
+server.tool(
+  "get_committee",
+  "Get detailed info about a specific Apache committee/PMC, including full " +
+    "roster with member names and dates, chair, charter, and homepage.",
+  {
+    id: z.string().describe(
+      "Committee ID (e.g. 'iceberg', 'httpd', 'spark')"
+    ),
+  },
+  async ({ id }) => {
+    const committees = await getData("committees");
+    const c = committees.find(
+      (x) => x.id === id.toLowerCase() || x.group === id.toLowerCase()
+    );
+
+    if (!c) {
+      return {
+        content: [{ type: "text", text: `Committee "${id}" not found.` }],
+      };
+    }
+
+    const lines = [];
+    lines.push(`# ${c.name}`);
+    lines.push(`ID: ${c.id}`);
+    lines.push(`Chair: ${c.chair}`);
+    lines.push(`Established: ${c.established}`);
+    lines.push(`Homepage: ${c.homepage || "N/A"}`);
+    lines.push(`Reporting cycle: ${c.reporting || "N/A"}`);
+    lines.push(`Short description: ${c.shortdesc || "N/A"}`);
+    lines.push("");
+    lines.push("## Charter");
+    lines.push(c.charter || "No charter available.");
+    lines.push("");
+
+    if (c.roster) {
+      const members = Object.entries(c.roster);
+      lines.push(`## PMC Roster (${members.length} members)`);
+      members.sort((a, b) => a[1].name.localeCompare(b[1].name));
+      for (const [uid, info] of members) {
+        lines.push(`- ${info.name} (${uid}) — joined ${info.date || 
"unknown"}`);
+      }
+    }
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: search_people ----------------------------------------------------
+server.tool(
+  "search_people",
+  "Search for ASF committers/members by Apache ID or name. Returns matching " +
+    "people with their full name, groups, and member status.",
+  {
+    query: z.string().describe(
+      "Apache ID or name (partial match supported)"
+    ),
+    limit: z.number().optional().describe("Max results (default 20)"),
+  },
+  async ({ query, limit }) => {
+    const people = await getData("people");
+    const names = await getData("people_name");
+    const max = limit || 20;
+    const lower = query.toLowerCase();
+
+    const matches = [];
+    for (const [uid, info] of Object.entries(people)) {
+      const name = names[uid] || info.name || "";
+      if (uid.toLowerCase().includes(lower) || 
name.toLowerCase().includes(lower)) {
+        matches.push({ uid, name, ...info });
+      }
+    }
+
+    const { items, truncated, total } = truncateList(matches, max);
+    const lines = [];
+    lines.push(`## People matching "${query}" (${matches.length} found)`);
+    lines.push("");
+
+    for (const p of items) {
+      const memberStr = p.member ? " [ASF Member]" : "";
+      lines.push(`- **${p.name}** (${p.uid})${memberStr}`);
+      lines.push(`  Groups: ${(p.groups || []).join(", ")}`);
+    }
+
+    if (truncated) {
+      lines.push(`\n... showing ${max} of ${total} results.`);
+    }
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: get_person -------------------------------------------------------
+server.tool(
+  "get_person",
+  "Get full details about an ASF person by their Apache ID. Includes name, " +
+    "group memberships (committer and PMC groups), and ASF member status.",
+  {
+    id: z.string().describe("Apache ID (e.g. 'rbowen', 'jmclean')"),
+  },
+  async ({ id }) => {
+    const people = await getData("people");
+    const names = await getData("people_name");
+    const uid = id.toLowerCase();
+
+    const person = people[uid];
+    if (!person) {
+      return {
+        content: [{ type: "text", text: `Person "${id}" not found.` }],
+      };
+    }
+
+    const name = names[uid] || person.name || uid;
+    const groups = person.groups || [];
+
+    // Separate committer groups from PMC groups
+    const pmcGroups = groups.filter((g) => g.endsWith("-pmc"));
+    const committerGroups = groups.filter((g) => !g.endsWith("-pmc"));
+
+    const lines = [];
+    lines.push(`# ${name} (${uid})`);
+    lines.push(`ASF Member: ${person.member ? "Yes" : "No"}`);
+    lines.push("");
+    lines.push(`## Committer Groups (${committerGroups.length})`);
+    lines.push(committerGroups.join(", ") || "None");
+    lines.push("");
+    lines.push(`## PMC Memberships (${pmcGroups.length})`);
+    lines.push(pmcGroups.map((g) => g.replace("-pmc", "")).join(", ") || 
"None");
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: list_podlings ----------------------------------------------------
+server.tool(
+  "list_podlings",
+  "List current Apache Incubator podlings with their description, start date, 
" +
+    "and homepage.",
+  {
+    query: z.string().optional().describe("Filter by name or description"),
+  },
+  async ({ query }) => {
+    const podlings = await getData("podlings");
+
+    let entries = Object.entries(podlings);
+    if (query) {
+      const lower = query.toLowerCase();
+      entries = entries.filter(
+        ([id, p]) =>
+          id.toLowerCase().includes(lower) ||
+          (p.name || "").toLowerCase().includes(lower) ||
+          (p.description || "").toLowerCase().includes(lower)
+      );
+    }
+
+    const lines = [];
+    lines.push(`## Apache Podlings (${entries.length})`);
+    lines.push("");
+
+    for (const [id, p] of entries) {
+      lines.push(`- **${p.name}** (${id})`);
+      lines.push(`  Started: ${p.started || "unknown"} | Homepage: 
${p.homepage || "N/A"}`);
+      const desc = (p.description || "").replace(/\s+/g, " ").trim();
+      if (desc) lines.push(`  ${desc}`);
+    }
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: get_releases -----------------------------------------------------
+server.tool(
+  "get_releases",
+  "Get release history for an Apache project. Returns release names and 
dates.",
+  {
+    project: z.string().describe(
+      "Project ID (e.g. 'iceberg', 'spark', 'httpd')"
+    ),
+  },
+  async ({ project }) => {
+    const releases = await getData("releases");
+    const key = project.toLowerCase();
+    const projectReleases = releases[key];
+
+    if (!projectReleases) {
+      // Try fuzzy match
+      const matches = Object.keys(releases).filter((k) => k.includes(key));
+      if (matches.length > 0) {
+        return {
+          content: [
+            {
+              type: "text",
+              text: `Project "${project}" not found. Did you mean: 
${matches.join(", ")}?`,
+            },
+          ],
+        };
+      }
+      return {
+        content: [{ type: "text", text: `No releases found for "${project}".` 
}],
+      };
+    }
+
+    const entries = Object.entries(projectReleases);
+    // Sort by date descending
+    entries.sort((a, b) => {
+      const dateA = typeof a[1] === "string" ? a[1] : a[1].date || "";
+      const dateB = typeof b[1] === "string" ? b[1] : b[1].date || "";
+      return dateB.localeCompare(dateA);
+    });
+
+    const lines = [];
+    lines.push(`## Releases for ${project} (${entries.length} total)`);
+    lines.push("");
+
+    for (const [name, info] of entries) {
+      const date = typeof info === "string" ? info : info.date || "unknown";
+      lines.push(`- **${name}** — ${date}`);
+    }
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: get_group_members ------------------------------------------------
+server.tool(
+  "get_group_members",
+  "List members of an ASF LDAP group (committer or PMC group). " +
+    "Use '{project}' for committers, '{project}-pmc' for PMC members.",
+  {
+    group: z.string().describe(
+      "Group name, e.g. 'iceberg' (committers) or 'iceberg-pmc' (PMC members)"
+    ),
+  },
+  async ({ group }) => {
+    const groups = await getData("groups");
+    const names = await getData("people_name");
+    const key = group.toLowerCase();
+
+    const members = groups[key];
+    if (!members) {
+      // Try to suggest
+      const matches = Object.keys(groups).filter((k) => k.includes(key));
+      if (matches.length > 0) {
+        return {
+          content: [
+            {
+              type: "text",
+              text: `Group "${group}" not found. Similar groups: 
${matches.slice(0, 10).join(", ")}`,
+            },
+          ],
+        };
+      }
+      return {
+        content: [{ type: "text", text: `Group "${group}" not found.` }],
+      };
+    }
+
+    const lines = [];
+    lines.push(`## Group: ${key} (${members.length} members)`);
+    lines.push("");
+
+    const enriched = members.map((uid) => ({
+      uid,
+      name: names[uid] || uid,
+    }));
+    enriched.sort((a, b) => a.name.localeCompare(b.name));
+
+    for (const m of enriched) {
+      lines.push(`- ${m.name} (${m.uid})`);
+    }
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: get_repositories -------------------------------------------------
+server.tool(
+  "get_repositories",
+  "Find source code repositories for an Apache project. " +
+    "Returns matching repo names and URLs.",
+  {
+    project: z.string().describe(
+      "Project name or keyword to search repos (e.g. 'iceberg', 'kafka')"
+    ),
+  },
+  async ({ project }) => {
+    const repos = await getData("repositories");
+    const lower = project.toLowerCase();
+
+    const matches = Object.entries(repos).filter(([name]) =>
+      name.toLowerCase().includes(lower)
+    );
+
+    if (matches.length === 0) {
+      return {
+        content: [
+          { type: "text", text: `No repositories found matching "${project}".` 
},
+        ],
+      };
+    }
+
+    const lines = [];
+    lines.push(`## Repositories matching "${project}" (${matches.length})`);
+    lines.push("");
+
+    for (const [name, url] of matches) {
+      lines.push(`- **${name}**: ${url}`);
+    }
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: search_projects --------------------------------------------------
+server.tool(
+  "search_projects",
+  "Search across all Apache projects (committees + podlings) by keyword. " +
+    "Searches names, descriptions, and charters. Returns a unified list.",
+  {
+    query: z.string().describe("Search keyword"),
+    limit: z.number().optional().describe("Max results (default 30)"),
+  },
+  async ({ query, limit }) => {
+    const max = limit || 30;
+    const committees = await getData("committees");
+    const podlings = await getData("podlings");
+    const lower = query.toLowerCase();
+
+    const results = [];
+
+    // Search committees
+    for (const c of committees) {
+      if (
+        matchesQuery(c.name || "", query) ||
+        matchesQuery(c.id || "", query) ||
+        matchesQuery(c.shortdesc || "", query) ||
+        matchesQuery(c.charter || "", query)
+      ) {
+        results.push({
+          type: "TLP",
+          id: c.id,
+          name: c.name,
+          desc: c.shortdesc || "",
+          homepage: c.homepage || "",
+        });
+      }
+    }
+
+    // Search podlings
+    for (const [id, p] of Object.entries(podlings)) {
+      if (
+        id.toLowerCase().includes(lower) ||
+        matchesQuery(p.name || "", query) ||
+        matchesQuery(p.description || "", query)
+      ) {
+        results.push({
+          type: "Podling",
+          id,
+          name: p.name,
+          desc: (p.description || "").replace(/\s+/g, " ").trim(),
+          homepage: p.homepage || "",
+        });
+      }
+    }
+
+    const { items, truncated, total } = truncateList(results, max);
+    const lines = [];
+    lines.push(`## Projects matching "${query}" (${results.length} found)`);
+    lines.push("");
+
+    for (const r of items) {
+      lines.push(`- **${r.name}** (${r.id}) [${r.type}]`);
+      if (r.desc) lines.push(`  ${r.desc}`);
+      if (r.homepage) lines.push(`  ${r.homepage}`);
+    }
+
+    if (truncated) {
+      lines.push(`\n... showing ${max} of ${total}.`);
+    }
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// --- Tool: project_stats ----------------------------------------------------
+server.tool(
+  "project_stats",
+  "Get summary statistics about the ASF: total committees, podlings, people, " 
+
+    "members, groups, and repositories.",
+  {},
+  async () => {
+    const committees = await getData("committees");
+    const podlings = await getData("podlings");
+    const people = await getData("people");
+    const groups = await getData("groups");
+    const repos = await getData("repositories");
+    const releases = await getData("releases");
+
+    const memberCount = Object.values(people).filter((p) => p.member).length;
+    const pmcGroups = Object.keys(groups).filter((g) => 
g.endsWith("-pmc")).length;
+    const committerGroups = Object.keys(groups).filter((g) => 
!g.endsWith("-pmc")).length;
+    const totalReleases = Object.values(releases).reduce(
+      (sum, r) => sum + Object.keys(r).length,
+      0
+    );
+
+    const lines = [];
+    lines.push("# Apache Software Foundation — Summary Statistics");
+    lines.push("");
+    lines.push(`- **Committees (TLPs):** ${committees.length}`);
+    lines.push(`- **Podlings:** ${Object.keys(podlings).length}`);
+    lines.push(`- **People (committers):** ${Object.keys(people).length}`);
+    lines.push(`- **ASF Members:** ${memberCount}`);
+    lines.push(`- **LDAP Groups:** ${Object.keys(groups).length} (${pmcGroups} 
PMC, ${committerGroups} committer)`);
+    lines.push(`- **Repositories:** ${Object.keys(repos).length}`);
+    lines.push(`- **Projects with releases:** 
${Object.keys(releases).length}`);
+    lines.push(`- **Total releases tracked:** ${totalReleases}`);
+
+    return { content: [{ type: "text", text: lines.join("\n") }] };
+  }
+);
+
+// ---------------------------------------------------------------------------
+// Start
+// ---------------------------------------------------------------------------
+
+// Warm cache on startup (non-blocking — tools will fetch on demand if this is 
slow)
+warmCache().catch(() => {});
+
+const transport = new StdioServerTransport();
+await server.connect(transport);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e6660cf
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "apache-projects-mcp",
+  "version": "1.0.0",
+  "description": "MCP server for querying Apache Software Foundation project 
data from projects.apache.org",
+  "type": "module",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js"
+  },
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.12.1"
+  }
+}

Reply via email to