Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/cli/commands/add/__tests__/actions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { buildGatewayTargetConfig } from '../actions.js';
import type { ValidatedAddGatewayTargetOptions } from '../actions.js';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

const mockCreateToolFromWizard = vi.fn().mockResolvedValue({ toolName: 'test', projectPath: '/tmp' });
const mockCreateExternalGatewayTarget = vi.fn().mockResolvedValue({ toolName: 'test', projectPath: '' });

vi.mock('../../../operations/mcp/create-mcp', () => ({
createToolFromWizard: (...args: unknown[]) => mockCreateToolFromWizard(...args),
createExternalGatewayTarget: (...args: unknown[]) => mockCreateExternalGatewayTarget(...args),
createGatewayFromWizard: vi.fn(),
}));

describe('buildGatewayTargetConfig', () => {
it('maps name, gateway, language correctly', () => {
Expand Down Expand Up @@ -66,3 +75,26 @@ describe('buildGatewayTargetConfig', () => {
expect(config.outboundAuth).toBeUndefined();
});
});

// Dynamic import to pick up mocks
const { handleAddGatewayTarget } = await import('../actions.js');

describe('handleAddGatewayTarget', () => {
afterEach(() => vi.clearAllMocks());

it('routes existing-endpoint to createExternalGatewayTarget', async () => {
const options: ValidatedAddGatewayTargetOptions = {
name: 'test-tool',
language: 'Other',
host: 'Lambda',
source: 'existing-endpoint',
endpoint: 'https://example.com/mcp',
gateway: 'my-gw',
};

await handleAddGatewayTarget(options);

expect(mockCreateExternalGatewayTarget).toHaveBeenCalledOnce();
expect(mockCreateToolFromWizard).not.toHaveBeenCalled();
});
});
12 changes: 12 additions & 0 deletions src/cli/commands/add/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,18 @@ describe('validate', () => {
expect(result.valid).toBe(false);
expect(result.error).toContain('--credential-name is required');
});

it('rejects --host with existing-endpoint', async () => {
const options: AddGatewayTargetOptions = {
name: 'test-tool',
source: 'existing-endpoint',
endpoint: 'https://example.com/mcp',
host: 'Lambda',
};
const result = await validateAddGatewayTargetOptions(options);
expect(result.valid).toBe(false);
expect(result.error).toBe('--host is not applicable for existing endpoint targets');
});
});

describe('validateAddMemoryOptions', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/cli/commands/add/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import {
createCredential,
resolveCredentialStrategy,
} from '../../operations/identity/create-identity';
import { createGatewayFromWizard, createToolFromWizard } from '../../operations/mcp/create-mcp';
import {
createExternalGatewayTarget,
createGatewayFromWizard,
createToolFromWizard,
} from '../../operations/mcp/create-mcp';
import { createMemory } from '../../operations/memory/create-memory';
import { createRenderer } from '../../templates';
import type { MemoryOption } from '../../tui/screens/generate/types';
Expand Down Expand Up @@ -334,6 +338,10 @@ export async function handleAddGatewayTarget(
}

const config = buildGatewayTargetConfig(options);
if (config.source === 'existing-endpoint') {
const result = await createExternalGatewayTarget(config);
return { success: true, toolName: result.toolName };
}
const result = await createToolFromWizard(config);
return { success: true, toolName: result.toolName, sourcePath: result.projectPath };
} catch (err) {
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/add/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO
}

