Add MCP agent client setup support

This commit is contained in:
Andrey Avtomonov 2026-05-16 00:12:36 +02:00
parent 658024dcf3
commit 0361449c8a
19 changed files with 745 additions and 146 deletions

View file

@ -80,7 +80,7 @@ skill**.
| Target | MCP tools + analytics skill | Adds with admin CLI skill |
|--------|------------------------------|---------------------------|
| Claude Code | `.mcp.json`, `.claude/skills/ktx-analytics/SKILL.md` | `.claude/skills/ktx/SKILL.md`, `.claude/rules/ktx.md` |
| Claude Desktop | `.ktx/agents/claude/ktx-plugin.zip` with analytics skill and local stdio MCP config | Adds `skills/ktx/SKILL.md` inside the plugin ZIP |
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` stdio entry + `.ktx/agents/claude/ktx-plugin.zip` with analytics skill | Adds `skills/ktx/SKILL.md` inside the plugin ZIP |
| Codex | Printed snippet for `~/.codex/config.toml`, `.agents/skills/ktx-analytics/SKILL.md` | `.agents/skills/ktx/SKILL.md`, `.codex/instructions/ktx.md` |
| Cursor | `.cursor/mcp.json`, `.cursor/rules/ktx-analytics.mdc` | `.cursor/rules/ktx.mdc` |
| OpenCode | Printed snippet for `opencode.json`, `.opencode/commands/ktx-analytics.md` | `.opencode/commands/ktx.md` |
@ -167,20 +167,32 @@ same markdown command definitions.
## Claude Desktop
During setup, select **Claude Desktop** from the agent targets. KTX generates a
local Claude plugin ZIP at `.ktx/agents/claude/ktx-plugin.zip`. The plugin
contains the KTX analytics skill and a local stdio MCP config that runs:
During setup, select **Claude Desktop** from the agent targets. KTX writes the
MCP server entry directly into Claude Desktop's config and generates a separate
plugin ZIP for the analytics skill:
```bash
ktx --project-dir /path/to/project mcp stdio
```
- `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
`%AppData%/Claude/claude_desktop_config.json` (Windows) gets an
`mcpServers.ktx` entry that runs the KTX MCP server over stdio via a local
launcher shim at `.ktx/agents/claude/ktx-plugin-runner.sh`. The shim locates
a usable Node.js (Volta, NVM, Homebrew, system) so Claude Desktop can spawn
the server without needing `node` in PATH.
- `.ktx/agents/claude/ktx-plugin.zip` contains the `ktx-analytics` skill (and
the admin `ktx` skill if you choose **MCP tools + analytics skill + admin
CLI skill**). Install the ZIP from Claude Desktop's plugin UI to load the
skill.
If you choose **MCP tools + analytics skill + admin CLI skill**, the plugin ZIP
also includes the admin `ktx` skill.
After `ktx setup`, restart Claude Desktop so it picks up the new MCP server
entry, then install the plugin ZIP. No daemon needs to be running — Claude
Desktop spawns the MCP server itself per session.
Install the generated ZIP from Claude Desktop's plugin UI. If you move the KTX
checkout or project directory, rerun `ktx setup --agents` and reinstall the
regenerated plugin.
Claude Desktop does not introspect local stdio MCP servers, so the per-tool
"Connector"-style UI is not rendered for KTX. The tools are still callable
from any Claude Desktop chat.
If you move the KTX checkout or project directory, rerun `ktx setup --agents`
to refresh the absolute paths in `claude_desktop_config.json` and the launcher
shim, then reinstall the regenerated plugin ZIP.
---
@ -267,7 +279,7 @@ Admin CLI skills call the same KTX CLI commands:
| | Claude Code | Claude Desktop | Cursor | Codex | OpenCode |
|---|---|---|---|---|---|
| MCP tools | Yes | Local stdio via plugin | Yes | Snippet | Snippet |
| MCP tools | Yes | Local stdio via `claude_desktop_config.json` | Yes | Snippet | Snippet |
| Analytics skill | `.claude/skills/ktx-analytics/SKILL.md` | Included in plugin ZIP | `.cursor/rules/ktx-analytics.mdc` | `.agents/skills/ktx-analytics/SKILL.md` | `.opencode/commands/ktx-analytics.md` |
| Admin CLI skills | Optional | Optional in plugin ZIP | Optional (.mdc) | Optional | Optional |
| Global install | Yes | Project-local ZIP | No | Yes | No |

View file

