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 75f3921224cbfe44efb9f55e46f5b5cb7a2ee27a Author: Justin Mclean <[email protected]> AuthorDate: Sun Apr 19 13:17:10 2026 +1000 Add structuredContent alongside existing text responses for MCP tools --- index.js | 193 ++++++++++++++++++++++++++++++++++++++++---------- package.json | 3 +- test/response.test.js | 21 ++++++ 3 files changed, 177 insertions(+), 40 deletions(-) diff --git a/index.js b/index.js index de96175..991c485 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { pathToFileURL } from "node:url"; import { z } from "zod"; const BASE_URL = "https://projects.apache.org/json"; @@ -68,6 +69,19 @@ function truncateList(items, max = 50) { return { items: items.slice(0, max), truncated: true, total: items.length }; } +function makeResponse(text, structuredContent) { + return { + content: [{ type: "text", text }], + structuredContent, + }; +} + +function makeTextResponse(text) { + return { + content: [{ type: "text", text }], + }; +} + // --------------------------------------------------------------------------- // Server // --------------------------------------------------------------------------- @@ -123,7 +137,20 @@ server.tool( lines.push(`\n... showing ${max} of ${total} results. Use a query to narrow down.`); } - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + query: query || null, + count: results.length, + shown: items.length, + truncated: !!truncated, + committees: items.map((c) => ({ + id: c.id, + name: c.name, + shortdesc: c.shortdesc || null, + chair: c.chair || null, + established: c.established || null, + homepage: c.homepage || null, + })), + }); } ); @@ -144,9 +171,7 @@ server.tool( ); if (!c) { - return { - content: [{ type: "text", text: `Committee "${id}" not found.` }], - }; + return makeTextResponse(`Committee "${id}" not found.`); } const lines = []; @@ -162,16 +187,34 @@ server.tool( lines.push(c.charter || "No charter available."); lines.push(""); + let roster = []; 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"}`); + roster = members.map(([uid, info]) => ({ + id: uid, + name: info.name || uid, + joined: info.date || null, + })); + + lines.push(`## PMC Roster (${members.length} members)`); + for (const member of roster) { + lines.push(`- ${member.name} (${member.id}) — joined ${member.joined || "unknown"}`); } } - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + id: c.id, + name: c.name, + group: c.group || null, + chair: c.chair || null, + established: c.established || null, + homepage: c.homepage || null, + reporting: c.reporting || null, + shortdesc: c.shortdesc || null, + charter: c.charter || null, + roster, + }); } ); @@ -215,7 +258,18 @@ server.tool( lines.push(`\n... showing ${max} of ${total} results.`); } - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + query, + count: matches.length, + shown: items.length, + truncated: !!truncated, + people: items.map((p) => ({ + id: p.uid, + name: p.name, + member: !!p.member, + groups: p.groups || [], + })), + }); } ); @@ -231,20 +285,17 @@ server.tool( 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.` }], - }; + return makeTextResponse(`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 pmcs = pmcGroups.map((g) => g.replace("-pmc", "")); const lines = []; lines.push(`# ${name} (${uid})`); @@ -254,9 +305,17 @@ server.tool( 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") }] }; + lines.push(pmcs.join(", ") || "None"); + + return makeResponse(lines.join("\n"), { + id: uid, + name, + member: !!person.member, + groups, + committerGroups, + pmcGroups, + pmcs, + }); } ); @@ -293,7 +352,16 @@ server.tool( if (desc) lines.push(` ${desc}`); } - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + count: entries.length, + podlings: entries.map(([id, p]) => ({ + id, + name: p.name || id, + started: p.started || null, + homepage: p.homepage || null, + description: (p.description || "").replace(/\s+/g, " ").trim() || null, + })), + }); } ); @@ -315,18 +383,17 @@ server.tool( // 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 makeResponse( + `Project "${project}" not found. Did you mean: ${matches.join(", ")}?`, + { + project: key, + count: 0, + releases: [], + suggestions: matches, + } + ); } - return { - content: [{ type: "text", text: `No releases found for "${project}".` }], - }; + return makeTextResponse(`No releases found for "${project}".`); } const entries = Object.entries(projectReleases); @@ -346,7 +413,14 @@ server.tool( lines.push(`- **${name}** — ${date}`); } - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + project: key, + count: entries.length, + releases: entries.map(([name, info]) => ({ + name, + date: typeof info === "string" ? info : info.date || null, + })), + }); } ); @@ -398,7 +472,14 @@ server.tool( lines.push(`- ${m.name} (${m.uid})`); } - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + group: key, + count: enriched.length, + members: enriched.map((m) => ({ + id: m.uid, + name: m.name, + })), + }); } ); @@ -436,7 +517,14 @@ server.tool( lines.push(`- **${name}**: ${url}`); } - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + project: project, + count: matches.length, + repositories: matches.map(([name, url]) => ({ + name, + url, + })), + }); } ); @@ -507,7 +595,19 @@ server.tool( lines.push(`\n... showing ${max} of ${total}.`); } - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + query, + count: results.length, + shown: items.length, + truncated: !!truncated, + projects: items.map((r) => ({ + type: r.type, + id: r.id, + name: r.name, + description: r.desc || null, + homepage: r.homepage || null, + })), + }); } ); @@ -545,7 +645,18 @@ server.tool( lines.push(`- **Projects with releases:** ${Object.keys(releases).length}`); lines.push(`- **Total releases tracked:** ${totalReleases}`); - return { content: [{ type: "text", text: lines.join("\n") }] }; + return makeResponse(lines.join("\n"), { + committees: committees.length, + podlings: Object.keys(podlings).length, + people: Object.keys(people).length, + members: memberCount, + groups: Object.keys(groups).length, + pmcGroups, + committerGroups, + repositories: Object.keys(repos).length, + projectsWithReleases: Object.keys(releases).length, + totalReleases, + }); } ); @@ -553,8 +664,12 @@ server.tool( // Start // --------------------------------------------------------------------------- -// Warm cache on startup (non-blocking — tools will fetch on demand if this is slow) -warmCache().catch(() => {}); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + // 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); +} -const transport = new StdioServerTransport(); -await server.connect(transport); +export { makeResponse, makeTextResponse }; diff --git a/package.json b/package.json index e6660cf..5f5ce02 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "index.js", "scripts": { - "start": "node index.js" + "start": "node index.js", + "test": "node --test" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1" diff --git a/test/response.test.js b/test/response.test.js new file mode 100644 index 0000000..56db4c9 --- /dev/null +++ b/test/response.test.js @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { makeResponse } from '../index.js'; + +test('makeResponse returns content and structuredContent', () => { + const text = 'hello'; + const data = { a: 1 }; + + const result = makeResponse(text, data); + + assert.ok(result.content); + assert.strictEqual(result.content[0].text, text); + assert.deepStrictEqual(result.structuredContent, data); +}); + +test('structuredContent is present and object-like', () => { + const result = makeResponse('test', { foo: 'bar' }); + + assert.strictEqual(typeof result.structuredContent, 'object'); + assert.strictEqual(result.structuredContent.foo, 'bar'); +}); \ No newline at end of file
