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
Original file line number Diff line number Diff line change
Expand Up @@ -1259,7 +1259,7 @@ describe("AcpRegistryAdapterV2", () => {
}).pipe(Effect.provide(layerTest)),
);

it.effect("gives Copilot T3's MCP server over HTTP when it advertises HTTP", () =>
it.effect("gives HTTP-capable agents T3's MCP server over HTTP", () =>
Effect.gen(function* () {
const selfInvocation = yield* resolveSelfInvocation();
const mockAgentPath = yield* (yield* Path.Path).fromFileUrl(
Expand Down Expand Up @@ -1345,6 +1345,14 @@ describe("AcpRegistryAdapterV2", () => {
};
return { new: sent("session/new"), load: sent("session/load") };
});
const plainHttp = (authorization: string) => [
{
type: "http",
name: "t3-code",
url: endpoint,
headers: [{ name: "Authorization", value: authorization }],
},
];
const http = (authorization: string) => [
{
type: "http",
Expand Down Expand Up @@ -1389,6 +1397,30 @@ describe("AcpRegistryAdapterV2", () => {
new: stdio("Bearer opened-token"),
load: stdio("Bearer rotated-token"),
});
// Command Code cannot start the stdio bridge on Windows: it spawns MCP commands through
// cmd.exe, which splits the unquoted T3 executable path at the space.
const commandCode = {
source: "local",
commandPath: "C:\\tools\\cmdc.exe",
commandArgs: ["acp"],
};
// Command Code is not Copilot: no deferred tool-exposure header.
assert.deepEqual(yield* sessionMcpServers(commandCode, { T3_ACP_MCP_HTTP: "1" }), {
new: plainHttp("Bearer opened-token"),
load: plainHttp("Bearer rotated-token"),
});
assert.deepEqual(yield* sessionMcpServers(commandCode), {
new: stdio("Bearer opened-token"),
load: stdio("Bearer rotated-token"),
});
// Any other local agent keeps the stdio bridge.
assert.deepEqual(
yield* sessionMcpServers(
{ source: "local", commandPath: "C:\\tools\\node.exe", commandArgs: ["agent.mjs"] },
{ T3_ACP_MCP_HTTP: "1" },
),
{ new: stdio("Bearer opened-token"), load: stdio("Bearer rotated-token") },
);
}).pipe(Effect.provide(layerTest)),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
import { prefersHttpMcpTransport } from "./mcpTransport.ts";
import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";

const registryUrl = "https://registry.test/registry.json";
Expand Down Expand Up @@ -336,6 +337,54 @@ describe("AcpRegistrySupport", () => {
),
);

it.effect("resolves the local Command Code configurations the HTTP selector matches", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-acp-command-code-",
});
const catalog = (executable: string) =>
AcpRegistrySupport.makeAcpRegistryCatalog({
cacheDir,
toolsDir: `${cacheDir}/tools`,
registryUrl,
}).pipe(
Effect.provideService(HostProcess.Platform, "win32"),
Effect.provideService(SpawnExecutableResolution, () => executable),
);

const bare = decodeAcpRegistrySettings({ source: "local", commandPath: "cmdc" });
expect(prefersHttpMcpTransport(bare)).toBe(true);
expect(
yield* (yield* catalog("C:\\bin\\cmdc.exe")).resolve(bare, "/workspace"),
).toMatchObject({ spawn: { command: "C:\\bin\\cmdc.exe", shell: false } });
// A bare name that PATHEXT resolves to a batch shim never launches, so the transport it
// selects cannot matter: there is no session to attach MCP servers to.
expect(
yield* (yield* catalog("C:\\bin\\cmdc.cmd")).resolve(bare, "/workspace").pipe(Effect.flip),
).toMatchObject({ reason: "runner_unavailable" });

const vendored = decodeAcpRegistrySettings({
source: "local",
commandPath: "node.exe",
commandArgs: [
"C:\\Users\\me\\.t3\\bin\\cmdcode\\node_modules\\command-code\\dist\\index.mjs",
"acp",
],
});
expect(prefersHttpMcpTransport(vendored)).toBe(true);
expect(
yield* (yield* catalog("C:\\Program Files\\nodejs\\node.exe")).resolve(
vendored,
"/workspace",
),
).toMatchObject({ spawn: { command: "C:\\Program Files\\nodejs\\node.exe" } });
}).pipe(
Effect.scoped,
Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))),
),
);

