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
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 @@ -5,8 +5,9 @@ import { describe, expect, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import { vi } from "vite-plus/test";

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

function makeHarness(responder: (request: Request) => Promise<Response> | Response) {
const input = new NodeStream.PassThrough();
Expand Down Expand Up @@ -38,6 +39,54 @@ function pendingFetch(started: Deferred.Deferred<AbortSignal | undefined>) {
}

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
27 changes: 26 additions & 1 deletion apps/server/src/mcp/AcpMcpStdioBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,13 +361,38 @@ export async function runAcpMcpCliFastPath(
process.exitCode = 2;
return;
}
const result = await Effect.runPromise(
let result = await Effect.runPromise(
callAcpMcpTool({
endpoint,
authorization,
tool,
arguments: parsed as Record<string, unknown>,
}),
);
// Terminal callers see both fields, unlike native MCP clients that choose one.
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);
result = {
...result,
content: result.content.filter(
(part: unknown) =>
!(
typeof part === "object" &&
part !== null &&
"type" in part &&
part.type === "text" &&
"text" in part &&
part.text === encoded
),
),
};
}
process.stdout.write(`${JSON.stringify(result)}\n`);
}
Loading