diff --git a/Cargo.lock b/Cargo.lock index 714cb38..6bbf4da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,27 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -254,6 +275,24 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "deadpool" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "displaydoc" version = "0.2.5" @@ -504,6 +543,12 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.3.1" @@ -930,6 +975,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "object" version = "0.36.7" @@ -1540,7 +1595,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "subgraph-mcp" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "axum", @@ -1559,6 +1614,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "wiremock", ] [[package]] @@ -2128,6 +2184,30 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wiremock" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b8b99d4cdbf36b239a9532e31fe4fb8acc38d1897c1761e161550a7dc78e6a" +dependencies = [ + "assert-json-diff", + "async-trait", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 7e21996..87896bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,14 @@ [package] name = "subgraph-mcp" -version = "0.1.0" +version = "0.1.1" edition = "2021" -authors = ["sahra"] +authors = ["GraphOps"] license = "Apache-2.0" +[lib] +name = "subgraph_mcp" +path = "src/lib.rs" + [dependencies] tokio = { version = "1.44.2", features = ["full"] } reqwest = { version = "0.12.15", features = ["json"] } @@ -26,3 +30,6 @@ http = "1.3.1" tracing = "0.1" once_cell = "1.20" prometheus-client = { version = "0.23.1" } + +[dev-dependencies] +wiremock = "0.6" diff --git a/README.md b/README.md index 7aa7590..8825e24 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,59 @@ For example, if `pwd` outputs `/Users/user/subgraph-mcp`, the full command path After adding the configuration, restart Claude Desktop. +#### Request Timeout Configuration (for Local Execution) + +The server includes configurable timeout settings for HTTP requests to The Graph's Gateway. This helps handle complex GraphQL queries that may take longer to execute. + +**Default Behavior** + +By default, the server uses a **120-second timeout** for all HTTP requests to The Graph's Gateway. This provides a good balance between allowing complex queries to complete while preventing indefinite hangs. + +**Custom Timeout Configuration** + +You can customize the timeout in several ways: + +Option 1: Environment Variable (Recommended) + +Set the `SUBGRAPH_REQUEST_TIMEOUT_SECONDS` environment variable: + +```bash +export SUBGRAPH_REQUEST_TIMEOUT_SECONDS=300 # 5 minutes +``` + +For Claude Desktop configuration: + +```json +{ + "mcpServers": { + "subgraph-mcp": { + "command": "/path/to/subgraph-mcp", + "env": { + "GATEWAY_API_KEY": "YOUR_GATEWAY_API_KEY", + "SUBGRAPH_REQUEST_TIMEOUT_SECONDS": "300" + } + } + } +} +``` + +Option 2: Programmatic Configuration (for developers) + +When building applications with the server library: + +```rust +use std::time::Duration; +use subgraph_mcp::SubgraphServer; + +// Use default timeout (120 seconds) +let server = SubgraphServer::new(); + +// Use custom timeout +let server = SubgraphServer::with_timeout(Duration::from_secs(300)); +``` + +**Note**: Very long timeouts (>5 minutes) should be used cautiously as they may impact overall application responsiveness. + **Important**: Claude Desktop may not automatically utilize server resources. To ensure proper functionality, manually add `Subgraph Server Instructions` resource to your chat context by clicking on the context menu and adding the resource. ## Available Tools @@ -263,6 +316,39 @@ The following application-specific metrics are exposed: Additionally, the `axum-prometheus` library provides standard HTTP request metrics for the metrics server itself (prefixed with `http_`). +## Troubleshooting + +### Request Timeout Errors + +If you encounter "Request timed out" or "MCP error -32001" errors, this typically indicates that GraphQL queries are taking longer than the configured timeout to complete. + +**Solutions:** + +**If you're running your own local server instance:** + +1. **Increase the timeout** using the `SUBGRAPH_REQUEST_TIMEOUT_SECONDS` environment variable: + ```bash + export SUBGRAPH_REQUEST_TIMEOUT_SECONDS=300 # 5 minutes + ``` + +**If you're using the remote hosted service:** + +1. **Contact support** - Timeout settings are managed by the hosted service and cannot be customized by end users. + +**For all users:** + +2. **Check query complexity** - Very complex queries with large result sets may need longer timeouts or query optimization. + +3. **Verify The Graph Gateway status** - Occasional timeout issues may be due to temporary Gateway performance issues. + +**Default Timeout**: Local server instances use a 120-second timeout by default (increased from 30 seconds in earlier versions). Remote hosted service timeout settings may differ. + +### Common Issues + +- **"API key not found"**: Ensure your `GATEWAY_API_KEY` environment variable is set correctly +- **"Configuration error"**: Check that your Gateway API key is valid and has appropriate permissions +- **Connection refused**: Verify the server is running and accessible on the configured port + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..fec2011 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,165 @@ +#!/bin/bash + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Check if we're in a git repository +if ! git rev-parse --git-dir > /dev/null 2>&1; then + print_error "Not in a git repository" + exit 1 +fi + +# Check if working directory is clean +if ! git diff-index --quiet HEAD --; then + print_error "Working directory is not clean. Please commit or stash your changes first." + git status --short + exit 1 +fi + +# Extract version from Cargo.toml +if [[ ! -f "Cargo.toml" ]]; then + print_error "Cargo.toml not found in current directory" + exit 1 +fi + +VERSION=$(grep '^version = ' Cargo.toml | head -n1 | sed 's/version = "\(.*\)"/\1/') + +if [[ -z "$VERSION" ]]; then + print_error "Could not extract version from Cargo.toml" + exit 1 +fi + +TAG="v$VERSION" + +print_info "Current version in Cargo.toml: $VERSION" +print_info "Git tag to create: $TAG" + +# Check if tag already exists locally +if git tag -l | grep -q "^$TAG$"; then + print_error "Tag $TAG already exists locally" + print_info "Existing tags:" + git tag -l | grep "^v" | sort -V | tail -5 + exit 1 +fi + +# Check if tag exists on remote +if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then + print_error "Tag $TAG already exists on remote" + print_info "Remote tags:" + git ls-remote --tags origin | grep "refs/tags/v" | sed 's/.*refs\/tags\///' | sort -V | tail -5 + exit 1 +fi + +# Check if we're on main branch +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "$CURRENT_BRANCH" != "main" ]]; then + print_warning "Not on main branch (currently on: $CURRENT_BRANCH)" + read -p "Continue anyway? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + print_info "Aborted" + exit 0 + fi +fi + +# Check if local main is up to date with remote +if git remote get-url origin > /dev/null 2>&1; then + print_info "Fetching latest changes from remote..." + git fetch origin + + LOCAL=$(git rev-parse HEAD) + REMOTE=$(git rev-parse origin/main 2>/dev/null || git rev-parse origin/master 2>/dev/null || echo "") + + if [[ -n "$REMOTE" && "$LOCAL" != "$REMOTE" ]]; then + print_error "Local branch is not up to date with remote" + print_info "Please pull latest changes first: git pull origin main" + exit 1 + fi +fi + +# Run full CI quality checks +print_info "Running quality checks (same as CI)..." + +# Check formatting +print_info "Checking code formatting..." +if ! cargo fmt --all -- --check; then + print_error "Code formatting check failed. Run 'cargo fmt' to fix formatting." + exit 1 +fi + +# Run clippy +print_info "Running clippy lints..." +if ! cargo clippy --all-targets --all-features -- -D warnings; then + print_error "Clippy lints failed. Please fix all warnings before creating a release." + exit 1 +fi + +# Run tests +print_info "Running tests..." +if ! cargo test --verbose; then + print_error "Tests failed. Please fix them before creating a release." + exit 1 +fi + +# Check release build +print_info "Checking release build..." +if ! cargo build --release --verbose; then + print_error "Release build failed. Please fix build errors before creating a release." + exit 1 +fi + +# Test CLI functionality (basic smoke tests) +print_info "Running CLI smoke tests..." +if ! cargo run --release -- --help > /dev/null; then + print_error "CLI help command failed" + exit 1 +fi + +if ! cargo run --release -- --init-config > /dev/null; then + print_error "CLI init-config command failed" + exit 1 +fi + +print_success "All quality checks passed!" + +# Show confirmation +echo +print_info "Ready to create release:" +echo " Version: $VERSION" +echo " Tag: $TAG" +echo " Branch: $CURRENT_BRANCH" +echo " Commit: $(git rev-parse --short HEAD) - $(git log -1 --pretty=format:'%s')" +echo + +read -p "Create and push release tag? [y/N] " -n 1 -r +echo + +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + print_info "Aborted" + exit 0 +fi + +# Create annotated tag +print_info "Creating tag $TAG..." +git tag -a "$TAG" -m "Release $TAG + +$(git log $(git describe --tags --abbrev=0 2>/dev/null || echo "HEAD~10")..HEAD --pretty=format:"- %s" --reverse 2>/dev/null || echo "- Initial release")" + +# Push tag to remote +print_info "Pushing tag to remote..." +git push origin "$TAG" + +print_success "Release $TAG created and pushed!" +print_info "GitHub Actions will now build and publish the release automatically." \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..53b2739 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 + +pub mod constants; +pub mod error; +pub mod metrics; +pub mod server; +pub mod server_helpers; +pub mod types; + +pub use error::SubgraphError; +pub use server::SubgraphServer; diff --git a/src/main.rs b/src/main.rs index 944d581..5132c10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,10 +109,14 @@ async fn start_sse_server(shutdown_token: CancellationToken) -> Result<()> { } async fn metrics_handler(State(registry): State>) -> impl IntoResponse { - tokio::time::sleep(Duration::from_millis(50)).await; - let mut buffer = String::new(); - encode(&mut buffer, ®istry).unwrap(); + if let Err(e) = encode(&mut buffer, ®istry) { + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from(format!("Failed to encode metrics: {}", e))) + .unwrap(); + } + Response::builder() .status(StatusCode::OK) .header( diff --git a/src/server.rs b/src/server.rs index f0aa3b7..ea90e70 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4,8 +4,12 @@ use crate::{constants::SUBGRAPH_SERVER_INSTRUCTIONS, error::SubgraphError, types use reqwest::Client; use rmcp::{model::*, service::RequestContext, tool, Error as McpError, RoleServer, ServerHandler}; use serde_json::json; +use std::time::Duration; #[derive(Clone)] pub struct SubgraphServer { + #[cfg(test)] + pub http_client: Client, + #[cfg(not(test))] pub(crate) http_client: Client, } @@ -17,10 +21,29 @@ impl Default for SubgraphServer { impl SubgraphServer { pub fn new() -> Self { + let timeout_seconds = std::env::var("SUBGRAPH_REQUEST_TIMEOUT_SECONDS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(120); // Default to 120 seconds instead of 30 + + Self::with_timeout(Duration::from_secs(timeout_seconds)) + } + + pub fn with_timeout(timeout: Duration) -> Self { + let client = Client::builder() + .timeout(timeout) + .build() + .expect("Failed to build HTTP client"); + SubgraphServer { - http_client: Client::new(), + http_client: client, } } + + #[cfg(test)] + pub fn get_http_client(&self) -> &Client { + &self.http_client + } } #[tool(tool_box)] diff --git a/tests/timeout_tests.rs b/tests/timeout_tests.rs new file mode 100644 index 0000000..305f56b --- /dev/null +++ b/tests/timeout_tests.rs @@ -0,0 +1,47 @@ +use std::time::Duration; +use subgraph_mcp::server::SubgraphServer; + +#[tokio::test] +async fn test_default_timeout_configuration() { + // Test that default timeout is 120 seconds (not 30) + std::env::remove_var("SUBGRAPH_REQUEST_TIMEOUT_SECONDS"); + + let _server = SubgraphServer::new(); + // If we reach here without panic, the client was created successfully with our default timeout + // This tests that our timeout configuration doesn't cause build failures +} + +#[tokio::test] +async fn test_custom_timeout_configuration() { + // Test that custom timeout can be set + let server = SubgraphServer::with_timeout(Duration::from_secs(60)); + // If we reach here without panic, the client was created successfully with custom timeout + // This tests that our timeout configuration method works + + // Verify we can create server instances without issues + drop(server); // This ensures the server was created successfully +} + +#[tokio::test] +async fn test_environment_variable_timeout_configuration() { + // Test that environment variable configuration works + std::env::set_var("SUBGRAPH_REQUEST_TIMEOUT_SECONDS", "90"); + + let _server = SubgraphServer::new(); + // If we reach here without panic, the environment variable was parsed correctly + + // Clean up environment variable + std::env::remove_var("SUBGRAPH_REQUEST_TIMEOUT_SECONDS"); +} + +#[tokio::test] +async fn test_invalid_environment_variable_fallback() { + // Test that invalid environment variable falls back to default + std::env::set_var("SUBGRAPH_REQUEST_TIMEOUT_SECONDS", "invalid"); + + let _server = SubgraphServer::new(); + // If we reach here without panic, the invalid env var was handled gracefully + + // Clean up environment variable + std::env::remove_var("SUBGRAPH_REQUEST_TIMEOUT_SECONDS"); +}