Chapter 1.4: Migration Between pforge and pmcp
This chapter provides practical migration strategies for moving between pforge and pmcp, including real-world examples and best practices.
Why Migrate?
Common Migration Scenarios
pmcp → pforge:
- Reduce boilerplate code
- Standardize on declarative configuration
- Add built-in quality gates
- Improve iteration speed
- Simplify maintenance
pforge → pmcp:
- Need custom protocol extensions
- Require complex stateful logic
- Build library/SDK
- Need WebAssembly support
- Require custom transports
Handler Compatibility
The good news: pforge handlers are compatible with pmcp!
Both frameworks share the same handler trait pattern, making migration straightforward.
// This handler works in BOTH pforge and pmcp
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use schemars::JsonSchema;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CalculateInput {
a: f64,
b: f64,
operation: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CalculateOutput {
result: f64,
}
pub struct CalculateHandler;
#[async_trait]
impl pforge_runtime::Handler for CalculateHandler {
type Input = CalculateInput;
type Output = CalculateOutput;
type Error = pforge_runtime::Error;
async fn handle(&self, input: Self::Input) -> pforge_runtime::Result<Self::Output> {
let result = match input.operation.as_str() {
"add" => input.a + input.b,
"subtract" => input.a - input.b,
"multiply" => input.a * input.b,
"divide" => {
if input.b == 0.0 {
return Err(pforge_runtime::Error::Handler(
"Division by zero".into()
));
}
input.a / input.b
}
_ => return Err(pforge_runtime::Error::Handler(
"Unknown operation".into()
)),
};
Ok(CalculateOutput { result })
}
}
Migrating from pmcp to pforge
Step 1: Analyze Your pmcp Server
Identify your tools and their types:
// Existing pmcp server
let server = ServerBuilder::new()
.name("my-server")
.tool_typed("calculate", /* handler */) // → Native handler
.tool_typed("run_git", /* subprocess */) // → CLI handler
.tool_typed("fetch_api", /* HTTP call */) // → HTTP handler
.tool_typed("complex", /* custom logic */) // → Keep in pmcp
.build()?;
Step 2: Extract Handlers
Convert tool closures to handler structs:
// Before (pmcp inline closure)
.tool_typed("calculate", |input: CalcInput, _| {
Box::pin(async move {
let result = input.a + input.b;
Ok(serde_json::json!({ "result": result }))
})
})
// After (pforge handler struct)
pub struct CalculateHandler;
#[async_trait]
impl Handler for CalculateHandler {
type Input = CalcInput;
type Output = CalcOutput;
type Error = Error;
async fn handle(&self, input: Self::Input) -> Result<Self::Output> {
Ok(CalcOutput { result: input.a + input.b })
}
}
Step 3: Create forge.yaml
Map your tools to pforge configuration:
forge:
name: my-server
version: 1.0.0
transport: stdio
tools:
# Native handlers (from pmcp tool_typed)
- type: native
name: calculate
description: "Perform calculations"
handler:
path: handlers::CalculateHandler
params:
a: { type: float, required: true }
b: { type: float, required: true }
operation: { type: string, required: true }
# CLI handlers (from subprocess calls)
- type: cli
name: run_git
description: "Run git commands"
command: git
args: ["{{subcommand}}", "{{args}}"]
cwd: /path/to/repo
stream: true
# HTTP handlers (from reqwest calls)
- type: http
name: fetch_api
description: "Fetch from external API"
endpoint: "https://api.example.com/{{path}}"
method: GET
headers:
Authorization: "Bearer {{API_TOKEN}}"
Step 4: Migrate State
// Before (pmcp manual state)
struct AppState {
data: Arc<RwLock<HashMap<String, Value>>>,
}
let state = Arc::new(AppState {
data: Arc::new(RwLock::new(HashMap::new())),
});
// After (pforge declarative state)
// In forge.yaml:
// state:
// backend: sled
// path: /tmp/my-server-state
// cache_size: 1000
// In handler:
async fn handle(&self, input: Input) -> Result<Output> {
let value = self.state.get("key").await?;
self.state.set("key", value, None).await?;
Ok(Output { value })
}
Step 5: Test Migration
# Run existing pmcp tests
cargo test --all
# Generate pforge server
pforge build
# Run pforge tests
pforge test
# Compare behavior
diff <(echo '{"a": 5, "b": 3}' | ./pmcp-server) \
<(echo '{"a": 5, "b": 3}' | pforge serve)
Complete Example: pmcp → pforge
Before (pmcp):
// src/main.rs (120 lines)
use pmcp::{ServerBuilder, TypedTool};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Deserialize, JsonSchema)]
struct CalcInput {
a: f64,
b: f64,
operation: String,
}
#[tokio::main]
async fn main() -> Result<()> {
let state = Arc::new(RwLock::new(HashMap::new()));
let server = ServerBuilder::new()
.name("calculator")
.version("1.0.0")
.tool_typed("calculate", {
let state = state.clone();
move |input: CalcInput, _| {
let state = state.clone();
Box::pin(async move {
// 20 lines of logic
let result = match input.operation.as_str() {
"add" => input.a + input.b,
// ... more operations
};
// Update state
state.write().await.insert("last_result", result);
Ok(serde_json::json!({ "result": result }))
})
}
})
.tool_typed("run_command", |input: CmdInput, _| {
Box::pin(async move {
// 30 lines of subprocess handling
let output = Command::new(&input.cmd)
.args(&input.args)
.output()
.await?;
// ... error handling
Ok(serde_json::json!({ "output": String::from_utf8(output.stdout)? }))
})
})
.build()?;
server.run_stdio().await
}
After (pforge):
# forge.yaml (25 lines)
forge:
name: calculator
version: 1.0.0
transport: stdio
state:
backend: sled
path: /tmp/calculator-state
tools:
- type: native
name: calculate
description: "Perform arithmetic operations"
handler:
path: handlers::CalculateHandler
params:
a: { type: float, required: true }
b: { type: float, required: true }
operation: { type: string, required: true }
- type: cli
name: run_command
description: "Run shell commands"
command: "{{cmd}}"
args: "{{args}}"
stream: true
// src/handlers.rs (30 lines)
use pforge_runtime::{Handler, Result, Error};
use serde::{Deserialize, Serialize};
use schemars::JsonSchema;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CalcInput {
a: f64,
b: f64,
operation: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CalcOutput {
result: f64,
}
pub struct CalculateHandler;
#[async_trait::async_trait]
impl Handler for CalculateHandler {
type Input = CalcInput;
type Output = CalcOutput;
type Error = Error;
async fn handle(&self, input: Self::Input) -> Result<Self::Output> {
let result = match input.operation.as_str() {
"add" => input.a + input.b,
"subtract" => input.a - input.b,
"multiply" => input.a * input.b,
"divide" => {
if input.b == 0.0 {
return Err(Error::Handler("Division by zero".into()));
}
input.a / input.b
}
_ => return Err(Error::Handler("Unknown operation".into())),
};
// State is managed automatically
self.state.set("last_result", &result.to_string(), None).await?;
Ok(CalcOutput { result })
}
}
Result:
- Code reduction: 120 lines → 55 lines (54% reduction)
- Complexity: Manual state → Automatic state
- Maintenance: Easier to modify (YAML vs Rust)
Migrating from pforge to pmcp
Step 1: Keep Your Handlers
pforge handlers work directly in pmcp:
// handlers.rs - NO CHANGES NEEDED
pub struct MyHandler;
#[async_trait]
impl pforge_runtime::Handler for MyHandler {
type Input = MyInput;
type Output = MyOutput;
type Error = pforge_runtime::Error;
async fn handle(&self, input: Self::Input) -> pforge_runtime::Result<Self::Output> {
// Handler logic stays the same
Ok(MyOutput { result: process(input) })
}
}
Step 2: Convert YAML to pmcp Code
# forge.yaml (pforge)
forge:
name: my-server
version: 1.0.0
tools:
- type: native
name: process
handler:
path: handlers::MyHandler
params:
input: { type: string, required: true }
Becomes:
// main.rs (pmcp)
use pmcp::ServerBuilder;
#[tokio::main]
async fn main() -> Result<()> {
let server = ServerBuilder::new()
.name("my-server")
.version("1.0.0")
.tool_typed("process", |input: MyInput, _| {
Box::pin(async move {
let handler = MyHandler;
let output = handler.handle(input).await?;
Ok(serde_json::to_value(output)?)
})
})
.build()?;
server.run_stdio().await
}
Step 3: Add Custom Logic
Now you can extend beyond pforge’s capabilities:
use pmcp::ServerBuilder;
#[tokio::main]
async fn main() -> Result<()> {
let server = ServerBuilder::new()
.name("advanced-server")
.version("1.0.0")
// Keep existing pforge handlers
.tool_typed("basic", |input: BasicInput, _| {
Box::pin(async move {
let handler = BasicHandler;
let output = handler.handle(input).await?;
Ok(serde_json::to_value(output)?)
})
})
// Add custom complex logic (not possible in pforge)
.tool_typed("complex", |input: ComplexInput, _| {
Box::pin(async move {
// Custom database transactions
let mut tx = db_pool.begin().await?;
// Complex business logic
let result = perform_analysis(&mut tx, input).await?;
// Custom error handling
match result {
Ok(data) => {
tx.commit().await?;
Ok(serde_json::to_value(data)?)
}
Err(e) => {
tx.rollback().await?;
Err(pmcp::Error::Handler(e.to_string()))
}
}
})
})
// Custom protocol extensions
.custom_method("custom/analyze", |params| {
Box::pin(async move {
custom_protocol_handler(params).await
})
})
.build()?;
server.run_stdio().await
}
Hybrid Approach: Using Both
You can use pforge and pmcp together in the same project:
Strategy 1: pforge for Simple, pmcp for Complex
// Use pforge for 80% of simple tools
mod pforge_tools {
include!(concat!(env!("OUT_DIR"), "/pforge_generated.rs"));
}
// Use pmcp for 20% of complex tools
use pmcp::ServerBuilder;
#[tokio::main]
async fn main() -> Result<()> {
let mut builder = ServerBuilder::new()
.name("hybrid-server")
.version("1.0.0");
// Add all pforge-generated tools
for (name, handler) in pforge_tools::handlers() {
builder = builder.tool_typed(name, handler);
}
// Add custom complex tools
builder = builder
.tool_typed("complex_analysis", |input: AnalysisInput, _| {
Box::pin(async move {
// Complex logic not expressible in pforge
let result = ml_model.predict(input).await?;
Ok(serde_json::to_value(result)?)
})
})
.tool_typed("database_query", |input: QueryInput, _| {
Box::pin(async move {
// Complex transactional database operations
let mut tx = pool.begin().await?;
let result = execute_query(&mut tx, input).await?;
tx.commit().await?;
Ok(serde_json::to_value(result)?)
})
});
let server = builder.build()?;
server.run_stdio().await
}
Strategy 2: Parallel Servers
Run pforge and pmcp servers side-by-side:
# Terminal 1: pforge server for standard tools
cd pforge-server
pforge serve
# Terminal 2: pmcp server for custom tools
cd pmcp-server
cargo run --release
# Claude Desktop config
{
"mcpServers": {
"standard-tools": {
"command": "pforge",
"args": ["serve"],
"cwd": "/path/to/pforge-server"
},
"custom-tools": {
"command": "/path/to/pmcp-server/target/release/custom-server",
"cwd": "/path/to/pmcp-server"
}
}
}
Migration Checklist
pmcp → pforge Migration
- Identify tool types (native/cli/http/pipeline)
- Extract handlers from closures
- Create forge.yaml configuration
- Convert state management to pforge state backend
- Set up quality gates (PMAT)
- Write tests for migrated handlers
- Benchmark performance (should improve)
- Update documentation
- Deploy and monitor
pforge → pmcp Migration
- Keep existing handler implementations
- Convert forge.yaml to ServerBuilder code
- Add custom logic as needed
- Implement custom state management (if needed)
- Set up CI/CD (manual configuration)
- Write additional tests
- Update documentation
- Deploy and monitor
Common Migration Pitfalls
Pitfall 1: State Management Mismatch
Problem:
// pmcp: Manual Arc<RwLock>
let data = state.read().await.get("key").cloned();
// pforge: Async state backend
let data = self.state.get("key").await?;
Solution: Choose consistent state backend or use adapter pattern.
Pitfall 2: Error Handling Differences
Problem:
// pmcp: Custom error types
Err(MyError::Database(e))
// pforge: Standardized errors
Err(Error::Handler(e.to_string()))
Solution: Map custom errors to pforge Error types:
impl From<MyError> for pforge_runtime::Error {
fn from(err: MyError) -> Self {
match err {
MyError::Database(e) => Error::Handler(format!("DB: {}", e)),
MyError::Validation(msg) => Error::Validation(msg),
MyError::Timeout => Error::Timeout,
}
}
}
Pitfall 3: Missing CLI/HTTP Wrappers
Problem: pmcp requires manual subprocess/HTTP handling.
Solution: Extract to separate pforge server or use libraries:
// Instead of reinventing CLI wrapper
use tokio::process::Command;
// Use pforge CLI handler type or simple wrapper
async fn run_command(cmd: &str, args: &[String]) -> Result<String> {
let output = Command::new(cmd)
.args(args)
.output()
.await?;
String::from_utf8(output.stdout)
.map_err(|e| Error::Handler(e.to_string()))
}
Performance Considerations
pmcp → pforge
Expected improvements:
- Tool dispatch: 11x faster (perfect hash vs HashMap)
- Throughput: 1.5-1.7x higher
- Memory per tool: ~50% reduction
Trade-offs:
- Cold start: ~2x slower (code generation)
- Binary size: 2-3x larger
pforge → pmcp
Expected changes:
- More control over performance tuning
- Custom allocator options
- Zero-copy optimizations possible
- Manual optimization needed
Testing Migration
Compatibility Test
#[cfg(test)]
mod migration_tests {
use super::*;
#[tokio::test]
async fn test_handler_compatibility() {
// Test handler works in both pforge and pmcp
let handler = MyHandler;
let input = MyInput { value: 42 };
let output = handler.handle(input).await.unwrap();
assert_eq!(output.result, 84);
}
#[tokio::test]
async fn test_behavior_equivalence() {
// Compare pforge and pmcp server responses
let pforge_response = test_pforge_server(input.clone()).await?;
let pmcp_response = test_pmcp_server(input.clone()).await?;
assert_eq!(pforge_response, pmcp_response);
}
}
Summary
Migration between pforge and pmcp is straightforward thanks to handler compatibility:
Key Points:
- pforge handlers work in pmcp without changes
- pmcp → pforge reduces code by ~50%
- pforge → pmcp adds flexibility for complex cases
- Hybrid approach combines benefits of both
- Choose based on current needs, migrate as requirements evolve
Migration Decision:
- More tools becoming standard? → Migrate to pforge
- Need custom protocols? → Migrate to pmcp
- Mixed requirements? → Use hybrid approach