diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 6ec3238a..77fbb1c3 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -7,6 +7,14 @@ on: - "Cargo.toml" - "crates/**" - "pctx-py/**" + - "scripts/test-mcp-cli.sh" + - ".github/workflows/integration-tests.yaml" + pull_request: + paths: + - "Cargo.toml" + - "crates/**" + - "pctx-py/**" + - "scripts/test-mcp-cli.sh" - ".github/workflows/integration-tests.yaml" workflow_dispatch: @@ -19,7 +27,51 @@ env: CARGO_INCREMENTAL: 0 jobs: - integration-tests: + # CLI integration tests - tests pctx mcp start command + cli-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Install build dependencies + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential libclang-dev libc6-dev + + # Set up Rust with caching + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: false + shared-key: "integration-tests" + + # Build pctx binary in release mode + - name: Build pctx + run: cargo build --release --bin pctx + + # Run the CLI integration test script with built binary + - name: Run CLI integration tests + env: + PCTX_CMD: ./target/release/pctx + run: ./scripts/test-mcp-cli.sh + + # Show server logs on failure + - name: Show server logs + if: failure() + run: | + # Find the temp directory log file + LOG_FILE=$(find /tmp -name "pctx-test.log" 2>/dev/null | head -1) + if [ -n "$LOG_FILE" ] && [ -f "$LOG_FILE" ]; then + echo "=== PCTX MCP Server Logs ===" + cat "$LOG_FILE" + else + echo "No log file found" + fi + + # Python client integration tests - tests HTTP client + python-client-tests: + needs: cli-tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -30,14 +82,15 @@ jobs: sudo apt-get update sudo apt-get install -y build-essential libclang-dev libc6-dev - # Set up Rust + # Set up Rust with caching - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: cache-on-failure: false + shared-key: "integration-tests" - # Build and install pctx server - - name: Build pctx server + # Build pctx binary in release mode (should hit cache from cli-tests) + - name: Build pctx run: cargo build --release --bin pctx # Set up Python @@ -79,7 +132,7 @@ jobs: fi # Run integration tests - - name: Run integration tests + - name: Run Python integration tests working-directory: pctx-py run: uv run pytest --integration -v diff --git a/Cargo.lock b/Cargo.lock index be97dbaf..ee5f0da3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7057,6 +7057,7 @@ name = "pctx_code_mode" version = "0.1.0" dependencies = [ "codegen", + "futures", "pctx_code_execution_runtime", "pctx_config", "pctx_executor", diff --git a/Makefile b/Makefile index 89db679a..e422b333 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help release docs test-python +.PHONY: help release docs test-python test-cli # Default target - show help when running just 'make' .DEFAULT_GOAL := help @@ -10,6 +10,7 @@ help: @echo " make docs - Generate CLI and Python documentation" @echo " make test-python - Run Python client tests" @echo " make test-python-integration - Run Python client tests with integration testing" + @echo " make test-cli - Run CLI integration tests (pctx mcp start)" @echo " make release - Interactive release script (bump version, update changelog)" @echo "" @@ -30,6 +31,10 @@ test-python: test-python-integration: @cd pctx-py && uv run pytest tests/ --integration -v +# Run CLI integration tests +test-cli: + @./scripts/test-mcp-cli.sh + # Interactive release workflow release: @./release.sh diff --git a/crates/pctx/src/commands/mcp/start.rs b/crates/pctx/src/commands/mcp/start.rs index 5693d7e3..18795d99 100644 --- a/crates/pctx/src/commands/mcp/start.rs +++ b/crates/pctx/src/commands/mcp/start.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pctx_code_mode::CodeMode; use pctx_config::Config; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; use pctx_mcp_server::PctxMcpServer; @@ -27,26 +27,36 @@ pub struct StartCmd { impl StartCmd { pub(crate) async fn load_code_mode(cfg: &Config) -> Result { - // Connect to each MCP server and fetch their tool definitions + // Connect to each MCP server and fetch their tool definitions in parallel info!( - "Creating code mode interface for {} upstream MCP servers", + "Creating code mode interface for {} upstream MCP servers (parallel)", cfg.servers.len() ); let mut code_mode = CodeMode::default(); - for server in &cfg.servers { - debug!("Creating code mode interface for {}", &server.name); - if let Err(e) = code_mode.add_server(server).await { - warn!( - err =? e, - server.name =? &server.name, - server.target =? server.display_target(), - "Failed creating creating code mode for `{}` MCP server", - &server.name - ); - } + // Use parallel registration with 30 second timeout per server + let mut results = + pctx_code_mode::parallel_registration::register_servers_parallel(&cfg.servers, 30) + .await; + + // Add successful registrations to code_mode + let registered = results.add_to_code_mode(&mut code_mode); + + // Log failures + for failure in &results.failed { + warn!( + server.name = failure.server_name, + error = failure.error_message, + "Failed creating code mode for MCP server" + ); } + info!( + "Code mode initialized with {}/{} MCP servers", + registered, + cfg.servers.len() + ); + Ok(code_mode) } diff --git a/crates/pctx_code_mode/Cargo.toml b/crates/pctx_code_mode/Cargo.toml index 726c1217..107c3962 100644 --- a/crates/pctx_code_mode/Cargo.toml +++ b/crates/pctx_code_mode/Cargo.toml @@ -19,6 +19,7 @@ serde_json = { workspace = true } serde = { workspace = true } utoipa = { workspace = true } tokio = { workspace = true } +futures = "0.3" schemars = "1.1.0" [dev-dependencies] diff --git a/crates/pctx_code_mode/src/code_mode.rs b/crates/pctx_code_mode/src/code_mode.rs index c3d43079..156c909f 100644 --- a/crates/pctx_code_mode/src/code_mode.rs +++ b/crates/pctx_code_mode/src/code_mode.rs @@ -112,7 +112,7 @@ impl CodeMode { ) -> Result { let registry = callback_registry.unwrap_or_default(); // Format for logging only - let formatted_code = codegen::format::format_ts(&code); + let formatted_code = codegen::format::format_ts(code); debug!( code_from_llm = %code, @@ -183,77 +183,6 @@ impl CodeMode { }) } - // Generates a ToolSet from the given MCP server config - pub async fn add_server(&mut self, server: &ServerConfig) -> Result<()> { - if self.tool_sets.iter().any(|t| t.name == server.name) { - return Err(Error::Message(format!( - "ToolSet with name `{}` already exists, MCP servers must have unique names", - &server.name - ))); - } - - // initialize and list tools - debug!( - "Fetching tools from MCP '{}'({})...", - &server.name, - server.display_target() - ); - let mcp_client = server.connect().await?; - debug!( - "Successfully connected to '{}', inspecting tools...", - server.name - ); - let listed_tools = mcp_client.list_all_tools().await?; - debug!("Found {} tools", listed_tools.len()); - - // convert tools into codegen tools - let mut codegen_tools = vec![]; - for mcp_tool in listed_tools { - let input_schema: codegen::RootSchema = - serde_json::from_value(json!(mcp_tool.input_schema)).map_err(|e| { - Error::Message(format!( - "Failed parsing inputSchema as json schema for tool `{}`: {e}", - &mcp_tool.name - )) - })?; - - let output_schema = if let Some(o) = mcp_tool.output_schema { - Some( - serde_json::from_value::(json!(o)).map_err(|e| { - Error::Message(format!( - "Failed parsing outputSchema as json schema for tool `{}`: {e}", - &mcp_tool.name - )) - })?, - ) - } else { - None - }; - - codegen_tools.push(codegen::Tool::new_mcp( - &mcp_tool.name, - mcp_tool.description.map(String::from), - input_schema, - output_schema, - )?); - } - - let description = mcp_client - .peer_info() - .and_then(|p| p.server_info.title.clone()) - .unwrap_or(format!("MCP server at {}", server.display_target())); - - // add toolset & it's server configuration - self.tool_sets.push(codegen::ToolSet::new( - &server.name, - &description, - codegen_tools, - )); - self.servers.push(server.clone()); - - Ok(()) - } - // Generates a Tool and add it to the correct Toolset from the given callback config pub fn add_callback(&mut self, cfg: &CallbackConfig) -> Result<()> { // find the correct toolset & check for clashes diff --git a/crates/pctx_code_mode/src/lib.rs b/crates/pctx_code_mode/src/lib.rs index 53ee4ef9..96d33122 100644 --- a/crates/pctx_code_mode/src/lib.rs +++ b/crates/pctx_code_mode/src/lib.rs @@ -1,5 +1,6 @@ mod code_mode; pub mod model; +pub mod parallel_registration; pub use code_mode::CodeMode; use codegen::CodegenError; diff --git a/crates/pctx_code_mode/src/parallel_registration.rs b/crates/pctx_code_mode/src/parallel_registration.rs new file mode 100644 index 00000000..b63ffd66 --- /dev/null +++ b/crates/pctx_code_mode/src/parallel_registration.rs @@ -0,0 +1,314 @@ +//! Parallel MCP server registration +//! +//! This module provides functionality to connect to and initialize multiple MCP servers +//! in parallel, significantly reducing startup time compared to sequential initialization. + +use pctx_config::server::ServerConfig; +use tracing::{debug, error, info, warn}; + +/// Result of successfully connecting and initializing an MCP server +pub struct ServerRegistrationResult { + pub server_config: ServerConfig, + pub tool_set: codegen::ToolSet, +} + +/// Error result from attempting to register a server +pub struct ServerRegistrationError { + pub server_name: String, + pub error_message: String, +} + +/// Results from parallel server registration +pub struct ParallelRegistrationResults { + pub successful: Vec, + pub failed: Vec, +} + +impl ParallelRegistrationResults { + /// Add successful registrations to a CodeMode instance, checking for duplicates + /// + /// Returns the number of servers successfully added + pub fn add_to_code_mode(&mut self, code_mode: &mut crate::CodeMode) -> usize { + let mut added = 0; + + // Drain successful results to avoid cloning + for server_result in self.successful.drain(..) { + // Check for duplicate names + if code_mode + .tool_sets + .iter() + .any(|t| t.name == server_result.server_config.name) + { + warn!( + "MCP server '{}' conflicts with existing ToolSet name, skipping", + server_result.server_config.name + ); + self.failed.push(ServerRegistrationError { + server_name: server_result.server_config.name, + error_message: "Conflicts with existing ToolSet name".to_string(), + }); + continue; + } + + code_mode.tool_sets.push(server_result.tool_set); + code_mode.servers.push(server_result.server_config); + added += 1; + } + + added + } +} + +/// Register multiple MCP servers in parallel with a timeout +/// +/// This function spawns parallel tasks to connect to, list tools from, and initialize +/// multiple MCP servers concurrently. This is much faster than sequential registration, +/// especially for stdio-based MCP servers which can be slow to start. +/// +/// # Arguments +/// * `servers` - Slice of ServerConfig to register +/// * `timeout_secs` - Timeout in seconds for each server registration (default: 30) +/// +/// # Returns +/// ParallelRegistrationResults containing successful registrations and failures +pub async fn register_servers_parallel( + servers: &[ServerConfig], + timeout_secs: u64, +) -> ParallelRegistrationResults { + let registration_timeout = std::time::Duration::from_secs(timeout_secs); + let mut tasks = Vec::new(); + + for server in servers { + let server = server.clone(); + let task = tokio::spawn(async move { + let server_name = server.name.clone(); + let result = + tokio::time::timeout(registration_timeout, register_single_server(&server)).await; + + match result { + Ok(Ok(server_result)) => Ok((server_name, server_result)), + Ok(Err(e)) => Err((server_name, e)), + Err(_) => Err(( + server_name, + format!( + "Registration timed out after {}s", + registration_timeout.as_secs() + ), + )), + } + }); + + tasks.push(task); + } + + // Wait for all tasks to complete + let results = futures::future::join_all(tasks).await; + + let mut successful = Vec::new(); + let mut failed = Vec::new(); + + // Process results + for result in results { + match result { + Ok(Ok((server_name, server_result))) => { + successful.push(server_result); + debug!("Successfully registered MCP server: {}", server_name); + } + Ok(Err((server_name, error_msg))) => { + error!( + "Failed to register MCP server {}: {}", + server_name, error_msg + ); + failed.push(ServerRegistrationError { + server_name, + error_message: error_msg, + }); + } + Err(e) => { + error!("Task panicked during server registration: {}", e); + failed.push(ServerRegistrationError { + server_name: "unknown".to_string(), + error_message: format!("Task panicked: {}", e), + }); + } + } + } + + ParallelRegistrationResults { successful, failed } +} + +/// Register servers from a custom conversion function +/// +/// This is useful when you have a different server config type (like HTTP API models) +/// that need to be converted to ServerConfig. The conversion function handles +/// creating ServerConfig instances and extracting server names for error reporting. +pub async fn register_servers_parallel_with_conversion( + servers: &[T], + timeout_secs: u64, + convert_fn: F, +) -> ParallelRegistrationResults +where + T: Clone + Send + 'static, + F: Fn(&T) -> Result<(String, ServerConfig), (String, String)> + Send + Sync + 'static, +{ + let registration_timeout = std::time::Duration::from_secs(timeout_secs); + let mut tasks = Vec::new(); + let convert_fn = std::sync::Arc::new(convert_fn); + + for server in servers { + let server = server.clone(); + let convert_fn = convert_fn.clone(); + + let task = tokio::spawn(async move { + // First convert to ServerConfig + let (server_name, server_config) = match convert_fn(&server) { + Ok(result) => result, + Err((name, err)) => return Err((name, err)), + }; + + // Then register + let result = + tokio::time::timeout(registration_timeout, register_single_server(&server_config)) + .await; + + match result { + Ok(Ok(server_result)) => Ok((server_name, server_result)), + Ok(Err(e)) => Err((server_name, e)), + Err(_) => Err(( + server_name, + format!( + "Registration timed out after {}s", + registration_timeout.as_secs() + ), + )), + } + }); + + tasks.push(task); + } + + // Wait for all tasks to complete + let results = futures::future::join_all(tasks).await; + + let mut successful = Vec::new(); + let mut failed = Vec::new(); + + // Process results + for result in results { + match result { + Ok(Ok((server_name, server_result))) => { + successful.push(server_result); + debug!("Successfully registered MCP server: {}", server_name); + } + Ok(Err((server_name, error_msg))) => { + error!( + "Failed to register MCP server {}: {}", + server_name, error_msg + ); + failed.push(ServerRegistrationError { + server_name, + error_message: error_msg, + }); + } + Err(e) => { + error!("Task panicked during server registration: {}", e); + failed.push(ServerRegistrationError { + server_name: "unknown".to_string(), + error_message: format!("Task panicked: {}", e), + }); + } + } + } + + ParallelRegistrationResults { successful, failed } +} + +/// Connect to and initialize a single MCP server +/// +/// This performs the following slow I/O operations: +/// 1. Connect to the MCP server (especially slow for stdio servers) +/// 2. List all available tools +/// 3. Convert MCP tool schemas to codegen tool schemas +/// 4. Create a ToolSet for the server +/// +/// This function is designed to run in parallel via tokio::spawn. +async fn register_single_server(server: &ServerConfig) -> Result { + // Connect to the MCP server (this is the slow operation) + debug!( + "Connecting to MCP server '{}'({})...", + &server.name, + server.display_target() + ); + let mcp_client = server + .connect() + .await + .map_err(|e| format!("Failed to connect: {e}"))?; + + debug!( + "Successfully connected to '{}', listing tools...", + server.name + ); + + // List all tools (another potentially slow operation) + let listed_tools = mcp_client + .list_all_tools() + .await + .map_err(|e| format!("Failed to list tools: {e}"))?; + + debug!("Found {} tools from '{}'", listed_tools.len(), server.name); + + // Convert MCP tools to codegen tools + let mut codegen_tools = vec![]; + for mcp_tool in listed_tools { + let input_schema: codegen::RootSchema = + serde_json::from_value(serde_json::json!(mcp_tool.input_schema)).map_err(|e| { + format!( + "Failed parsing inputSchema for tool `{}`: {e}", + &mcp_tool.name + ) + })?; + + let output_schema = if let Some(o) = mcp_tool.output_schema { + Some( + serde_json::from_value::(serde_json::json!(o)).map_err( + |e| { + format!( + "Failed parsing outputSchema for tool `{}`: {e}", + &mcp_tool.name + ) + }, + )?, + ) + } else { + None + }; + + codegen_tools.push( + codegen::Tool::new_mcp( + &mcp_tool.name, + mcp_tool.description.map(String::from), + input_schema, + output_schema, + ) + .map_err(|e| format!("Failed to create tool `{}`: {e}", &mcp_tool.name))?, + ); + } + + let description = mcp_client + .peer_info() + .and_then(|p| p.server_info.title.clone()) + .unwrap_or(format!("MCP server at {}", server.display_target())); + + let tool_set = codegen::ToolSet::new(&server.name, &description, codegen_tools); + + info!( + "Successfully initialized MCP server '{}' with {} tools", + server.name, + tool_set.tools.len() + ); + + Ok(ServerRegistrationResult { + server_config: server.clone(), + tool_set, + }) +} diff --git a/crates/pctx_session_server/src/routes.rs b/crates/pctx_session_server/src/routes.rs index 7757736d..d004966d 100644 --- a/crates/pctx_session_server/src/routes.rs +++ b/crates/pctx_session_server/src/routes.rs @@ -5,7 +5,7 @@ use pctx_code_mode::{ CodeMode, model::{GetFunctionDetailsInput, GetFunctionDetailsOutput, ListFunctionsOutput}, }; -use tracing::{debug, error, info}; +use tracing::info; use uuid::Uuid; use crate::extractors::CodeModeSession; @@ -277,26 +277,24 @@ pub(crate) async fn register_servers( }, ))?; - let mut registered = 0; - let mut failed = Vec::new(); - - for server in &request.servers { - let server_name = match server { - McpServerConfig::Http { name, .. } => name, - McpServerConfig::Stdio { name, .. } => name, - }; - - match register_mcp_server(&mut code_mode, server).await { - Ok(()) => { - registered += 1; - debug!("Successfully registered MCP server: {}", server_name); - } - Err(e) => { - error!("Failed to register MCP server {}: {}", server_name, e); - failed.push(server_name.clone()); - } - } - } + // Use parallel server registration with conversion function + let mut results = + pctx_code_mode::parallel_registration::register_servers_parallel_with_conversion( + &request.servers, + 30, // 30 second timeout + convert_mcp_server_config, + ) + .await; + + // Add successful registrations to code_mode + let registered = results.add_to_code_mode(&mut code_mode); + + // Collect failed server names for response + let failed: Vec = results + .failed + .iter() + .map(|f| f.server_name.clone()) + .collect(); // Update the backend with the modified CodeMode state @@ -308,14 +306,22 @@ pub(crate) async fn register_servers( Ok(Json(RegisterMcpServersResponse { registered, failed })) } -async fn register_mcp_server( - code_mode: &mut CodeMode, +/// Convert `McpServerConfig` (HTTP API model) `ServerConfig` (internal config type) +/// +/// Returns (`server_name`, `ServerConfig`) on success, or (`server_name`, `error_message`) on failure +fn convert_mcp_server_config( server: &McpServerConfig, -) -> Result<(), String> { +) -> Result<(String, pctx_config::server::ServerConfig), (String, String)> { + let server_name = match server { + McpServerConfig::Http { name, .. } => name.clone(), + McpServerConfig::Stdio { name, .. } => name.clone(), + }; + let server_config = match server { McpServerConfig::Http { name, url, auth } => { // Parse and validate URL - let parsed_url = url::Url::parse(url).map_err(|e| format!("Invalid URL: {e}"))?; + let parsed_url = url::Url::parse(url) + .map_err(|e| (server_name.clone(), format!("Invalid URL '{url}': {e}")))?; // Create HTTP ServerConfig let mut server_config = @@ -324,7 +330,7 @@ async fn register_mcp_server( // Add auth if provided if let Some(auth_value) = auth { let auth = serde_json::from_value(auth_value.clone()) - .map_err(|e| format!("Invalid auth config: {e}"))?; + .map_err(|e| (server_name.clone(), format!("Invalid auth config: {e}")))?; server_config.set_auth(Some(auth)); } @@ -346,25 +352,5 @@ async fn register_mcp_server( } }; - let server_name = match server { - McpServerConfig::Http { name, .. } => name, - McpServerConfig::Stdio { name, .. } => name, - }; - - code_mode - .add_server(&server_config) - .await - .map_err(|e| format!("Failed to add MCP server: {e}"))?; - - info!( - "Successfully registered MCP server '{}' with {} tools", - server_name, - code_mode - .tool_sets - .iter() - .find(|ts| ts.name == *server_name) - .map_or(0, |ts| ts.tools.len()) - ); - - Ok(()) + Ok((server_name, server_config)) } diff --git a/scripts/test-config.json b/scripts/test-config.json new file mode 100644 index 00000000..8ff7ad52 --- /dev/null +++ b/scripts/test-config.json @@ -0,0 +1,22 @@ +{ + "name": "pctx-cli-test", + "version": "0.1.0", + "servers": [ + { + "name": "memory", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-memory" + ] + }, + { + "name": "everything", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-everything" + ] + } + ] +} \ No newline at end of file diff --git a/scripts/test-mcp-cli.sh b/scripts/test-mcp-cli.sh new file mode 100755 index 00000000..4721a1e2 --- /dev/null +++ b/scripts/test-mcp-cli.sh @@ -0,0 +1,245 @@ +#!/bin/bash +set -e + +# CLI Integration Test Script for pctx mcp start +# Tests the full CLI with both stdio and HTTP MCP servers + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Create temp directory for test files +TEST_DIR=$(mktemp -d) + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Cleanup function +cleanup() { + echo -e "${YELLOW}Cleaning up...${NC}" + if [ -f "$TEST_DIR/pctx-test.pid" ]; then + kill $(cat "$TEST_DIR/pctx-test.pid") 2>/dev/null || true + fi + # Kill any processes on port 8080 (may not exist, so ignore errors) + lsof -ti:8080 2>/dev/null | xargs kill -9 2>/dev/null || true + rm -rf "$TEST_DIR" +} + +trap cleanup EXIT + +echo -e "${GREEN}Starting CLI Integration Tests${NC}" +echo "======================================" + +# Test 1: Start server (tests HTTP endpoint with parallel MCP initialization) +echo -e "\n${YELLOW}Test 1: Starting pctx MCP server${NC}" + +# Create test config with both stdio and HTTP MCP servers +# This tests parallel initialization with mixed transport types +cat > "$TEST_DIR/pctx-test.json" <<'EOF' +{ + "name": "pctx-test", + "version": "0.1.0", + "servers": [ + { + "name": "memory", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"] + }, + { + "name": "time", + "url": "https://mcp.run/time" + } + ] +} +EOF + +# Use PCTX_CMD if set (for CI with pre-built binary), otherwise use cargo run +PCTX_CMD="${PCTX_CMD:-cargo run --bin pctx --}" + +cd "$PROJECT_ROOT" +$PCTX_CMD mcp start \ + --config "$TEST_DIR/pctx-test.json" \ + --no-banner \ + > "$TEST_DIR/pctx-test.log" 2>&1 & +echo $! > "$TEST_DIR/pctx-test.pid" + +# Wait for server to be ready (check logs for initialization message) +echo "Waiting for server to start..." +for i in {1..60}; do + # Check if server has initialized by looking for log message + if grep -q "PCTX listening at" "$TEST_DIR/pctx-test.log" 2>/dev/null; then + echo -e "${GREEN}✓ Server started successfully in $i seconds${NC}" + break + fi + if [ $i -eq 60 ]; then + echo -e "${RED}✗ Server failed to start within 60 seconds${NC}" + echo "Server logs:" + cat "$TEST_DIR/pctx-test.log" + exit 1 + fi + sleep 1 +done + +# Give server a moment to be fully ready +sleep 1 + +# Test 2: MCP endpoint check +echo -e "\n${YELLOW}Test 2: MCP endpoint check${NC}" +mcp_response=$(curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}') + +if echo "$mcp_response" | grep -q "result"; then + echo -e "${GREEN}✓ MCP endpoint is responding${NC}" +else + echo -e "${RED}✗ MCP endpoint check failed${NC}" + echo "Response: $mcp_response" + echo "Server logs:" + tail -20 "$TEST_DIR/pctx-test.log" + exit 1 +fi + +# Test 3: List tools via MCP protocol +echo -e "\n${YELLOW}Test 3: List tools from MCP server${NC}" +response=$(curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list" + }') + +echo "Response preview: ${response:0:200}..." + +# Check that we got tools back +if echo "$response" | grep -q '"tools"'; then + echo -e "${GREEN}✓ Successfully listed tools from MCP server${NC}" + + # Count how many tools we found + tool_count=$(echo "$response" | grep -o '"name"' | wc -l | tr -d ' ') + echo " Found $tool_count tools" +else + echo -e "${RED}✗ No tools found in response${NC}" + echo "Full response: $response" + exit 1 +fi + +# Test 4: Call list_functions tool +echo -e "\n${YELLOW}Test 4: Call list_functions${NC}" +list_response=$(curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "list_functions", + "arguments": {} + } + }') + +if echo "$list_response" | grep -q '"result"'; then + echo -e "${GREEN}✓ list_functions called successfully${NC}" + # Check that we got function definitions back + if echo "$list_response" | grep -q '"functions"'; then + echo " Response contains function definitions" + fi +else + echo -e "${RED}✗ list_functions call failed${NC}" + echo "Response: $list_response" + exit 1 +fi + +# Test 5: Call get_function_details tool +echo -e "\n${YELLOW}Test 5: Call get_function_details${NC}" +details_response=$(curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "get_function_details", + "arguments": { + "functions": ["Time.get_current_time"] + } + } + }') + +if echo "$details_response" | grep -q '"result"'; then + echo -e "${GREEN}✓ get_function_details called successfully${NC}" + # Check that we got function details back + if echo "$details_response" | grep -q '"functions"'; then + echo " Response contains function details" + fi +else + echo -e "${RED}✗ get_function_details call failed${NC}" + echo "Response: $details_response" + exit 1 +fi + +# Test 6: Call execute tool +echo -e "\n${YELLOW}Test 6: Call execute with TypeScript code${NC}" +execute_response=$(curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "execute", + "arguments": { + "code": "async function run() { const result = await Time.get_current_time(); return result; }" + } + } + }') + +if echo "$execute_response" | grep -q '"result"'; then + echo -e "${GREEN}✓ execute called successfully${NC}" + # Check that we got a result back with content + if echo "$execute_response" | grep -q '"content"'; then + echo " Code executed and returned result" + fi +else + echo -e "${RED}✗ execute call failed${NC}" + echo "Response: $execute_response" + exit 1 +fi + +# Test 7: Verify MCP server initialization in logs +echo -e "\n${YELLOW}Test 7: Verify MCP server initialization${NC}" +echo "Checking server logs..." + +if grep -q "Creating code mode interface" "$TEST_DIR/pctx-test.log"; then + echo -e "${GREEN}✓ MCP server initialization logged${NC}" + + # Check if any servers were initialized + if grep -q "Successfully initialized MCP server\|PCTX listening" "$TEST_DIR/pctx-test.log"; then + echo -e "${GREEN}✓ Server started successfully${NC}" + fi +else + echo -e "${YELLOW}⚠ MCP server initialization logs not found${NC}" +fi + +# Show summary +echo -e "\n${GREEN}======================================" +echo "✓ All tests PASSED!" +echo -e "======================================${NC}" +echo "" + +if [ "${SHOW_LOGS}" = "1" ]; then + echo -e "${YELLOW}Server logs:${NC}" + echo "---" + tail -20 "$TEST_DIR/pctx-test.log" + echo "---" + echo "" +fi + +exit 0