@kaelio/ktx 0.1.0-rc.1 → 0.1.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/cli-program.js +3 -0
  2. package/dist/cli-runtime.d.ts +1 -0
  3. package/dist/commands/mcp-commands.js +14 -1
  4. package/dist/commands/mcp-commands.test.js +35 -0
  5. package/dist/commands/setup-commands.js +9 -0
  6. package/dist/context-build-view.js +1 -0
  7. package/dist/context-build-view.test.js +18 -0
  8. package/dist/index.test.js +23 -0
  9. package/dist/ingest.d.ts +1 -0
  10. package/dist/ingest.js +1 -1
  11. package/dist/ingest.test.js +3 -1
  12. package/dist/managed-mcp-daemon.d.ts +1 -1
  13. package/dist/managed-mcp-daemon.js +12 -1
  14. package/dist/managed-mcp-daemon.test.js +58 -0
  15. package/dist/mcp-http-server.js +2 -54
  16. package/dist/mcp-server-factory.d.ts +9 -0
  17. package/dist/mcp-server-factory.js +51 -0
  18. package/dist/mcp-stdio-server.d.ts +14 -0
  19. package/dist/mcp-stdio-server.js +48 -0
  20. package/dist/memory-flow-tui.test.js +1 -1
  21. package/dist/public-ingest.d.ts +1 -0
  22. package/dist/public-ingest.js +17 -7
  23. package/dist/public-ingest.test.js +10 -3
  24. package/dist/scan.d.ts +1 -0
  25. package/dist/scan.js +1 -1
  26. package/dist/scan.test.js +3 -2
  27. package/dist/setup-agents.d.ts +4 -3
  28. package/dist/setup-agents.js +346 -43
  29. package/dist/setup-agents.test.js +385 -41
  30. package/dist/setup-demo-tour.js +1 -1
  31. package/dist/setup-demo-tour.test.js +1 -1
  32. package/dist/setup-models.d.ts +1 -0
  33. package/dist/setup-models.js +69 -12
  34. package/dist/setup-models.test.js +25 -2
  35. package/dist/setup-prompts.d.ts +4 -1
  36. package/dist/setup-prompts.js +5 -2
  37. package/dist/setup.d.ts +1 -0
  38. package/dist/setup.js +24 -17
  39. package/dist/setup.test.js +44 -9
  40. package/dist/skills/analytics/SKILL.md +62 -0
  41. package/dist/text-ingest.d.ts +5 -5
  42. package/dist/text-ingest.js +10 -10
  43. package/dist/text-ingest.test.js +32 -32
  44. package/node_modules/@ktx/context/dist/connections/connection-type.d.ts +4 -4
  45. package/node_modules/@ktx/context/dist/core/git-env.js +1 -1
  46. package/node_modules/@ktx/context/dist/core/git.service.js +5 -0
  47. package/node_modules/@ktx/context/dist/core/git.service.test.js +33 -1
  48. package/node_modules/@ktx/context/dist/daemon/semantic-layer-compute.d.ts +11 -3
  49. package/node_modules/@ktx/context/dist/ingest/adapters/historic-sql/types.d.ts +5 -5
  50. package/node_modules/@ktx/context/dist/ingest/adapters/looker/types.d.ts +4 -4
  51. package/node_modules/@ktx/context/dist/ingest/git-env.js +2 -1
  52. package/node_modules/@ktx/context/dist/ingest/memory-flow/schema.d.ts +3 -3
  53. package/node_modules/@ktx/context/dist/llm/claude-code-runtime.js +11 -1
  54. package/node_modules/@ktx/context/dist/llm/claude-code-runtime.test.js +11 -0
  55. package/node_modules/@ktx/context/dist/llm/local-config.js +13 -1
  56. package/node_modules/@ktx/context/dist/llm/local-config.test.js +24 -0
  57. package/node_modules/@ktx/context/dist/mcp/context-tools.d.ts +2 -2
  58. package/node_modules/@ktx/context/dist/mcp/context-tools.js +447 -292
  59. package/node_modules/@ktx/context/dist/mcp/index.d.ts +1 -1
  60. package/node_modules/@ktx/context/dist/mcp/local-project-ports.d.ts +0 -2
  61. package/node_modules/@ktx/context/dist/mcp/local-project-ports.js +15 -442
  62. package/node_modules/@ktx/context/dist/mcp/local-project-ports.test.js +108 -811
  63. package/node_modules/@ktx/context/dist/mcp/server.js +1 -46
  64. package/node_modules/@ktx/context/dist/mcp/server.test.js +376 -509
  65. package/node_modules/@ktx/context/dist/mcp/types.d.ts +38 -227
  66. package/node_modules/@ktx/context/dist/memory/index.d.ts +2 -2
  67. package/node_modules/@ktx/context/dist/memory/index.js +2 -2
  68. package/node_modules/@ktx/context/dist/memory/local-memory.d.ts +3 -3
  69. package/node_modules/@ktx/context/dist/memory/local-memory.js +5 -5
  70. package/node_modules/@ktx/context/dist/memory/local-memory.test.js +10 -10
  71. package/node_modules/@ktx/context/dist/memory/memory-runs.d.ts +8 -8
  72. package/node_modules/@ktx/context/dist/memory/memory-runs.js +5 -5
  73. package/node_modules/@ktx/context/dist/memory/memory-runs.test.js +25 -25
  74. package/node_modules/@ktx/context/dist/project/config.d.ts +2 -2
  75. package/node_modules/@ktx/context/dist/sl/local-query.d.ts +2 -0
  76. package/node_modules/@ktx/context/dist/sl/local-query.js +10 -3
  77. package/node_modules/@ktx/context/dist/sl/local-query.test.js +65 -0
  78. package/node_modules/@ktx/context/dist/tools/context-candidate-write.tool.d.ts +2 -2
  79. package/package.json +2 -1
  80. package/dist/skills/research/SKILL.md +0 -49
