diff --git a/packages/cli/src/commands/mcp/McpCommand.ts b/packages/cli/src/commands/mcp/McpCommand.ts index 1dab960dd..e0fb427de 100644 --- a/packages/cli/src/commands/mcp/McpCommand.ts +++ b/packages/cli/src/commands/mcp/McpCommand.ts @@ -2,6 +2,8 @@ import {command} from "@tsed/cli-core"; import {mcpServerConnect} from "@tsed/platform-mcp/cli"; import {s} from "@tsed/schema"; +import {waitForShutdown} from "./utils/waitForShutdown.js"; + const McpSchema = s.object({ http: s.boolean().default(false).description("Run MCP using HTTP server").opt("--http") }); @@ -14,5 +16,8 @@ export const McpCommand = command({ const mode = data.http ? "streamable-http" : "stdio"; await mcpServerConnect(mode); + + // the injector is destroyed when this handler resolves: stay pending while the server runs + await waitForShutdown(mode); } }).token(); diff --git a/packages/cli/src/commands/mcp/resources/projectInfoResource.ts b/packages/cli/src/commands/mcp/resources/projectInfoResource.ts index a47b4b8dd..7bd79f720 100644 --- a/packages/cli/src/commands/mcp/resources/projectInfoResource.ts +++ b/packages/cli/src/commands/mcp/resources/projectInfoResource.ts @@ -17,6 +17,7 @@ export const projectInfoResource = defineResource({ isInitialized: !!projectPackage.preferences?.packageManager, preferences: { convention: projectPackage.preferences.convention, + architecture: projectPackage.preferences.architecture, packageManager: projectPackage.preferences.packageManager, platform: projectPackage.preferences.platform, runtime: projectPackage.preferences.runtime diff --git a/packages/cli/src/commands/mcp/schema/ProjectPreferencesSchema.ts b/packages/cli/src/commands/mcp/schema/ProjectPreferencesSchema.ts index 047f9e12b..00e2b98ed 100644 --- a/packages/cli/src/commands/mcp/schema/ProjectPreferencesSchema.ts +++ b/packages/cli/src/commands/mcp/schema/ProjectPreferencesSchema.ts @@ -1,12 +1,14 @@ import {PackageManager} from "@tsed/cli-core"; import {s} from "@tsed/schema"; +import {ArchitectureConvention} from "../../../interfaces/ArchitectureConvention.js"; import {PlatformType} from "../../../interfaces/PlatformType.js"; import {ProjectConvention} from "../../../interfaces/ProjectConvention.js"; export const ProjectPreferenceSchema = s .object({ convention: s.string().enum(ProjectConvention).description("Project convention (Ts.ED or Angular style)"), + architecture: s.string().enum(ArchitectureConvention).description("Directory architecture (by type or by feature)"), packageManager: s.string().enum(PackageManager).description("Used project manager to install dependencies"), runtime: s .string() diff --git a/packages/cli/src/commands/mcp/tools/setWorkspaceTool.spec.ts b/packages/cli/src/commands/mcp/tools/setWorkspaceTool.spec.ts index 0c5e8f30d..61b91548f 100644 --- a/packages/cli/src/commands/mcp/tools/setWorkspaceTool.spec.ts +++ b/packages/cli/src/commands/mcp/tools/setWorkspaceTool.spec.ts @@ -18,6 +18,7 @@ describe("setWorkspaceTool", () => { cwd: "/project", preferences: { convention: "conv_default", + architecture: "feature", packageManager: "yarn", platform: "express", runtime: "node" @@ -49,6 +50,7 @@ describe("setWorkspaceTool", () => { pkg: {name: "tsed-app"}, preferences: { convention: "conv_default", + architecture: "feature", packageManager: "yarn", platform: "express", runtime: "node" diff --git a/packages/cli/src/commands/mcp/tools/setWorkspaceTool.ts b/packages/cli/src/commands/mcp/tools/setWorkspaceTool.ts index a3bbfc614..645ae343a 100644 --- a/packages/cli/src/commands/mcp/tools/setWorkspaceTool.ts +++ b/packages/cli/src/commands/mcp/tools/setWorkspaceTool.ts @@ -50,6 +50,7 @@ export const setWorkspaceTool = defineTool({ pkg: projectPackage.toJSON(), preferences: { convention: projectPackage.preferences.convention, + architecture: projectPackage.preferences.architecture, packageManager: projectPackage.preferences.packageManager, platform: projectPackage.preferences.platform, runtime: projectPackage.preferences.runtime diff --git a/packages/cli/src/commands/mcp/utils/waitForShutdown.spec.ts b/packages/cli/src/commands/mcp/utils/waitForShutdown.spec.ts new file mode 100644 index 000000000..57737ba6e --- /dev/null +++ b/packages/cli/src/commands/mcp/utils/waitForShutdown.spec.ts @@ -0,0 +1,39 @@ +import {EventEmitter} from "node:events"; + +import {describe, expect, it, vi} from "vitest"; + +import {waitForShutdown} from "./waitForShutdown.js"; + +async function isPending(promise: Promise) { + const pending = Symbol("pending"); + + return (await Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(pending), 10))])) === pending; +} + +describe("waitForShutdown", () => { + it("should stay pending while stdin is open (stdio)", async () => { + const stdin = new EventEmitter() as unknown as NodeJS.ReadableStream; + + expect(await isPending(waitForShutdown("stdio", stdin))).toBe(true); + }); + + it.each(["end", "close"])("should resolve when stdin emits %s (stdio)", async (event) => { + const stdin = new EventEmitter(); + const done = vi.fn(); + const promise = waitForShutdown("stdio", stdin as unknown as NodeJS.ReadableStream).then(done); + + stdin.emit(event); + await promise; + + expect(done).toHaveBeenCalledTimes(1); + }); + + it("should never resolve with the HTTP transport", async () => { + const stdin = new EventEmitter(); + const promise = waitForShutdown("streamable-http", stdin as unknown as NodeJS.ReadableStream); + + stdin.emit("end"); + + expect(await isPending(promise)).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/mcp/utils/waitForShutdown.ts b/packages/cli/src/commands/mcp/utils/waitForShutdown.ts new file mode 100644 index 000000000..50346832d --- /dev/null +++ b/packages/cli/src/commands/mcp/utils/waitForShutdown.ts @@ -0,0 +1,17 @@ +/** + * Keep the `mcp` command pending while the MCP server is running. + * + * The CLI destroys the injector as soon as a command handler resolves. An MCP server keeps answering requests after + * the connection is established, so the handler must stay pending or every later tool call runs on an empty injector. + * + * - `stdio`: resolves when the client closes stdin. + * - `streamable-http`: never resolves, the HTTP server lives until the process is stopped. + */ +export function waitForShutdown(mode: "stdio" | "streamable-http", stdin: NodeJS.ReadableStream = process.stdin): Promise { + return new Promise((resolve) => { + if (mode === "stdio") { + stdin.once("end", resolve); + stdin.once("close", resolve); + } + }); +}