@ -903,14 +903,16 @@ Add this test after the `dictionary_search` registration test:
limit: 5,
}),
).resolves.toMatchObject({
structuredContent: [
{
kind: 'table',
id: 'public.orders',
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
},
],
structuredContent: {
refs: [
{
kind: 'table',
id: 'public.orders',
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
},
],
},
});
expect(discover.search).toHaveBeenCalledWith({
query: 'orders',

View file

@ -143,7 +143,9 @@ or `entity_details` tools.
}
```
**Output:** array of refs, each:
**Output:** `{ refs: Ref[] }` — the MCP protocol requires `structuredContent`
to be a JSON object, so the array of matches is wrapped under `refs`. Each
ref is shaped:
```typescript
{

View file

@ -186,6 +186,10 @@ function shouldSuppressProjectDirLine(path: string[], options: Record<string, un
return true;
}
if (commandPathKey === 'ktx mcp stdio') {
return true;
}
if (
commandPathKey === 'ktx status' &&
typeof options.projectDir !== 'string' &&

View file

@ -56,6 +56,33 @@ describe('registerMcpCommands', () => {
expect(startDaemon).not.toHaveBeenCalled();
});
it('prints "already running" when startDaemon reports already-running', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const startDaemon = vi.fn().mockResolvedValue({
status: 'already-running',
url: 'http://127.0.0.1:7878/mcp',
state: {
schemaVersion: 1,
pid: 4242,
host: '127.0.0.1',
port: 7878,
tokenAuth: false,
projectDir: '/tmp/ktx-already',
startedAt: '2026-05-14T00:00:00.000Z',
logPath: '/tmp/ktx-already/.ktx/logs/mcp.log',
},
});
const context = makeContext({ deps: { mcp: { startDaemon } } });
registerMcpCommands(program, context);
await program.parseAsync(['--project-dir', '/tmp/ktx-already', 'mcp', 'start'], { from: 'user' });
expect(startDaemon).toHaveBeenCalledTimes(1);
expect(context.io.stdout.write).toHaveBeenCalledWith(
'KTX MCP daemon already running: http://127.0.0.1:7878/mcp\n',
);
});
it('runs the stdio server with the resolved project directory', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const runStdioServer = vi.fn().mockResolvedValue(undefined);

View file

@ -82,7 +82,11 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
allowedOrigins: options.allowedOrigin,
binPath: binPath(),
});
context.io.stdout.write(`KTX MCP daemon started: ${result.url}\n`);
context.io.stdout.write(
result.status === 'started'
? `KTX MCP daemon started: ${result.url}\n`
: `KTX MCP daemon already running: ${result.url}\n`,
);
});
mcp

View file

@ -94,6 +94,78 @@ describe('managed MCP daemon lifecycle', () => {
);
});
it('returns already-running without spawning when the daemon is alive at the same host/port', async () => {
await mkdir(join(projectDir, '.ktx'), { recursive: true });
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
const spawnDaemon = vi.fn(() => child(9999));
const result = await startKtxMcpDaemon({
projectDir,
cliVersion: '0.0.0-test',
host: '127.0.0.1',
port: 7878,
allowedHosts: [],
allowedOrigins: [],
binPath: '/repo/packages/cli/dist/bin.js',
spawnDaemon,
processAlive: vi.fn(() => true),
portAvailable: vi.fn(async () => true),
});
expect(result.status).toBe('already-running');
expect(result.url).toBe('http://127.0.0.1:7878/mcp');
expect(result.state.pid).toBe(4242);
expect(spawnDaemon).not.toHaveBeenCalled();
});
it('throws when the recorded daemon uses a different host or port', async () => {
await mkdir(join(projectDir, '.ktx'), { recursive: true });
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
const spawnDaemon = vi.fn(() => child(9999));
await expect(
startKtxMcpDaemon({
projectDir,
cliVersion: '0.0.0-test',
host: '127.0.0.1',
port: 9000,
allowedHosts: [],
allowedOrigins: [],
binPath: '/repo/packages/cli/dist/bin.js',
spawnDaemon,
processAlive: vi.fn(() => true),
portAvailable: vi.fn(async () => true),
}),
).rejects.toThrow(/different configuration[\s\S]*ktx mcp stop/);
expect(spawnDaemon).not.toHaveBeenCalled();
});
it('throws when token-auth presence differs from the recorded daemon', async () => {
await mkdir(join(projectDir, '.ktx'), { recursive: true });
await writeFile(
join(projectDir, '.ktx/mcp.json'),
`${JSON.stringify(state(projectDir, { tokenAuth: false }), null, 2)}\n`,
);
const spawnDaemon = vi.fn(() => child(9999));
await expect(
startKtxMcpDaemon({
projectDir,
cliVersion: '0.0.0-test',
host: '127.0.0.1',
port: 7878,
token: 'secret-token',
allowedHosts: [],
allowedOrigins: [],
binPath: '/repo/packages/cli/dist/bin.js',
spawnDaemon,
processAlive: vi.fn(() => true),
portAvailable: vi.fn(async () => true),
}),
).rejects.toThrow(/different configuration/);
expect(spawnDaemon).not.toHaveBeenCalled();
});
it('reports running when the process is alive and health passes', async () => {
await mkdir(join(projectDir, '.ktx'), { recursive: true });
await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);

View file

@ -121,11 +121,25 @@ export async function startKtxMcpDaemon(options: {
portAvailable?: (host: string, port: number) => Promise<boolean>;
spawnDaemon?: typeof defaultSpawnDaemon;
now?: () => Date;
}): Promise<{ status: 'started'; state: KtxMcpDaemonState; url: string }> {
}): Promise<{ status: 'started' | 'already-running'; state: KtxMcpDaemonState; url: string }> {
const existing = await readState(options.projectDir).catch(() => undefined);
const processAlive = options.processAlive ?? defaultProcessAlive;
if (existing && processAlive(existing.pid)) {
throw new Error(`KTX MCP daemon is already recorded at http://${existing.host}:${existing.port}/mcp`);
const sameConfig =
existing.host === options.host &&
existing.port === options.port &&
existing.tokenAuth === Boolean(options.token);
if (sameConfig) {
return {
status: 'already-running',
state: existing,
url: `http://${existing.host}:${existing.port}/mcp`,
};
}
throw new Error(
`KTX MCP daemon is already running at http://${existing.host}:${existing.port}/mcp ` +
'with a different configuration. Run `ktx mcp stop` first, then start again.',
);
}
const portAvailable = options.portAvailable ?? defaultPortAvailable;
if (!(await portAvailable(options.host, options.port))) {

View file

@ -1,3 +1,4 @@
import process from 'node:process';
import type { Readable, Writable } from 'node:stream';
import { loadKtxProject } from '@ktx/context/project';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@ -32,14 +33,32 @@ export async function runKtxMcpStdioServer(options: RunKtxMcpStdioServerOptions)
cliVersion: options.cliVersion ?? '0.0.0-private',
io: protocolIo,
}));
const transport = new StdioServerTransport(options.stdin, options.stdout);
const stdin = options.stdin ?? process.stdin;
const transport = new StdioServerTransport(stdin, options.stdout);
await new Promise<void>((resolve, reject) => {
transport.onclose = resolve;
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
stdin.off('end', closeTransport);
stdin.off('close', closeTransport);
callback();
};
const closeTransport = () => {
transport.close().catch((error: unknown) => {
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
});
};
transport.onclose = () => settle(resolve);
transport.onerror = (error) => {
options.io?.stderr.write(`KTX MCP stdio transport error: ${error.message}\n`);
reject(error);
settle(() => reject(error));
};
createMcpServer().connect(transport).catch(reject);
stdin.once('end', closeTransport);
stdin.once('close', closeTransport);
createMcpServer().connect(transport).catch((error: unknown) => {
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
});
});
}