@@ -116,6 +116,9 @@ function shouldSuppressProjectDirLine(path, options) {
116
116
  if (commandPathKey === 'ktx setup') {
117
117
  return true;
118
118
  }
119
+ if (commandPathKey === 'ktx mcp stdio') {
120
+ return true;
121
+ }
119
122
  if (commandPathKey === 'ktx status' &&
120
123
  typeof options.projectDir !== 'string' &&
121
124
  process.env.KTX_PROJECT_DIR === undefined &&
@@ -35,6 +35,7 @@ export interface KtxCliDeps {
35
35
  stopDaemon?: typeof import('./managed-mcp-daemon.js').stopKtxMcpDaemon;
36
36
  readStatus?: typeof import('./managed-mcp-daemon.js').readKtxMcpDaemonStatus;
37
37
  runServer?: typeof import('./mcp-http-server.js').runKtxMcpHttpServer;
38
+ runStdioServer?: typeof import('./mcp-stdio-server.js').runKtxMcpStdioServer;
38
39
  };
39
40
  }
40
41
  export declare function getKtxCliPackageInfo(): KtxCliPackageInfo;
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { collectOption, parsePositiveIntegerOption, resolveCommandProjectDir, } from '../cli-program.js';
5
5
  import { mcpDaemonLayout, readKtxMcpDaemonStatus, startKtxMcpDaemon, stopKtxMcpDaemon, } from '../managed-mcp-daemon.js';
6
6
  import { buildMcpSecurityConfig, runKtxMcpHttpServer } from '../mcp-http-server.js';
7
+ import { runKtxMcpStdioServer } from '../mcp-stdio-server.js';
7
8
  function tokenFromOption(value) {
8
9
  return value ?? process.env.KTX_MCP_TOKEN;
9
10
  }
@@ -12,6 +13,16 @@ function binPath() {
12
13
  }
13
14
  export function registerMcpCommands(program, context) {
14
15
  const mcp = program.command('mcp').description('Run the KTX MCP HTTP server');
16
+ mcp
17
+ .command('stdio')
18
+ .description('Run the KTX MCP server over stdio')
19
+ .action(async (_options, command) => {
20
+ await (context.deps.mcp?.runStdioServer ?? runKtxMcpStdioServer)({
21
+ projectDir: resolveCommandProjectDir(command),
22
+ cliVersion: context.packageInfo.version,
23
+ io: context.io,
24
+ });
25
+ });
15
26
  mcp
16
27
  .command('start')
17
28
  .description('Start the KTX MCP HTTP server')
@@ -55,7 +66,9 @@ export function registerMcpCommands(program, context) {
55
66
  allowedOrigins: options.allowedOrigin,
56
67
  binPath: binPath(),
57
68
  });
58
- context.io.stdout.write(`KTX MCP daemon started: ${result.url}\n`);
69
+ context.io.stdout.write(result.status === 'started'
70
+ ? `KTX MCP daemon started: ${result.url}\n`
71
+ : `KTX MCP daemon already running: ${result.url}\n`);
59
72
  });
60
73
  mcp
61
74
  .command('stop')
@@ -31,6 +31,7 @@ describe('registerMcpCommands', () => {
31
31
  'serve-internal',
32
32
  'start',
33
33
  'status',
34
+ 'stdio',
34
35
  'stop',
35
36
  ]);
