Exercise: Building and Validating Hard Workflows

ch06-02-workflow-validation
⭐⭐ intermediate ⏱️ 30 min

Your team is building a code review workflow that automates the analysis, review, and formatting pipeline. The workflow needs to execute deterministically on the server side, binding data between steps automatically.

Your task is to build a hard workflow using SequentialWorkflow, validate it with tests, and verify it using cargo pmcp validate.

🎯 Learning Objectives

Thinking

Doing

💬 Discussion

  • Why are hard workflows preferable when steps are deterministic?
  • What's the difference between a step name and a binding name?
  • When would you add `.with_guidance()` to create a hybrid workflow?
workflow.rs

💡 Hints

Hint 1: Workflow structure template

Start with the basic structure:

#![allow(unused)]
fn main() {
SequentialWorkflow::new("code_review", "Description")
    .argument("code", "Source code to review", true)
    .argument("language", "Programming language", false)
    .step(
        WorkflowStep::new("step_name", ToolHandle::new("tool_name"))
            .arg("param", /* source */)
            .bind("binding_name")
    )
}

Remember: you reference BINDING names in from_step(), not step names!

Hint 2: DSL helper functions

Four ways to source argument values:

#![allow(unused)]
fn main() {
// From workflow arguments (user provides)
.arg("code", prompt_arg("code"))

// From previous step's entire output .arg("data", from_step("analysis_result"))

// From specific field of previous step .arg("summary", field("analysis_result", "summary"))

// Constant value .arg("format", constant(json!("markdown"))) }

Hint 3: Common validation error

If you see "UnknownBinding" error, check:

  1. Binding name mismatch: .bind("analysis_result") but from_step("analysis")
  2. Step vs binding confusion: Step is "analyze", binding is "analysis_result"
  3. Typos: "analysis_result" vs "analyis_result"

The workflow validator shows available bindings in error messages.

⚠️ Try the exercise first! Show Solution
#![allow(unused)]
fn main() {
use pmcp::server::workflow::{SequentialWorkflow, WorkflowStep, ToolHandle};
use pmcp::server::workflow::dsl::*;
use serde_json::json;

pub fn create_code_review_workflow() -> SequentialWorkflow { SequentialWorkflow::new( "code_review", "Comprehensive code review with analysis and formatting" ) // Declare workflow arguments .argument("code", "Source code to review", true) .argument("language", "Programming language (default: rust)", false)

// Step 1: Analyze the code
.step(
    WorkflowStep::new("analyze", ToolHandle::new("analyze_code"))
        .arg("code", prompt_arg("code"))
        .arg("language", prompt_arg("language"))
        .bind("analysis_result")  // Other steps reference this binding name
)

// Step 2: Review based on analysis
.step(
    WorkflowStep::new("review", ToolHandle::new("review_code"))
        // Use field() to extract specific part of previous output
        .arg("analysis", field("analysis_result", "summary"))
        // Use constant() for fixed values
        .arg("focus", constant(json!(["security", "performance"])))
        .bind("review_result")
)

// Step 3: Format results with annotations
.step(
    WorkflowStep::new("format", ToolHandle::new("format_results"))
        // Can reference workflow args AND previous steps
        .arg("code", prompt_arg("code"))
        // Use from_step() for entire previous output
        .arg("recommendations", from_step("review_result"))
        .bind("formatted_output")
)
}

}

#[cfg(test)] mod tests { use super::*;

#[test]
fn test_workflow_validates() {
    let workflow = create_code_review_workflow();
    workflow.validate().expect("Workflow should be valid");
}

#[test]
fn test_workflow_has_expected_structure() {
    let workflow = create_code_review_workflow();

    assert_eq!(workflow.name(), "code_review");
    assert_eq!(workflow.steps().len(), 3);

    // Check step order
    let steps = workflow.steps();
    assert_eq!(steps[0].name(), "analyze");
    assert_eq!(steps[1].name(), "review");
    assert_eq!(steps[2].name(), "format");
}

#[test]
fn test_workflow_bindings() {
    let workflow = create_code_review_workflow();
    let bindings = workflow.output_bindings();

    assert!(bindings.contains(&"analysis_result".into()));
    assert!(bindings.contains(&"review_result".into()));
    assert!(bindings.contains(&"formatted_output".into()));
}

#[test]
fn test_workflow_arguments() {
    let workflow = create_code_review_workflow();
    let args = workflow.arguments();

    // code is required
    let code_arg = args.iter().find(|a| a.name == "code").unwrap();
    assert!(code_arg.required);

    // language is optional
    let lang_arg = args.iter().find(|a| a.name == "language").unwrap();
    assert!(!lang_arg.required);
}

}

Explanation

Running validation:

Example output:

Key takeaways:

  1. Bindings connect steps - Use descriptive binding names like analysis_result
  2. Reference bindings, not step names - from_step("analysis_result") not from_step("analyze")
  3. Validation is automatic - .prompt_workflow() validates at registration
  4. Tests catch errors early - Write unit tests with workflow.validate()
  5. CLI validates projects - cargo pmcp validate workflows for CI/pre-commit

🧪 Tests

Run these tests locally with:

cargo test
View Test Code
#![allow(unused)]
fn main() {
#[cfg(test)]
mod exercise_tests {
    use super::*;
#[test]
fn workflow_compiles_and_validates() {
    let workflow = create_code_review_workflow();
    assert!(workflow.validate().is_ok());
}

#[test]
fn workflow_has_three_steps() {
    let workflow = create_code_review_workflow();
    assert_eq!(workflow.steps().len(), 3);
}

#[test]
fn workflow_has_required_code_argument() {
    let workflow = create_code_review_workflow();
    let code_arg = workflow.arguments().iter()
        .find(|a| a.name == "code")
        .expect("Should have code argument");
    assert!(code_arg.required);
}

#[test]
fn workflow_has_all_bindings() {
    let workflow = create_code_review_workflow();
    let bindings = workflow.output_bindings();

    assert!(bindings.contains(&"analysis_result".into()));
    assert!(bindings.contains(&"review_result".into()));
    assert!(bindings.contains(&"formatted_output".into()));
}
}

}

🤔 Reflection

  • How would you extend this workflow with error handling steps?
  • When would you convert this to a hybrid workflow with `.with_guidance()`?
  • How does `cargo pmcp validate` fit into your CI/CD pipeline?
  • What other MCP components could benefit from similar validation patterns?