| #!/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); |