36
37
  expect(mcp?.commands.find((command) => command.name() === 'serve-internal')
@@ -44,4 +45,38 @@ describe('registerMcpCommands', () => {
44
45
  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');
45
46
  expect(startDaemon).not.toHaveBeenCalled();
46
47
  });
48
+ it('prints "already running" when startDaemon reports already-running', async () => {
49
+ const program = new Command().exitOverride().option('--project-dir <path>');
50
+ const startDaemon = vi.fn().mockResolvedValue({
51
+ status: 'already-running',
52
+ url: 'http://127.0.0.1:7878/mcp',
53
+ state: {
54
+ schemaVersion: 1,
55
+ pid: 4242,
56
+ host: '127.0.0.1',
57
+ port: 7878,
58
+ tokenAuth: false,
59
+ projectDir: '/tmp/ktx-already',
60
+ startedAt: '2026-05-14T00:00:00.000Z',
61
+ logPath: '/tmp/ktx-already/.ktx/logs/mcp.log',
62
+ },
63
+ });
64
+ const context = makeContext({ deps: { mcp: { startDaemon } } });
65
+ registerMcpCommands(program, context);
66
+ await program.parseAsync(['--project-dir', '/tmp/ktx-already', 'mcp', 'start'], { from: 'user' });
67
+ expect(startDaemon).toHaveBeenCalledTimes(1);
68
+ expect(context.io.stdout.write).toHaveBeenCalledWith('KTX MCP daemon already running: http://127.0.0.1:7878/mcp\n');
69
+ });
70
+ it('runs the stdio server with the resolved project directory', async () => {
71
+ const program = new Command().exitOverride().option('--project-dir <path>');
72
+ const runStdioServer = vi.fn().mockResolvedValue(undefined);
73
+ const context = makeContext({ deps: { mcp: { runStdioServer } } });
74
+ registerMcpCommands(program, context);
75
+ await expect(program.parseAsync(['--project-dir', '/tmp/ktx6', 'mcp', 'stdio'], { from: 'user' })).resolves.toBe(program);
76
+ expect(runStdioServer).toHaveBeenCalledWith({
77
+ projectDir: '/tmp/ktx6',
78
+ cliVersion: '0.0.0-test',
79
+ io: context.io,
80
+ });
81
+ });
47
82
  });
