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" + } +}
