diff --git a/actions/setup/js/parse_mcp_gateway_log.cjs b/actions/setup/js/parse_mcp_gateway_log.cjs index 367f5752201..24751b9c0a4 100644 --- a/actions/setup/js/parse_mcp_gateway_log.cjs +++ b/actions/setup/js/parse_mcp_gateway_log.cjs @@ -85,6 +85,119 @@ function generateDifcFilteredSummary(filteredEvents) { return lines.join("\n"); } +/** + * Parses rpc-messages.jsonl content and returns entries categorized by type. + * DIFC_FILTERED entries are excluded here because they are handled separately + * by parseGatewayJsonlForDifcFiltered. + * @param {string} jsonlContent - The rpc-messages.jsonl file content + * @returns {{requests: Array, responses: Array, other: Array}} + */ +function parseRpcMessagesJsonl(jsonlContent) { + const requests = []; + const responses = []; + const other = []; + + const lines = jsonlContent.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed); + if (!entry || typeof entry !== "object" || !entry.type) continue; + + if (entry.type === "REQUEST") { + requests.push(entry); + } else if (entry.type === "RESPONSE") { + responses.push(entry); + } else if (entry.type !== "DIFC_FILTERED") { + other.push(entry); + } + } catch { + // skip malformed lines + } + } + + return { requests, responses, other }; +} + +/** + * Extracts a human-readable label for an MCP REQUEST entry. + * For tools/call requests, returns the tool name; for other methods, returns the method name. + * @param {Object} entry - REQUEST entry from rpc-messages.jsonl + * @returns {string} Display label for the request + */ +function getRpcRequestLabel(entry) { + const payload = entry.payload; + if (!payload) return "unknown"; + const method = payload.method; + if (method === "tools/call") { + const toolName = payload.params && payload.params.name; + return toolName || method; + } + return method || "unknown"; +} + +/** + * Generates a markdown step summary for rpc-messages.jsonl entries (mcpg v0.2.0+ format). + * Shows a table of REQUEST entries (tool calls), a count of RESPONSE entries, any other + * message types, and the DIFC_FILTERED section if there are blocked events. + * @param {{requests: Array, responses: Array, other: Array}} entries + * @param {Array} difcFilteredEvents - DIFC_FILTERED events parsed separately + * @returns {string} Markdown summary, or empty string if nothing to show + */ +function generateRpcMessagesSummary(entries, difcFilteredEvents) { + const { requests, responses, other } = entries; + const blockedCount = difcFilteredEvents ? difcFilteredEvents.length : 0; + const totalMessages = requests.length + responses.length + other.length + blockedCount; + + if (totalMessages === 0) return ""; + + const parts = []; + + // Tool calls / requests table + if (requests.length > 0) { + const blockedNote = blockedCount > 0 ? `, ${blockedCount} blocked` : ""; + const callLines = []; + callLines.push("
"); + callLines.push(`MCP Gateway Activity (${requests.length} request${requests.length !== 1 ? "s" : ""}${blockedNote})\n`); + callLines.push(""); + callLines.push("| Time | Server | Tool / Method |"); + callLines.push("|------|--------|---------------|"); + + for (const req of requests) { + const time = req.timestamp ? req.timestamp.replace("T", " ").replace(/\.\d+Z$/, "Z") : "-"; + const server = req.server_id || "-"; + const label = getRpcRequestLabel(req); + callLines.push(`| ${time} | ${server} | \`${label}\` |`); + } + + callLines.push(""); + callLines.push("
\n"); + parts.push(callLines.join("\n")); + } else if (blockedCount > 0) { + // No requests, but there are DIFC_FILTERED events — add a minimal header + parts.push(`
\nMCP Gateway Activity (${blockedCount} blocked)\n\n*All tool calls were blocked by the integrity filter.*\n\n
\n`); + } + + // Other message types (not REQUEST, RESPONSE, DIFC_FILTERED) + if (other.length > 0) { + /** @type {Record} */ + const typeCounts = {}; + for (const entry of other) { + typeCounts[entry.type] = (typeCounts[entry.type] || 0) + 1; + } + const otherLines = Object.entries(typeCounts).map(([type, count]) => `- **${type}**: ${count} message${count !== 1 ? "s" : ""}`); + parts.push("
\nOther Gateway Messages\n\n" + otherLines.join("\n") + "\n\n
\n"); + } + + // DIFC_FILTERED section (re-uses existing table renderer) + if (blockedCount > 0) { + parts.push(generateDifcFilteredSummary(difcFilteredEvents)); + } + + return parts.join("\n"); +} + /** * Main function to parse and display MCP gateway logs */ @@ -102,6 +215,7 @@ async function main() { // Parse DIFC_FILTERED events from gateway.jsonl (preferred) or rpc-messages.jsonl (fallback). // Both files use the same JSONL format with DIFC_FILTERED entries interleaved. let difcFilteredEvents = []; + let rpcMessagesContent = null; if (fs.existsSync(gatewayJsonlPath)) { const jsonlContent = fs.readFileSync(gatewayJsonlPath, "utf8"); core.info(`Found gateway.jsonl (${jsonlContent.length} bytes)`); @@ -110,9 +224,9 @@ async function main() { core.info(`Found ${difcFilteredEvents.length} DIFC_FILTERED event(s) in gateway.jsonl`); } } else if (fs.existsSync(rpcMessagesPath)) { - const jsonlContent = fs.readFileSync(rpcMessagesPath, "utf8"); - core.info(`No gateway.jsonl found; scanning rpc-messages.jsonl (${jsonlContent.length} bytes) for DIFC_FILTERED events`); - difcFilteredEvents = parseGatewayJsonlForDifcFiltered(jsonlContent); + rpcMessagesContent = fs.readFileSync(rpcMessagesPath, "utf8"); + core.info(`Found rpc-messages.jsonl (${rpcMessagesContent.length} bytes)`); + difcFilteredEvents = parseGatewayJsonlForDifcFiltered(rpcMessagesContent); if (difcFilteredEvents.length > 0) { core.info(`Found ${difcFilteredEvents.length} DIFC_FILTERED event(s) in rpc-messages.jsonl`); } @@ -142,6 +256,25 @@ async function main() { core.info(`No gateway.md found at: ${gatewayMdPath}, falling back to log files`); } + // When no gateway.md exists, check if rpc-messages.jsonl is available (mcpg v0.2.0+ unified format). + // In this format, all message types (REQUEST, RESPONSE, DIFC_FILTERED, etc.) are written to a + // single rpc-messages.jsonl file instead of separate gateway.md / gateway.log streams. + if (rpcMessagesContent !== null) { + const rpcEntries = parseRpcMessagesJsonl(rpcMessagesContent); + const totalMessages = rpcEntries.requests.length + rpcEntries.responses.length + rpcEntries.other.length; + core.info(`rpc-messages.jsonl: ${rpcEntries.requests.length} request(s), ${rpcEntries.responses.length} response(s), ${rpcEntries.other.length} other, ${difcFilteredEvents.length} DIFC_FILTERED`); + + if (totalMessages > 0 || difcFilteredEvents.length > 0) { + const rpcSummary = generateRpcMessagesSummary(rpcEntries, difcFilteredEvents); + if (rpcSummary.length > 0) { + core.summary.addRaw(rpcSummary).write(); + } + } else { + core.info("rpc-messages.jsonl is present but contains no renderable messages"); + } + return; + } + // Fallback to legacy log files let gatewayLogContent = ""; let stderrLogContent = ""; @@ -298,6 +431,9 @@ if (typeof module !== "undefined" && module.exports) { generatePlainTextLegacySummary, parseGatewayJsonlForDifcFiltered, generateDifcFilteredSummary, + parseRpcMessagesJsonl, + getRpcRequestLabel, + generateRpcMessagesSummary, printAllGatewayFiles, }; } diff --git a/actions/setup/js/parse_mcp_gateway_log.test.cjs b/actions/setup/js/parse_mcp_gateway_log.test.cjs index ca9614d4f2a..76e97a00df6 100644 --- a/actions/setup/js/parse_mcp_gateway_log.test.cjs +++ b/actions/setup/js/parse_mcp_gateway_log.test.cjs @@ -1,7 +1,17 @@ // @ts-check /// -const { generateGatewayLogSummary, generatePlainTextGatewaySummary, generatePlainTextLegacySummary, parseGatewayJsonlForDifcFiltered, generateDifcFilteredSummary, printAllGatewayFiles } = require("./parse_mcp_gateway_log.cjs"); +const { + generateGatewayLogSummary, + generatePlainTextGatewaySummary, + generatePlainTextLegacySummary, + parseGatewayJsonlForDifcFiltered, + generateDifcFilteredSummary, + parseRpcMessagesJsonl, + getRpcRequestLabel, + generateRpcMessagesSummary, + printAllGatewayFiles, +} = require("./parse_mcp_gateway_log.cjs"); describe("parse_mcp_gateway_log", () => { // Note: The main() function now checks for gateway.md first before falling back to log files. @@ -723,4 +733,157 @@ Some content here.`; expect(summary).toContain("DIFC Filtered Events (3)"); }); }); + + describe("parseRpcMessagesJsonl", () => { + test("returns empty arrays for empty content", () => { + const result = parseRpcMessagesJsonl(""); + expect(result.requests).toHaveLength(0); + expect(result.responses).toHaveLength(0); + expect(result.other).toHaveLength(0); + }); + + test("categorizes REQUEST entries", () => { + const content = [ + JSON.stringify({ timestamp: "2026-01-18T11:10:49Z", direction: "OUT", type: "REQUEST", server_id: "github", payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_issues", arguments: {} } } }), + JSON.stringify({ timestamp: "2026-01-18T11:10:50Z", direction: "IN", type: "RESPONSE", server_id: "github", payload: { jsonrpc: "2.0", id: 1, result: {} } }), + ].join("\n"); + + const result = parseRpcMessagesJsonl(content); + expect(result.requests).toHaveLength(1); + expect(result.responses).toHaveLength(1); + expect(result.other).toHaveLength(0); + expect(result.requests[0].server_id).toBe("github"); + }); + + test("excludes DIFC_FILTERED entries (handled separately)", () => { + const content = [ + JSON.stringify({ type: "REQUEST", server_id: "github", payload: { method: "tools/call", params: { name: "list_issues" } } }), + JSON.stringify({ type: "DIFC_FILTERED", server_id: "github", tool_name: "get_issue", reason: "blocked" }), + ].join("\n"); + + const result = parseRpcMessagesJsonl(content); + expect(result.requests).toHaveLength(1); + expect(result.other).toHaveLength(0); + }); + + test("captures unknown message types in other array", () => { + const content = [ + JSON.stringify({ type: "SESSION_START", server_id: "github" }), + JSON.stringify({ type: "SESSION_END", server_id: "github" }), + JSON.stringify({ type: "REQUEST", server_id: "github", payload: { method: "initialize" } }), + ].join("\n"); + + const result = parseRpcMessagesJsonl(content); + expect(result.requests).toHaveLength(1); + expect(result.other).toHaveLength(2); + }); + + test("skips malformed JSON lines", () => { + const content = ["not valid json", JSON.stringify({ type: "REQUEST", server_id: "github", payload: { method: "tools/call", params: { name: "list_issues" } } }), "{broken}"].join("\n"); + + const result = parseRpcMessagesJsonl(content); + expect(result.requests).toHaveLength(1); + }); + + test("skips entries without a type field", () => { + const content = [JSON.stringify({ server_id: "github" }), JSON.stringify({ type: "REQUEST", server_id: "ok", payload: { method: "tools/list" } })].join("\n"); + + const result = parseRpcMessagesJsonl(content); + expect(result.requests).toHaveLength(1); + expect(result.other).toHaveLength(0); + }); + }); + + describe("getRpcRequestLabel", () => { + test("returns tool name for tools/call requests", () => { + const entry = { type: "REQUEST", payload: { method: "tools/call", params: { name: "list_issues" } } }; + expect(getRpcRequestLabel(entry)).toBe("list_issues"); + }); + + test("returns method name for non-tools/call requests", () => { + const entry = { type: "REQUEST", payload: { method: "tools/list" } }; + expect(getRpcRequestLabel(entry)).toBe("tools/list"); + }); + + test("returns tools/call as fallback when params.name is missing", () => { + const entry = { type: "REQUEST", payload: { method: "tools/call" } }; + expect(getRpcRequestLabel(entry)).toBe("tools/call"); + }); + + test("returns unknown when payload is missing", () => { + const entry = { type: "REQUEST" }; + expect(getRpcRequestLabel(entry)).toBe("unknown"); + }); + + test("returns unknown when method is missing", () => { + const entry = { type: "REQUEST", payload: {} }; + expect(getRpcRequestLabel(entry)).toBe("unknown"); + }); + }); + + describe("generateRpcMessagesSummary", () => { + const sampleRequests = [ + { timestamp: "2026-01-18T11:10:49Z", direction: "OUT", type: "REQUEST", server_id: "github", payload: { method: "tools/call", params: { name: "list_issues" } } }, + { timestamp: "2026-01-18T11:10:51Z", direction: "OUT", type: "REQUEST", server_id: "safeoutputs", payload: { method: "tools/call", params: { name: "add_comment" } } }, + ]; + const sampleResponses = [{ timestamp: "2026-01-18T11:10:50Z", direction: "IN", type: "RESPONSE", server_id: "github", payload: { jsonrpc: "2.0", result: {} } }]; + + test("returns empty string for no messages", () => { + expect(generateRpcMessagesSummary({ requests: [], responses: [], other: [] }, [])).toBe(""); + }); + + test("generates details/summary with request count", () => { + const summary = generateRpcMessagesSummary({ requests: sampleRequests, responses: sampleResponses, other: [] }, []); + expect(summary).toContain("
"); + expect(summary).toContain("MCP Gateway Activity (2 requests)"); + expect(summary).toContain("
"); + }); + + test("renders request table with time, server, and tool columns", () => { + const summary = generateRpcMessagesSummary({ requests: sampleRequests, responses: [], other: [] }, []); + expect(summary).toContain("| Time | Server | Tool / Method |"); + expect(summary).toContain("`list_issues`"); + expect(summary).toContain("`add_comment`"); + expect(summary).toContain("github"); + expect(summary).toContain("safeoutputs"); + }); + + test("formats ISO timestamp as readable date-time", () => { + const summary = generateRpcMessagesSummary({ requests: sampleRequests, responses: [], other: [] }, []); + expect(summary).toContain("2026-01-18 11:10:49Z"); + }); + + test("shows blocked count in summary when DIFC events present", () => { + const difcEvents = [{ type: "DIFC_FILTERED", tool_name: "get_issue", reason: "blocked" }]; + const summary = generateRpcMessagesSummary({ requests: sampleRequests, responses: [], other: [] }, difcEvents); + expect(summary).toContain("1 blocked"); + }); + + test("includes DIFC_FILTERED table when events are present", () => { + const difcEvents = [{ type: "DIFC_FILTERED", tool_name: "get_issue", server_id: "github", reason: "Integrity check failed", author_login: "user1", author_association: "MEMBER" }]; + const summary = generateRpcMessagesSummary({ requests: sampleRequests, responses: [], other: [] }, difcEvents); + expect(summary).toContain("DIFC Filtered Events"); + expect(summary).toContain("`get_issue`"); + }); + + test("renders other message types section", () => { + const other = [ + { type: "SESSION_START", server_id: "github" }, + { type: "SESSION_START", server_id: "github" }, + { type: "SESSION_END", server_id: "github" }, + ]; + const summary = generateRpcMessagesSummary({ requests: [], responses: [], other }, []); + expect(summary).toContain("Other Gateway Messages"); + expect(summary).toContain("SESSION_START"); + expect(summary).toContain("SESSION_END"); + expect(summary).toContain("2 messages"); + }); + + test("shows minimal header when only DIFC events exist (no requests)", () => { + const difcEvents = [{ type: "DIFC_FILTERED", tool_name: "list_issues", reason: "blocked" }]; + const summary = generateRpcMessagesSummary({ requests: [], responses: [], other: [] }, difcEvents); + expect(summary).toContain("1 blocked"); + expect(summary).toContain("All tool calls were blocked"); + }); + }); });