@@ -91,6 +91,7 @@ function shouldShowSetupEntryMenu(options, command) {
91
91
  'llmBackend',
92
92
  'anthropicApiKeyEnv',
93
93
  'anthropicApiKeyFile',
94
+ 'llmModel',
94
95
  'anthropicModel',
95
96
  'vertexProject',
96
97
  'vertexLocation',
@@ -136,6 +137,7 @@ export function registerSetupCommands(program, context) {
136
137
  .option('--agents', 'Install agent integration only', false)
137
138
  .addOption(new Option('--target <target>', 'Agent target').choices([
138
139
  'claude-code',
140
+ 'claude-desktop',
139
141
  'codex',
140
142
  'cursor',
141
143
  'opencode',
@@ -149,6 +151,7 @@ export function registerSetupCommands(program, context) {
149
151
  .addOption(new Option('--llm-backend <backend>', 'LLM backend').argParser(llmBackend).hideHelp())
150
152
  .addOption(new Option('--anthropic-api-key-env <name>', 'Environment variable containing the Anthropic API key').hideHelp())
151
153
  .addOption(new Option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key').hideHelp())
154
+ .addOption(new Option('--llm-model <model>', 'LLM model ID or backend model alias').hideHelp())
152
155
  .addOption(new Option('--anthropic-model <model>', 'Anthropic model ID to validate and save').hideHelp())
153
156
  .addOption(new Option('--vertex-project <project>', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp())
154
157
  .addOption(new Option('--vertex-location <location>', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp())
@@ -235,6 +238,11 @@ export function registerSetupCommands(program, context) {
235
238
  context.setExitCode(1);
236
239
  return;
237
240
  }
241
+ if (options.llmModel && options.anthropicModel) {
242
+ context.io.stderr.write('Choose only one LLM model flag: --llm-model or --anthropic-model.\n');
243
+ context.setExitCode(1);
244
+ return;
245
+ }
238
246
  if (options.llmBackend &&
239
247
  options.llmBackend !== 'anthropic' &&
240
248
  (options.anthropicApiKeyEnv || options.anthropicApiKeyFile)) {
@@ -293,6 +301,7 @@ export function registerSetupCommands(program, context) {
293
301
  ...(options.llmBackend ? { llmBackend: options.llmBackend } : {}),
294
302
  ...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
295
303
  ...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
304
+ ...(options.llmModel ? { llmModel: options.llmModel } : {}),
296
305
  ...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}),
297
306
  ...(options.vertexProject ? { vertexProject: options.vertexProject } : {}),
298
307
  ...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}),
@@ -801,6 +801,7 @@ export async function runContextBuild(project, args, io, deps = {}) {
801
801
  const progressDeps = {
802
802
  scanProgress: createContextBuildProgressPort(updateSchemaPhase),
803
803
  ingestProgress: updateIngestPhase,
804
+ runtimeIo: io,
804
805
  onPhaseStart,
805
806
  onPhaseEnd,
806
807
  };
@@ -764,6 +764,24 @@ describe('runContextBuild', () => {
764
764
  ingestProgress: expect.any(Function),
765
765
  }));
766
766
  });
767
+ it('threads the original runtime IO into captured target execution', async () => {
768
+ const io = makeIo({ isTTY: true });
769
+ const project = projectWithConnections({
770
+ warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true } } },
771
+ });
772
+ const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
773
+ await runContextBuild(project, {
774
+ projectDir: '/tmp/project',
775
+ inputMode: 'auto',
776
+ cliVersion: '0.2.0',
777
+ runtimeInstallPolicy: 'auto',
778
+ }, io.io, { executeTarget, now: () => 1000 });
779
+ expect(executeTarget).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'warehouse' }), expect.objectContaining({ runtimeInstallPolicy: 'auto' }), expect.objectContaining({
780
+ stdout: expect.objectContaining({ isTTY: false }),
781
+ }), expect.objectContaining({
782
+ runtimeIo: io.io,
783
+ }));
784
+ });
767
785
  it('calls onSourceProgress when sources start and finish', async () => {
768
786
  const io = makeIo();
769
787
  const project = projectWithConnections({
@@ -785,6 +785,29 @@ describe('runKtxCli', () => {
785
785
  skipLlm: false,
786
786
  }), setupIo.io);
787
787
  });
788
+ it('dispatches the provider-neutral LLM model setup flag to the setup runner', async () => {
789
+ const setup = vi.fn(async () => 0);
790
+ const setupIo = makeIo();
791
+ await expect(runKtxCli([
792
+ '--project-dir',
793
+ tempDir,
794
+ 'setup',
795
+ '--no-input',
796
+ '--llm-backend',
797
+ 'claude-code',
798
+ '--llm-model',
799
+ 'opus',
800
+ ], setupIo.io, { setup })).resolves.toBe(0);
801
+ expect(setup).toHaveBeenCalledWith(expect.objectContaining({
802
+ command: 'run',
803
+ projectDir: tempDir,
804
+ inputMode: 'disabled',
805
+ cliVersion: '0.0.0-private',
806
+ llmBackend: 'claude-code',
807
+ llmModel: 'opus',
808
+ skipLlm: false,
809
+ }), setupIo.io);
810
+ });
788
811
  it('rejects conflicting Anthropic credential setup flags', async () => {
789
812
  const setup = vi.fn(async () => 0);
790
813
  const setupIo = makeIo();
package/dist/ingest.d.ts CHANGED
@@ -59,6 +59,7 @@ export interface KtxIngestDeps {
59
59
  env?: NodeJS.ProcessEnv;
60
60
  localIngestOptions?: Pick<RunLocalIngestOptions, 'agentRunner' | 'llmRuntime' | 'memoryModel' | 'semanticLayerCompute' | 'queryExecutor' | 'logger' | 'pullConfigOptions'>;
61
61
  progress?: (update: KtxIngestProgressUpdate) => void;
62
+ runtimeIo?: KtxIngestIo;
62
63
  }
63
64
  export declare function runKtxIngest(args: KtxIngestArgs, io?: KtxIngestIo, deps?: KtxIngestDeps): Promise<number>;
64
65
  export {};
package/dist/ingest.js CHANGED
@@ -412,7 +412,7 @@ export async function runKtxIngest(args, io = process, deps = {}) {
412
412
  (deps.runLocalIngest || deps.runLocalMetabaseIngest ? () => [] : createKtxCliLocalIngestAdapters);
413
413
  const executeLocalIngest = deps.runLocalIngest ?? runLocalIngest;
414
414
  const localIngestOptions = deps.localIngestOptions ?? {};
415
- const managedDaemon = managedDaemonOptionsForIngestRun(args, io);
415
+ const managedDaemon = managedDaemonOptionsForIngestRun(args, deps.runtimeIo ?? io);
416
416
  const operationalLogger = createCliOperationalLogger(io, args.outputMode);
417
417
  const adapterOptions = {
418
418
  ...(localIngestOptions.pullConfigOptions ?? {}),
@@ -852,6 +852,7 @@ describe('runKtxIngest', () => {
852
852
  const createAdapters = vi.fn(() => createdAdapters);
853
853
  const runLocal = vi.fn(async (input) => completedLocalBundleRun(input, input.jobId ?? 'local-job-1'));
854
854
  const io = makeIo();
855
+ const runtimeIo = makeIo({ isTTY: true });
855
856
  await expect(runKtxIngest({
856
857
  command: 'run',
857
858
  projectDir,
@@ -864,12 +865,13 @@ describe('runKtxIngest', () => {
864
865
  createAdapters,
865
866
  runLocalIngest: runLocal,
866
867
  jobIdFactory: () => 'local-job-1',
868
+ runtimeIo: runtimeIo.io,
867
869
  })).resolves.toBe(0);
868
870
  const expectedManagedDaemon = {
869
871
  cliVersion: '0.2.0',
870
872
  projectDir,
871
873
  installPolicy: 'auto',
872
- io: io.io,
874
+ io: runtimeIo.io,
873
875
  };
874
876
  expect(createAdapters).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), expect.objectContaining({
875
877
  managedDaemon: expectedManagedDaemon,
@@ -48,7 +48,7 @@ export declare function startKtxMcpDaemon(options: {
48
48
  spawnDaemon?: typeof defaultSpawnDaemon;
49
49
  now?: () => Date;
50
50
  }): Promise<{
51
- status: 'started';
51
+ status: 'started' | 'already-running';
52
52
  state: KtxMcpDaemonState;
53
53
  url: string;
54
54
  }>;
@@ -81,7 +81,18 @@ export async function startKtxMcpDaemon(options) {
81
81
  const existing = await readState(options.projectDir).catch(() => undefined);
82
82
  const processAlive = options.processAlive ?? defaultProcessAlive;
83
83
  if (existing && processAlive(existing.pid)) {
84
- throw new Error(`KTX MCP daemon is already recorded at http://${existing.host}:${existing.port}/mcp`);
84
+ const sameConfig = existing.host === options.host &&
85
+ existing.port === options.port &&
86
+ existing.tokenAuth === Boolean(options.token);
87
+ if (sameConfig) {
88
+ return {
89
+ status: 'already-running',
90
+ state: existing,
91
+ url: `http://${existing.host}:${existing.port}/mcp`,
92
+ };
93
+ }
94
+ throw new Error(`KTX MCP daemon is already running at http://${existing.host}:${existing.port}/mcp ` +
95
+ 'with a different configuration. Run `ktx mcp stop` first, then start again.');
85
96
  }
86
97
  const portAvailable = options.portAvailable ?? defaultPortAvailable;
87
98
  if (!(await portAvailable(options.host, options.port))) {
@@ -72,6 +72,64 @@ describe('managed MCP daemon lifecycle', () => {
72
72
  }));
73
73
  expect(JSON.stringify(JSON.parse(await readFile(join(projectDir, '.ktx/mcp.json'), 'utf8')))).not.toContain('secret-token');
74
74
  });
75
+ it('returns already-running without spawning when the daemon is alive at the same host/port', async () => {
76
+ await mkdir(join(projectDir, '.ktx'), { recursive: true });
77
+ await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
78
+ const spawnDaemon = vi.fn(() => child(9999));
79
+ const result = await startKtxMcpDaemon({
80
+ projectDir,
81
+ cliVersion: '0.0.0-test',
82
+ host: '127.0.0.1',
83
+ port: 7878,
84
+ allowedHosts: [],
85
+ allowedOrigins: [],
86
+ binPath: '/repo/packages/cli/dist/bin.js',
87
+ spawnDaemon,
88
+ processAlive: vi.fn(() => true),
89
+ portAvailable: vi.fn(async () => true),
90
+ });
91
+ expect(result.status).toBe('already-running');
92
+ expect(result.url).toBe('http://127.0.0.1:7878/mcp');
93
+ expect(result.state.pid).toBe(4242);
94
+ expect(spawnDaemon).not.toHaveBeenCalled();
95
+ });
96
+ it('throws when the recorded daemon uses a different host or port', async () => {
97
+ await mkdir(join(projectDir, '.ktx'), { recursive: true });
98
+ await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
99
+ const spawnDaemon = vi.fn(() => child(9999));
100
+ await expect(startKtxMcpDaemon({
101
+ projectDir,
102
+ cliVersion: '0.0.0-test',
103
+ host: '127.0.0.1',
104
+ port: 9000,
105
+ allowedHosts: [],
106
+ allowedOrigins: [],
107
+ binPath: '/repo/packages/cli/dist/bin.js',
108
+ spawnDaemon,
109
+ processAlive: vi.fn(() => true),
110
+ portAvailable: vi.fn(async () => true),
111
+ })).rejects.toThrow(/different configuration[\s\S]*ktx mcp stop/);
112
+ expect(spawnDaemon).not.toHaveBeenCalled();
113
+ });
114
+ it('throws when token-auth presence differs from the recorded daemon', async () => {
115
+ await mkdir(join(projectDir, '.ktx'), { recursive: true });
116
+ await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir, { tokenAuth: false }), null, 2)}\n`);
117
+ const spawnDaemon = vi.fn(() => child(9999));
118
+ await expect(startKtxMcpDaemon({
119
+ projectDir,
120
+ cliVersion: '0.0.0-test',
121
+ host: '127.0.0.1',
122
+ port: 7878,
123
+ token: 'secret-token',
124
+ allowedHosts: [],
125
+ allowedOrigins: [],
126
+ binPath: '/repo/packages/cli/dist/bin.js',
127
+ spawnDaemon,
128
+ processAlive: vi.fn(() => true),
129
+ portAvailable: vi.fn(async () => true),
130
+ })).rejects.toThrow(/different configuration/);
131
+ expect(spawnDaemon).not.toHaveBeenCalled();
132
+ });
75
133
  it('reports running when the process is alive and health passes', async () => {
76
134
  await mkdir(join(projectDir, '.ktx'), { recursive: true });
77
135
  await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
@@ -1,14 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
- import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp';
4
- import { createLocalProjectMemoryCapture } from '@ktx/context/memory';
5
3
  import { loadKtxProject } from '@ktx/context/project';
6
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
5
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
8
- import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
9
- import { createKtxCliScanConnector } from './local-scan-connectors.js';
10
- import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js';
11
- import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
6
+ import { createKtxMcpServerFactory } from './mcp-server-factory.js';
12
7
  const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1', '::1'];
13
8
  function isLoopbackHost(host) {
14
9
  const normalized = normalizeHostHeader(host);
@@ -79,12 +74,6 @@ export function isMcpRequestAuthorized(request, config) {
79
74
  }
80
75
  return { ok: true };
81
76
  }
82
- function noopIo() {
83
- return {
84
- stdout: { write() { } },
85
- stderr: { write() { } },
86
- };
87
- }
88
77
  function writeJson(res, status, body) {
89
78
  const payload = `${JSON.stringify(body)}\n`;
90
79
  res.writeHead(status, {
@@ -109,47 +98,6 @@ async function readJsonBody(req) {
109
98
  const raw = Buffer.concat(chunks).toString('utf8');
110
99
  return raw.trim().length === 0 ? undefined : JSON.parse(raw);
111
100
  }
112
- async function defaultMcpServerFactory(input) {
113
- const io = input.io ?? noopIo();
114
- const queryExecutor = createKtxCliIngestQueryExecutor(input.project);
115
- const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({
116
- cliVersion: input.cliVersion,
117
- installPolicy: 'auto',
118
- io,
119
- });
120
- const sqlAnalysis = createManagedDaemonSqlAnalysisPort({
121
- cliVersion: input.cliVersion,
122
- projectDir: input.projectDir,
123
- installPolicy: 'auto',
124
- io,
125
- });
126
- const contextTools = createLocalProjectMcpContextPorts(input.project, {
127
- semanticLayerCompute,
128
- queryExecutor,
129
- sqlAnalysis,
130
- localScan: {
131
- createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId),
132
- },
133
- localIngest: {
134
- semanticLayerCompute,
135
- queryExecutor,
136
- },
137
- });
138
- let memoryCapture;
139
- try {
140
- memoryCapture = createLocalProjectMemoryCapture(input.project, { semanticLayerCompute, queryExecutor });
141
- }
142
- catch (error) {
143
- input.io?.stderr.write(`KTX MCP memory_capture disabled: ${error instanceof Error ? error.message : String(error)}\n`);
144
- }
145
- return () => createDefaultKtxMcpServer({
146
- name: 'ktx',
147
- version: input.cliVersion,
148
- userContext: { userId: 'local' },
149
- contextTools,
150
- memoryCapture,
151
- });
152
- }
153
101
  function listenerPort(server, fallback) {
154
102
  const address = server.address();
155
103
  return typeof address === 'object' && address ? address.port : fallback;
@@ -171,7 +119,7 @@ export async function runKtxMcpHttpServer(options) {
171
119
  ? await (options.loadProject ?? loadKtxProject)({ projectDir: options.projectDir })
172
120
  : undefined;
173
121
  const createMcpServer = options.createMcpServer ??
174
- (await defaultMcpServerFactory({
122
+ (await createKtxMcpServerFactory({
175
123
  project: project,
176
124
  projectDir: options.projectDir,
177
125
  cliVersion: options.cliVersion ?? '0.0.0-private',
@@ -0,0 +1,9 @@
1
+ import type { KtxLocalProject } from '@ktx/context/project';
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import type { KtxCliIo } from './cli-runtime.js';
4
+ export declare function createKtxMcpServerFactory(input: {
5
+ project: KtxLocalProject;
6
+ projectDir: string;
7
+ cliVersion: string;
8
+ io?: KtxCliIo;
9
+ }): Promise<() => McpServer>;
@@ -0,0 +1,51 @@
1
+ import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp';
2
+ import { createLocalProjectMemoryIngest } from '@ktx/context/memory';
3
+ import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
4
+ import { createKtxCliScanConnector } from './local-scan-connectors.js';
5
+ import { createManagedPythonSemanticLayerComputePort } from './managed-python-command.js';
6
+ import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
7
+ function noopMcpIo() {
8
+ return {
9
+ stdout: { write() { } },
10
+ stderr: { write() { } },
11
+ };
12
+ }
13
+ export async function createKtxMcpServerFactory(input) {
14
+ const io = input.io ?? noopMcpIo();
15
+ const queryExecutor = createKtxCliIngestQueryExecutor(input.project);
16
+ const semanticLayerCompute = await createManagedPythonSemanticLayerComputePort({
17
+ cliVersion: input.cliVersion,
18
+ installPolicy: 'auto',
19
+ io,
20
+ });
21
+ const sqlAnalysis = createManagedDaemonSqlAnalysisPort({
22
+ cliVersion: input.cliVersion,
23
+ projectDir: input.projectDir,
24
+ installPolicy: 'auto',
25
+ io,
26
+ });
27
+ const contextTools = createLocalProjectMcpContextPorts(input.project, {
28
+ semanticLayerCompute,
29
+ queryExecutor,
30
+ sqlAnalysis,
31
+ localScan: {
32
+ createConnector: async (connectionId) => createKtxCliScanConnector(input.project, connectionId),
33
+ },
34
+ });
35
+ let memoryIngest;
36
+ try {
37
+ memoryIngest = createLocalProjectMemoryIngest(input.project, { semanticLayerCompute, queryExecutor });
38
+ }
39
+ catch (error) {
40
+ input.io?.stderr.write(`KTX MCP memory_ingest disabled: ${error instanceof Error ? error.message : String(error)}\n`);
41
+ }
42
+ return () => createDefaultKtxMcpServer({
43
+ name: 'ktx',
44
+ version: input.cliVersion,
45
+ userContext: { userId: 'local' },
46
+ contextTools: {
47
+ ...contextTools,
48
+ ...(memoryIngest ? { memoryIngest } : {}),
49
+ },
50
+ });
51
+ }
@@ -0,0 +1,14 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ import { loadKtxProject } from '@ktx/context/project';
3
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import type { KtxCliIo } from './cli-runtime.js';
5
+ export interface RunKtxMcpStdioServerOptions {
6
+ projectDir: string;
7
+ cliVersion?: string;
8
+ io?: KtxCliIo;
9
+ createMcpServer?: () => McpServer;
10
+ loadProject?: typeof loadKtxProject;
11
+ stdin?: Readable;
12
+ stdout?: Writable;
13
+ }
14
+ export declare function runKtxMcpStdioServer(options: RunKtxMcpStdioServerOptions): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import process from 'node:process';
2
+ import { loadKtxProject } from '@ktx/context/project';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { createKtxMcpServerFactory } from './mcp-server-factory.js';
5
+ export async function runKtxMcpStdioServer(options) {
6
+ const project = options.createMcpServer === undefined
7
+ ? await (options.loadProject ?? loadKtxProject)({ projectDir: options.projectDir })
8
+ : undefined;
9
+ const protocolIo = {
10
+ stdout: { write() { } },
11
+ stderr: options.io?.stderr ?? { write() { } },
12
+ };
13
+ const createMcpServer = options.createMcpServer ??
14
+ (await createKtxMcpServerFactory({
15
+ project: project,
16
+ projectDir: options.projectDir,
17
+ cliVersion: options.cliVersion ?? '0.0.0-private',
18
+ io: protocolIo,
19
+ }));
20
+ const stdin = options.stdin ?? process.stdin;
21
+ const transport = new StdioServerTransport(stdin, options.stdout);
22
+ await new Promise((resolve, reject) => {
23
+ let settled = false;
24
+ const settle = (callback) => {
25
+ if (settled)
26
+ return;
27
+ settled = true;
28
+ stdin.off('end', closeTransport);
29
+ stdin.off('close', closeTransport);
30
+ callback();
31
+ };
32
+ const closeTransport = () => {
33
+ transport.close().catch((error) => {
34
+ settle(() => reject(error instanceof Error ? error : new Error(String(error))));
35
+ });
36
+ };
37
+ transport.onclose = () => settle(resolve);
38
+ transport.onerror = (error) => {
39
+ options.io?.stderr.write(`KTX MCP stdio transport error: ${error.message}\n`);
40
+ settle(() => reject(error));
41
+ };
42
+ stdin.once('end', closeTransport);
43
+ stdin.once('close', closeTransport);
44
+ createMcpServer().connect(transport).catch((error) => {
45
+ settle(() => reject(error instanceof Error ? error : new Error(String(error))));
46
+ });
47
+ });
48
+ }
@@ -17,7 +17,7 @@ function replayInput() {
17
17
  { unitKey: 'customers', target: 'sl', action: 'updated', key: 'orbit_demo.customers', summary: 'customer metrics', rawFiles: ['customers'], status: 'success' },
18
18
  ],
19
19
  provenance: [{ rawPath: 'orders', artifactKind: 'wiki', artifactKey: 'wiki/orders.md', actionType: 'wiki_written' }],
20
- transcripts: [{ unitKey: 'orders', path: '/tmp/t.jsonl', toolCallCount: 2, errorCount: 0, toolNames: ['read_raw_span', 'wiki_write'] }],
20
+ transcripts: [{ unitKey: 'orders', path: '/tmp/t.jsonl', toolCallCount: 2, errorCount: 0, toolNames: ['read_raw_span', 'memory_ingest'] }],
21
21
  },
22
22
  events: [
23
23
  { type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 2 },
@@ -75,6 +75,7 @@ export interface KtxPublicIngestDeps {
75
75
  }>;
76
76
  scanProgress?: KtxProgressPort;
77
77
  ingestProgress?: (update: KtxIngestProgressUpdate) => void;
78
+ runtimeIo?: KtxCliIo;
78
79
  onPhaseStart?: (phaseKey: KtxPublicIngestPhaseKey) => void;
79
80
  onPhaseEnd?: (phaseKey: KtxPublicIngestPhaseKey, status: 'done' | 'failed' | 'skipped', summary?: string) => void;
80
81
  }
@@ -434,10 +434,12 @@ export async function executePublicIngestTarget(target, args, io, deps) {
434
434
  const runScan = deps.runScan ?? runKtxScan;
435
435
  const capturedScanIo = deps.scanProgress ? null : createCapturedPublicIngestIo();
436
436
  const scanIo = capturedScanIo ?? io;
437
+ const scanDeps = {
438
+ ...(deps.scanProgress ? { progress: deps.scanProgress } : {}),
439
+ ...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}),
440
+ };
437
441
  deps.onPhaseStart?.('database-schema');
438
- const scanExitCode = deps.scanProgress
439
- ? await runScan(scanArgs, scanIo, { progress: deps.scanProgress })
440
- : await runScan(scanArgs, scanIo);
442
+ const scanExitCode = Object.keys(scanDeps).length > 0 ? await runScan(scanArgs, scanIo, scanDeps) : await runScan(scanArgs, scanIo);
441
443
  if (scanExitCode !== 0) {
442
444
  deps.onPhaseEnd?.('database-schema', 'failed');
443
445
  if (target.queryHistory?.enabled === true) {
@@ -466,9 +468,13 @@ export async function executePublicIngestTarget(target, args, io, deps) {
466
468
  };
467
469
  const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo();
468
470
  const ingestIo = capturedIngestIo ?? io;
471
+ const ingestDeps = {
472
+ ...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}),
473
+ ...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}),
474
+ };
469
475
  deps.onPhaseStart?.('query-history');
470
- const qhExitCode = deps.ingestProgress
471
- ? await runIngest(ingestArgs, ingestIo, { progress: deps.ingestProgress })
476
+ const qhExitCode = Object.keys(ingestDeps).length > 0
477
+ ? await runIngest(ingestArgs, ingestIo, ingestDeps)
472
478
  : await runIngest(ingestArgs, ingestIo);
473
479
  if (qhExitCode !== 0) {
474
480
  deps.onPhaseEnd?.('query-history', 'failed');
@@ -494,9 +500,13 @@ export async function executePublicIngestTarget(target, args, io, deps) {
494
500
  const runIngest = deps.runIngest ?? runKtxIngest;
495
501
  const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo();
496
502
  const ingestIo = capturedIngestIo ?? io;
503
+ const ingestDeps = {
504
+ ...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}),
505
+ ...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}),
506
+ };
497
507
  deps.onPhaseStart?.('source-ingest');
498
- const exitCode = deps.ingestProgress
499
- ? await runIngest(ingestArgs, ingestIo, { progress: deps.ingestProgress })
508
+ const exitCode = Object.keys(ingestDeps).length > 0
509
+ ? await runIngest(ingestArgs, ingestIo, ingestDeps)
500
510
  : await runIngest(ingestArgs, ingestIo);
501
511
  deps.onPhaseEnd?.('source-ingest', exitCode === 0 ? 'done' : 'failed');
502
512
  return markTargetResult(target, args, exitCode === 0 ? 'done' : 'failed', 'source-ingest', capturedIngestIo ? firstCapturedFailureLine(capturedIngestIo.capturedOutput()) : undefined);