diff --git a/examples/26-server-tester/src/main.rs b/examples/26-server-tester/src/main.rs index e1f7bb5f..00d59cdb 100644 --- a/examples/26-server-tester/src/main.rs +++ b/examples/26-server-tester/src/main.rs @@ -215,6 +215,7 @@ async fn main() -> Result<()> { cli.insecure, cli.api_key.as_deref(), cli.transport.as_deref(), + cli.verbose > 0, ) .await }, @@ -409,6 +410,7 @@ async fn run_tools_test( insecure: bool, api_key: Option<&str>, transport: Option<&str>, + verbose: bool, ) -> Result { let mut tester = ServerTester::new( url, @@ -421,7 +423,10 @@ async fn run_tools_test( println!("{}", "Discovering and testing tools...".green()); println!(); - tester.run_tools_discovery(test_all).await + // Pass verbose flag to the tester for detailed output + tester + .run_tools_discovery_with_verbose(test_all, verbose) + .await } async fn run_diagnostics( diff --git a/examples/26-server-tester/src/scenario_executor.rs b/examples/26-server-tester/src/scenario_executor.rs index 3399ed0f..63437aa6 100644 --- a/examples/26-server-tester/src/scenario_executor.rs +++ b/examples/26-server-tester/src/scenario_executor.rs @@ -237,12 +237,62 @@ impl<'a> ScenarioExecutor<'a> { match operation { Operation::ToolCall { tool, arguments } => { - let result = self.tester.test_tool(&tool, arguments).await?; - Ok(json!({ - "success": result.status == crate::report::TestStatus::Passed, - "result": result.details, - "error": result.error - })) + // Call the tool directly to get raw response for assertions + match self.tester.transport_type { + crate::tester::TransportType::Http => { + if let Some(ref client) = self.tester.pmcp_client { + match client.call_tool(tool.clone(), arguments).await { + Ok(result) => { + // Extract the text content from the response + let content_text = result + .content + .into_iter() + .filter_map(|c| match c { + pmcp::types::Content::Text { text } => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); + + // Check if the content indicates an error + if content_text.starts_with("Error:") { + Ok(json!({ + "success": false, + "result": null, + "error": content_text + })) + } else { + Ok(json!({ + "success": true, + "result": content_text, + "error": null + })) + } + }, + Err(e) => Ok(json!({ + "success": false, + "result": null, + "error": e.to_string() + })), + } + } else { + Ok(json!({ + "success": false, + "result": null, + "error": "Client not initialized" + })) + } + }, + _ => { + // Fall back to test_tool for other transport types + let result = self.tester.test_tool(&tool, arguments).await?; + Ok(json!({ + "success": result.status == crate::report::TestStatus::Passed, + "result": result.details, + "error": result.error + })) + }, + } }, Operation::ListTools => { @@ -523,7 +573,10 @@ impl<'a> ScenarioExecutor<'a> { }, Assertion::Success => { - let has_error = response.get("error").is_some(); + let has_error = response + .get("error") + .and_then(|e| if e.is_null() { None } else { Some(e) }) + .is_some(); AssertionResult { assertion: "Success".to_string(), passed: !has_error, @@ -538,7 +591,10 @@ impl<'a> ScenarioExecutor<'a> { }, Assertion::Failure => { - let has_error = response.get("error").is_some(); + let has_error = response + .get("error") + .and_then(|e| if e.is_null() { None } else { Some(e) }) + .is_some(); AssertionResult { assertion: "Failure".to_string(), passed: has_error, diff --git a/examples/26-server-tester/src/tester.rs b/examples/26-server-tester/src/tester.rs index e6ec8b22..160287d8 100644 --- a/examples/26-server-tester/src/tester.rs +++ b/examples/26-server-tester/src/tester.rs @@ -40,7 +40,7 @@ pub enum TransportType { pub struct ServerTester { url: String, - transport_type: TransportType, + pub transport_type: TransportType, http_config: Option, json_rpc_client: Option, #[allow(dead_code)] @@ -53,7 +53,7 @@ pub struct ServerTester { server_info: Option, tools: Option>, // Store the initialized pmcp client for reuse across tests - pmcp_client: Option>, + pub pmcp_client: Option>, stdio_client: Option>, } @@ -288,6 +288,14 @@ impl ServerTester { } pub async fn run_tools_discovery(&mut self, test_all: bool) -> Result { + self.run_tools_discovery_with_verbose(test_all, false).await + } + + pub async fn run_tools_discovery_with_verbose( + &mut self, + test_all: bool, + verbose: bool, + ) -> Result { let mut report = TestReport::new(); let start = Instant::now(); @@ -295,6 +303,18 @@ impl ServerTester { let init_result = self.test_initialize().await; report.add_test(init_result.clone()); + if verbose && init_result.status == TestStatus::Passed { + println!(" ✓ Server initialized successfully"); + if let Some(ref server) = self.server_info { + println!( + " Server: {} v{}", + server.server_info.name, server.server_info.version + ); + } + } else if verbose && init_result.status != TestStatus::Passed { + println!(" ✗ Initialization failed: {:?}", init_result.error); + } + if init_result.status != TestStatus::Passed { return Ok(report); } @@ -303,6 +323,32 @@ impl ServerTester { let tools_result = self.test_tools_list().await; report.add_test(tools_result.clone()); + if verbose { + if tools_result.status == TestStatus::Passed { + if let Some(ref tools) = self.tools { + println!(" ✓ Found {} tools:", tools.len()); + for tool in tools { + println!( + " • {} - {}", + tool.name, + tool.description.as_deref().unwrap_or("No description") + ); + } + } else { + println!(" ✓ No tools found"); + } + } else { + println!(" ✗ Failed to list tools: {:?}", tools_result.error); + if verbose { + // Print the actual error details + println!( + " Error details: {}", + tools_result.error.as_deref().unwrap_or("Unknown error") + ); + } + } + } + if tools_result.status == TestStatus::Passed && test_all { let tools_to_test: Vec<(String, Value)> = self .tools @@ -319,7 +365,14 @@ impl ServerTester { .unwrap_or_default(); for (tool_name, test_args) in tools_to_test { - report.add_test(self.test_tool(&tool_name, test_args).await?); + let test_result = self.test_tool(&tool_name, test_args.clone()).await?; + if verbose { + println!(" Testing tool '{}': {:?}", tool_name, test_result.status); + if test_result.status != TestStatus::Passed { + println!(" Error: {:?}", test_result.error); + } + } + report.add_test(test_result); } } diff --git a/examples/wasm-mcp-server/README.md b/examples/wasm-mcp-server/README.md index 7239d473..0e0fdc60 100644 --- a/examples/wasm-mcp-server/README.md +++ b/examples/wasm-mcp-server/README.md @@ -45,7 +45,7 @@ cargo build --target wasm32-unknown-unknown --release cd deployments/cloudflare make deploy -# Live at: https://mcp-sdk-worker.guy-ernest.workers.dev +# Your deployment will be available at: https://.workers.dev ``` ### Deploy to Fermyon Spin @@ -54,7 +54,7 @@ make deploy cd deployments/fermyon-spin make deploy -# Live at: https://mcp-fermyon-spin-3juc7zc4.fermyon.app/ +# Your deployment will be available at: https://.fermyon.app/ ``` ## 🏗️ Architecture @@ -116,7 +116,58 @@ Reports the runtime environment (Cloudflare vs Fermyon) ## 🧪 Testing -### Test Any Deployment +### Using MCP Tester with Scenario Files + +The repository includes comprehensive test scenarios that can be run with the `mcp-tester` tool: + +```bash +# Test with simple calculator scenario +mcp-tester scenario test-scenarios/calculator-simple.json + +# Test with comprehensive calculator tests (including error cases) +mcp-tester scenario test-scenarios/calculator-test.yaml + +# Test with minimal tool listing +mcp-tester scenario test-scenarios/minimal-test.json +``` + +#### Example: Testing Cloudflare Deployment +```bash +# From the rust-mcp-sdk root directory +# Replace with your Cloudflare Worker subdomain +./target/release/mcp-tester scenario \ + https://.workers.dev \ + examples/wasm-mcp-server/test-scenarios/calculator-test.yaml +``` + +#### Example: Testing Fermyon Spin Deployment +```bash +# From the rust-mcp-sdk root directory +# Replace with your Fermyon app URL +./target/release/mcp-tester scenario \ + https://.fermyon.app/ \ + examples/wasm-mcp-server/test-scenarios/calculator-test.yaml +``` + +### Available Test Scenarios + +1. **`calculator-simple.json`** - Basic calculator operations + - Tests addition, multiplication, division, and subtraction + - Validates correct results for each operation + +2. **`calculator-test.yaml`** - Comprehensive calculator test suite + - Tests all arithmetic operations with various inputs + - Tests negative numbers and decimals + - Tests error handling (division by zero, invalid operations, missing parameters) + - Tests large numbers and edge cases + +3. **`minimal-test.json`** - Minimal connectivity test + - Simply lists available tools + - Quick smoke test for deployment health + +### Manual Testing with curl + +You can also test deployments manually: ```bash # Initialize connection @@ -135,6 +186,17 @@ curl -X POST \ -d '{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"calculator","arguments":{"operation":"add","a":5,"b":3}}}' ``` +### Building the MCP Tester + +If you need to build the mcp-tester tool: + +```bash +# From the rust-mcp-sdk root directory +cargo build --release --package mcp-server-tester + +# The binary will be at: ./target/release/mcp-tester +``` + ## 📊 Deployment Comparison | Platform | Build Target | Runtime | Global Edge | Cold Start | State Management | @@ -222,6 +284,9 @@ MIT --- -**Current Production Deployments:** -- 🌐 Cloudflare: https://mcp-sdk-worker.guy-ernest.workers.dev -- 🔄 Fermyon: https://mcp-fermyon-spin-3juc7zc4.fermyon.app/ \ No newline at end of file +**Example Deployments for Testing:** +You can test the MCP protocol with these example deployments: +- 🌐 Cloudflare Example: https://mcp-sdk-worker.guy-ernest.workers.dev +- 🔄 Fermyon Example: https://mcp-fermyon-spin-3juc7zc4.fermyon.app/ + +Note: These are example deployments for testing. Deploy your own instances using the instructions above. \ No newline at end of file diff --git a/examples/wasm-mcp-server/deployments/cloudflare/README.md b/examples/wasm-mcp-server/deployments/cloudflare/README.md index 026298e2..34efca0f 100644 --- a/examples/wasm-mcp-server/deployments/cloudflare/README.md +++ b/examples/wasm-mcp-server/deployments/cloudflare/README.md @@ -24,6 +24,51 @@ make deploy make test-prod ``` +## Testing with MCP Tester + +### Automated Scenario Testing + +The deployment can be tested using the mcp-tester tool with predefined scenarios: + +```bash +# From the rust-mcp-sdk root directory +# Replace with your deployed Worker subdomain + +# Test with comprehensive calculator scenario +./target/release/mcp-tester scenario \ + https://.workers.dev \ + examples/wasm-mcp-server/test-scenarios/calculator-test.yaml + +# Quick connectivity test +./target/release/mcp-tester scenario \ + https://.workers.dev \ + examples/wasm-mcp-server/test-scenarios/minimal-test.json + +# Basic calculator operations test +./target/release/mcp-tester scenario \ + https://.workers.dev \ + examples/wasm-mcp-server/test-scenarios/calculator-simple.json +``` + +### Expected Test Results + +All scenarios should pass with output like: +``` +╔════════════════════════════════════════════════════════════╗ +║ MCP SERVER TESTING TOOL v0.1.0 ║ +╚════════════════════════════════════════════════════════════╝ + +TEST RESULTS +════════════════════════════════════════════════════════════ + ✓ Test Addition - 10 + 5 + ✓ Test Multiplication - 4 * 7 + ✓ Test Division - 20 / 4 + ✓ Test Division by Zero (error case) + ✓ Test Invalid Operation (error case) + +SUMMARY: PASSED +``` + ## Configuration The `wrangler.toml` file specifies: @@ -50,6 +95,11 @@ The `wrangler.toml` file specifies: - If runtime fails: Check the JavaScript wrapper initialization - For CORS issues: Headers are set in the Rust code -## Live Deployment +## Deployment URL + +After deploying, your MCP server will be available at: +🌐 `https://.workers.dev` +### Example Deployment for Testing +You can test the MCP protocol with this example deployment: 🌐 https://mcp-sdk-worker.guy-ernest.workers.dev \ No newline at end of file diff --git a/examples/wasm-mcp-server/deployments/fermyon-spin/Cargo.toml b/examples/wasm-mcp-server/deployments/fermyon-spin/Cargo.toml new file mode 100644 index 00000000..c624100a --- /dev/null +++ b/examples/wasm-mcp-server/deployments/fermyon-spin/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "mcp-fermyon-spin" +version = "0.1.0" +edition = "2021" + +[dependencies] +spin-sdk = "3" +pmcp = { path = "../../../..", default-features = false, features = ["wasm"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +futures = "0.3" + +[lib] +name = "mcp_fermyon_spin" +crate-type = ["cdylib"] + +[profile.release] +lto = true +strip = true +codegen-units = 1 +opt-level = "z" + +# Exclude from parent workspace +[workspace] \ No newline at end of file diff --git a/examples/wasm-mcp-server/deployments/fermyon-spin/README.md b/examples/wasm-mcp-server/deployments/fermyon-spin/README.md index 1112a666..8f3523f0 100644 --- a/examples/wasm-mcp-server/deployments/fermyon-spin/README.md +++ b/examples/wasm-mcp-server/deployments/fermyon-spin/README.md @@ -42,6 +42,61 @@ spin up ## Testing +### Using MCP Tester with Scenario Files + +The deployment can be tested using the mcp-tester tool with predefined scenarios: + +```bash +# Test locally running instance +./target/release/mcp-tester scenario \ + http://localhost:3000 \ + examples/wasm-mcp-server/test-scenarios/calculator-test.yaml + +# Test production deployment on Fermyon Cloud +# Replace with your Fermyon app URL +./target/release/mcp-tester scenario \ + https://.fermyon.app/ \ + examples/wasm-mcp-server/test-scenarios/calculator-test.yaml + +# Quick connectivity test +./target/release/mcp-tester scenario \ + https://.fermyon.app/ \ + examples/wasm-mcp-server/test-scenarios/minimal-test.json + +# Basic calculator operations +./target/release/mcp-tester scenario \ + https://.fermyon.app/ \ + examples/wasm-mcp-server/test-scenarios/calculator-simple.json +``` + +#### Expected Results + +All test scenarios should pass: +``` +╔════════════════════════════════════════════════════════════╗ +║ MCP SERVER TESTING TOOL v0.1.0 ║ +╚════════════════════════════════════════════════════════════╝ + +TEST RESULTS +════════════════════════════════════════════════════════════ + ✓ Test Addition - 10 + 5 + ✓ Test Subtraction - 20 - 8 + ✓ Test Multiplication - 4 * 7 + ✓ Test Division - 20 / 4 + ✓ Test Division by Zero (error case) + ✓ Test Invalid Operation (error case) + ✓ Test Missing Parameter (error case) + +SUMMARY +════════════════════════════════════════════════════════════ +| Total Tests | 14 | +| Passed | 14 | + +Overall Status: PASSED +``` + +### Manual Testing with curl + Test with curl: ```bash @@ -71,7 +126,7 @@ curl -X POST http://localhost:3000 \ "params": {} }' -# Call a tool +# Call a tool (calculator example) curl -X POST http://localhost:3000 \ -H "Content-Type: application/json" \ -d '{ @@ -79,8 +134,9 @@ curl -X POST http://localhost:3000 \ "id": "3", "method": "tools/call", "params": { - "name": "add", + "name": "calculator", "arguments": { + "operation": "add", "a": 10, "b": 20 } @@ -137,9 +193,18 @@ The MCP logic (`WasmMcpServer`) remains identical across platforms. Only the HTT ## Tools Included -- **add**: Add two numbers -- **reverse**: Reverse a string -- **environment**: Get runtime information +- **calculator**: Perform arithmetic operations (add, subtract, multiply, divide) +- **weather**: Get weather information for a location (mock data) +- **system_info**: Get runtime information (reports "Fermyon Spin" environment) + +## Deployment URL + +After deploying with `spin deploy`, your MCP server will be available at: +🔄 `https://.fermyon.app/` + +### Example Deployment for Testing +You can test the MCP protocol with this example deployment: +🔄 https://mcp-fermyon-spin-3juc7zc4.fermyon.app/ ## License diff --git a/examples/wasm-mcp-server/deployments/fermyon-spin/src/lib.rs b/examples/wasm-mcp-server/deployments/fermyon-spin/src/lib.rs new file mode 100644 index 00000000..74d6acc1 --- /dev/null +++ b/examples/wasm-mcp-server/deployments/fermyon-spin/src/lib.rs @@ -0,0 +1,285 @@ +use pmcp::server::wasm_server::{SimpleTool, WasmMcpServer}; +use pmcp::types::{ClientRequest, Request as McpRequest, ServerCapabilities}; +use serde_json::{json, Value}; +use spin_sdk::http::{IntoResponse, Request, Response}; +use spin_sdk::http_component; + +/// Environment-agnostic MCP server running on Fermyon Spin +/// +/// This is a thin platform-specific wrapper around the shared MCP server logic. +/// The actual MCP implementation is shared across all WASM platforms. +#[http_component] +fn handle_request(req: Request) -> anyhow::Result { + // Handle CORS preflight + if *req.method() == spin_sdk::http::Method::Options { + let mut response = Response::new(200, ()); + response.set_header("access-control-allow-origin", "*"); + response.set_header("access-control-allow-methods", "POST, OPTIONS"); + response.set_header("access-control-allow-headers", "Content-Type"); + return Ok(response); + } + + // Handle GET requests with server info + if *req.method() == spin_sdk::http::Method::Get { + let info = json!({ + "name": "fermyon-spin-mcp-server", + "version": "1.0.0", + "protocol_version": "2024-11-05", + "description": "MCP server running on Fermyon Spin", + "capabilities": { + "tools": true, + "resources": false, + "prompts": false + } + }); + let mut response = Response::new(200, serde_json::to_string_pretty(&info)?); + response.set_header("content-type", "application/json"); + response.set_header("access-control-allow-origin", "*"); + return Ok(response); + } + + // Only handle POST requests for MCP protocol + if *req.method() != spin_sdk::http::Method::Post { + let mut response = Response::new(405, "Only GET and POST methods are supported"); + response.set_header("content-type", "text/plain"); + return Ok(response); + } + + // Get request body + let body_bytes = req.body(); + let body = std::str::from_utf8(&body_bytes)?; + + // Parse the JSON-RPC request + let request_value: Value = serde_json::from_str(body)?; + + // Check if this is a notification (no id field means it's a notification) + let maybe_id = request_value.get("id"); + + let method = request_value.get("method") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let params = request_value.get("params") + .cloned() + .unwrap_or(Value::Null); + + // Handle notifications - they don't require a response + if maybe_id.is_none() { + match method { + "notifications/initialized" => { + // Client is telling us it's initialized - return empty 200 OK + let response = Response::new(200, ()); + return Ok(response); + }, + "notifications/cancelled" => { + // Cancellation notification - return empty 200 OK + let response = Response::new(200, ()); + return Ok(response); + }, + _ => { + // Unknown notification - still return empty 200 OK + let response = Response::new(200, ()); + return Ok(response); + } + } + } + + // Extract request ID for regular requests + let id = maybe_id + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or(pmcp::types::RequestId::String("0".to_string())); + + // Build the environment-agnostic MCP server + let server = create_mcp_server(); + + // Construct ClientRequest based on method + let client_request = match method { + "initialize" => { + let mut init_params = params.clone(); + if init_params.is_object() && !init_params.get("capabilities").is_some() { + if let Some(obj) = init_params.as_object_mut() { + obj.insert("capabilities".to_string(), json!({})); + } + } + let init_params: pmcp::types::InitializeParams = serde_json::from_value(init_params)?; + ClientRequest::Initialize(init_params) + }, + "tools/list" => { + ClientRequest::ListTools(pmcp::types::ListToolsParams { cursor: None }) + }, + "tools/call" => { + let call_params: pmcp::types::CallToolParams = serde_json::from_value(params)?; + ClientRequest::CallTool(call_params) + }, + _ => { + // Return proper JSON-RPC error for unknown methods + let error_response = json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("Method not found: {}", method) + } + }); + let mut response = Response::new(200, serde_json::to_string(&error_response)?); + response.set_header("content-type", "application/json"); + response.set_header("access-control-allow-origin", "*"); + return Ok(response); + } + }; + + // Handle the request with the environment-agnostic server + let response = futures::executor::block_on( + server.handle_request(id, McpRequest::Client(Box::new(client_request))) + ); + + // Return the JSON-RPC response + let response_json = serde_json::to_string(&response)?; + let mut http_response = Response::new(200, response_json); + http_response.set_header("content-type", "application/json"); + http_response.set_header("access-control-allow-origin", "*"); + + Ok(http_response) +} + +/// Create the shared MCP server with tools +/// This function contains the "write once" MCP logic that's shared across platforms +fn create_mcp_server() -> WasmMcpServer { + WasmMcpServer::builder() + .name("fermyon-spin-mcp-server") + .version("1.0.0") + .capabilities(ServerCapabilities { + tools: Some(Default::default()), + resources: None, + prompts: None, + logging: None, + experimental: None, + completions: None, + sampling: None, + }) + // Calculator tool + .tool( + "calculator", + SimpleTool::new( + "calculator", + "Perform arithmetic calculations", + |args: Value| { + let operation = args.get("operation") + .and_then(|v| v.as_str()) + .ok_or_else(|| pmcp::Error::protocol( + pmcp::ErrorCode::INVALID_PARAMS, + "operation is required" + ))?; + + let a = args.get("a") + .and_then(|v| v.as_f64()) + .ok_or_else(|| pmcp::Error::protocol( + pmcp::ErrorCode::INVALID_PARAMS, + "parameter 'a' is required" + ))?; + + let b = args.get("b") + .and_then(|v| v.as_f64()) + .ok_or_else(|| pmcp::Error::protocol( + pmcp::ErrorCode::INVALID_PARAMS, + "parameter 'b' is required" + ))?; + + let result = match operation { + "add" => a + b, + "subtract" => a - b, + "multiply" => a * b, + "divide" => { + if b == 0.0 { + return Err(pmcp::Error::protocol( + pmcp::ErrorCode::INVALID_PARAMS, + "Division by zero" + )); + } + a / b + } + _ => return Err(pmcp::Error::protocol( + pmcp::ErrorCode::INVALID_PARAMS, + &format!("Unknown operation: {}", operation) + )) + }; + + Ok(json!({ + "operation": operation, + "a": a, + "b": b, + "result": result + })) + } + ).with_schema(json!({ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "The arithmetic operation to perform" + }, + "a": { + "type": "number", + "description": "First operand" + }, + "b": { + "type": "number", + "description": "Second operand" + } + }, + "required": ["operation", "a", "b"] + })) + ) + // Weather tool + .tool( + "weather", + SimpleTool::new( + "weather", + "Get weather information", + |args: Value| { + let location = args.get("location") + .and_then(|v| v.as_str()) + .unwrap_or("San Francisco"); + + Ok(json!({ + "location": location, + "temperature": "72°F", + "conditions": "Sunny", + "humidity": "45%" + })) + } + ).with_schema(json!({ + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "Location to get weather for" + } + }, + "required": [] + })) + ) + // System info tool + .tool( + "system_info", + SimpleTool::new( + "system_info", + "Get system information", + |_args: Value| { + Ok(json!({ + "runtime": "Fermyon Spin", + "sdk": "pmcp", + "version": env!("CARGO_PKG_VERSION"), + "architecture": "wasm32-wasip1", + "message": "Environment-agnostic MCP server running in Fermyon Spin!" + })) + } + ).with_schema(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + ) + .build() +} \ No newline at end of file diff --git a/examples/wasm-mcp-server/src/lib.rs b/examples/wasm-mcp-server/src/lib.rs index c1f045fa..596b0079 100644 --- a/examples/wasm-mcp-server/src/lib.rs +++ b/examples/wasm-mcp-server/src/lib.rs @@ -207,10 +207,8 @@ async fn main(mut req: Request, _env: Env, _ctx: Context) -> Result { } }; - // Extract request ID and method - let id = request_value.get("id") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or(pmcp::types::RequestId::String("0".to_string())); + // Check if this is a notification (no id field means it's a notification) + let maybe_id = request_value.get("id"); let method = request_value.get("method") .and_then(|v| v.as_str()) @@ -220,6 +218,32 @@ async fn main(mut req: Request, _env: Env, _ctx: Context) -> Result { .cloned() .unwrap_or(Value::Null); + // Handle notifications - they don't require a response + if maybe_id.is_none() { + // This is a notification + match method { + "notifications/initialized" => { + // Client is telling us it's initialized - no action needed + console_log!("Received initialized notification"); + // Return an empty successful response (notifications don't get responses in JSON-RPC) + return Response::empty(); + }, + "notifications/cancelled" => { + console_log!("Received cancellation notification"); + return Response::empty(); + }, + _ => { + console_log!("Received unknown notification: {}", method); + return Response::empty(); + } + } + } + + // Extract request ID for regular requests + let id = maybe_id + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or(pmcp::types::RequestId::String("0".to_string())); + // Construct ClientRequest based on method let client_request = match method { "initialize" => { @@ -256,7 +280,21 @@ async fn main(mut req: Request, _env: Env, _ctx: Context) -> Result { }, _ => { console_error!("Unknown method: {}", method); - return Response::error(&format!("Unknown method: {}", method), 400); + // Return proper JSON-RPC error response for unknown methods + let error_response = json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("Method not found: {}", method) + } + }); + + return Response::ok(&error_response.to_string()) + .and_then(|mut res| { + res.headers_mut().set("Content-Type", "application/json")?; + Ok(res) + }) } }; diff --git a/examples/wasm-mcp-server/test-scenarios/all-tools-test.yaml b/examples/wasm-mcp-server/test-scenarios/all-tools-test.yaml new file mode 100644 index 00000000..a4a77d1d --- /dev/null +++ b/examples/wasm-mcp-server/test-scenarios/all-tools-test.yaml @@ -0,0 +1,199 @@ +name: "All Tools Integration Test" +description: "Tests calculator, weather, and system_info tools" +timeout: 10 + +steps: + - name: "Initialize MCP Session" + action: initialize + params: + protocolVersion: "2024-11-05" + clientInfo: + name: "integration-tester" + version: "1.0.0" + expect: + success: true + + - name: "Verify All Tools Available" + action: tools/list + expect: + success: true + response: + tools: + - name: "calculator" + - name: "weather" + - name: "system_info" + + # Calculator Tool Tests + - name: "Calculator: Complex Calculation (10 + 5) * 2" + action: tools/call + params: + name: "calculator" + arguments: + operation: "add" + a: 10 + b: 5 + expect: + success: true + response: + content: + - type: "text" + text_contains: "\"result\": 15" + + - name: "Calculator: Multiply Previous Result" + action: tools/call + params: + name: "calculator" + arguments: + operation: "multiply" + a: 15 + b: 2 + expect: + success: true + response: + content: + - type: "text" + text_contains: "\"result\": 30" + + # Weather Tool Tests + - name: "Weather: Get Default Location" + action: tools/call + params: + name: "weather" + arguments: {} + expect: + success: true + response: + content: + - type: "text" + text_contains: "San Francisco" + + - name: "Weather: Get Weather for New York" + action: tools/call + params: + name: "weather" + arguments: + location: "New York" + expect: + success: true + response: + content: + - type: "text" + text_contains: "\"location\": \"New York\"" + + - name: "Weather: Get Weather for London" + action: tools/call + params: + name: "weather" + arguments: + location: "London" + expect: + success: true + response: + content: + - type: "text" + text_contains: "temperature" + + # System Info Tool Tests + - name: "System Info: Get Runtime Information" + action: tools/call + params: + name: "system_info" + arguments: {} + expect: + success: true + response: + content: + - type: "text" + text_contains: "\"sdk\": \"pmcp\"" + + - name: "System Info: Verify Architecture" + action: tools/call + params: + name: "system_info" + arguments: {} + expect: + success: true + response: + content: + - type: "text" + text_contains: "wasm" + + # Mixed Operations + - name: "Calculate Temperature Conversion: (72°F - 32) * 5/9" + action: tools/call + params: + name: "calculator" + arguments: + operation: "subtract" + a: 72 + b: 32 + expect: + success: true + response: + content: + - type: "text" + text_contains: "\"result\": 40" + + - name: "Continue Temperature Conversion: 40 * 5" + action: tools/call + params: + name: "calculator" + arguments: + operation: "multiply" + a: 40 + b: 5 + expect: + success: true + response: + content: + - type: "text" + text_contains: "\"result\": 200" + + - name: "Complete Temperature Conversion: 200 / 9" + action: tools/call + params: + name: "calculator" + arguments: + operation: "divide" + a: 200 + b: 9 + expect: + success: true + response: + content: + - type: "text" + text_contains: "22.22" # ~22.22°C + + # Edge Cases + - name: "Calculator: Large Numbers" + action: tools/call + params: + name: "calculator" + arguments: + operation: "multiply" + a: 1000000 + b: 1000000 + expect: + success: true + response: + content: + - type: "text" + text_contains: "1000000000000" # 1 trillion + + - name: "Weather: Empty String Location" + action: tools/call + params: + name: "weather" + arguments: + location: "" + expect: + success: true + # Should still return some weather data + + # Notifications Test (should be handled silently) + - name: "Send Initialized Notification" + action: notifications/initialized + params: {} + expect: + success: true + # Notifications don't return responses \ No newline at end of file diff --git a/examples/wasm-mcp-server/test-scenarios/calculator-simple.json b/examples/wasm-mcp-server/test-scenarios/calculator-simple.json new file mode 100644 index 00000000..bbc849c1 --- /dev/null +++ b/examples/wasm-mcp-server/test-scenarios/calculator-simple.json @@ -0,0 +1,111 @@ +{ + "name": "Simple Calculator Test", + "description": "Basic calculator operations test", + "timeout": 15, + "steps": [ + { + "name": "List available tools", + "operation": { + "type": "list_tools" + }, + "assertions": [ + { + "type": "success" + }, + { + "type": "array_length", + "path": "tools", + "greater_than": 0 + } + ] + }, + { + "name": "Addition: 2 + 3", + "operation": { + "type": "tool_call", + "tool": "calculator", + "arguments": { + "operation": "add", + "a": 2, + "b": 3 + } + }, + "assertions": [ + { + "type": "success" + }, + { + "type": "contains", + "path": "result", + "value": "5" + } + ] + }, + { + "name": "Multiplication: 6 * 7", + "operation": { + "type": "tool_call", + "tool": "calculator", + "arguments": { + "operation": "multiply", + "a": 6, + "b": 7 + } + }, + "assertions": [ + { + "type": "success" + }, + { + "type": "contains", + "path": "result", + "value": "42" + } + ] + }, + { + "name": "Division: 100 / 4", + "operation": { + "type": "tool_call", + "tool": "calculator", + "arguments": { + "operation": "divide", + "a": 100, + "b": 4 + } + }, + "assertions": [ + { + "type": "success" + }, + { + "type": "contains", + "path": "result", + "value": "25" + } + ] + }, + { + "name": "Subtraction: 10 - 3", + "operation": { + "type": "tool_call", + "tool": "calculator", + "arguments": { + "operation": "subtract", + "a": 10, + "b": 3 + } + }, + "assertions": [ + { + "type": "success" + }, + { + "type": "contains", + "path": "result", + "value": "7" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/wasm-mcp-server/test-scenarios/calculator-test.yaml b/examples/wasm-mcp-server/test-scenarios/calculator-test.yaml new file mode 100644 index 00000000..551cb80d --- /dev/null +++ b/examples/wasm-mcp-server/test-scenarios/calculator-test.yaml @@ -0,0 +1,205 @@ +name: Calculator Tool Test Suite +description: Comprehensive tests for the calculator tool functionality +timeout: 30 + +steps: + # Test Addition + - name: Test Addition - 10 + 5 + operation: + type: tool_call + tool: calculator + arguments: + operation: add + a: 10 + b: 5 + assertions: + - type: success + - type: contains + path: result + value: '"result": 15' + + - name: Test Addition - Negative numbers + operation: + type: tool_call + tool: calculator + arguments: + operation: add + a: -5 + b: 3 + assertions: + - type: success + - type: contains + path: result + value: '"result": -2' + + - name: Test Addition - Decimals + operation: + type: tool_call + tool: calculator + arguments: + operation: add + a: 3.5 + b: 2.5 + assertions: + - type: success + - type: contains + path: result + value: '"result": 6' + + # Test Subtraction + - name: Test Subtraction - 20 - 8 + operation: + type: tool_call + tool: calculator + arguments: + operation: subtract + a: 20 + b: 8 + assertions: + - type: success + - type: contains + path: result + value: '"result": 12' + + - name: Test Subtraction - Negative result + operation: + type: tool_call + tool: calculator + arguments: + operation: subtract + a: 5 + b: 10 + assertions: + - type: success + - type: contains + path: result + value: '"result": -5' + + # Test Multiplication + - name: Test Multiplication - 4 * 7 + operation: + type: tool_call + tool: calculator + arguments: + operation: multiply + a: 4 + b: 7 + assertions: + - type: success + - type: contains + path: result + value: '"result": 28' + + - name: Test Multiplication - By zero + operation: + type: tool_call + tool: calculator + arguments: + operation: multiply + a: 0 + b: 100 + assertions: + - type: success + - type: contains + path: result + value: '"result": 0' + + - name: Test Multiplication - Decimals + operation: + type: tool_call + tool: calculator + arguments: + operation: multiply + a: 7.25 + b: 4 + assertions: + - type: success + - type: contains + path: result + value: '"result": 29' + + # Test Division + - name: Test Division - 20 / 4 + operation: + type: tool_call + tool: calculator + arguments: + operation: divide + a: 20 + b: 4 + assertions: + - type: success + - type: contains + path: result + value: '"result": 5' + + - name: Test Division - With decimal result + operation: + type: tool_call + tool: calculator + arguments: + operation: divide + a: 10 + b: 3 + assertions: + - type: success + - type: contains + path: result + value: '3.333' # Check for partial match + + - name: Test Division - Negative numbers + operation: + type: tool_call + tool: calculator + arguments: + operation: divide + a: -15 + b: 3 + assertions: + - type: success + - type: contains + path: result + value: '"result": -5' + + # Error Cases + - name: Test Division by Zero + operation: + type: tool_call + tool: calculator + arguments: + operation: divide + a: 10 + b: 0 + assertions: + - type: failure + - type: contains + path: error + value: "Division by zero" + + - name: Test Invalid Operation + operation: + type: tool_call + tool: calculator + arguments: + operation: power + a: 2 + b: 3 + assertions: + - type: failure + - type: contains + path: error + value: "Unknown operation" + + - name: Test Missing Parameter + operation: + type: tool_call + tool: calculator + arguments: + operation: add + a: 10 + # b is missing + assertions: + - type: failure + - type: contains + path: error + value: "parameter 'b' is required" \ No newline at end of file diff --git a/examples/wasm-mcp-server/test-scenarios/minimal-test.json b/examples/wasm-mcp-server/test-scenarios/minimal-test.json new file mode 100644 index 00000000..7d45bb7e --- /dev/null +++ b/examples/wasm-mcp-server/test-scenarios/minimal-test.json @@ -0,0 +1,13 @@ +{ + "name": "Minimal Test", + "description": "Just list tools", + "timeout": 10, + "steps": [ + { + "name": "List tools", + "operation": { + "type": "list_tools" + } + } + ] +} \ No newline at end of file diff --git a/src/shared/streamable_http.rs b/src/shared/streamable_http.rs index 5f2b6b87..479c9596 100644 --- a/src/shared/streamable_http.rs +++ b/src/shared/streamable_http.rs @@ -366,11 +366,63 @@ impl StreamableHttpTransport { )))); } + // Get response metadata before potentially consuming the response + let status_code = response.status(); let content_type = response .headers() .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) - .unwrap_or(""); + .unwrap_or("") + .to_string(); + let content_length = response + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + + // If it's a 200 response with either Content-Length: 0 or no Content-Type + // (often happens with notifications), check if it's actually empty + if status_code == 200 && (content_length == Some(0) || content_type.is_empty()) { + // Check if there's actually no body by consuming it + let body = response + .bytes() + .await + .map_err(|e| Error::Transport(TransportError::Request(e.to_string())))?; + + if body.is_empty() { + // Empty 200 response (e.g., for notifications) - just return Ok + return Ok(()); + } + + // If there was a body but no content-type, that's an error + if content_type.is_empty() { + return Err(Error::Transport(TransportError::Request( + "Response has body but no Content-Type header".to_string(), + ))); + } + + // We have a body with content, parse it as JSON + // Try to parse as array first (batch response - JSON-RPC 2.0) + if let Ok(batch) = serde_json::from_slice::>(&body) { + for json_msg in batch { + let json_str = serde_json::to_string(&json_msg).map_err(|e| { + Error::Transport(TransportError::Deserialization(e.to_string())) + })?; + // Use JSON-RPC compatibility layer + let msg = crate::shared::StdioTransport::parse_message(json_str.as_bytes())?; + self.sender + .send(msg) + .map_err(|e| Error::Transport(TransportError::Send(e.to_string())))?; + } + } else { + // Single message - use JSON-RPC compatibility layer + let message = crate::shared::StdioTransport::parse_message(&body)?; + self.sender + .send(message) + .map_err(|e| Error::Transport(TransportError::Send(e.to_string())))?; + } + return Ok(()); + } if content_type.contains(APPLICATION_JSON) { // JSON response (single or batch) @@ -430,7 +482,7 @@ impl StreamableHttpTransport { } } }); - } else if response.status().as_u16() == 202 { + } else if status_code.as_u16() == 202 { // 202 Accepted with no body is valid return Ok(()); } else {