From 3bba9eec79c5ba80dabea80d597cfd3bff2364b8 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Thu, 14 May 2026 18:52:00 +0200 Subject: [PATCH] feat(cli): add ktx mcp commands --- packages/cli/src/cli-program.ts | 4 +- packages/cli/src/cli-runtime.ts | 6 + .../cli/src/commands/mcp-commands.test.ts | 57 ++++++++ packages/cli/src/commands/mcp-commands.ts | 136 ++++++++++++++++++ 4 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/mcp-commands.test.ts create mode 100644 packages/cli/src/commands/mcp-commands.ts diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index cfbc86b0..185cd250 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -5,6 +5,7 @@ import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; import { registerConnectionCommands } from './commands/connection-commands.js'; import { registerIngestCommands } from './commands/ingest-commands.js'; import { registerWikiCommands } from './commands/knowledge-commands.js'; +import { registerMcpCommands } from './commands/mcp-commands.js'; import { registerSetupCommands } from './commands/setup-commands.js'; import { registerSlCommands } from './commands/sl-commands.js'; import { registerStatusCommands } from './commands/status-commands.js'; @@ -55,7 +56,7 @@ type CommandPathNode = CommandWithGlobalOptions & { parent?: CommandPathNode | null; }; -const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']); +const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'mcp']); const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']); const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']); @@ -439,6 +440,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command { registerWikiCommands(program, context); registerSlCommands(program, context); registerStatusCommands(program, context); + registerMcpCommands(program, context); registerDevCommands(program, context); return program; diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index a2147904..56ffd655 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -34,6 +34,12 @@ export interface KtxCliDeps { runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise; knowledge?: (args: KtxKnowledgeArgs, io: KtxCliIo) => Promise; sl?: (args: KtxSlArgs, io: KtxCliIo) => Promise; + mcp?: { + startDaemon?: typeof import('./managed-mcp-daemon.js').startKtxMcpDaemon; + stopDaemon?: typeof import('./managed-mcp-daemon.js').stopKtxMcpDaemon; + readStatus?: typeof import('./managed-mcp-daemon.js').readKtxMcpDaemonStatus; + runServer?: typeof import('./mcp-http-server.js').runKtxMcpHttpServer; + }; } export function getKtxCliPackageInfo(): KtxCliPackageInfo { diff --git a/packages/cli/src/commands/mcp-commands.test.ts b/packages/cli/src/commands/mcp-commands.test.ts new file mode 100644 index 00000000..f31996f2 --- /dev/null +++ b/packages/cli/src/commands/mcp-commands.test.ts @@ -0,0 +1,57 @@ +import { Command } from '@commander-js/extra-typings'; +import { describe, expect, it, vi } from 'vitest'; +import type { KtxCliCommandContext } from '../cli-program.js'; +import { registerMcpCommands } from './mcp-commands.js'; + +function makeContext(overrides: Partial = {}): KtxCliCommandContext { + let exitCode = 0; + return { + io: { + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, + }, + deps: {}, + packageInfo: { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' }, + setExitCode: (code) => { + exitCode = code; + }, + runInit: vi.fn(), + writeDebug: vi.fn(), + ...overrides, + get exitCode() { + return exitCode; + }, + } as KtxCliCommandContext; +} + +describe('registerMcpCommands', () => { + it('registers the public mcp lifecycle commands', () => { + const program = new Command().exitOverride(); + registerMcpCommands(program, makeContext()); + const mcp = program.commands.find((command) => command.name() === 'mcp'); + + expect(mcp?.commands.map((command) => command.name()).sort()).toEqual([ + 'logs', + 'serve-internal', + 'start', + 'status', + 'stop', + ]); + expect( + (mcp?.commands.find((command) => command.name() === 'serve-internal') as { _hidden?: boolean } | undefined) + ?._hidden, + ).toBe(true); + }); + + it('rejects non-loopback start without token before spawning', async () => { + const program = new Command().exitOverride(); + const startDaemon = vi.fn(); + const context = makeContext({ deps: { mcp: { startDaemon } } }); + registerMcpCommands(program, context); + + await expect(program.parseAsync(['mcp', 'start', '--host', '0.0.0.0'], { from: 'user' })).rejects.toThrow( + 'Binding KTX MCP to 0.0.0.0 requires --token or KTX_MCP_TOKEN', + ); + expect(startDaemon).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/mcp-commands.ts b/packages/cli/src/commands/mcp-commands.ts new file mode 100644 index 00000000..7880f608 --- /dev/null +++ b/packages/cli/src/commands/mcp-commands.ts @@ -0,0 +1,136 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { Command } from '@commander-js/extra-typings'; +import type { KtxCliCommandContext } from '../cli-program.js'; +import { + collectOption, + parsePositiveIntegerOption, + resolveCommandProjectDir, +} from '../cli-program.js'; +import { + mcpDaemonLayout, + readKtxMcpDaemonStatus, + startKtxMcpDaemon, + stopKtxMcpDaemon, +} from '../managed-mcp-daemon.js'; +import { buildMcpSecurityConfig, runKtxMcpHttpServer } from '../mcp-http-server.js'; + +function tokenFromOption(value: string | undefined): string | undefined { + return value ?? process.env.KTX_MCP_TOKEN; +} + +function binPath(): string { + return fileURLToPath(new URL('../bin.js', import.meta.url)); +} + +export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void { + const mcp = program.command('mcp').description('Run the KTX MCP HTTP server'); + + mcp + .command('start') + .description('Start the KTX MCP HTTP server') + .option('--host ', 'Host to bind', '127.0.0.1') + .option('--port ', 'Port to bind', parsePositiveIntegerOption, 7878) + .option('--token ', 'Bearer token required for non-loopback binding') + .option('--foreground', 'Run in the foreground', false) + .option('--allowed-host ', 'Additional allowed Host header', collectOption, []) + .option('--allowed-origin ', 'Allowed browser Origin header', collectOption, []) + .action(async (options, command) => { + const projectDir = resolveCommandProjectDir(command); + const token = tokenFromOption(options.token); + buildMcpSecurityConfig({ + host: options.host, + port: options.port, + token, + allowedHosts: options.allowedHost, + allowedOrigins: options.allowedOrigin, + }); + if (options.foreground) { + await (context.deps.mcp?.runServer ?? runKtxMcpHttpServer)({ + projectDir, + cliVersion: context.packageInfo.version, + host: options.host, + port: options.port, + token, + allowedHosts: options.allowedHost, + allowedOrigins: options.allowedOrigin, + io: context.io, + }); + context.io.stdout.write(`KTX MCP server listening at http://${options.host}:${options.port}/mcp\n`); + return; + } + const result = await (context.deps.mcp?.startDaemon ?? startKtxMcpDaemon)({ + projectDir, + cliVersion: context.packageInfo.version, + host: options.host, + port: options.port, + token, + allowedHosts: options.allowedHost, + allowedOrigins: options.allowedOrigin, + binPath: binPath(), + }); + context.io.stdout.write(`KTX MCP daemon started: ${result.url}\n`); + }); + + mcp + .command('stop') + .description('Stop the KTX MCP daemon') + .action(async (_options, command) => { + const result = await (context.deps.mcp?.stopDaemon ?? stopKtxMcpDaemon)({ + projectDir: resolveCommandProjectDir(command), + }); + context.io.stdout.write(result.status === 'stopped' ? 'KTX MCP daemon stopped.\n' : 'KTX MCP daemon is not running.\n'); + }); + + mcp + .command('status') + .description('Show KTX MCP daemon status') + .action(async (_options, command) => { + const status = await (context.deps.mcp?.readStatus ?? readKtxMcpDaemonStatus)({ + projectDir: resolveCommandProjectDir(command), + }); + context.io.stdout.write(`${status.detail}\n`); + if (status.kind === 'running') { + context.io.stdout.write(`URL: ${status.url}\n`); + context.io.stdout.write(`PID: ${status.state.pid}\n`); + context.io.stdout.write(`Token auth: ${status.state.tokenAuth ? 'enabled' : 'disabled'}\n`); + context.io.stdout.write(`Project: ${status.state.projectDir}\n`); + } + }); + + mcp + .command('logs') + .description('Print the KTX MCP daemon log') + .option('--follow', 'Follow log output', false) + .action(async (options, command) => { + const logPath = mcpDaemonLayout(resolveCommandProjectDir(command)).logPath; + if (options.follow) { + const child = spawn('tail', ['-f', logPath], { stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout?.on('data', (chunk: Buffer) => context.io.stdout.write(chunk.toString('utf8'))); + child.stderr?.on('data', (chunk: Buffer) => context.io.stderr.write(chunk.toString('utf8'))); + await new Promise((resolve) => child.on('close', resolve)); + return; + } + context.io.stdout.write(await readFile(logPath, 'utf8')); + }); + + mcp + .command('serve-internal', { hidden: true }) + .option('--host ', 'Host to bind', '127.0.0.1') + .requiredOption('--port ', 'Port to bind', parsePositiveIntegerOption) + .option('--allowed-host ', 'Additional allowed Host header', collectOption, []) + .option('--allowed-origin ', 'Allowed browser Origin header', collectOption, []) + .action(async (options, command) => { + await (context.deps.mcp?.runServer ?? runKtxMcpHttpServer)({ + projectDir: resolveCommandProjectDir(command), + cliVersion: context.packageInfo.version, + host: options.host, + port: options.port, + token: process.env.KTX_MCP_TOKEN, + allowedHosts: options.allowedHost, + allowedOrigins: options.allowedOrigin, + io: context.io, + }); + }); +}