Skip to content

Commit f0f600e

Browse files
authored
Merge pull request #76 from guyernest/feature/workflow-resource-fetching
feat: Server-Side Resource Fetching for Workflow Steps
2 parents 67cdd41 + 36779a8 commit f0f600e

15 files changed

+4106
-897
lines changed

docs/DESIGN_WORKFLOW_PROMPTS.md

Lines changed: 399 additions & 0 deletions
Large diffs are not rendered by default.

examples/50_workflow_minimal.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ fn main() {
159159
println!(" - Use binding names with from_step() and field(), not step names");
160160

161161
println!("\n📖 Next Steps:");
162-
println!(" - See examples/51_workflow_server.rs for server integration");
163-
println!(" - See examples/52_workflow_tools.rs for tool registration");
164-
println!(" - See examples/53_workflow_execution.rs for runtime execution");
162+
println!(" - See examples/51_workflow_error_messages.rs for validation error examples");
163+
println!(" - See examples/52_workflow_dsl_cookbook.rs for DSL patterns and recipes");
164+
println!(" - See examples/53_typed_tools_workflow_integration.rs for server-side execution");
165165
}

examples/53_typed_tools_workflow_integration.rs

Lines changed: 292 additions & 196 deletions
Large diffs are not rendered by default.
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
//! Hybrid Workflow Execution Example
2+
//!
3+
//! This example demonstrates the hybrid execution model where the server executes
4+
//! deterministic steps and provides structured guidance for steps requiring LLM reasoning.
5+
//!
6+
//! # Use Case: Logseq Task Management with Fuzzy Matching
7+
//!
8+
//! The workflow adds a task to a Logseq project, but the user's input may not
9+
//! match the exact project name. The server:
10+
//! 1. Lists all available pages (deterministic API call)
11+
//! 2. Fetches and embeds task formatting documentation (resource)
12+
//! 3. Provides guidance for the client LLM to fuzzy-match the project name
13+
//! 4. Client LLM continues by matching "MCP Tester" to "mcp-tester" and formatting the task
14+
//!
15+
//! ## Key Concepts
16+
//!
17+
//! - **Server-side execution**: Deterministic operations (data fetching)
18+
//! - **Client-side reasoning**: Fuzzy matching, context-aware decisions
19+
//! - **Graceful handoff**: Server stops when it can't proceed, returns partial trace
20+
//! - **Guidance messages**: Help LLM understand what to do next
21+
//! - **Argument substitution**: `{arg_name}` in guidance → actual values
22+
23+
use async_trait::async_trait;
24+
use pmcp::server::workflow::{SequentialWorkflow, ToolHandle, WorkflowStep};
25+
use pmcp::{
26+
Content, ListResourcesResult, ReadResourceResult, ResourceHandler, Result, Server, SimpleTool,
27+
};
28+
use serde_json::json;
29+
30+
#[tokio::main]
31+
async fn main() -> Result<()> {
32+
println!("=== Hybrid Workflow Execution: Logseq Task Management ===\n");
33+
34+
// Define workflow with hybrid execution
35+
let workflow = create_logseq_task_workflow();
36+
37+
// Create mock Logseq tools
38+
let list_pages_tool = SimpleTool::new("list_pages", |_args, _extra| {
39+
Box::pin(async move {
40+
Ok(json!({
41+
"page_names": [
42+
"mcp-tester",
43+
"MCP Rust SDK",
44+
"Test Page",
45+
"Documentation",
46+
"rust-projects"
47+
]
48+
}))
49+
})
50+
})
51+
.with_description("List all pages in Logseq knowledge base")
52+
.with_schema(json!({
53+
"type": "object",
54+
"properties": {},
55+
"required": []
56+
}));
57+
58+
let add_task_tool = SimpleTool::new("add_journal_task", |args, _extra| {
59+
Box::pin(async move {
60+
let task = args
61+
.get("formatted_task")
62+
.and_then(|v| v.as_str())
63+
.unwrap_or("");
64+
Ok(json!({
65+
"success": true,
66+
"task_id": "task-123",
67+
"task": task,
68+
"created_at": "2025-10-03T10:30:00Z"
69+
}))
70+
})
71+
})
72+
.with_description("Add a task to Logseq journal")
73+
.with_schema(json!({
74+
"type": "object",
75+
"properties": {
76+
"formatted_task": {"type": "string"}
77+
},
78+
"required": ["formatted_task"]
79+
}));
80+
81+
// Create mock resource handler for task formatting documentation
82+
struct LogseqDocsHandler;
83+
84+
#[async_trait]
85+
impl ResourceHandler for LogseqDocsHandler {
86+
async fn read(
87+
&self,
88+
uri: &str,
89+
_extra: pmcp::RequestHandlerExtra,
90+
) -> Result<ReadResourceResult> {
91+
match uri {
92+
"docs://logseq/task-format" => Ok(ReadResourceResult {
93+
contents: vec![Content::Text {
94+
text: r#"Logseq Task Formatting Guide
95+
================================
96+
97+
Task Format:
98+
- Use [[page-name]] to link to pages
99+
- Add TODO prefix for tasks
100+
- Format: TODO [[page-name]] Task description
101+
102+
Examples:
103+
- TODO [[mcp-tester]] Fix workflow execution bug
104+
- TODO [[rust-projects]] Update documentation
105+
- TODO [[Documentation]] Add examples
106+
107+
Best Practices:
108+
- Always use lowercase, hyphenated page names
109+
- Be specific in task descriptions
110+
- Link to the relevant project page
111+
"#
112+
.to_string(),
113+
}],
114+
}),
115+
_ => Err(pmcp::Error::validation(format!(
116+
"Unknown resource: {}",
117+
uri
118+
))),
119+
}
120+
}
121+
122+
async fn list(
123+
&self,
124+
_cursor: Option<String>,
125+
_extra: pmcp::RequestHandlerExtra,
126+
) -> Result<ListResourcesResult> {
127+
Ok(ListResourcesResult {
128+
resources: vec![pmcp::ResourceInfo {
129+
uri: "docs://logseq/task-format".to_string(),
130+
name: "Logseq Task Formatting Guide".to_string(),
131+
description: Some("Guide for formatting tasks in Logseq".to_string()),
132+
mime_type: Some("text/plain".to_string()),
133+
}],
134+
next_cursor: None,
135+
})
136+
}
137+
}
138+
139+
// Build server with workflow
140+
let server = Server::builder()
141+
.name("logseq-task-server")
142+
.version("1.0.0")
143+
.tool("list_pages", list_pages_tool)
144+
.tool("add_journal_task", add_task_tool)
145+
.resources(LogseqDocsHandler)
146+
.prompt_workflow(workflow)?
147+
.build()?;
148+
149+
println!("✓ Server built successfully\n");
150+
151+
// =================================================================
152+
// Demonstrate Hybrid Execution
153+
// =================================================================
154+
155+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
156+
println!("📝 User Invokes Workflow");
157+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
158+
159+
println!("User types:");
160+
println!(" /add_project_task \"MCP Tester\" \"Fix workflow execution bug\"\n");
161+
162+
println!("Note: User typed \"MCP Tester\" (with capitals and space)");
163+
println!(" But the actual page name is \"mcp-tester\" (lowercase, hyphenated)\n");
164+
165+
// Get the workflow prompt handler
166+
let prompt_handler = server
167+
.get_prompt("add_project_task")
168+
.expect("Workflow should be registered");
169+
170+
let mut args = std::collections::HashMap::new();
171+
args.insert("project".to_string(), "MCP Tester".to_string());
172+
args.insert("task".to_string(), "Fix workflow execution bug".to_string());
173+
174+
let extra = pmcp::server::cancellation::RequestHandlerExtra {
175+
cancellation_token: Default::default(),
176+
request_id: "demo-request".to_string(),
177+
session_id: None,
178+
auth_info: None,
179+
auth_context: None,
180+
};
181+
182+
// Execute workflow (hybrid execution)
183+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
184+
println!("⚙️ Server Executing Workflow (Hybrid Mode)");
185+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
186+
187+
let result = prompt_handler
188+
.handle(args, extra)
189+
.await
190+
.expect("Workflow execution should succeed");
191+
192+
println!(
193+
"Server executed what it could and returned {} messages\n",
194+
result.messages.len()
195+
);
196+
197+
// =================================================================
198+
// Display Conversation Trace
199+
// =================================================================
200+
201+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
202+
println!("💬 Conversation Trace (Returned to Client LLM)");
203+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
204+
205+
for (i, msg) in result.messages.iter().enumerate() {
206+
println!("Message {} [{:?}]:", i + 1, msg.role);
207+
if let pmcp::types::MessageContent::Text { text } = &msg.content {
208+
println!("{}\n", text);
209+
}
210+
}
211+
212+
// =================================================================
213+
// Explain What Happens Next
214+
// =================================================================
215+
216+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
217+
println!("🤖 Client LLM Continues Execution");
218+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
219+
220+
println!("The client LLM receives the conversation trace above and:");
221+
println!("1. Sees the list of available pages");
222+
println!("2. Reads the guidance on what to do next");
223+
println!("3. Matches \"MCP Tester\" to \"mcp-tester\" (fuzzy matching)");
224+
println!("4. Formats the task as: [[mcp-tester]] Fix workflow execution bug");
225+
println!("5. Calls add_journal_task with the formatted task\n");
226+
227+
println!("Expected client response:");
228+
println!("┌────────────────────────────────────────────────┐");
229+
println!("│ I can see \"mcp-tester\" in the page list, │");
230+
println!("│ which matches \"MCP Tester\" (case-insensitive, │");
231+
println!("│ with hyphen instead of space). │");
232+
println!("│ │");
233+
println!("│ I'll format the task with the correct link: │");
234+
println!("│ │");
235+
println!("│ <function_calls> │");
236+
println!("│ <invoke name=\"add_journal_task\"> │");
237+
println!("│ <parameter name=\"formatted_task\"> │");
238+
println!("│ [[mcp-tester]] Fix workflow execution bug│");
239+
println!("│ </parameter> │");
240+
println!("│ </invoke> │");
241+
println!("│ </function_calls> │");
242+
println!("└────────────────────────────────────────────────┘\n");
243+
244+
// =================================================================
245+
// Key Takeaways
246+
// =================================================================
247+
248+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
249+
println!("🎯 Key Takeaways: Hybrid Execution Model");
250+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
251+
252+
println!("✓ Server handles deterministic operations:");
253+
println!(" - Listing pages (API call)");
254+
println!(" - Fetching and embedding resource content");
255+
println!(" - Data fetching, validation");
256+
println!(" - Structured data operations\n");
257+
258+
println!("✓ Client LLM handles reasoning:");
259+
println!(" - Fuzzy matching (\"MCP Tester\"\"mcp-tester\")");
260+
println!(" - Context-aware decisions");
261+
println!(" - Natural language understanding\n");
262+
263+
println!("✓ Guidance enables seamless handoff:");
264+
println!(" - with_guidance() provides instructions to LLM");
265+
println!(" - {{arg_name}} syntax substitutes actual values");
266+
println!(" - Clear separation of concerns\n");
267+
268+
println!("✓ Benefits:");
269+
println!(" - Efficient: Server does what it can");
270+
println!(" - Intelligent: LLM handles complex reasoning");
271+
println!(" - Maintainable: Declarative workflow definition");
272+
println!(" - Flexible: Works for simple and complex cases\n");
273+
274+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
275+
println!("📚 When to Use Hybrid Execution");
276+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
277+
278+
println!("Use hybrid execution when:");
279+
println!(" ❌ Server can't match user input to structured data");
280+
println!(" ❌ Context-aware decisions are needed");
281+
println!(" ❌ Multiple valid options require LLM judgment");
282+
println!(" ❌ User might need to clarify intent\n");
283+
284+
println!("Use full server-side execution when:");
285+
println!(" ✅ All parameters are deterministic");
286+
println!(" ✅ No fuzzy matching or reasoning needed");
287+
println!(" ✅ Single-shot execution is sufficient\n");
288+
289+
Ok(())
290+
}
291+
292+
fn create_logseq_task_workflow() -> SequentialWorkflow {
293+
SequentialWorkflow::new(
294+
"add_project_task",
295+
"add a task to a Logseq project with intelligent project name matching",
296+
)
297+
.argument("project", "The project name (can be fuzzy, e.g., 'MCP Tester')", true)
298+
.argument("task", "The task description", true)
299+
// Step 1: Server executes this (deterministic)
300+
.step(
301+
WorkflowStep::new("list_pages", ToolHandle::new("list_pages"))
302+
.with_guidance("I'll first get all available page names from Logseq")
303+
.bind("pages"),
304+
)
305+
// Step 2: Client continues (needs LLM reasoning for fuzzy matching)
306+
// This step has guidance but no .arg() mappings
307+
// The tool schema requires 'formatted_task' parameter (see tool definition)
308+
// Server can't provide it -> automatic handoff to client
309+
// Guidance tells the client LLM what to do
310+
// Resource is fetched and embedded by server before handoff
311+
.step(
312+
WorkflowStep::new("add_task", ToolHandle::new("add_journal_task"))
313+
.with_guidance(
314+
"I'll now:\n\
315+
1. Find the page name from the list above that best matches '{project}'\n\
316+
2. Format the task according to the guide below\n\
317+
3. Call add_journal_task with the formatted_task parameter",
318+
)
319+
.with_resource("docs://logseq/task-format")
320+
.expect("Valid resource URI")
321+
// No .arg() mappings - server will detect this doesn't satisfy schema
322+
// and gracefully hand off to client LLM
323+
.bind("result"),
324+
)
325+
}

pmcp-book/src/SUMMARY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
[Foreword](foreword.md)
55
[Introduction](introduction.md)
66

7+
- [Chapter 0: Why MCP?](ch00-why-mcp.md)
8+
79
## Part I: Getting Started
810

911
- [Chapter 1: Installation & Setup](ch01-installation.md)

0 commit comments

Comments
 (0)