if (options.source === 'existing-endpoint') {
if (options.host) {
return { valid: false, error: '--host is not applicable for existing endpoint targets' };
}
if (!options.endpoint) {
return { valid: false, error: '--endpoint is required when source is existing-endpoint' };
}
Expand Down
71 changes: 2 additions & 69 deletions src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,8 @@ import type { SelectableItem } from '../../components';
import { HELP_TEXT } from '../../constants';
import { useListNavigation } from '../../hooks';
import { generateUniqueName } from '../../utils';
import type { AddGatewayTargetConfig, ComputeHost, TargetLanguage } from './types';
import {
COMPUTE_HOST_OPTIONS,
MCP_TOOL_STEP_LABELS,
SKIP_FOR_NOW,
SOURCE_OPTIONS,
TARGET_LANGUAGE_OPTIONS,
} from './types';
import type { AddGatewayTargetConfig } from './types';
import { MCP_TOOL_STEP_LABELS, SKIP_FOR_NOW } from './types';
import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard';
import { Box, Text } from 'ink';
import React, { useMemo } from 'react';
Expand All @@ -31,16 +25,6 @@ export function AddGatewayTargetScreen({
}: AddGatewayTargetScreenProps) {
const wizard = useAddGatewayTargetWizard(existingGateways);

const sourceItems: SelectableItem[] = useMemo(
() => SOURCE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
[]
);

const languageItems: SelectableItem[] = useMemo(
() => TARGET_LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
[]
);

const gatewayItems: SelectableItem[] = useMemo(
() => [
...existingGateways.map(g => ({ id: g, title: g })),
Expand All @@ -49,47 +33,18 @@ export function AddGatewayTargetScreen({
[existingGateways]
);

const hostItems: SelectableItem[] = useMemo(
() => COMPUTE_HOST_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
[]
);

const isSourceStep = wizard.step === 'source';
const isLanguageStep = wizard.step === 'language';
const isGatewayStep = wizard.step === 'gateway';
const isHostStep = wizard.step === 'host';
const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint';
const isConfirmStep = wizard.step === 'confirm';
const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0;

const sourceNav = useListNavigation({
items: sourceItems,
onSelect: item => wizard.setSource(item.id as 'existing-endpoint' | 'create-new'),
onExit: () => wizard.goBack(),
isActive: isSourceStep,
});

const languageNav = useListNavigation({
items: languageItems,
onSelect: item => wizard.setLanguage(item.id as TargetLanguage),
onExit: () => onExit(),
isActive: isLanguageStep,
});

const gatewayNav = useListNavigation({
items: gatewayItems,
onSelect: item => wizard.setGateway(item.id),
onExit: () => wizard.goBack(),
isActive: isGatewayStep && !noGatewaysAvailable,
});

const hostNav = useListNavigation({
items: hostItems,
onSelect: item => wizard.setHost(item.id as ComputeHost),
onExit: () => wizard.goBack(),
isActive: isHostStep,
});

useListNavigation({
items: [{ id: 'confirm', title: 'Confirm' }],
onSelect: () => onComplete(wizard.config),
Expand All @@ -108,19 +63,6 @@ export function AddGatewayTargetScreen({
return (
<Screen title="Add MCP Tool" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Panel>
{isSourceStep && (
<WizardSelect
title="Select source"
description="How would you like to create this MCP tool?"
items={sourceItems}
selectedIndex={sourceNav.selectedIndex}
/>
)}

{isLanguageStep && (
<WizardSelect title="Select language" items={languageItems} selectedIndex={languageNav.selectedIndex} />
)}

{isGatewayStep && !noGatewaysAvailable && (
<WizardSelect
title="Select gateway"
Expand All @@ -132,15 +74,6 @@ export function AddGatewayTargetScreen({

{noGatewaysAvailable && <NoGatewaysMessage />}

{isHostStep && (
<WizardSelect
title="Select compute host"
description="Where will this tool run?"
items={hostItems}
selectedIndex={hostNav.selectedIndex}
/>
)}

{isTextStep && (
<TextInput
key={wizard.step}
Expand Down
62 changes: 11 additions & 51 deletions src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib';
import type { ToolDefinition } from '../../../../schema';
import type { AddGatewayTargetConfig, AddGatewayTargetStep, ComputeHost, TargetLanguage } from './types';
import type { AddGatewayTargetConfig, AddGatewayTargetStep } from './types';
import { SKIP_FOR_NOW } from './types';
import { useCallback, useMemo, useState } from 'react';

/**
* Dynamic steps based on source.
* - Existing endpoint: name → source → endpoint → gateway → confirm
* - Create new: name → source → language → gateway → host → confirm
* Steps for adding a gateway target (existing endpoint only).
* name → endpoint → gateway → confirm
*/
function getSteps(source?: 'existing-endpoint' | 'create-new'): AddGatewayTargetStep[] {
if (source === 'existing-endpoint') {
return ['name', 'source', 'endpoint', 'gateway', 'confirm'];
}
return ['name', 'source', 'language', 'gateway', 'host', 'confirm'];
function getSteps(): AddGatewayTargetStep[] {
return ['name', 'endpoint', 'gateway', 'confirm'];
}

function deriveToolDefinition(name: string): ToolDefinition {
Expand All @@ -29,6 +25,7 @@ function getDefaultConfig(): AddGatewayTargetConfig {
name: '',
description: '',
sourcePath: '',
source: 'existing-endpoint',
language: 'Python',
host: 'Lambda',
toolDefinition: deriveToolDefinition(''),
Expand All @@ -39,16 +36,15 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) {
const [config, setConfig] = useState<AddGatewayTargetConfig>(getDefaultConfig);
const [step, setStep] = useState<AddGatewayTargetStep>('name');

const steps = useMemo(() => getSteps(config.source), [config.source]);
const steps = useMemo(() => getSteps(), []);
const currentIndex = steps.indexOf(step);

const goBack = useCallback(() => {
// Recalculate steps in case source changed
const currentSteps = getSteps(config.source);
const currentSteps = getSteps();
const idx = currentSteps.indexOf(step);
const prevStep = currentSteps[idx - 1];
if (prevStep) setStep(prevStep);
}, [config.source, step]);
}, [step]);

const setName = useCallback((name: string) => {
setConfig(c => ({
Expand All @@ -58,19 +54,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) {
sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`,
toolDefinition: deriveToolDefinition(name),
}));
setStep('source');
}, []);

const setSource = useCallback((source: 'existing-endpoint' | 'create-new') => {
setConfig(c => ({
...c,
source,
}));
if (source === 'existing-endpoint') {
setStep('endpoint');
} else {
setStep('language');
}
setStep('endpoint');
}, []);

const setEndpoint = useCallback((endpoint: string) => {
Expand All @@ -81,35 +65,14 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) {
setStep('gateway');
}, []);

const setLanguage = useCallback((language: TargetLanguage) => {
setConfig(c => ({
...c,
language,
}));
setStep('gateway');
}, []);

const setGateway = useCallback((gateway: string) => {
setConfig(c => {
const isExternal = c.source === 'existing-endpoint';
const isSkipped = gateway === SKIP_FOR_NOW;
if (isExternal || isSkipped) {
setStep('confirm');
} else {
setStep('host');
}
setStep('confirm');
return { ...c, gateway: isSkipped ? undefined : gateway };
});
}, []);

const setHost = useCallback((host: ComputeHost) => {
setConfig(c => ({
...c,
host,
}));
setStep('confirm');
}, []);

const reset = useCallback(() => {
setConfig(getDefaultConfig());
setStep('name');
Expand All @@ -123,11 +86,8 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) {
existingGateways,
goBack,
setName,
setSource,
setEndpoint,
setLanguage,
setGateway,
setHost,
reset,
};
}
Loading