Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 139 additions & 3 deletions actions/setup/js/parse_mcp_gateway_log.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>, responses: Array<Object>, other: Array<Object>}}
*/
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<Object>, responses: Array<Object>, other: Array<Object>}} entries
* @param {Array<Object>} 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("<details>");
callLines.push(`<summary>MCP Gateway Activity (${requests.length} request${requests.length !== 1 ? "s" : ""}${blockedNote})</summary>\n`);
callLines.push("");
callLines.push("| Time | Server | Tool / Method |");
callLines.push("|------|--------|---------------|");

Comment on lines +141 to +166
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateRpcMessagesSummary parses and counts responses, but the rendered step summary never mentions RESPONSE messages (only requests table + other type counts + DIFC section). This seems to conflict with the PR goal/description of rendering REQUEST and RESPONSE message types. Consider including at least a response count in the summary (or a collapsible section), or update the PR description/title if responses are intentionally omitted.

Copilot uses AI. Check for mistakes.
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}\` |`);
}
Comment on lines +168 to +172
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Values interpolated into the markdown table for REQUESTs (server_id, and the label from getRpcRequestLabel) are not escaped for table-breaking characters like | or newlines. generateDifcFilteredSummary already escapes reason for |, so it would be more robust to similarly escape server_id (and optionally the label) before writing table rows.

Copilot uses AI. Check for mistakes.

callLines.push("");
callLines.push("</details>\n");
parts.push(callLines.join("\n"));
} else if (blockedCount > 0) {
// No requests, but there are DIFC_FILTERED events — add a minimal header
parts.push(`<details>\n<summary>MCP Gateway Activity (${blockedCount} blocked)</summary>\n\n*All tool calls were blocked by the integrity filter.*\n\n</details>\n`);
}

// Other message types (not REQUEST, RESPONSE, DIFC_FILTERED)
if (other.length > 0) {
/** @type {Record<string, number>} */
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("<details>\n<summary>Other Gateway Messages</summary>\n\n" + otherLines.join("\n") + "\n\n</details>\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
*/
Expand All @@ -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)`);
Expand All @@ -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`);
}
Expand Down Expand Up @@ -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;
Comment on lines +268 to +275
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core.summary.addRaw(rpcSummary).write() is not awaited. Since main() returns immediately afterward, the step summary write can be dropped/flaky depending on event loop timing. Please await core.summary.addRaw(rpcSummary).write(); (and keep summary writes consistent across branches).

Copilot uses AI. Check for mistakes.
}

// Fallback to legacy log files
let gatewayLogContent = "";
let stderrLogContent = "";
Expand Down Expand Up @@ -298,6 +431,9 @@ if (typeof module !== "undefined" && module.exports) {
generatePlainTextLegacySummary,
parseGatewayJsonlForDifcFiltered,
generateDifcFilteredSummary,
parseRpcMessagesJsonl,
getRpcRequestLabel,
generateRpcMessagesSummary,
printAllGatewayFiles,
};
}
Expand Down
165 changes: 164 additions & 1 deletion actions/setup/js/parse_mcp_gateway_log.test.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
// @ts-check
/// <reference types="@actions/github-script" />

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.
Expand Down Expand Up @@ -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("<details>");
expect(summary).toContain("MCP Gateway Activity (2 requests)");
expect(summary).toContain("</details>");
});

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");
});
});
});
Loading