diff --git a/docs-site/content/docs/integrations/agent-clients.mdx b/docs-site/content/docs/integrations/agent-clients.mdx index 62297067..6db65d26 100644 --- a/docs-site/content/docs/integrations/agent-clients.mdx +++ b/docs-site/content/docs/integrations/agent-clients.mdx @@ -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 | diff --git a/docs/superpowers/plans/2026-05-14-research-agent-mcp-discover-data.md b/docs/superpowers/plans/2026-05-14-research-agent-mcp-discover-data.md index a917eb72..351aed67 100644 --- a/docs/superpowers/plans/2026-05-14-research-agent-mcp-discover-data.md +++ b/docs/superpowers/plans/2026-05-14-research-agent-mcp-discover-data.md @@ -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', diff --git a/docs/superpowers/specs/2026-05-14-research-agent-mcp-tools-design.md b/docs/superpowers/specs/2026-05-14-research-agent-mcp-tools-design.md index a8044076..b65c77b6 100644 --- a/docs/superpowers/specs/2026-05-14-research-agent-mcp-tools-design.md +++ b/docs/superpowers/specs/2026-05-14-research-agent-mcp-tools-design.md @@ -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 { diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index e82cf0b5..f4021be6 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -186,6 +186,10 @@ function shouldSuppressProjectDirLine(path: string[], options: Record { expect(startDaemon).not.toHaveBeenCalled(); }); + it('prints "already running" when startDaemon reports already-running', async () => { + const program = new Command().exitOverride().option('--project-dir '); + 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 '); const runStdioServer = vi.fn().mockResolvedValue(undefined); diff --git a/packages/cli/src/commands/mcp-commands.ts b/packages/cli/src/commands/mcp-commands.ts index 349ca27b..9e583ce4 100644 --- a/packages/cli/src/commands/mcp-commands.ts +++ b/packages/cli/src/commands/mcp-commands.ts @@ -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 diff --git a/packages/cli/src/managed-mcp-daemon.test.ts b/packages/cli/src/managed-mcp-daemon.test.ts index 7ffc277c..e28c4a3e 100644 --- a/packages/cli/src/managed-mcp-daemon.test.ts +++ b/packages/cli/src/managed-mcp-daemon.test.ts @@ -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`); diff --git a/packages/cli/src/managed-mcp-daemon.ts b/packages/cli/src/managed-mcp-daemon.ts index 96394f69..ef3df2a9 100644 --- a/packages/cli/src/managed-mcp-daemon.ts +++ b/packages/cli/src/managed-mcp-daemon.ts @@ -121,11 +121,25 @@ export async function startKtxMcpDaemon(options: { portAvailable?: (host: string, port: number) => Promise; 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))) { diff --git a/packages/cli/src/mcp-stdio-server.ts b/packages/cli/src/mcp-stdio-server.ts index 4596f91e..a755c7ae 100644 --- a/packages/cli/src/mcp-stdio-server.ts +++ b/packages/cli/src/mcp-stdio-server.ts @@ -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((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)))); + }); }); } diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index 05d24901..68a1401f 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -32,6 +32,37 @@ async function readZipText(path: string, entry: string): Promise { return strFromU8(content); } +function captureEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): Record { + const snapshot: Record = {}; + 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 { + const snapshot: Record = {}; + 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): 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 } }; }; - 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 } }; + }; + 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; + }; + 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; + }; + 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 () => { diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index aa050500..b8cbfe8b 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -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 { + const captured: Record = {}; + 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 { + 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 { - 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 = { '.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 { + 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(); + 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 => + 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) { diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts index ad97ec48..f5faacd8 100644 --- a/packages/cli/src/setup-prompts.ts +++ b/packages/cli/src/setup-prompts.ts @@ -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`); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index e9caacd8..b95b7122 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -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; } diff --git a/packages/context/src/llm/local-config.test.ts b/packages/context/src/llm/local-config.test.ts index 59ad34b7..539afe45 100644 --- a/packages/context/src/llm/local-config.test.ts +++ b/packages/context/src/llm/local-config.test.ts @@ -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( diff --git a/packages/context/src/llm/local-config.ts b/packages/context/src/llm/local-config.ts index 2709c4b7..e2ee45e0 100644 --- a/packages/context/src/llm/local-config.ts +++ b/packages/context/src/llm/local-config.ts @@ -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: { diff --git a/packages/context/src/mcp/context-tools.ts b/packages/context/src/mcp/context-tools.ts index 773155bf..d8a567a1 100644 --- a/packages/context/src/mcp/context-tools.ts +++ b/packages/context/src/mcp/context-tools.ts @@ -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) }; + 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) }), ); } diff --git a/packages/context/src/mcp/server.test.ts b/packages/context/src/mcp/server.test.ts index abf678bb..bd582755 100644 --- a/packages/context/src/mcp/server.test.ts +++ b/packages/context/src/mcp/server.test.ts @@ -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().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', diff --git a/packages/context/src/sl/local-query.test.ts b/packages/context/src/sl/local-query.test.ts index 2852b35a..8c8003b3 100644 --- a/packages/context/src/sl/local-query.test.ts +++ b/packages/context/src/sl/local-query.test.ts @@ -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).mock.calls.at(-1)?.[0]; + const invoices = lastCall?.sources.find((s: Record) => 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: [] }, diff --git a/packages/context/src/sl/local-query.ts b/packages/context/src/sl/local-query.ts index a6f224dd..f5983892 100644 --- a/packages/context/src/sl/local-query.ts +++ b/packages/context/src/sl/local-query.ts @@ -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[]> { 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); } function headersFromColumns(columns: Array>): string[] {