Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
51 changes: 50 additions & 1 deletion apps/server/src/mcp/AcpMcpStdioBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import * as NodeStream from "node:stream";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import { vi } from "vite-plus/test";

import { runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts";
import { runAcpMcpCliFastPath, runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts";

function makeHarness(responder: (request: Request) => Promise<Response> | Response) {
const input = new NodeStream.PassThrough();
Expand All @@ -30,6 +31,54 @@ function makeHarness(responder: (request: Request) => Promise<Response> | Respon
}

describe("AcpMcpStdioBridge", () => {
it("prints structured results once in the terminal while retaining other content and errors", async () => {
const originalEnvironment = process.env;
using _environment = {
[Symbol.dispose]: () => {
process.env = originalEnvironment;
},
};
process.env = {
...originalEnvironment,
T3_ACP_MCP_ENDPOINT: "http://127.0.0.1:1/mcp",
T3_ACP_MCP_AUTHORIZATION: "Bearer bridge-test",
};
const structuredContent = { taskId: "task-1", status: "failed" };
const explanation = { type: "text", text: "The command failed." };
const image = { type: "image", data: "image-data", mimeType: "image/png" };
const result = {
structuredContent,
content: [{ type: "text", text: JSON.stringify(structuredContent) }, explanation, image],
isError: true,
};
using fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
if (init?.method === "DELETE") return new Response(null, { status: 204 });
const message = JSON.parse(String(init?.body)) as { id?: string; method: string };
if (message.method === "notifications/initialized") {
return new Response(null, { status: 202 });
}
return Response.json(
{
jsonrpc: "2.0",
id: message.id,
result: message.method === "initialize" ? { protocolVersion: "2025-06-18" } : result,
},
{ headers: { "mcp-session-id": "session-42" } },
);
});
using output = vi.spyOn(process.stdout, "write").mockReturnValue(true);

await runAcpMcpCliFastPath("acp-mcp-call", ["task_status", '{"taskId":"task-1"}']);

expect(output).toHaveBeenCalledExactlyOnceWith(
`${JSON.stringify({ ...result, content: [explanation, image] })}\n`,
);
expect(fetch).toHaveBeenLastCalledWith(
"http://127.0.0.1:1/mcp",
expect.objectContaining({ method: "DELETE" }),
);
});

it.effect("forwards requests and replays the session id and protocol version", () =>
Effect.gen(function* () {
const { input, written, requests, bridge } = makeHarness((request) => {
Expand Down
31 changes: 30 additions & 1 deletion apps/server/src/mcp/AcpMcpStdioBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,5 +378,34 @@ export async function runAcpMcpCliFastPath(
arguments: parsed as Record<string, unknown>,
}),
);
process.stdout.write(`${JSON.stringify(result)}\n`);
// Terminal callers see the whole envelope; native MCP clients choose a field.
// Keep distinct content, but avoid printing the JSON fallback a second time.
if (
typeof result === "object" &&
result !== null &&
"structuredContent" in result &&
result.structuredContent !== undefined &&
"content" in result &&
Array.isArray(result.content)
) {
const encoded = JSON.stringify(result.structuredContent);
process.stdout.write(
`${JSON.stringify({
...result,
content: result.content.filter(
(part: unknown) =>
!(
typeof part === "object" &&
part !== null &&
"type" in part &&
part.type === "text" &&
"text" in part &&
part.text === encoded
),
),
})}\n`,
);
} else {
process.stdout.write(`${JSON.stringify(result)}\n`);
}
}
Loading