View file

@ -32,6 +32,37 @@ async function readZipText(path: string, entry: string): Promise<string> {
return strFromU8(content);
}
function captureEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of keys) snapshot[key] = env[key];
return snapshot;
}
function clearEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): void {
for (const key of keys) delete env[key];
}
function captureKtxEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of Object.keys(env)) {
if (key.startsWith('KTX_')) snapshot[key] = env[key];
}
return snapshot;
}
function clearKtxEnv(env: NodeJS.ProcessEnv): void {
for (const key of Object.keys(env)) {
if (key.startsWith('KTX_')) delete env[key];
}
}
function restoreEnvKeys(env: NodeJS.ProcessEnv, snapshot: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) delete env[key];
else env[key] = value;
}
}
describe('setup agents', () => {
let tempDir: string;
@ -50,6 +81,7 @@ describe('setup agents', () => {
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'codex', scope: 'project', mode: 'mcp' })).toEqual([
@ -90,6 +122,7 @@ describe('setup agents', () => {
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') },
]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin.zip'), role: 'claude-plugin' },
]);
});
@ -301,10 +334,14 @@ describe('setup agents', () => {
}
});
it('generates a Claude Desktop plugin zip with analytics skill and stdio MCP config', async () => {
it('registers Claude Desktop MCP via claude_desktop_config.json and ships a skills-only plugin', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
const envSnapshot = captureEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
const ktxEnvSnapshot = captureKtxEnv(process.env);
process.env.HOME = home;
clearEnvKeys(process.env, ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']);
clearKtxEnv(process.env);
try {
const io = makeIo();
@ -328,31 +365,62 @@ describe('setup agents', () => {
});
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
await expect(stat(pluginPath)).resolves.toBeDefined();
await expect(stat(join(home, 'Library/Application Support/Claude/claude_desktop_config.json'))).rejects.toThrow();
expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"');
const mcpJson = JSON.parse(await readZipText(pluginPath, '.mcp.json')) as {
mcpServers: { ktx: { type: string; command: string; args: string[] } };
const launcherStat = await stat(launcherPath);
expect(launcherStat.mode & 0o111).not.toBe(0);
const launcher = await readFile(launcherPath, 'utf-8');
expect(launcher).toContain('KTX_CLI_BIN=');
expect(launcher).toContain('.nvm/versions/node');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: { ktx: { command: string; args: string[]; env?: Record<string, string> } };
};
expect(mcpJson.mcpServers.ktx.type).toBe('stdio');
expect(mcpJson.mcpServers.ktx.command).toBe(process.execPath);
expect(mcpJson.mcpServers.ktx.args).toEqual(expect.arrayContaining(['--project-dir', tempDir, 'mcp', 'stdio']));
expect(config.mcpServers.ktx).toEqual({
command: launcherPath,
args: ['--project-dir', tempDir, 'mcp', 'stdio'],
});
expect(await readZipText(pluginPath, '.claude-plugin/plugin.json')).toContain('"name": "ktx"');
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
expect(await readZipText(pluginPath, 'skills/ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
const setupMd = await readZipText(pluginPath, 'SETUP.md');
expect(setupMd).not.toContain('ktx mcp start');
expect(setupMd).toContain('claude_desktop_config.json');
await expect(readZipText(pluginPath, 'skills/ktx/SKILL.md')).rejects.toThrow('Missing zip entry');
expect(io.stdout()).toContain('Claude plugin generated');
expect(io.stdout()).toContain('.ktx/agents/claude/ktx-plugin.zip');
expect(io.stdout()).toContain('Install the generated KTX plugin ZIP from Claude Desktop Plugins');
expect(io.stdout()).toContain('KTX MCP server registered');
expect(io.stdout()).toContain('claude_desktop_config.json');
expect(io.stdout()).toContain('Restart Claude Desktop');
expect(io.stdout()).not.toContain('Run `ktx mcp start`');
} finally {
process.env.HOME = previousHome;
restoreEnvKeys(process.env, envSnapshot);
restoreEnvKeys(process.env, ktxEnvSnapshot);
await rm(home, { recursive: true, force: true });
}
});
it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
it('captures KTX_*, OPENAI_API_KEY, and ANTHROPIC_API_KEY into the Claude Desktop MCP env block', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
const envSnapshot = captureEnvKeys(process.env, [
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'KTX_LOG_LEVEL',
]);
const ktxEnvSnapshot = captureKtxEnv(process.env);
process.env.HOME = home;
clearKtxEnv(process.env);
process.env.OPENAI_API_KEY = 'sk-test-openai'; // pragma: allowlist secret
process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; // pragma: allowlist secret
process.env.KTX_LOG_LEVEL = 'debug';
try {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
@ -360,19 +428,65 @@ describe('setup agents', () => {
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }],
});
);
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
expect(await readZipText(pluginPath, 'skills/ktx/SKILL.md')).toContain(`--project-dir ${tempDir}`);
expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: { ktx: { env?: Record<string, string> } };
};
expect(config.mcpServers.ktx.env).toEqual({
OPENAI_API_KEY: 'sk-test-openai', // pragma: allowlist secret
ANTHROPIC_API_KEY: 'sk-ant-test', // pragma: allowlist secret
KTX_LOG_LEVEL: 'debug',
});
} finally {
process.env.HOME = previousHome;
restoreEnvKeys(process.env, envSnapshot);
restoreEnvKeys(process.env, ktxEnvSnapshot);
await rm(home, { recursive: true, force: true });
}
});
it('includes the admin CLI skill in the Claude Desktop plugin zip when requested', async () => {
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
installs: [{ target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' }],
});
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const adminSkill = await readZipText(pluginPath, 'skills/ktx/SKILL.md');
expect(adminSkill).toContain(`--project-dir ${tempDir}`);
expect(adminSkill).toContain('status --json');
expect(await readZipText(pluginPath, 'SETUP.md')).toContain('admin CLI skill');
await expect(readZipText(pluginPath, '.mcp.json')).rejects.toThrow('Missing zip entry');
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('installs MCP client config and analytics skill without admin CLI files', async () => {
@ -592,27 +706,47 @@ describe('setup agents', () => {
});
it('removes generated Claude Desktop plugin from the manifest', async () => {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
await expect(stat(pluginPath)).resolves.toBeDefined();
const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-'));
const previousHome = process.env.HOME;
process.env.HOME = home;
try {
const io = makeIo();
await runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: true,
agents: true,
target: 'claude-desktop',
scope: 'project',
mode: 'mcp-cli',
skipAgents: false,
},
io.io,
);
const pluginPath = join(tempDir, '.ktx/agents/claude/ktx-plugin.zip');
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
await expect(stat(pluginPath)).resolves.toBeDefined();
await expect(stat(launcherPath)).resolves.toBeDefined();
const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
};
expect(beforeConfig.mcpServers.ktx).toBeDefined();
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
await expect(removeKtxAgentInstall(tempDir, io.io)).resolves.toBe(0);
await expect(stat(pluginPath)).rejects.toThrow();
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
await expect(stat(pluginPath)).rejects.toThrow();
await expect(stat(launcherPath)).rejects.toThrow();
const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
};
expect(afterConfig.mcpServers.ktx).toBeUndefined();
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
} finally {
process.env.HOME = previousHome;
await rm(home, { recursive: true, force: true });
}
});
it('treats cancel as skip in interactive mode', async () => {

View file

@ -1,5 +1,5 @@
import { existsSync } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
@ -9,9 +9,11 @@ import {
} from '@ktx/context/project';
import { strToU8, zipSync } from 'fflate';
import type { KtxCliIo } from './cli-runtime.js';
import { bold, dim, green } from './io/symbols.js';
import { withMultiselectNavigation } from './prompt-navigation.js';
import {
createKtxSetupPromptAdapter,
createKtxSetupUiAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js';
@ -48,7 +50,7 @@ export interface KtxAgentInstallManifest {
installedAt: string;
installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>;
entries: Array<
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' }
| { kind: 'file'; path: string; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-plugin' | 'launcher' }
| { kind: 'json-key'; path: string; jsonPath: string[] }
>;
}
@ -197,16 +199,63 @@ function cursorConfigPath(projectDir: string, scope: KtxAgentScope): { path: str
};
}
function claudeDesktopConfigPath(): { path: string; jsonPath: string[] } {
const home = process.env.HOME ?? '';
const path =
process.platform === 'win32'
? join(process.env.APPDATA ?? join(home, 'AppData/Roaming'), 'Claude/claude_desktop_config.json')
: join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
return { path, jsonPath: ['mcpServers', 'ktx'] };
}
const CLAUDE_DESKTOP_FORWARDED_ENV_KEYS = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const;
export function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record<string, string> {
const captured: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (value === undefined || value === '') continue;
if (key.startsWith('KTX_') || (CLAUDE_DESKTOP_FORWARDED_ENV_KEYS as readonly string[]).includes(key)) {
captured[key] = value;
}
}
return captured;
}
function claudeDesktopMcpEntry(input: {
launcherPath: string;
projectDir: string;
env?: NodeJS.ProcessEnv;
}): Record<string, unknown> {
const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env);
return {
command: input.launcherPath,
args: ['--project-dir', input.projectDir, 'mcp', 'stdio'],
...(Object.keys(captured).length > 0 ? { env: captured } : {}),
};
}
async function installMcpClientConfig(input: {
projectDir: string;
target: KtxAgentTarget;
scope: KtxAgentScope;
}): Promise<KtxMcpClientInstallResult> {
const endpoint = await resolveMcpEndpoint(input.projectDir);
const entries: InstallEntry[] = [];
const snippets: string[] = [];
const notices: string[] = [];
if (input.target === 'claude-desktop') {
const config = claudeDesktopConfigPath();
const launcherPath = claudeDesktopLauncherPath(input.projectDir);
await writeJsonKey(
config.path,
config.jsonPath,
claudeDesktopMcpEntry({ launcherPath, projectDir: input.projectDir }),
);
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
return { entries, snippets, notices };
}
const endpoint = await resolveMcpEndpoint(input.projectDir);
if (!endpoint.running) {
notices.push('Run `ktx mcp start` to enable the configured KTX MCP server.');
}
@ -244,7 +293,8 @@ function plannedMcpJsonEntries(input: {
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
}
if (input.target === 'claude-desktop') {
return [];
const config = claudeDesktopConfigPath();
return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
}
if (input.target === 'cursor') {
const config = cursorConfigPath(input.projectDir, input.scope);
@ -261,6 +311,10 @@ function claudeDesktopPluginPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin.zip');
}
function claudeDesktopLauncherPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin-runner.sh');
}
export function plannedKtxAgentFiles(input: {
projectDir: string;
target: KtxAgentTarget;
@ -298,7 +352,10 @@ export function plannedKtxAgentFiles(input: {
return [];
}
if (input.target === 'claude-desktop') {
return [{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const }];
return [
{ kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const },
{ kind: 'file', path: claudeDesktopPluginPath(input.projectDir), role: 'claude-plugin' as const },
];
}
throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
}
@ -373,6 +430,10 @@ function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function shellScriptQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function ktxCommandLine(launcher: KtxCliLauncher, args: string[]): string {
return [launcher.command, ...launcher.args, ...args].map(shellQuote).join(' ');
}
@ -436,27 +497,11 @@ function claudePluginVersionContent(): string {
return `${JSON.stringify({ version: '0.0.0-local' }, null, 2)}\n`;
}
function claudePluginMcpContent(input: { projectDir: string; launcher: KtxCliLauncher }): string {
return `${JSON.stringify(
{
mcpServers: {
ktx: {
type: 'stdio',
command: input.launcher.command,
args: [...input.launcher.args, '--project-dir', input.projectDir, 'mcp', 'stdio'],
},
},
},
null,
2,
)}\n`;
}
function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boolean }): string {
return [
'# KTX Claude Plugin',
'',
'Install this plugin ZIP from Claude Desktop, then use KTX tools for local analytics questions.',
'Install this plugin ZIP from Claude Desktop to load the KTX analytics skill.',
'',
`KTX project: \`${input.projectDir}\``,
'',
@ -464,13 +509,69 @@ function claudePluginSetupContent(input: { projectDir: string; withAdminCli: boo
'',
'- `ktx-analytics` skill for the MCP analytics workflow',
...(input.withAdminCli ? ['- `ktx` admin CLI skill for KTX maintenance commands'] : []),
'- Local stdio MCP server launched through the KTX CLI',
'',
'The KTX MCP server is registered separately in `claude_desktop_config.json` by `ktx setup` and runs as a local stdio child of Claude Desktop — no daemon to start.',
'',
'If this checkout or project directory moves, rerun `ktx setup --agents` and reinstall the regenerated plugin.',
'',
].join('\n');
}
function claudePluginLauncherContent(input: { launcher: KtxCliLauncher }): string {
const binPath = input.launcher.args[0];
if (!binPath) {
throw new Error('Expected KTX CLI launcher to include a bin path.');
}
const candidates = [
input.launcher.command,
'/opt/homebrew/bin/node',
'/usr/local/bin/node',
'/usr/bin/node',
];
return [
'#!/bin/sh',
'set -eu',
'',
`KTX_CLI_BIN=${shellScriptQuote(binPath)}`,
'',
'run_with_node() {',
' node_bin=$1',
' shift',
' exec "$node_bin" "$KTX_CLI_BIN" "$@"',
'}',
'',
'if [ -n "${KTX_NODE:-}" ] && [ -x "${KTX_NODE:-}" ]; then',
' run_with_node "$KTX_NODE" "$@"',
'fi',
'',
'if [ -x "$HOME/.volta/bin/node" ]; then',
' run_with_node "$HOME/.volta/bin/node" "$@"',
'fi',
'',
...candidates.map((candidate) =>
[
`if [ -x ${shellScriptQuote(candidate)} ]; then`,
` run_with_node ${shellScriptQuote(candidate)} "$@"`,
'fi',
].join('\n'),
),
'',
'for candidate in "$HOME"/.nvm/versions/node/*/bin/node; do',
' if [ -x "$candidate" ]; then',
' run_with_node "$candidate" "$@"',
' fi',
'done',
'',
'if command -v node >/dev/null 2>&1; then',
' run_with_node "$(command -v node)" "$@"',
'fi',
'',
'echo "KTX plugin could not find Node.js. Set KTX_NODE to a Node executable and reinstall the plugin." >&2',
'exit 127',
'',
].join('\n');
}
async function writeClaudeDesktopPlugin(input: {
projectDir: string;
path: string;
@ -481,7 +582,6 @@ async function writeClaudeDesktopPlugin(input: {
const files: Record<string, Uint8Array> = {
'.claude-plugin/plugin.json': strToU8(claudePluginJsonContent()),
'version.json': strToU8(claudePluginVersionContent()),
'.mcp.json': strToU8(claudePluginMcpContent({ projectDir: input.projectDir, launcher: input.launcher })),
'skills/ktx-analytics/SKILL.md': strToU8(await readAnalyticsSkillContent()),
'SETUP.md': strToU8(claudePluginSetupContent({ projectDir: input.projectDir, withAdminCli })),
};
@ -494,6 +594,15 @@ async function writeClaudeDesktopPlugin(input: {
await writeFile(input.path, Buffer.from(zipSync(files)));
}
async function writeClaudeDesktopLauncher(input: {
path: string;
launcher: KtxCliLauncher;
}): Promise<void> {
await mkdir(dirname(input.path), { recursive: true });
await writeFile(input.path, claudePluginLauncherContent({ launcher: input.launcher }), 'utf-8');
await chmod(input.path, 0o755);
}
function ruleInstructionContent(input: { projectDir: string }): string {
return [
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` +
@ -658,7 +767,8 @@ export function formatInstallSummary(
skill: 'teaches admin agents which KTX CLI commands to run',
rule: 'tells admin agents when to use KTX CLI',
'analytics-skill': 'teaches your agent the KTX MCP analytics workflow',
'claude-plugin': 'bundles KTX skills and local stdio MCP config for Claude Desktop',
'claude-plugin': 'bundles KTX skills for Claude Desktop (MCP server is registered in claude_desktop_config.json)',
launcher: 'runs the local KTX CLI with an available Node.js for Claude Desktop',
};
const lines: string[] = [];
@ -667,20 +777,24 @@ export function formatInstallSummary(
lines.push(` ${targetDisplayNames[install.target]}`);
for (const entry of targetEntries) {
if (entry.kind === 'file') {
const displayPath =
install.scope === 'global' && entry.role !== 'claude-plugin' ? entry.path : relative(projectDir, entry.path);
const isRule = entry.role === 'rule' || (!entry.role && fileEntryLabels[install.target] === 'Rule installed');
const label =
entry.role === 'analytics-skill'
? 'Analytics skill installed'
: entry.role === 'claude-plugin'
? 'Claude plugin generated'
: entry.role === 'launcher'
? 'Launcher installed'
: isRule
? 'Rule installed'
: fileEntryLabels[install.target];
const hint = fileHints[isRule ? 'rule' : (entry.role ?? 'skill')] ?? '';
lines.push(` + ${label}${hint}`);
lines.push(` ${displayPath}`);
if (entry.role !== 'claude-plugin') {
const displayPath =
install.scope === 'global' ? entry.path : relative(projectDir, entry.path);
lines.push(` ${displayPath}`);
}
}
}
for (const entry of mcpEntriesByTarget
@ -704,6 +818,10 @@ async function installTarget(input: {
const launcher = ktxCliLauncher();
for (const entry of entries) {
if (entry.kind !== 'file') continue;
if (entry.role === 'launcher') {
await writeClaudeDesktopLauncher({ path: entry.path, launcher });
continue;
}
if (entry.role === 'claude-plugin') {
await writeClaudeDesktopPlugin({
projectDir: input.projectDir,
@ -800,20 +918,37 @@ export async function runKtxSetupAgentsStep(
const entries: InstallEntry[] = [];
const snippets: string[] = [];
const notices = new Set<string>();
let claudeDesktopTutorial: string | undefined;
try {
for (const install of installs) {
entries.push(...(await installTarget({ projectDir: args.projectDir, ...install })));
const targetEntries = await installTarget({ projectDir: args.projectDir, ...install });
entries.push(...targetEntries);
const mcpResult = await installMcpClientConfig({
projectDir: args.projectDir,
target: install.target,
scope: install.scope,
});
entries.push(...mcpResult.entries);
for (const snippet of mcpResult.snippets) snippets.push(snippet);
for (const notice of mcpResult.notices) notices.add(notice);
if (install.target === 'claude-desktop') {
notices.add('Install the generated KTX plugin ZIP from Claude Desktop Plugins, then restart or reload Claude.');
} else {
const mcpResult = await installMcpClientConfig({
projectDir: args.projectDir,
target: install.target,
scope: install.scope,
});
entries.push(...mcpResult.entries);
for (const snippet of mcpResult.snippets) snippets.push(snippet);
for (const notice of mcpResult.notices) notices.add(notice);
const pluginEntry = targetEntries.find(
(entry): entry is Extract<InstallEntry, { kind: 'file' }> =>
entry.kind === 'file' && entry.role === 'claude-plugin',
);
const pluginPath = pluginEntry?.path ?? '';
const configPath = claudeDesktopConfigPath().path;
claudeDesktopTutorial = [
`${green('✓')} ${bold('KTX MCP server registered')}`,
` ${dim(configPath)}`,
'',
bold('1. Restart Claude Desktop'),
' Quit and reopen so it picks up the new MCP server.',
'',
bold('2. Install the KTX plugin'),
' Open Claude Desktop → Settings → Plugins and install from file:',
` ${dim(pluginPath)}`,
].join('\n');
}
}
await writeManifest(
@ -821,12 +956,20 @@ export async function runKtxSetupAgentsStep(
mergeManifest(args.projectDir, await readKtxAgentInstallManifest(args.projectDir), installs, entries),
);
await markAgentsComplete(args.projectDir);
io.stdout.write(`\nAgent integration complete\n\n${formatInstallSummary(installs, entries, args.projectDir)}\n`);
for (const snippet of snippets) {
io.stdout.write(`\n${snippet}\n`);
const setupUi = createKtxSetupUiAdapter();
setupUi.note(
formatInstallSummary(installs, entries, args.projectDir),
'Agent integration complete',
io,
);
if (claudeDesktopTutorial) {
setupUi.note(claudeDesktopTutorial, 'Finish Claude Desktop setup', io);
}
for (const notice of notices) {
io.stdout.write(`\n${notice}\n`);
const nextStepBlocks: string[] = [];
for (const notice of notices) nextStepBlocks.push(notice);
for (const snippet of snippets) nextStepBlocks.push(snippet);
if (nextStepBlocks.length > 0) {
setupUi.note(nextStepBlocks.join('\n\n'), 'Next steps', io, { format: bold });
}
return { status: 'ready', projectDir: args.projectDir, installs };
} catch (error) {

View file

@ -138,9 +138,13 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption
};
}
interface KtxSetupNoteOptions {
format?: (line: string) => string;
}
export interface KtxSetupUiAdapter {
intro(title: string, io: KtxCliIo): void;
note(message: string, title: string, io: KtxCliIo): void;
note(message: string, title: string, io: KtxCliIo, options?: KtxSetupNoteOptions): void;
}
function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
@ -160,9 +164,12 @@ export function createKtxSetupUiAdapter(): KtxSetupUiAdapter {
}
io.stdout.write(`${title}\n`);
},
note(message, title, io) {
note(message, title, io, options) {
if (isWritableTtyOutput(io.stdout)) {
note(message, title, { output: io.stdout });
note(message, title, {
output: io.stdout,
...(options?.format ? { format: options.format } : {}),
});
return;
}
io.stdout.write(`\n${title}:\n`);

View file

@ -709,15 +709,18 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
const status = await readKtxSetupStatus(projectResult.projectDir);
io.stdout.write(formatKtxSetupStatus(status));
setupUi.note(
formatSetupNextStepLines({
setupReady: setupStatusReady(status),
hasContextTargets: setupHasContextTargets(status),
contextReady: setupContextReady(status),
agentIntegrationReady: status.agents.some((agent) => agent.ready),
}).join('\n'),
'What you can do next',
io,
);
const focusedOnAgents = args.agents || entryAction === 'agents';
if (!focusedOnAgents) {
setupUi.note(
formatSetupNextStepLines({
setupReady: setupStatusReady(status),
hasContextTargets: setupHasContextTargets(status),
contextReady: setupContextReady(status),
agentIntegrationReady: status.agents.some((agent) => agent.ready),
}).join('\n'),
'What you can do next',
io,
);
}
return 0;
}

View file

@ -191,6 +191,36 @@ describe('local KTX embedding config', () => {
expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull();
});
it('returns null when backend is openai but no apiKey is resolvable from env', () => {
const config: KtxProjectEmbeddingConfig = {
backend: 'openai',
model: 'text-embedding-3-small',
dimensions: 1536,
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
};
expect(resolveLocalKtxEmbeddingConfig(config, {})).toBeNull();
});
it('resolves openai embedding config from env', () => {
const config: KtxProjectEmbeddingConfig = {
backend: 'openai',
model: 'text-embedding-3-small',
dimensions: 1536,
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
};
expect(
resolveLocalKtxEmbeddingConfig(config, { OPENAI_API_KEY: 'sk-test' }), // pragma: allowlist secret
).toEqual({
backend: 'openai',
model: 'text-embedding-3-small',
dimensions: 1536,
openai: { apiKey: 'sk-test' }, // pragma: allowlist secret
batchSize: undefined,
});
});
it('constructs deterministic embeddings from the default project config', () => {
const createKtxEmbeddingProvider = vi.fn(() => ({}) as never);
const provider = createLocalKtxEmbeddingProviderFromConfig(

View file

@ -145,11 +145,23 @@ export function resolveLocalKtxEmbeddingConfig(
batchSize: config.batchSize,
};
}
if (config.backend === 'openai') {
const openai = resolvedProviderConfig(config.openai, env);
if (!openai?.apiKey) {
return null;
}
return {
backend: config.backend,
model: config.model ?? 'deterministic',
dimensions: config.dimensions,
openai,
batchSize: config.batchSize,
};
}
return {
backend: config.backend,
model: config.model ?? 'deterministic',
dimensions: config.dimensions,
...(resolvedProviderConfig(config.openai, env) ? { openai: resolvedProviderConfig(config.openai, env) } : {}),
...(config.sentenceTransformers
? {
sentenceTransformers: {

View file

@ -89,13 +89,36 @@ const slQueryDimensionSchema = z.union([
}),
]);
const slQueryOrderBySchema = z.union([
z.string(),
const slQueryOrderBySchema = z.preprocess(
(value) => {
if (typeof value === 'string') {
return { field: value };
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = { ...(value as Record<string, unknown>) };
if (!('field' in obj) && typeof obj.id === 'string') {
obj.field = obj.id;
}
if (!('direction' in obj) && 'desc' in obj) {
obj.direction = obj.desc === true ? 'desc' : 'asc';
}
return obj;
}
return value;
},
z.object({
field: z.string().min(1),
direction: z.enum(['asc', 'desc']).default('asc'),
field: z
.string()
.min(1)
.describe(
'Field/measure/dimension id to order by, e.g. "orders.created_at", a dimension key like "mart_nrr_quarterly.quarter_label", or a measure alias.',
),
direction: z
.enum(['asc', 'desc'])
.default('asc')
.describe('Sort direction: "asc" or "desc". Defaults to "asc".'),
}),
]);
);
const slQuerySchema = z.object({
connectionId: connectionIdSchema.optional(),
@ -378,7 +401,10 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
'sl_query',
{
title: 'Semantic Layer Query',
description: 'Execute a semantic-layer query and return rows, headers, SQL, and the query plan.',
description:
'Execute a semantic-layer query and return rows, headers, SQL, and the query plan. ' +
'order_by items use the shape {"field": "orders.created_at", "direction": "asc"|"desc"}; ' +
'a bare string is treated as field with direction "asc".',
inputSchema: slQuerySchema.shape,
},
slQuerySchema,
@ -443,7 +469,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
inputSchema: discoverDataSchema.shape,
},
discoverDataSchema,
async (input) => jsonToolResult(await discover.search(input)),
async (input) => jsonToolResult({ refs: await discover.search(input) }),
);
}

View file

@ -256,6 +256,51 @@ describe('createKtxMcpServer', () => {
});
});
it('sl_query normalizes order_by from cube-style {id, desc} and bare strings to {field, direction}', async () => {
const fake = makeFakeServer();
const semanticLayer: KtxSemanticLayerMcpPort = {
listSources: vi.fn(),
readSource: vi.fn(),
writeSource: vi.fn(),
validate: vi.fn(),
query: vi.fn<KtxSemanticLayerMcpPort['query']>().mockResolvedValue({
sql: '',
headers: [],
rows: [],
totalRows: 0,
}),
};
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: { semanticLayer },
});
await getTool(fake.tools, 'sl_query').handler({
connectionId: 'warehouse',
measures: ['orders.count'],
order_by: [
{ field: 'orders.total', direction: 'desc' },
{ id: 'orders.quarter_label', desc: false },
{ id: 'orders.created_at', desc: true },
'orders.segment',
],
});
expect(semanticLayer.query).toHaveBeenCalledWith({
connectionId: 'warehouse',
query: expect.objectContaining({
order_by: [
{ field: 'orders.total', direction: 'desc' },
{ field: 'orders.quarter_label', direction: 'asc' },
{ field: 'orders.created_at', direction: 'desc' },
{ field: 'orders.segment', direction: 'asc' },
],
}),
});
});
it('registers discover_data when the host provides a discover port', async () => {
const fake = makeFakeServer();
const discover: KtxDiscoverDataMcpPort = {
@ -288,14 +333,16 @@ describe('createKtxMcpServer', () => {
limit: 5,
}),
).resolves.toMatchObject({
structuredContent: [
{
kind: 'table',
id: 'public.orders',
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
},
],
structuredContent: {
refs: [
{
kind: 'table',
id: 'public.orders',
connectionId: 'warehouse',
tableRef: { catalog: null, db: 'public', name: 'orders' },
},
],
},
});
expect(discover.search).toHaveBeenCalledWith({
query: 'orders',

View file

@ -182,6 +182,46 @@ grain: []
});
});
it('strips authoring-only fields (usage, inherits_columns_from) before sending sources to the daemon', async () => {
await project.fileStore.writeFile(
'semantic-layer/warehouse/_schema/public.yaml',
`tables:
invoices:
table: public.invoices
columns:
- name: invoice_id
type: number
pk: true
- name: amount
type: number
usage:
narrative: Activation policy windows table for invoice analytics.
frequencyTier: mid
commonFilters:
- amount
commonGroupBys: []
commonJoins: []
staleSince: null
`,
'ktx',
'ktx@example.com',
'Add manifest shard with usage',
);
await compileLocalSlQuery(project, {
connectionId: 'warehouse',
query: { measures: ['sum(invoices.amount)'], dimensions: [] },
compute,
});
const lastCall = (compute.query as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0];
const invoices = lastCall?.sources.find((s: Record<string, unknown>) => s.name === 'invoices');
expect(invoices).toBeDefined();
expect(invoices).not.toHaveProperty('usage');
expect(invoices).not.toHaveProperty('inherits_columns_from');
expect(invoices).not.toHaveProperty('source_type');
});
it('resolves the only configured connection when connectionId is omitted', async () => {
await compileLocalSlQuery(project, {
query: { measures: ['orders.order_count'], dimensions: [] },

View file

@ -2,6 +2,7 @@ import type { KtxSqlQueryExecutorPort } from '../connections/index.js';
import type { KtxSemanticLayerComputePort } from '../daemon/index.js';
import type { KtxLocalProject } from '../project/index.js';
import { loadLocalSlSourceRecords } from './local-sl.js';
import { toResolvedWire } from './semantic-layer.service.js';
import type { SemanticLayerQueryExecutionResult, SemanticLayerQueryInput } from './types.js';
const COMPILE_ONLY_REASON =
@ -77,8 +78,8 @@ async function loadComputableSources(
connectionId: string,
): Promise<Record<string, unknown>[]> {
return (await loadLocalSlSourceRecords(project, { connectionId: assertSafeConnectionId(connectionId) }))
.map((record) => ({ ...record.source }))
.filter((source) => source.table || source.sql);
.filter((record) => record.source.table || record.source.sql)
.map((record) => toResolvedWire(record.source) as unknown as Record<string, unknown>);
}
function headersFromColumns(columns: Array<Record<string, unknown>>): string[] {