diff --git a/packages/mcp-workflow/tests/routers/checkPropertiesFulfilledRouter.test.ts b/packages/mcp-workflow/tests/routers/checkPropertiesFulfilledRouter.test.ts index ab7ec861..e98196b1 100644 --- a/packages/mcp-workflow/tests/routers/checkPropertiesFulfilledRouter.test.ts +++ b/packages/mcp-workflow/tests/routers/checkPropertiesFulfilledRouter.test.ts @@ -712,12 +712,12 @@ describe('CheckPropertiesFulfilledRouter', () => { it('should match production graph configuration', () => { // This tests the actual node names used in typical workflow graphs const router = new CheckPropertiesFulfilledRouter( - 'templateDiscovery', + 'selectTemplateCandidates', 'getUserInput', TEST_PROPERTIES ); - // All properties fulfilled - should route to templateDiscovery + // All properties fulfilled - should route to selectTemplateCandidates (or platformCheckNode in actual graph) const fulfilledState = createTestState({ platform: 'iOS', projectName: 'MyApp', @@ -725,7 +725,7 @@ describe('CheckPropertiesFulfilledRouter', () => { organization: 'TestOrg', loginHost: 'https://login.salesforce.com', }); - expect(router.execute(fulfilledState)).toBe('templateDiscovery'); + expect(router.execute(fulfilledState)).toBe('selectTemplateCandidates'); // Some properties missing - should route to getUserInput const unfulfilledState = createTestState({ diff --git a/packages/mobile-native-mcp-server/src/common/schemas.ts b/packages/mobile-native-mcp-server/src/common/schemas.ts index 984339e6..c75f962b 100644 --- a/packages/mobile-native-mcp-server/src/common/schemas.ts +++ b/packages/mobile-native-mcp-server/src/common/schemas.ts @@ -29,3 +29,39 @@ export const PROJECT_PATH_FIELD = z.string().describe('Path to the mobile projec * Project name field used in multiple tools */ export const PROJECT_NAME_FIELD = z.string().describe('Name for the mobile app project'); + +/** + * Template-related schemas + * Used for validating template discovery and selection data structures + */ + +// Schema for template metadata - only require platform and displayName, allow everything else to passthrough +const TemplateMetadataSchema = z + .object({ + platform: z.enum(['ios', 'android']), + }) + .passthrough(); + +// Schema for individual template entry in the list - require metadata and path (template identifier) +// Note: path is not defined in template-schema.json (which only defines the metadata structure), +// but it IS present in the CLI output from `sf mobilesdk listtemplates --doc --json` +const TemplateEntrySchema = z + .object({ + path: z + .string() + .describe( + 'Template path/name identifier used for template selection and detail fetching. Present in CLI output but not in template-schema.json' + ), + metadata: TemplateMetadataSchema, + }) + .passthrough(); + +// Schema for the complete template list output structure - allow passthrough for flexibility +export const TEMPLATE_LIST_SCHEMA = z + .object({ + templates: z.array(TemplateEntrySchema), + }) + .passthrough(); + +// Type inferred from the schema +export type TemplateListOutput = z.infer; diff --git a/packages/mobile-native-mcp-server/src/index.ts b/packages/mobile-native-mcp-server/src/index.ts index 42ce2805..893af07a 100644 --- a/packages/mobile-native-mcp-server/src/index.ts +++ b/packages/mobile-native-mcp-server/src/index.ts @@ -9,7 +9,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { SFMobileNativeTemplateDiscoveryTool } from './tools/plan/sfmobile-native-template-discovery/tool.js'; +import { SFMobileNativeTemplateSelectionTool } from './tools/plan/sfmobile-native-template-selection/tool.js'; import { UtilsXcodeAddFilesTool } from './tools/utils/utils-xcode-add-files/tool.js'; import { SFMobileNativeDeploymentTool } from './tools/run/sfmobile-native-deployment/tool.js'; @@ -52,7 +52,7 @@ const orchestratorAnnotations: ToolAnnotations = { const orchestrator = new MobileNativeOrchestrator(server); const getInputTool = createSFMobileNativeGetInputTool(server); const inputExtractionTool = createSFMobileNativeInputExtractionTool(server); -const templateDiscoveryTool = new SFMobileNativeTemplateDiscoveryTool(server); +const templateSelectionTool = new SFMobileNativeTemplateSelectionTool(server); const projectGenerationTool = new SFMobileNativeProjectGenerationTool(server); const buildTool = new SFMobileNativeBuildTool(server); const buildRecoveryTool = new SFMobileNativeBuildRecoveryTool(server); @@ -73,7 +73,7 @@ orchestrator.register(orchestratorAnnotations); // Register all other tools with read-only annotations getInputTool.register(readOnlyAnnotations); inputExtractionTool.register(readOnlyAnnotations); -templateDiscoveryTool.register(readOnlyAnnotations); +templateSelectionTool.register(readOnlyAnnotations); projectGenerationTool.register(readOnlyAnnotations); buildTool.register(readOnlyAnnotations); buildRecoveryTool.register(readOnlyAnnotations); diff --git a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-project-generation/metadata.ts b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-project-generation/metadata.ts index e5436c5a..8bfac920 100644 --- a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-project-generation/metadata.ts +++ b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-project-generation/metadata.ts @@ -30,6 +30,10 @@ export const PROJECT_GENERATION_WORKFLOW_INPUT_SCHEMA = WORKFLOW_TOOL_BASE_INPUT .string() .optional() .describe('Optional Salesforce login host URL (e.g., https://test.salesforce.com for sandbox)'), + templateProperties: z + .record(z.string()) + .optional() + .describe('Custom template-specific properties required by the selected template'), }); export type ProjectGenerationWorkflowInput = z.infer< diff --git a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-project-generation/tool.ts b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-project-generation/tool.ts index 6985cc8d..7dcc99d5 100644 --- a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-project-generation/tool.ts +++ b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-project-generation/tool.ts @@ -52,12 +52,9 @@ export class SFMobileNativeProjectGenerationTool extends AbstractNativeProjectMa ${this.generateStepExecuteCliCommand(1, input)} - ${this.generateStepVerifyProjectStructure(2, input)} - ## Success Criteria ✅ Project generated successfully from template "${input.selectedTemplate}" - ✅ Project structure verified `; } @@ -67,36 +64,27 @@ export class SFMobileNativeProjectGenerationTool extends AbstractNativeProjectMa ): string { const platformLower = input.platform.toLowerCase(); + // Build template properties flags if they exist + let templatePropertiesFlags = ''; + if (input.templateProperties && Object.keys(input.templateProperties).length > 0) { + const propertyFlags = Object.entries(input.templateProperties) + .map(([key, value]) => `--template-property-${key}="${value}"`) + .join(' '); + templatePropertiesFlags = ` ${propertyFlags}`; + } + return dedent` ## Step ${stepNumber}: Execute Platform-Specific CLI Command Generate the project using the Salesforce Mobile SDK CLI: \`\`\`bash - sf mobilesdk ${platformLower} createwithtemplate --templatesource="${MOBILE_SDK_TEMPLATES_PATH}" --template="${input.selectedTemplate}" --appname="${input.projectName}" --packagename="${input.packageName}" --organization="${input.organization} --consumerkey="${input.connectedAppClientId}" --callbackurl="${input.connectedAppCallbackUri}" --loginserver="${input.loginHost}" + sf mobilesdk ${platformLower} createwithtemplate --templatesource="${MOBILE_SDK_TEMPLATES_PATH}" --template="${input.selectedTemplate}" --appname="${input.projectName}" --packagename="${input.packageName}" --organization="${input.organization} --consumerkey="${input.connectedAppClientId}" --callbackurl="${input.connectedAppCallbackUri}" --loginserver="${input.loginHost} ${templatePropertiesFlags}" \`\`\` - **Expected Outcome**: A new ${input.platform} project directory named "${input.projectName}" will be created with the template structure. The output of the command will indicate the location of the bootconfig.plist file, take note of this for oauth configuration! + **Expected Outcome**: A new ${input.platform} project directory named "${input.projectName}" will be created with the template structure. NOTE: it is VERY IMPORTANT to use the above command EXACTLY to generate the project. Do not use any other configuration method to generate the project. If the above command fails do not try to generate the project using any other method. Instead report back error to the user. `; } - - private generateStepVerifyProjectStructure( - stepNumber: number, - input: ProjectGenerationWorkflowInput - ): string { - return dedent` - ## Step ${stepNumber}: Verify Project Structure - - Navigate to the project directory and verify the basic structure: - - \`\`\`bash - cd "${input.projectName}" - ls -la - \`\`\` - - **Expected Structure**: You should see platform-specific files and directories appropriate for ${input.platform} development. - `; - } } diff --git a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-discovery/metadata.ts b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-discovery/metadata.ts deleted file mode 100644 index a1a6e3d0..00000000 --- a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-discovery/metadata.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2025, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import z from 'zod'; -import { PLATFORM_ENUM } from '../../../common/schemas.js'; -import { - WORKFLOW_TOOL_BASE_INPUT_SCHEMA, - MCP_WORKFLOW_TOOL_OUTPUT_SCHEMA, - WorkflowToolMetadata, -} from '@salesforce/magen-mcp-workflow'; - -/** - * Template Discovery Tool Input Schema - */ -export const TEMPLATE_DISCOVERY_WORKFLOW_INPUT_SCHEMA = WORKFLOW_TOOL_BASE_INPUT_SCHEMA.extend({ - platform: PLATFORM_ENUM, -}); - -export type TemplateDiscoveryWorkflowInput = z.infer< - typeof TEMPLATE_DISCOVERY_WORKFLOW_INPUT_SCHEMA ->; - -export const TEMPLATE_DISCOVERY_WORKFLOW_RESULT_SCHEMA = z.object({ - selectedTemplate: z.string().describe('The template name selected from template discovery'), -}); - -/** - * Template Discovery Tool Metadata - */ -export const TEMPLATE_DISCOVERY_TOOL: WorkflowToolMetadata< - typeof TEMPLATE_DISCOVERY_WORKFLOW_INPUT_SCHEMA, - typeof TEMPLATE_DISCOVERY_WORKFLOW_RESULT_SCHEMA -> = { - toolId: 'sfmobile-native-template-discovery', - title: 'Salesforce Mobile Native Template Discovery', - description: - 'Guides LLM through template discovery and selection for Salesforce mobile app development', - inputSchema: TEMPLATE_DISCOVERY_WORKFLOW_INPUT_SCHEMA, - outputSchema: MCP_WORKFLOW_TOOL_OUTPUT_SCHEMA, - resultSchema: TEMPLATE_DISCOVERY_WORKFLOW_RESULT_SCHEMA, -} as const; diff --git a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-discovery/tool.ts b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-discovery/tool.ts deleted file mode 100644 index 7336673d..00000000 --- a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-discovery/tool.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (c) 2025, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import dedent from 'dedent'; -import { MOBILE_SDK_TEMPLATES_PATH } from '../../../common.js'; -import { Logger } from '@salesforce/magen-mcp-workflow'; -import { TEMPLATE_DISCOVERY_TOOL, TemplateDiscoveryWorkflowInput } from './metadata.js'; -import { AbstractNativeProjectManagerTool } from '../../base/abstractNativeProjectManagerTool.js'; - -export class SFMobileNativeTemplateDiscoveryTool extends AbstractNativeProjectManagerTool< - typeof TEMPLATE_DISCOVERY_TOOL -> { - constructor(server: McpServer, logger?: Logger) { - super(server, TEMPLATE_DISCOVERY_TOOL, 'TemplateDiscoveryTool', logger); - } - - public handleRequest = async (input: TemplateDiscoveryWorkflowInput) => { - try { - const guidance = this.generateTemplateDiscoveryGuidance(input); - - return this.finalizeWorkflowToolOutput(guidance, input.workflowStateData); - } catch (error) { - return { - isError: true, - content: [ - { - type: 'text' as const, - text: `Error: ${error instanceof Error ? error.message : 'Unknown error occurred'}`, - }, - ], - }; - } - }; - - private generateTemplateDiscoveryGuidance(input: TemplateDiscoveryWorkflowInput): string { - return dedent` - # Template Discovery Guidance for ${input.platform} - - You MUST follow the steps in this guide in order. Do not execute any commands that are not part of the steps in this guide. - - ${this.generateTemplateDiscoveryStep(1, input)} - - ${this.generateDetailedInvestigationStep(2, input)} - `; - } - - private generateTemplateDiscoveryStep( - stepNumber: number, - input: TemplateDiscoveryWorkflowInput - ): string { - const platformLower = input.platform.toLowerCase(); - - return dedent` - ## Step ${stepNumber}: Template Discovery - - Discover available ${input.platform} templates using: - - \`\`\`bash - sf mobilesdk ${platformLower} listtemplates --templatesource=${MOBILE_SDK_TEMPLATES_PATH} --doc --json - \`\`\` - - You MUST use the --templatesource=${MOBILE_SDK_TEMPLATES_PATH} flag to specify the templates source, do not use any other source. - - This will show a detailed JSON representation of all available templates with their: - - path: the relative path to the template from the templates source - - description: the description of the template - - features: the features of the template - - useCase: the use case of the template - - complexity: the complexity of the template - - customizationPoints: the customization points of the template - - Inspect the JSON output from the template discovery command to identify templates that best match the user's requirements and filter the templates to the most promising candidates. Prioritize templates that match multiple keywords and have comprehensive documentation. - `; - } - - private generateDetailedInvestigationStep( - stepNumber: number, - input: TemplateDiscoveryWorkflowInput - ): string { - const platformLower = input.platform.toLowerCase(); - - return dedent` - ## Step ${stepNumber}: Detailed Template Investigation - - For each promising template, get detailed documentation: - - \`\`\`bash - sf mobilesdk ${platformLower} describetemplate --templatesource=${MOBILE_SDK_TEMPLATES_PATH} --template= --doc --json - \`\`\` - - Choose the template that best matches: - - **Platform compatibility**: ${input.platform} - - **Feature requirements**: General mobile app needs - - **Use case alignment**: Record management, data display, CRUD operations - - **Complexity level**: Appropriate for the user's requirements - `; - } -} diff --git a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-selection/metadata.ts b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-selection/metadata.ts new file mode 100644 index 00000000..b24e6c23 --- /dev/null +++ b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-selection/metadata.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import z from 'zod'; +import { PLATFORM_ENUM, TEMPLATE_LIST_SCHEMA } from '../../../common/schemas.js'; +import { + WORKFLOW_TOOL_BASE_INPUT_SCHEMA, + MCP_WORKFLOW_TOOL_OUTPUT_SCHEMA, + WorkflowToolMetadata, +} from '@salesforce/magen-mcp-workflow'; + +/** + * Template Selection Tool Input Schema + */ +export const TEMPLATE_SELECTION_WORKFLOW_INPUT_SCHEMA = WORKFLOW_TOOL_BASE_INPUT_SCHEMA.extend({ + platform: PLATFORM_ENUM, + templateOptions: TEMPLATE_LIST_SCHEMA.describe( + 'The template options from listtemplates command. Each template includes metadata with platform, displayName, and other descriptive information.' + ), +}); + +export type TemplateSelectionWorkflowInput = z.infer< + typeof TEMPLATE_SELECTION_WORKFLOW_INPUT_SCHEMA +>; + +export const TEMPLATE_SELECTION_WORKFLOW_RESULT_SCHEMA = z.object({ + selectedTemplate: z + .string() + .describe('The template path/name selected from the available templates'), +}); + +/** + * Template Selection Tool Metadata + */ +export const TEMPLATE_SELECTION_TOOL: WorkflowToolMetadata< + typeof TEMPLATE_SELECTION_WORKFLOW_INPUT_SCHEMA, + typeof TEMPLATE_SELECTION_WORKFLOW_RESULT_SCHEMA +> = { + toolId: 'sfmobile-native-template-selection', + title: 'Salesforce Mobile Native Template Selection', + description: 'Guides LLM through template selection from available template options', + inputSchema: TEMPLATE_SELECTION_WORKFLOW_INPUT_SCHEMA, + outputSchema: MCP_WORKFLOW_TOOL_OUTPUT_SCHEMA, + resultSchema: TEMPLATE_SELECTION_WORKFLOW_RESULT_SCHEMA, +} as const; diff --git a/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-selection/tool.ts b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-selection/tool.ts new file mode 100644 index 00000000..d13c96ad --- /dev/null +++ b/packages/mobile-native-mcp-server/src/tools/plan/sfmobile-native-template-selection/tool.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import dedent from 'dedent'; +import { Logger } from '@salesforce/magen-mcp-workflow'; +import { TEMPLATE_SELECTION_TOOL, TemplateSelectionWorkflowInput } from './metadata.js'; +import { AbstractNativeProjectManagerTool } from '../../base/abstractNativeProjectManagerTool.js'; + +export class SFMobileNativeTemplateSelectionTool extends AbstractNativeProjectManagerTool< + typeof TEMPLATE_SELECTION_TOOL +> { + constructor(server: McpServer, logger?: Logger) { + super(server, TEMPLATE_SELECTION_TOOL, 'TemplateSelectionTool', logger); + } + + public handleRequest = async (input: TemplateSelectionWorkflowInput) => { + try { + const guidance = this.generateTemplateSelectionGuidance(input); + + return this.finalizeWorkflowToolOutput(guidance, input.workflowStateData); + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Error: ${error instanceof Error ? error.message : 'Unknown error occurred'}`, + }, + ], + }; + } + }; + + private generateTemplateSelectionGuidance(input: TemplateSelectionWorkflowInput): string { + const templateOptionsJson = JSON.stringify(input.templateOptions, null, 2); + + return dedent` + # Template Selection Guidance for ${input.platform} + + ## Task: Select the Best Template + + The following template options are available: + + \`\`\`json + ${templateOptionsJson} + \`\`\` + + Review the available templates and choose the template that best matches: + - **Platform compatibility**: ${input.platform} + - **Feature requirements**: General mobile app needs + - **Use case alignment**: Record management, data display, CRUD operations + - **Complexity level**: Appropriate for the user's requirements + + Each template includes: + - **path**: The template identifier to use as the selectedTemplate value + - **metadata**: Contains descriptive information about the template + `; + } +} diff --git a/packages/mobile-native-mcp-server/src/tools/run/sfmobile-native-deployment/tool.ts b/packages/mobile-native-mcp-server/src/tools/run/sfmobile-native-deployment/tool.ts index e4cdcc42..0ae50eec 100644 --- a/packages/mobile-native-mcp-server/src/tools/run/sfmobile-native-deployment/tool.ts +++ b/packages/mobile-native-mcp-server/src/tools/run/sfmobile-native-deployment/tool.ts @@ -37,7 +37,7 @@ export class SFMobileNativeDeploymentTool extends AbstractNativeProjectManagerTo // of the user selection of virtual devices more generally. In the meantime, this just // helps the LLM to not have to typically "vibe" a default value in all use cases, since // we don't really take in a virtual device at this point. - validatedInput.targetDevice = validatedInput.targetDevice ?? 'iPhone 15 Pro'; + validatedInput.targetDevice = validatedInput.targetDevice ?? 'iPhone 16 Pro Max'; const guidance = this.generateDeploymentGuidance(validatedInput); return this.finalizeWorkflowToolOutput(guidance, validatedInput.workflowStateData); @@ -172,7 +172,7 @@ export class SFMobileNativeDeploymentTool extends AbstractNativeProjectManagerTo return dedent` ## Next Steps - Once the app is deployed successfully, you can launch the app on the target device by running the following command: + Once the app is deployed successfully, you MUST launch the app on the target device by running the following command: ${this.generateLaunchCommand(input)} `; } diff --git a/packages/mobile-native-mcp-server/src/workflow/graph.ts b/packages/mobile-native-mcp-server/src/workflow/graph.ts index 7c145034..e66caae8 100644 --- a/packages/mobile-native-mcp-server/src/workflow/graph.ts +++ b/packages/mobile-native-mcp-server/src/workflow/graph.ts @@ -13,7 +13,8 @@ import { ANDROID_SETUP_PROPERTIES, } from './metadata.js'; import { EnvironmentValidationNode } from './nodes/environment.js'; -import { TemplateDiscoveryNode } from './nodes/templateDiscovery.js'; +import { TemplateOptionsFetchNode } from './nodes/templateOptionsFetch.js'; +import { TemplateSelectionNode } from './nodes/templateSelection.js'; import { ProjectGenerationNode } from './nodes/projectGeneration.js'; import { BuildValidationNode } from './nodes/buildValidation.js'; import { BuildRecoveryNode } from './nodes/buildRecovery.js'; @@ -24,6 +25,9 @@ import { FailureNode } from './nodes/failureNode.js'; import { CheckEnvironmentValidatedRouter } from './nodes/checkEnvironmentValidated.js'; import { PlatformCheckNode } from './nodes/checkPlatformSetup.js'; import { CheckSetupValidatedRouter } from './nodes/checkSetupValidatedRouter.js'; +import { TemplatePropertiesExtractionNode } from './nodes/templatePropertiesExtraction.js'; +import { TemplatePropertiesUserInputNode } from './nodes/templatePropertiesUserInput.js'; +import { CheckTemplatePropertiesFulfilledRouter } from './nodes/checkTemplatePropertiesFulfilledRouter.js'; import { CheckAndroidSetupExtractedRouter } from './nodes/checkAndroidSetupExtractedRouter.js'; import { ExtractAndroidSetupNode } from './nodes/extractAndroidSetup.js'; import { PluginCheckNode } from './nodes/checkPluginSetup.js'; @@ -60,7 +64,10 @@ const extractAndroidSetupNode = new ExtractAndroidSetupNode(); const environmentValidationNode = new EnvironmentValidationNode(); const platformCheckNode = new PlatformCheckNode(); const pluginCheckNode = new PluginCheckNode(); -const templateDiscoveryNode = new TemplateDiscoveryNode(); +const templateOptionsFetchNode = new TemplateOptionsFetchNode(); +const templateSelectionNode = new TemplateSelectionNode(); +const templatePropertiesExtractionNode = new TemplatePropertiesExtractionNode(); +const templatePropertiesUserInputNode = new TemplatePropertiesUserInputNode(); const projectGenerationNode = new ProjectGenerationNode(); const buildValidationNode = new BuildValidationNode(); const buildRecoveryNode = new BuildRecoveryNode(); @@ -83,7 +90,7 @@ const checkSetupValidatedRouter = new CheckSetupValidatedRouter( ); const checkPluginValidatedRouter = new CheckPluginValidatedRouter( - templateDiscoveryNode.name, + templateOptionsFetchNode.name, failureNode.name ); const checkAndroidSetupExtractedRouter = new CheckAndroidSetupExtractedRouter( @@ -97,6 +104,11 @@ const checkBuildSuccessfulRouter = new CheckBuildSuccessfulRouter( failureNode.name ); +const checkTemplatePropertiesFulfilledRouter = new CheckTemplatePropertiesFulfilledRouter( + projectGenerationNode.name, + templatePropertiesUserInputNode.name +); + /** * The main workflow graph for mobile native app development * Follows the Plan → Design/Iterate → Run three-phase architecture @@ -111,7 +123,10 @@ export const mobileNativeWorkflow = new StateGraph(MobileNativeWorkflowState) .addNode(getAndroidSetupNode.name, getAndroidSetupNode.execute) .addNode(extractAndroidSetupNode.name, extractAndroidSetupNode.execute) .addNode(pluginCheckNode.name, pluginCheckNode.execute) - .addNode(templateDiscoveryNode.name, templateDiscoveryNode.execute) + .addNode(templateOptionsFetchNode.name, templateOptionsFetchNode.execute) + .addNode(templateSelectionNode.name, templateSelectionNode.execute) + .addNode(templatePropertiesExtractionNode.name, templatePropertiesExtractionNode.execute) + .addNode(templatePropertiesUserInputNode.name, templatePropertiesUserInputNode.execute) .addNode(projectGenerationNode.name, projectGenerationNode.execute) .addNode(buildValidationNode.name, buildValidationNode.execute) .addNode(buildRecoveryNode.name, buildRecoveryNode.execute) @@ -129,7 +144,13 @@ export const mobileNativeWorkflow = new StateGraph(MobileNativeWorkflowState) .addEdge(getAndroidSetupNode.name, extractAndroidSetupNode.name) .addConditionalEdges(extractAndroidSetupNode.name, checkAndroidSetupExtractedRouter.execute) .addConditionalEdges(pluginCheckNode.name, checkPluginValidatedRouter.execute) - .addEdge(templateDiscoveryNode.name, projectGenerationNode.name) + .addEdge(templateOptionsFetchNode.name, templateSelectionNode.name) + .addEdge(templateSelectionNode.name, templatePropertiesExtractionNode.name) + .addConditionalEdges( + templatePropertiesExtractionNode.name, + checkTemplatePropertiesFulfilledRouter.execute + ) + .addEdge(templatePropertiesUserInputNode.name, templatePropertiesExtractionNode.name) .addEdge(projectGenerationNode.name, buildValidationNode.name) // Build validation with recovery loop (similar to user input loop) .addConditionalEdges(buildValidationNode.name, checkBuildSuccessfulRouter.execute) diff --git a/packages/mobile-native-mcp-server/src/workflow/metadata.ts b/packages/mobile-native-mcp-server/src/workflow/metadata.ts index a87b5f43..42d277f1 100644 --- a/packages/mobile-native-mcp-server/src/workflow/metadata.ts +++ b/packages/mobile-native-mcp-server/src/workflow/metadata.ts @@ -7,9 +7,24 @@ import { Annotation } from '@langchain/langgraph'; import z from 'zod'; -import { PLATFORM_ENUM, PROJECT_NAME_FIELD } from '../common/schemas.js'; +import { PLATFORM_ENUM, PROJECT_NAME_FIELD, TemplateListOutput } from '../common/schemas.js'; import { PropertyMetadata, PropertyMetadataCollection } from '@salesforce/magen-mcp-workflow'; +/** + * Metadata for a custom template property + * This matches the structure returned from template discovery + */ +export interface TemplatePropertyMetadata { + value?: string; + required: boolean; + description: string; +} + +/** + * Collection of template property metadata + */ +export type TemplatePropertiesMetadata = Record; + /** * Definition of all user input properties required by the mobile native workflow. * Each property includes metadata for extraction, validation, and user prompting. @@ -81,6 +96,7 @@ export type AndroidSetupProperties = typeof ANDROID_SETUP_PROPERTIES; export const MobileNativeWorkflowState = Annotation.Root({ // Core workflow data userInput: Annotation, + templatePropertiesUserInput: Annotation, platform: Annotation>, // Plan phase state @@ -88,6 +104,7 @@ export const MobileNativeWorkflowState = Annotation.Root({ validPlatformSetup: Annotation, validPluginSetup: Annotation, workflowFatalErrorMessages: Annotation, + templateOptions: Annotation, // Android setup state (for recovery flow) androidInstalled: Annotation>, @@ -95,6 +112,8 @@ export const MobileNativeWorkflowState = Annotation.Root({ javaHome: Annotation>, selectedTemplate: Annotation, + templateProperties: Annotation>, + templatePropertiesMetadata: Annotation, projectName: Annotation>, projectPath: Annotation, packageName: Annotation>, diff --git a/packages/mobile-native-mcp-server/src/workflow/nodes/checkTemplatePropertiesFulfilledRouter.ts b/packages/mobile-native-mcp-server/src/workflow/nodes/checkTemplatePropertiesFulfilledRouter.ts new file mode 100644 index 00000000..3ab5b822 --- /dev/null +++ b/packages/mobile-native-mcp-server/src/workflow/nodes/checkTemplatePropertiesFulfilledRouter.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { State } from '../metadata.js'; + +/** + * Conditional router to check whether all required template properties have been collected. + * + * This router checks if: + * 1. There are no template properties required (template has no custom properties) + * 2. All required template properties have been collected from the user + */ +export class CheckTemplatePropertiesFulfilledRouter { + private readonly propertiesFulfilledNodeName: string; + private readonly propertiesUnfulfilledNodeName: string; + + /** + * Creates a new CheckTemplatePropertiesFulfilledRouter. + * + * @param propertiesFulfilledNodeName - The name of the node to route to if all properties are fulfilled + * @param propertiesUnfulfilledNodeName - The name of the node to route to if any property is unfulfilled + */ + constructor(propertiesFulfilledNodeName: string, propertiesUnfulfilledNodeName: string) { + this.propertiesFulfilledNodeName = propertiesFulfilledNodeName; + this.propertiesUnfulfilledNodeName = propertiesUnfulfilledNodeName; + } + + execute = (state: State): string => { + return this.getPropertyFulfillmentStatus(state); + }; + + private getPropertyFulfillmentStatus(state: State): string { + // If no template has been selected yet, we shouldn't be checking template properties + // This is a safety check to prevent routing to project generation before template selection + if (!state.selectedTemplate) { + return this.propertiesUnfulfilledNodeName; + } + + // If no template properties metadata exists, all properties are fulfilled (none required) + if ( + !state.templatePropertiesMetadata || + Object.keys(state.templatePropertiesMetadata).length === 0 + ) { + return this.propertiesFulfilledNodeName; + } + + // If templateProperties haven't been initialized, properties are unfulfilled + if (!state.templateProperties) { + return this.propertiesUnfulfilledNodeName; + } + + // Check each required property + for (const [propertyName, metadata] of Object.entries(state.templatePropertiesMetadata)) { + // If property is required and not present in templateProperties, it's unfulfilled + if (metadata.required && !state.templateProperties[propertyName]) { + return this.propertiesUnfulfilledNodeName; + } + } + + return this.propertiesFulfilledNodeName; + } +} diff --git a/packages/mobile-native-mcp-server/src/workflow/nodes/projectGeneration.ts b/packages/mobile-native-mcp-server/src/workflow/nodes/projectGeneration.ts index a68fd1bb..ff152a84 100644 --- a/packages/mobile-native-mcp-server/src/workflow/nodes/projectGeneration.ts +++ b/packages/mobile-native-mcp-server/src/workflow/nodes/projectGeneration.ts @@ -35,6 +35,7 @@ export class ProjectGenerationNode extends AbstractToolNode { connectedAppClientId: state.connectedAppClientId, connectedAppCallbackUri: state.connectedAppCallbackUri, loginHost: state.loginHost, + templateProperties: state.templateProperties, }, }; diff --git a/packages/mobile-native-mcp-server/src/workflow/nodes/templateDiscovery.ts b/packages/mobile-native-mcp-server/src/workflow/nodes/templateDiscovery.ts deleted file mode 100644 index 60fd1094..00000000 --- a/packages/mobile-native-mcp-server/src/workflow/nodes/templateDiscovery.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2025, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import { - AbstractToolNode, - Logger, - MCPToolInvocationData, - ToolExecutor, -} from '@salesforce/magen-mcp-workflow'; -import { TEMPLATE_DISCOVERY_TOOL } from '../../tools/plan/sfmobile-native-template-discovery/metadata.js'; -import { State } from '../metadata.js'; - -export class TemplateDiscoveryNode extends AbstractToolNode { - constructor(toolExecutor?: ToolExecutor, logger?: Logger) { - super('discoverTemplates', toolExecutor, logger); - } - - execute = (state: State): Partial => { - const toolInvocationData: MCPToolInvocationData = { - llmMetadata: { - name: TEMPLATE_DISCOVERY_TOOL.toolId, - description: TEMPLATE_DISCOVERY_TOOL.description, - inputSchema: TEMPLATE_DISCOVERY_TOOL.inputSchema, - }, - input: { - platform: state.platform, - }, - }; - - const validatedResult = this.executeToolWithLogging( - toolInvocationData, - TEMPLATE_DISCOVERY_TOOL.resultSchema - ); - return validatedResult; - }; -} diff --git a/packages/mobile-native-mcp-server/src/workflow/nodes/templateOptionsFetch.ts b/packages/mobile-native-mcp-server/src/workflow/nodes/templateOptionsFetch.ts new file mode 100644 index 00000000..e2d82578 --- /dev/null +++ b/packages/mobile-native-mcp-server/src/workflow/nodes/templateOptionsFetch.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { State } from '../metadata.js'; +import { BaseNode, createComponentLogger, Logger } from '@salesforce/magen-mcp-workflow'; +import { MOBILE_SDK_TEMPLATES_PATH } from '../../common.js'; +import { execSync } from 'child_process'; +import { TEMPLATE_LIST_SCHEMA, TemplateListOutput } from '../../common/schemas.js'; + +export class TemplateOptionsFetchNode extends BaseNode { + protected readonly logger: Logger; + + constructor(logger?: Logger) { + super('fetchTemplateOptions'); + this.logger = logger ?? createComponentLogger('TemplateOptionsFetchNode'); + } + + execute = (state: State): Partial => { + // Check if we already have template options (e.g., when resuming from interrupt) + // This prevents re-executing expensive operations when LangGraph re-runs the node after resume + if (state.templateOptions) { + this.logger.debug('Template options already exist in state, skipping fetch'); + return {}; // Return empty update to avoid overwriting existing state + } + + // Execute the sf mobilesdk listtemplates command directly + const platformLower = state.platform.toLowerCase(); + const command = `sf mobilesdk ${platformLower} listtemplates --templatesource=${MOBILE_SDK_TEMPLATES_PATH} --doc --json`; + + this.logger.debug(`Executing template options fetch command`, { command }); + + let templateOptions: TemplateListOutput; + + try { + const output = execSync(command, { encoding: 'utf-8', timeout: 30000 }); + templateOptions = this.parseTemplateOutput(output); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : `${error}`; + const errorObj = error instanceof Error ? error : new Error(errorMessage); + this.logger.error(`Failed to execute template options fetch command`, errorObj); + return { + workflowFatalErrorMessages: [`Failed to fetch template options: ${errorMessage}`], + }; + } + + this.logger.info(`Fetched template options for ${templateOptions.templates.length} templates`); + return { + templateOptions, + }; + }; + + private parseTemplateOutput(output: string): TemplateListOutput { + try { + const parsed = JSON.parse(output); + + return TEMPLATE_LIST_SCHEMA.parse(parsed); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : `${error}`; + throw new Error(`Failed to parse template list output: ${errorMessage}`); + } + } +} diff --git a/packages/mobile-native-mcp-server/src/workflow/nodes/templatePropertiesExtraction.ts b/packages/mobile-native-mcp-server/src/workflow/nodes/templatePropertiesExtraction.ts new file mode 100644 index 00000000..c8921f95 --- /dev/null +++ b/packages/mobile-native-mcp-server/src/workflow/nodes/templatePropertiesExtraction.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { + createUserInputExtractionNode, + PropertyMetadataCollection, +} from '@salesforce/magen-mcp-workflow'; +import { State } from '../metadata.js'; +import { SFMOBILE_NATIVE_INPUT_EXTRACTION_TOOL_ID } from '../../tools/utils/sfmobile-native-input-extraction/metadata.js'; +import z from 'zod'; + +/** + * Factory function to create a template properties extraction node + * + * This node dynamically extracts template-specific properties from user input + * based on the templatePropertiesMetadata stored in state. + */ +export function createTemplatePropertiesExtractionNode() { + return createUserInputExtractionNode({ + requiredProperties: {} as PropertyMetadataCollection, // Will be determined dynamically + toolId: SFMOBILE_NATIVE_INPUT_EXTRACTION_TOOL_ID, + userInputProperty: 'templatePropertiesUserInput', + }); +} +/** + * Custom node that converts template properties metadata to PropertyMetadataCollection + * and then extracts properties from user input + */ +export class TemplatePropertiesExtractionNode { + name = 'extractTemplateProperties'; + + execute = (state: State): Partial => { + // If no template properties metadata exists, skip extraction + if ( + !state.templatePropertiesMetadata || + Object.keys(state.templatePropertiesMetadata).length === 0 + ) { + return { templateProperties: {} }; + } + + // If templatePropertiesUserInput doesn't exist yet, don't initialize templateProperties + // This allows the router to route to templatePropertiesUserInputNode to prompt for input + if (!state.templatePropertiesUserInput) { + return {}; + } + + // Convert template properties metadata to PropertyMetadataCollection format + const requiredProperties: PropertyMetadataCollection = {}; + + for (const [propertyName, metadata] of Object.entries(state.templatePropertiesMetadata)) { + requiredProperties[propertyName] = { + zodType: z.string(), + description: metadata.description, + friendlyName: propertyName, + }; + } + + // Create and execute the extraction node with the dynamic properties + const extractionNode = createUserInputExtractionNode({ + requiredProperties, + toolId: SFMOBILE_NATIVE_INPUT_EXTRACTION_TOOL_ID, + userInputProperty: 'templatePropertiesUserInput', + }); + + // Execute the extraction and map results to templateProperties + const result = extractionNode.execute(state); + + // Convert extracted properties to Record format + const templateProperties: Record = {}; + const resultRecord = result as Record; + for (const key of Object.keys(requiredProperties)) { + const value = resultRecord[key]; + if (value && typeof value === 'string') { + templateProperties[key] = value; + } + } + + return { templateProperties }; + }; +} diff --git a/packages/mobile-native-mcp-server/src/workflow/nodes/templatePropertiesUserInput.ts b/packages/mobile-native-mcp-server/src/workflow/nodes/templatePropertiesUserInput.ts new file mode 100644 index 00000000..0cd160d3 --- /dev/null +++ b/packages/mobile-native-mcp-server/src/workflow/nodes/templatePropertiesUserInput.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { createGetUserInputNode, PropertyMetadataCollection } from '@salesforce/magen-mcp-workflow'; +import { State } from '../metadata.js'; +import { SFMOBILE_NATIVE_GET_INPUT_TOOL_ID } from '../../tools/utils/sfmobile-native-get-input/metadata.js'; +import z from 'zod'; + +/** + * Custom node that prompts for template-specific properties + * based on templatePropertiesMetadata in state + */ +export class TemplatePropertiesUserInputNode { + name = 'getTemplatePropertiesInput'; + + execute = (state: State): Partial => { + // If no template properties metadata exists, skip input gathering + if ( + !state.templatePropertiesMetadata || + Object.keys(state.templatePropertiesMetadata).length === 0 + ) { + return {}; + } + + // Convert template properties metadata to PropertyMetadataCollection format + const requiredProperties: PropertyMetadataCollection = {}; + + for (const [propertyName, metadata] of Object.entries(state.templatePropertiesMetadata)) { + requiredProperties[propertyName] = { + zodType: z.string(), + description: metadata.description, + friendlyName: propertyName, + }; + } + + // Create and execute the user input node with the dynamic properties + const userInputNode = createGetUserInputNode({ + requiredProperties, + toolId: SFMOBILE_NATIVE_GET_INPUT_TOOL_ID, + userInputProperty: 'templatePropertiesUserInput', + }); + + // Execute the user input node + return userInputNode.execute(state); + }; +} diff --git a/packages/mobile-native-mcp-server/src/workflow/nodes/templateSelection.ts b/packages/mobile-native-mcp-server/src/workflow/nodes/templateSelection.ts new file mode 100644 index 00000000..c66cd64f --- /dev/null +++ b/packages/mobile-native-mcp-server/src/workflow/nodes/templateSelection.ts @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { + AbstractToolNode, + Logger, + MCPToolInvocationData, + ToolExecutor, + createComponentLogger, +} from '@salesforce/magen-mcp-workflow'; +import { TEMPLATE_SELECTION_TOOL } from '../../tools/plan/sfmobile-native-template-selection/metadata.js'; +import { State, TemplatePropertiesMetadata } from '../metadata.js'; + +export class TemplateSelectionNode extends AbstractToolNode { + protected readonly logger: Logger; + + constructor(toolExecutor?: ToolExecutor, logger?: Logger) { + super('selectTemplate', toolExecutor, logger); + this.logger = logger ?? createComponentLogger('TemplateSelectionNode'); + } + + execute = (state: State): Partial => { + // Check if we already have a selected template (e.g., when resuming from interrupt) + // This prevents re-executing when LangGraph re-runs the node after resume + if (state.selectedTemplate) { + this.logger.debug('Template already selected, skipping selection'); + return {}; // Return empty update to avoid overwriting existing state + } + + if (!state.templateOptions) { + return { + workflowFatalErrorMessages: ['No template options available for selection'], + }; + } + + const toolInvocationData: MCPToolInvocationData = { + llmMetadata: { + name: TEMPLATE_SELECTION_TOOL.toolId, + description: TEMPLATE_SELECTION_TOOL.description, + inputSchema: TEMPLATE_SELECTION_TOOL.inputSchema, + }, + input: { + platform: state.platform, + templateOptions: state.templateOptions, + }, + }; + + const validatedResult = this.executeToolWithLogging( + toolInvocationData, + TEMPLATE_SELECTION_TOOL.resultSchema + ); + + if (!validatedResult.selectedTemplate) { + return { + workflowFatalErrorMessages: ['Template selection did not return a selectedTemplate'], + }; + } + + // Extract template properties metadata from the selected template's options + const templatePropertiesMetadata = this.extractTemplatePropertiesMetadata( + validatedResult.selectedTemplate, + state.templateOptions + ); + + return { + selectedTemplate: validatedResult.selectedTemplate, + templatePropertiesMetadata, + }; + }; + + private extractTemplatePropertiesMetadata( + selectedTemplate: string, + templateOptions: State['templateOptions'] + ): TemplatePropertiesMetadata | undefined { + try { + // Find the selected template in the templates array + const template = templateOptions.templates.find(t => t.path === selectedTemplate); + if (!template) { + this.logger.warn(`Template not found in templateOptions: ${selectedTemplate}`); + return undefined; + } + + // Navigate to metadata.properties.templatePrerequisites.properties.templateProperties.properties + // This matches the deeply nested structure in template.json files + const metadata = template.metadata as Record | undefined; + if (!metadata) { + this.logger.debug(`No metadata found for template ${selectedTemplate}`); + return undefined; + } + + const properties = metadata.properties as Record | undefined; + if (!properties) { + this.logger.debug(`No properties found for template ${selectedTemplate}`); + return undefined; + } + + const templatePrerequisites = properties.templatePrerequisites as + | Record + | undefined; + if (!templatePrerequisites) { + this.logger.debug(`No templatePrerequisites found for template ${selectedTemplate}`); + return undefined; + } + + const templatePrerequisitesProperties = templatePrerequisites.properties as + | Record + | undefined; + if (!templatePrerequisitesProperties) { + this.logger.debug( + `No templatePrerequisites.properties found for template ${selectedTemplate}` + ); + return undefined; + } + + const templatePropertiesContainer = templatePrerequisitesProperties.templateProperties as + | Record + | undefined; + if (!templatePropertiesContainer) { + this.logger.debug(`No templateProperties found for template ${selectedTemplate}`); + return undefined; + } + + const templateProperties = templatePropertiesContainer.properties as + | Record + | undefined; + if (!templateProperties || Object.keys(templateProperties).length === 0) { + this.logger.debug( + `No templateProperties.properties found for template ${selectedTemplate}` + ); + return undefined; + } + + // Convert template properties to TemplatePropertiesMetadata format + const propertiesMetadata: TemplatePropertiesMetadata = {}; + + for (const [propertyName, propertyValue] of Object.entries(templateProperties)) { + // Property can be a simple value or an object with value, required, description + if ( + typeof propertyValue === 'object' && + propertyValue !== null && + !Array.isArray(propertyValue) + ) { + const propObj = propertyValue as Record; + propertiesMetadata[propertyName] = { + value: propObj.value !== undefined ? String(propObj.value) : undefined, + required: typeof propObj.required === 'boolean' ? propObj.required : false, + description: typeof propObj.description === 'string' ? propObj.description : '', + }; + } else { + // Simple value - treat as optional with empty description + propertiesMetadata[propertyName] = { + value: propertyValue !== undefined ? String(propertyValue) : undefined, + required: false, + description: '', + }; + } + } + + this.logger.info( + `Extracted ${Object.keys(propertiesMetadata).length} template properties for ${selectedTemplate}` + ); + return Object.keys(propertiesMetadata).length > 0 ? propertiesMetadata : undefined; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : `${error}`; + this.logger.error( + `Failed to extract template properties metadata`, + error instanceof Error ? error : new Error(errorMessage) + ); + return undefined; + } + } +} diff --git a/packages/mobile-native-mcp-server/templates b/packages/mobile-native-mcp-server/templates index d7f5ce2d..179eacd9 160000 --- a/packages/mobile-native-mcp-server/templates +++ b/packages/mobile-native-mcp-server/templates @@ -1 +1 @@ -Subproject commit d7f5ce2d09cdd93e7066b2c96bd1f798a683bc02 +Subproject commit 179eacd9330216ee7599a54bbcf4fcaeac5b1802 diff --git a/packages/mobile-native-mcp-server/tests/tools/plan/sfmobile-native-project-generation/tool.test.ts b/packages/mobile-native-mcp-server/tests/tools/plan/sfmobile-native-project-generation/tool.test.ts index d7e28eb6..c3bf0506 100644 --- a/packages/mobile-native-mcp-server/tests/tools/plan/sfmobile-native-project-generation/tool.test.ts +++ b/packages/mobile-native-mcp-server/tests/tools/plan/sfmobile-native-project-generation/tool.test.ts @@ -81,7 +81,6 @@ describe('SFMobileNativeProjectGenerationTool', () => { expect(workflowOutput.promptForLLM).toContain('Mobile App Project Generation Guide'); expect(workflowOutput.promptForLLM).toContain('MobileSyncExplorerSwift'); expect(workflowOutput.promptForLLM).toContain('iOS'); - expect(workflowOutput.promptForLLM).toContain('bootconfig.plist'); expect(workflowOutput.promptForLLM).toContain('https://test.salesforce.com'); // Verify the result schema defines projectPath @@ -110,7 +109,6 @@ describe('SFMobileNativeProjectGenerationTool', () => { // Verify CLI command includes OAuth parameters expect(workflowOutput.promptForLLM).toContain('--consumerkey="3MVG9test123"'); expect(workflowOutput.promptForLLM).toContain('--callbackurl="testapp://oauth/callback"'); - expect(workflowOutput.promptForLLM).toContain('bootconfig.plist'); }); }); @@ -141,7 +139,6 @@ describe('SFMobileNativeProjectGenerationTool', () => { expect(workflowOutput.promptForLLM).toContain('Mobile App Project Generation Guide'); expect(workflowOutput.promptForLLM).toContain('MobileSyncExplorerKotlin'); expect(workflowOutput.promptForLLM).toContain('Android'); - expect(workflowOutput.promptForLLM).toContain('bootconfig.plist'); // Verify the result schema defines projectPath const resultSchema = JSON.parse(workflowOutput.resultSchema); @@ -169,7 +166,6 @@ describe('SFMobileNativeProjectGenerationTool', () => { // Verify CLI command includes OAuth parameters expect(workflowOutput.promptForLLM).toContain('--consumerkey="3MVG9android123"'); expect(workflowOutput.promptForLLM).toContain('--callbackurl="androidapp://oauth/callback"'); - expect(workflowOutput.promptForLLM).toContain('bootconfig.plist'); }); }); diff --git a/packages/mobile-native-mcp-server/tests/tools/plan/sfmobile-native-template-discovery/tool.test.ts b/packages/mobile-native-mcp-server/tests/tools/plan/sfmobile-native-template-discovery/tool.test.ts deleted file mode 100644 index 6b591bab..00000000 --- a/packages/mobile-native-mcp-server/tests/tools/plan/sfmobile-native-template-discovery/tool.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (c) 2025, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { SFMobileNativeTemplateDiscoveryTool } from '../../../../src/tools/plan/sfmobile-native-template-discovery/tool.js'; -import { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; - -describe('SFMobileNativeTemplateDiscoveryTool', () => { - let tool: SFMobileNativeTemplateDiscoveryTool; - let mockServer: McpServer; - let annotations: ToolAnnotations; - - beforeEach(() => { - mockServer = new McpServer({ name: 'test-server', version: '1.0.0' }); - tool = new SFMobileNativeTemplateDiscoveryTool(mockServer); - annotations = { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }; - }); - - describe('Tool Metadata', () => { - it('should have correct tool metadata', () => { - expect(tool.toolMetadata.toolId).toBe('sfmobile-native-template-discovery'); - expect(tool.toolMetadata.title).toBe('Salesforce Mobile Native Template Discovery'); - expect(tool.toolMetadata.description).toBe( - 'Guides LLM through template discovery and selection for Salesforce mobile app development' - ); - expect(tool.toolMetadata.inputSchema).toBeDefined(); - }); - - it('should register without throwing errors', () => { - expect(() => tool.register(annotations)).not.toThrow(); - }); - }); - - describe('Input Schema Validation', () => { - it('should accept platform parameter', () => { - const validInput = { platform: 'iOS', workflowStateData: { thread_id: 'test-123' } }; - const result = tool.toolMetadata.inputSchema.safeParse(validInput); - expect(result.success).toBe(true); - }); - - it('should accept workflow state data', () => { - const validInput = { - platform: 'Android' as const, - workflowStateData: { thread_id: 'test-123' }, - }; - const result = tool.toolMetadata.inputSchema.safeParse(validInput); - expect(result.success).toBe(true); - }); - - it('should reject invalid platform values', () => { - const invalidInput = { platform: 'Windows' }; - const result = tool.toolMetadata.inputSchema.safeParse(invalidInput); - expect(result.success).toBe(false); - }); - }); - - describe('Template Discovery Guidance', () => { - it('should generate guidance for iOS platform', async () => { - const input = { - platform: 'iOS' as const, - workflowStateData: { thread_id: 'test-123' }, - }; - - const result = await tool.handleRequest(input); - - expect(result.content).toBeDefined(); - expect(result.content[0].type).toBe('text'); - - const responseText = result.content[0].text as string; - const response = JSON.parse(responseText); - - expect(response.promptForLLM).toContain('Template Discovery Guidance for iOS'); - expect(response.promptForLLM).toContain('Step 1: Template Discovery'); - expect(response.promptForLLM).toContain('Step 2: Detailed Template Investigation'); - expect(response.promptForLLM).toContain('sf mobilesdk ios listtemplates'); - expect(response.promptForLLM).toContain('sf mobilesdk ios describetemplate'); - }); - - it('should generate guidance for Android platform', async () => { - const input = { - platform: 'Android' as const, - workflowStateData: { thread_id: 'test-123' }, - }; - - const result = await tool.handleRequest(input); - - expect(result.content).toBeDefined(); - expect(result.content[0].type).toBe('text'); - - const responseText = result.content[0].text as string; - const response = JSON.parse(responseText); - - expect(response.promptForLLM).toContain('Template Discovery Guidance for Android'); - expect(response.promptForLLM).toContain('sf mobilesdk android listtemplates'); - expect(response.promptForLLM).toContain('sf mobilesdk android describetemplate'); - }); - - it('should include template source path', async () => { - const input = { - platform: 'iOS' as const, - workflowStateData: { thread_id: 'test-123' }, - }; - - const result = await tool.handleRequest(input); - const responseText = result.content[0].text as string; - const response = JSON.parse(responseText); - - expect(response.promptForLLM).toContain('--templatesource='); - expect(response.promptForLLM).toContain('--template='); - }); - - it('should include next steps guidance', async () => { - const input = { - platform: 'iOS' as const, - workflowStateData: { thread_id: 'test-123' }, - }; - - const result = await tool.handleRequest(input); - const responseText = result.content[0].text as string; - const response = JSON.parse(responseText); - - expect(response.promptForLLM).toContain('Post-Tool-Invocation Instructions'); - expect(response.promptForLLM).toContain('sfmobile-native-project-manager'); - }); - }); -}); diff --git a/packages/mobile-native-mcp-server/tests/workflow/nodes/templateDiscovery.test.ts b/packages/mobile-native-mcp-server/tests/workflow/nodes/templateDiscovery.test.ts deleted file mode 100644 index 7bb76c04..00000000 --- a/packages/mobile-native-mcp-server/tests/workflow/nodes/templateDiscovery.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2025, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { TemplateDiscoveryNode } from '../../../src/workflow/nodes/templateDiscovery.js'; -import { MockToolExecutor } from '../../utils/MockToolExecutor.js'; -import { MockLogger } from '../../utils/MockLogger.js'; -import { TEMPLATE_DISCOVERY_TOOL } from '../../../src/tools/plan/sfmobile-native-template-discovery/metadata.js'; -import { createTestState } from '../../utils/stateBuilders.js'; - -// Helper to create valid discovery result matching schema -function createDiscoveryResult(selectedTemplate: string) { - return { - selectedTemplate, - }; -} - -describe('TemplateDiscoveryNode', () => { - let mockToolExecutor: MockToolExecutor; - let mockLogger: MockLogger; - let node: TemplateDiscoveryNode; - - beforeEach(() => { - mockToolExecutor = new MockToolExecutor(); - mockLogger = new MockLogger(); - node = new TemplateDiscoveryNode(mockToolExecutor, mockLogger); - }); - - describe('Node Properties', () => { - it('should have correct node name', () => { - expect(node.name).toBe('discoverTemplates'); - }); - }); - - describe('execute()', () => { - it('should pass platform to tool and return validated result', () => { - const inputState = createTestState({ - userInput: 'test', - platform: 'iOS', - }); - - const discoveryResult = createDiscoveryResult('ios-template'); - mockToolExecutor.setResult(TEMPLATE_DISCOVERY_TOOL.toolId, discoveryResult); - - const result = node.execute(inputState); - - // Verify tool was called with correct input - const lastCall = mockToolExecutor.getLastCall(); - expect(lastCall).toBeDefined(); - expect(lastCall?.llmMetadata.name).toBe(TEMPLATE_DISCOVERY_TOOL.toolId); - expect(lastCall?.llmMetadata.description).toBe(TEMPLATE_DISCOVERY_TOOL.description); - expect(lastCall?.llmMetadata.inputSchema).toBe(TEMPLATE_DISCOVERY_TOOL.inputSchema); - expect(lastCall?.input).toEqual({ platform: 'iOS' }); - - // Verify result is passed through with expected structure - expect(result).toEqual(discoveryResult); - expect(result.selectedTemplate).toBe('ios-template'); - }); - - it('should log tool execution', () => { - const inputState = createTestState({ - userInput: 'test', - platform: 'iOS', - }); - - const discoveryResult = createDiscoveryResult('test-template'); - mockToolExecutor.setResult(TEMPLATE_DISCOVERY_TOOL.toolId, discoveryResult); - mockLogger.reset(); - - node.execute(inputState); - - const debugLogs = mockLogger.getLogsByLevel('debug'); - expect(debugLogs.length).toBeGreaterThan(0); - - const preExecutionLog = debugLogs.find(log => log.message.includes('pre-execution')); - const postExecutionLog = debugLogs.find(log => log.message.includes('post-execution')); - - expect(preExecutionLog).toBeDefined(); - expect(postExecutionLog).toBeDefined(); - }); - - it('should handle undefined platform gracefully', () => { - // Edge case: node called with undefined platform - // (shouldn't happen in normal flow, but should not crash) - const inputState = createTestState({ - userInput: 'test', - platform: undefined, - }); - - const discoveryResult = createDiscoveryResult('fallback-template'); - mockToolExecutor.setResult(TEMPLATE_DISCOVERY_TOOL.toolId, discoveryResult); - - const result = node.execute(inputState); - - expect(result.selectedTemplate).toBe('fallback-template'); - }); - }); -});