diff --git a/pkg/vmcp/composer/elicitation_integration_test.go b/pkg/vmcp/composer/elicitation_integration_test.go index d4edd40c05..b7fe25af69 100644 --- a/pkg/vmcp/composer/elicitation_integration_test.go +++ b/pkg/vmcp/composer/elicitation_integration_test.go @@ -527,3 +527,79 @@ func TestDefaultElicitationHandler_SDKErrorHandling(t *testing.T) { }) } } + +func TestWorkflowEngine_ElicitationMessageTemplateExpansion(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + message string + params map[string]any + expectedMessage string + }{ + { + name: "expands template message", + message: "Deploy {{.params.repo}} to {{.params.env}}?", + params: map[string]any{"repo": "acme/widget", "env": "production"}, + expectedMessage: "Deploy acme/widget to production?", + }, + { + name: "passes through plain message", + message: "Deploy now?", + params: map[string]any{}, + expectedMessage: "Deploy now?", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + te := newTestEngine(t) + mockSDK := mocks.NewMockSDKElicitationRequester(te.Ctrl) + + var capturedReq mcp.ElicitationRequest + mockSDK.EXPECT().RequestElicitation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req mcp.ElicitationRequest) (*mcp.ElicitationResult, error) { + capturedReq = req + return &mcp.ElicitationResult{ + ElicitationResponse: mcp.ElicitationResponse{ + Action: mcp.ElicitationResponseActionAccept, + Content: map[string]any{"confirmed": true}, + }, + }, nil + }, + ) + + handler := NewDefaultElicitationHandler(mockSDK) + stateStore := NewInMemoryStateStore(1*time.Minute, 1*time.Hour) + engine := NewWorkflowEngine(te.Router, te.Backend, handler, stateStore, nil, nil) + + workflow := &WorkflowDefinition{ + Name: "template-elicit", + Steps: []WorkflowStep{ + { + ID: "ask", + Type: StepTypeElicitation, + Elicitation: &ElicitationConfig{ + Message: tt.message, + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "confirmed": map[string]any{"type": "boolean"}, + }, + }, + Timeout: 1 * time.Minute, + }, + }, + }, + } + + result, err := engine.ExecuteWorkflow(context.Background(), workflow, tt.params) + require.NoError(t, err) + assert.Equal(t, WorkflowStatusCompleted, result.Status) + assert.Equal(t, tt.expectedMessage, capturedReq.Params.Message) + assert.Equal(t, tt.message, workflow.Steps[0].Elicitation.Message) + }) + } +} diff --git a/pkg/vmcp/composer/workflow_engine.go b/pkg/vmcp/composer/workflow_engine.go index ecba1b7b10..f2867dcf37 100644 --- a/pkg/vmcp/composer/workflow_engine.go +++ b/pkg/vmcp/composer/workflow_engine.go @@ -634,9 +634,26 @@ func (e *workflowEngine) executeElicitationStep( return err } + // Expand template expressions in elicitation message (e.g. {{.params.owner}}) + // without mutating the workflow step definition. + elicitationCfg := *step.Elicitation + if elicitationCfg.Message != "" { + wrapper := map[string]any{"message": elicitationCfg.Message} + expanded, expandErr := e.templateExpander.Expand(ctx, wrapper, workflowCtx) + if expandErr != nil { + err := fmt.Errorf("%w: failed to expand elicitation message for step %s: %v", + ErrTemplateExpansion, step.ID, expandErr) + workflowCtx.RecordStepFailure(step.ID, err) + return err + } + if msg, ok := expanded["message"].(string); ok { + elicitationCfg.Message = msg + } + } + // Request elicitation (synchronous - blocks until response or timeout) // Per MCP 2025-06-18: SDK handles JSON-RPC ID correlation internally - response, err := e.elicitationHandler.RequestElicitation(ctx, workflowCtx.WorkflowID, step.ID, step.Elicitation) + response, err := e.elicitationHandler.RequestElicitation(ctx, workflowCtx.WorkflowID, step.ID, &elicitationCfg) if err != nil { // Handle timeout if errors.Is(err, ErrElicitationTimeout) {