it.effect("reports missing or non-executable local commands without registry fallback", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
9 changes: 7 additions & 2 deletions packages/provider-acp-registry/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
isCopilotLostSessionError,
makeCopilotSubagentRouting,
} from "./copilotAcp.ts";
import { prefersHttpMcpTransport } from "./mcpTransport.ts";
import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts";
import { readCustomModelEntries } from "@t3tools/shared/model";
import { AcpRegistrySettings } from "../settings.ts";
Expand Down Expand Up @@ -221,6 +222,11 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu
}
: options;
const copilotRouting = isCopilot ? makeCopilotSubagentRouting() : undefined;
// Copilot CLI 1.0.105 rejects stdio MCP servers from the client, and Command Code cannot
// start the stdio bridge on Windows (it spawns MCP commands through cmd.exe, which splits
// the unquoted "T3 Code (Beta).exe" path). Both take T3's MCP endpoint over ACP HTTP.
const useHttpMcpServers =
copilotRouting !== undefined || prefersHttpMcpTransport(options.settings);
const flavor: AcpAdapterV2Flavor = {
driver: ACP_REGISTRY_PROVIDER,
// Cancelling a Copilot prompt also kills the background agents it started.
Expand Down Expand Up @@ -268,8 +274,6 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu
? {}
: {
clientCapabilitiesMeta: copilotClientCapabilitiesMeta,
// Copilot CLI 1.0.105 rejects stdio MCP servers from the client.
mcpOverHttp: true,
deferMcpTools: true,
registerExtensions: copilotRouting.registerExtensions,
normalizeSessionUpdate: copilotRouting.normalizeSessionUpdate,
Expand All @@ -278,6 +282,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu
// session/new reports a built-in default, not the model Copilot runs (github/copilot-cli#5363).
alwaysSetModel: true,
}),
mcpOverHttp: useHttpMcpServers,
makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(runtimeOptions, catalog),
...(runtimeCoordinator === undefined
? {}
Expand Down
53 changes: 53 additions & 0 deletions packages/provider-acp-registry/src/server/mcpTransport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "@effect/vitest";
import * as Schema from "effect/Schema";

import { AcpRegistrySettings } from "../settings.ts";
import { prefersHttpMcpTransport } from "./mcpTransport.ts";

const decodeSettings = Schema.decodeSync(AcpRegistrySettings);

const local = (commandPath: string, commandArgs: string[] = []) =>
decodeSettings({ source: "local", commandPath, commandArgs });

describe("prefersHttpMcpTransport", () => {
it("uses HTTP for the vendored Command Code launcher", () => {
expect(
prefersHttpMcpTransport(
local("C:\\Users\\me\\.t3\\bin\\cmdcode\\node.exe", [
"C:\\Users\\me\\.t3\\bin\\cmdcode\\node_modules\\command-code\\dist\\index.mjs",
"acp",
]),
),
).toBe(true);
expect(
prefersHttpMcpTransport(
local("C:\\tools\\node.exe", ["C:\\src\\command-code\\dist\\cli.mjs", "acp"]),
),
).toBe(true);
});

it("uses HTTP for the global Command Code executable", () => {
expect(prefersHttpMcpTransport(local("C:\\tools\\cmdc.exe", ["acp"]))).toBe(true);
expect(prefersHttpMcpTransport(local("/usr/local/bin/cmdc"))).toBe(true);
});

it("keeps the stdio bridge for a batch shim T3 refuses to launch", () => {
expect(prefersHttpMcpTransport(local("C:\\tools\\cmdc.cmd", ["acp"]))).toBe(false);
});

it("keeps the stdio bridge for other local agents", () => {
expect(
prefersHttpMcpTransport(
local("C:\\tools\\node.exe", ["C:\\tools\\some-other-agent\\dist\\index.mjs", "acp"]),
),
).toBe(false);
expect(prefersHttpMcpTransport(local("C:\\tools\\claude-agent-acp.exe"))).toBe(false);
expect(prefersHttpMcpTransport(local("C:\\tools\\command-code\\runner.mjs"))).toBe(false);
});

it("keeps the stdio bridge for registry agents", () => {
expect(
prefersHttpMcpTransport(decodeSettings({ source: "registry", agentId: "claude-acp" })),
).toBe(false);
});
});
27 changes: 27 additions & 0 deletions packages/provider-acp-registry/src/server/mcpTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { AcpRegistrySettings } from "../settings.ts";

// AcpRegistrySupport.validateLocalExecutable refuses .cmd/.bat on Windows, so only native
// executables are launchable.
const COMMAND_CODE_EXECUTABLES = /^(?:cmdc|command-code|commandcode)(?:\.exe)?$/iu;
const COMMAND_CODE_ENTRY_POINTS = [
/(?:^|[\\/])node_modules[\\/]command-code[\\/]/iu,
/(?:^|[\\/])command-code[\\/]dist[\\/](?:index|cli)\.mjs$/iu,
];

/**
* Agents that take T3's MCP endpoint over ACP HTTP: Command Code cannot start the stdio
* bridge on Windows, where it spawns MCP commands through cmd.exe and the unquoted T3
* executable path splits at the space.
*/
export function prefersHttpMcpTransport(settings: AcpRegistrySettings): boolean {
if (settings.source !== "local") {
return false;
}
const executable = settings.commandPath.split(/[\\/]/u).pop() ?? "";
return (
COMMAND_CODE_EXECUTABLES.test(executable) ||
settings.commandArgs.some((argument) =>
COMMAND_CODE_ENTRY_POINTS.some((pattern) => pattern.test(argument)),
)
);
}
Loading