Skip to content

Commit 9cf106c

Browse files
authored
feat: support MCP-as-CLI progress messages on stderr (#28109)
1 parent 87f7d57 commit 9cf106c

3 files changed

Lines changed: 147 additions & 6 deletions

File tree

actions/setup/js/mcp_cli_bridge.cjs

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,118 @@ function showToolHelp(serverName, toolName, tools) {
732732
// Response formatting
733733
// ---------------------------------------------------------------------------
734734

735+
/**
736+
* Extract JSON-RPC messages from a response body that may be:
737+
* - A JSON object
738+
* - A JSON string
739+
* - Server-Sent Events (SSE) payload containing multiple `data:` lines
740+
*
741+
* @param {unknown} responseBody
742+
* @returns {unknown[]}
743+
*/
744+
function extractJSONRPCMessages(responseBody) {
745+
if (responseBody == null) {
746+
return [];
747+
}
748+
749+
if (Array.isArray(responseBody)) {
750+
return responseBody;
751+
}
752+
753+
if (typeof responseBody === "object") {
754+
return [responseBody];
755+
}
756+
757+
if (typeof responseBody !== "string") {
758+
return [];
759+
}
760+
761+
const trimmed = responseBody.trim();
762+
if (!trimmed) {
763+
return [];
764+
}
765+
766+
try {
767+
return [JSON.parse(trimmed)];
768+
} catch {
769+
// Fall through to SSE parsing.
770+
}
771+
772+
/** @type {unknown[]} */
773+
const messages = [];
774+
for (const line of trimmed.split(/\r?\n/)) {
775+
if (!line.startsWith("data:")) {
776+
continue;
777+
}
778+
const payload = line.slice(5).trim();
779+
if (!payload || payload === "[DONE]") {
780+
continue;
781+
}
782+
try {
783+
messages.push(JSON.parse(payload));
784+
} catch {
785+
// Ignore non-JSON SSE data lines.
786+
}
787+
}
788+
789+
return messages;
790+
}
791+
792+
/**
793+
* Render MCP progress notifications to stderr.
794+
*
795+
* @param {unknown[]} messages - Parsed JSON-RPC message stream
796+
*/
797+
function renderProgressMessages(messages) {
798+
for (const message of messages) {
799+
if (!message || typeof message !== "object" || !("method" in message) || message.method !== "notifications/progress") {
800+
continue;
801+
}
802+
803+
const params = "params" in message && message.params && typeof message.params === "object" ? message.params : null;
804+
if (!params) {
805+
continue;
806+
}
807+
808+
const progressText = "message" in params && params.message ? String(params.message) : "";
809+
const progress = "progress" in params && typeof params.progress === "number" ? params.progress : null;
810+
const total = "total" in params && typeof params.total === "number" ? params.total : null;
811+
812+
if (progressText) {
813+
process.stderr.write(progressText + "\n");
814+
continue;
815+
}
816+
817+
if (progress != null && total != null) {
818+
process.stderr.write(`Progress: ${progress}/${total}\n`);
819+
continue;
820+
}
821+
822+
if (progress != null) {
823+
process.stderr.write(`Progress: ${progress}\n`);
824+
continue;
825+
}
826+
827+
process.stderr.write(`Progress: ${JSON.stringify(params)}\n`);
828+
}
829+
}
830+
831+
/**
832+
* @param {unknown} message
833+
* @returns {boolean}
834+
*/
835+
function isErrorMessage(message) {
836+
return !!(message && typeof message === "object" && "error" in message);
837+
}
838+
839+
/**
840+
* @param {unknown} message
841+
* @returns {boolean}
842+
*/
843+
function isResultMessage(message) {
844+
return !!(message && typeof message === "object" && "result" in message);
845+
}
846+
735847
/**
736848
* Format and display the MCP tool call response.
737849
*
@@ -740,7 +852,10 @@ function showToolHelp(serverName, toolName, tools) {
740852
*/
741853
function formatResponse(responseBody, serverName) {
742854
const core = global.core;
743-
const resp = responseBody;
855+
const messages = extractJSONRPCMessages(responseBody);
856+
renderProgressMessages(messages);
857+
858+
const resp = messages.find(isErrorMessage) || messages.find(isResultMessage) || responseBody;
744859

745860
// Check for JSON-RPC error
746861
if (resp && typeof resp === "object" && "error" in resp && resp.error && typeof resp.error === "object") {
@@ -905,6 +1020,8 @@ if (require.main === module) {
9051020
module.exports = {
9061021
parseToolArgs,
9071022
coerceToolArgValue,
1023+
extractJSONRPCMessages,
1024+
renderProgressMessages,
9081025
formatResponse,
9091026
main,
9101027
};

actions/setup/js/mcp_cli_bridge.test.cjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,28 @@ describe("mcp_cli_bridge.cjs", () => {
187187
expect(stderrChunks.join("")).toContain("failed to audit workflow run");
188188
expect(process.exitCode).toBe(1);
189189
});
190+
191+
it("prints progress notifications to stderr and final text result to stdout for SSE responses", () => {
192+
const sseBody = [
193+
'data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"abc","progress":1,"total":3,"message":"Step 1/3"}}',
194+
'data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"done"}]}}',
195+
"",
196+
].join("\n");
197+
198+
formatResponse(sseBody, "agenticworkflows");
199+
200+
expect(stderrChunks.join("")).toContain("Step 1/3");
201+
expect(stdoutChunks.join("")).toBe("done\n");
202+
expect(process.exitCode).toBe(0);
203+
});
204+
205+
it("prints numeric progress to stderr when progress notification has no message", () => {
206+
const sseBody = ['data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"abc","progress":2,"total":5}}', 'data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"ok"}]}}', ""].join("\n");
207+
208+
formatResponse(sseBody, "agenticworkflows");
209+
210+
expect(stderrChunks.join("")).toContain("Progress: 2/5");
211+
expect(stdoutChunks.join("")).toBe("ok\n");
212+
expect(process.exitCode).toBe(0);
213+
});
190214
});

pkg/cli/spec_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,11 +1117,11 @@ func TestSpec_PublicAPI_ValidateWorkflowIntent(t *testing.T) {
11171117
// Spec: "Sets a field in frontmatter YAML"
11181118
func TestSpec_PublicAPI_UpdateFieldInFrontmatter(t *testing.T) {
11191119
tests := []struct {
1120-
name string
1121-
content string
1122-
fieldName string
1123-
fieldValue string
1124-
wantErr bool
1120+
name string
1121+
content string
1122+
fieldName string
1123+
fieldValue string
1124+
wantErr bool
11251125
checkContains string
11261126
}{
11271127
{

0 commit comments

Comments
 (0)