diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 0000000..01a2e0b --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,73 @@ +name: Integration testing + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: write + actions: read + +jobs: + discover-testcases: + runs-on: ubuntu-latest + outputs: + testcases: ${{ steps.discover.outputs.testcases }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Discover testcases + id: discover + run: | + # Find all testcase folders (excluding common folders like README, etc.) + testcase_dirs=$(find testcases -maxdepth 1 -type d -name "*-*" | sed 's|testcases/||' | sort) + + echo "Found testcase directories:" + echo "$testcase_dirs" + + # Convert to JSON array for matrix + testcases_json=$(echo "$testcase_dirs" | jq -R -s -c 'split("\n")[:-1]') + echo "testcases=$testcases_json" >> $GITHUB_OUTPUT + + integration-tests: + needs: [discover-testcases] + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm + strategy: + fail-fast: false + matrix: + testcase: ${{ fromJson(needs.discover-testcases.outputs.testcases) }} + environment: [alpha] + + name: "${{ matrix.testcase }} / ${{ matrix.environment }}" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: uv sync + + - name: Run testcase + env: + UIPATH_TENANT_ID: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TENANT_ID || secrets.CLOUD_TENANT_ID }} + UIPATH_FOLDER_KEY: ${{ matrix.environment == 'alpha' && secrets.ALPHA_FOLDER_KEY || secrets.CLOUD_FOLDER_KEY }} + PAT_TOKEN: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_PAT_TOKEN || secrets.CLOUD_TEST_PAT_TOKEN }} + CLIENT_ID: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_ID || secrets.CLOUD_TEST_CLIENT_ID }} + CLIENT_SECRET: ${{ matrix.environment == 'alpha' && secrets.ALPHA_TEST_CLIENT_SECRET || secrets.CLOUD_TEST_CLIENT_SECRET }} + BASE_URL: ${{ matrix.environment == 'alpha' && secrets.ALPHA_BASE_URL || secrets.CLOUD_BASE_URL }} + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_RUN_ID: ${{ github.run_number }} + working-directory: testcases/${{ matrix.testcase }} + run: | + echo "Running testcase: ${{ matrix.testcase }}" + echo "Environment: ${{ matrix.environment }}" + echo "Working directory: $(pwd)" + + # Execute the testcase run script directly + bash run.sh \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f40b3ca..eddc6f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dev = [ [tool.ruff] line-length = 88 indent-width = 4 +exclude = ["testcases/**"] [tool.ruff.lint] select = ["E", "F", "B", "I"] @@ -61,12 +62,8 @@ skip-magic-trailing-comma = false line-ending = "auto" [tool.mypy] -plugins = [ - "pydantic.mypy" -] -exclude = [ - "samples/.*" -] +plugins = ["pydantic.mypy"] +exclude = ["samples/.*", "testcases/.*"] follow_imports = "silent" warn_redundant_casts = true diff --git a/testcases/ground-to-cloud/mcp.json b/testcases/ground-to-cloud/mcp.json new file mode 100644 index 0000000..3aaa34e --- /dev/null +++ b/testcases/ground-to-cloud/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "mathmcp-PRNUMBER": { + "transport": "stdio", + "command": "python", + "args": ["server.py"] + } + } +} diff --git a/testcases/ground-to-cloud/pyproject.toml b/testcases/ground-to-cloud/pyproject.toml new file mode 100644 index 0000000..acbaa95 --- /dev/null +++ b/testcases/ground-to-cloud/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "mathmcp" +version = "0.0.1" +description = "Description for mathmcp project" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +dependencies = [ + "mcp>=1.11.0", + "retry>=0.9.2", + "uipath-mcp", +] +requires-python = ">=3.11" + +[tool.uv.sources] +uipath-mcp = { path = "../../", editable = true } diff --git a/testcases/ground-to-cloud/run.sh b/testcases/ground-to-cloud/run.sh new file mode 100644 index 0000000..bfb9484 --- /dev/null +++ b/testcases/ground-to-cloud/run.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +cleanup() { + echo "Cleaning up..." + if [ ! -z "$MCP_PID" ]; then + echo "Stopping MCP server (PID: $MCP_PID)..." + kill $MCP_PID 2>/dev/null || true + wait $MCP_PID 2>/dev/null || true + fi +} + +# Set trap to cleanup on script exit +trap cleanup EXIT + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +# Generate dynamic values +PR_NUMBER=${GITHUB_PR_NUMBER:-"local"} +UNIQUE_ID=$(cat /proc/sys/kernel/random/uuid) +MCP_SERVER_NAME="mathmcp-${PR_NUMBER}" + +echo "Updating uipath.json with dynamic values... PR Number: $PR_NUMBER, MCP Server Name: $MCP_SERVER_NAME, Unique ID: $UNIQUE_ID" + +# Remove empty tenantId line if exists +sed -i '/^UIPATH_TENANT_ID=[[:space:]]*$/d' .env + +# Replace placeholders in uipath.json using sed +sed -i "s/PRNUMBER/$PR_NUMBER/g" mcp.json +sed -i "s/PRNUMBER/$PR_NUMBER/g" uipath.json +sed -i "s/163f06b8-31e6-4639-aa31-ae4a88968a92/$UNIQUE_ID/g" uipath.json + +echo "Packing agent..." +uv run uipath pack + +# uipath run will block, so we run it in the background +echo "Starting MCP server in background..." +uv run uipath run "$MCP_SERVER_NAME" > mcp_server_output.log 2>&1 & +MCP_PID=$! + +echo "MCP server started with PID: $MCP_PID" +echo "Waiting a moment for server to initialize..." +sleep 20 + +echo "Running integration test..." +MCP_SERVER_NAME="$MCP_SERVER_NAME" uv run test.py + +# Capture test exit code +TEST_EXIT_CODE=$? + +echo "====== MCP Server Output ======" +cat mcp_server_output.log + +echo "Test completed with exit code: $TEST_EXIT_CODE" + +# Cleanup will happen automatically due to trap +exit $TEST_EXIT_CODE \ No newline at end of file diff --git a/testcases/ground-to-cloud/server.py b/testcases/ground-to-cloud/server.py new file mode 100644 index 0000000..6ffcd49 --- /dev/null +++ b/testcases/ground-to-cloud/server.py @@ -0,0 +1,457 @@ +import math +from typing import Any, Dict, List + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Advanced Math Operations Server") + + +# Basic arithmetic operations +@mcp.tool() +def add(a: float, b: float) -> float: + """Add two numbers together. + + Args: + a: First number + b: Second number + + Returns: + Sum of a and b + """ + return a + b + + +@mcp.tool() +def subtract(a: float, b: float) -> float: + """Subtract the second number from the first. + + Args: + a: Number to subtract from + b: Number to subtract + + Returns: + Difference of a and b + """ + return a - b + + +@mcp.tool() +def multiply(a: float, b: float) -> float: + """Multiply two numbers together. + + Args: + a: First number + b: Second number + + Returns: + Product of a and b + """ + return a * b + + +@mcp.tool() +def divide(a: float, b: float) -> float: + """Divide the first number by the second. + + Args: + a: Numerator + b: Denominator + + Returns: + Quotient of a and b + + Raises: + ValueError: If b is zero + """ + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + + +# Power and root operations +@mcp.tool() +def power(base: float, exponent: float) -> float: + """Calculate the base raised to the exponent power. + + Args: + base: The base number + exponent: The exponent + + Returns: + The result of base^exponent + """ + return math.pow(base, exponent) + + +@mcp.tool() +def square_root(number: float) -> float: + """Calculate the square root of a number. + + Args: + number: The number to find the square root of + + Returns: + The square root of the number + + Raises: + ValueError: If number is negative + """ + if number < 0: + raise ValueError("Cannot calculate the square root of a negative number") + return math.sqrt(number) + + +@mcp.tool() +def nth_root(number: float, n: float) -> float: + """Calculate the nth root of a number. + + Args: + number: The number to find the root of + n: The root value + + Returns: + The nth root of the number + + Raises: + ValueError: If number is negative and n is even + """ + if number < 0 and n % 2 == 0: + raise ValueError("Cannot calculate even root of a negative number") + return math.pow(number, 1 / n) + + +# Trigonometric functions +@mcp.tool() +def sin(angle_degrees: float) -> float: + """Calculate the sine of an angle in degrees. + + Args: + angle_degrees: Angle in degrees + + Returns: + Sine of the angle + """ + return math.sin(math.radians(angle_degrees)) + + +@mcp.tool() +def cos(angle_degrees: float) -> float: + """Calculate the cosine of an angle in degrees. + + Args: + angle_degrees: Angle in degrees + + Returns: + Cosine of the angle + """ + return math.cos(math.radians(angle_degrees)) + + +@mcp.tool() +def tan(angle_degrees: float) -> float: + """Calculate the tangent of an angle in degrees. + + Args: + angle_degrees: Angle in degrees + + Returns: + Tangent of the angle + + Raises: + ValueError: If angle is 90 degrees + k*180 degrees (undefined tangent) + """ + # Check for undefined tangent values + if angle_degrees % 180 == 90: + raise ValueError(f"Tangent is undefined at {angle_degrees} degrees") + return math.tan(math.radians(angle_degrees)) + + +# Logarithmic functions +@mcp.tool() +def log10(number: float) -> float: + """Calculate the base-10 logarithm of a number. + + Args: + number: The input number + + Returns: + The base-10 logarithm of the number + + Raises: + ValueError: If number is less than or equal to zero + """ + if number <= 0: + raise ValueError("Cannot calculate logarithm of zero or negative number") + return math.log10(number) + + +@mcp.tool() +def natural_log(number: float) -> float: + """Calculate the natural logarithm (base e) of a number. + + Args: + number: The input number + + Returns: + The natural logarithm of the number + + Raises: + ValueError: If number is less than or equal to zero + """ + if number <= 0: + raise ValueError("Cannot calculate logarithm of zero or negative number") + return math.log(number) + + +@mcp.tool() +def log_base(number: float, base: float) -> float: + """Calculate the logarithm of a number with a custom base. + + Args: + number: The input number + base: The logarithm base + + Returns: + The logarithm of the number with the specified base + + Raises: + ValueError: If number or base is less than or equal to zero, or if base is 1 + """ + if number <= 0: + raise ValueError("Cannot calculate logarithm of zero or negative number") + if base <= 0: + raise ValueError("Logarithm base must be greater than zero") + if base == 1: + raise ValueError("Logarithm base cannot be 1") + return math.log(number, base) + + +# Statistical operations +@mcp.tool() +def mean(numbers: List[float]) -> float: + """Calculate the arithmetic mean (average) of a list of numbers. + + Args: + numbers: List of numbers + + Returns: + The mean of the numbers + + Raises: + ValueError: If the list is empty + """ + if not numbers: + raise ValueError("Cannot calculate mean of an empty list") + return sum(numbers) / len(numbers) + + +@mcp.tool() +def median(numbers: List[float]) -> float: + """Calculate the median of a list of numbers. + + Args: + numbers: List of numbers + + Returns: + The median of the numbers + + Raises: + ValueError: If the list is empty + """ + if not numbers: + raise ValueError("Cannot calculate median of an empty list") + + sorted_numbers = sorted(numbers) + n = len(sorted_numbers) + + if n % 2 == 0: + # Even number of elements, take average of middle two + return (sorted_numbers[n // 2 - 1] + sorted_numbers[n // 2]) / 2 + else: + # Odd number of elements, take the middle one + return sorted_numbers[n // 2] + + +@mcp.tool() +def standard_deviation(numbers: List[float], sample: bool = True) -> float: + """Calculate the standard deviation of a list of numbers. + + Args: + numbers: List of numbers + sample: If True, calculate sample standard deviation, otherwise population + + Returns: + The standard deviation of the numbers + + Raises: + ValueError: If the list is empty or has only one element for sample calculation + """ + if not numbers: + raise ValueError("Cannot calculate standard deviation of an empty list") + + n = len(numbers) + + if sample and n <= 1: + raise ValueError("Sample standard deviation requires at least two values") + + avg = sum(numbers) / n + variance = sum((x - avg) ** 2 for x in numbers) + + if sample: + # Sample standard deviation (Bessel's correction) + return math.sqrt(variance / (n - 1)) + else: + # Population standard deviation + return math.sqrt(variance / n) + + +# Complex number operations +@mcp.tool() +def complex_add( + a_real: float, a_imag: float, b_real: float, b_imag: float +) -> Dict[str, float]: + """Add two complex numbers. + + Args: + a_real: Real part of first complex number + a_imag: Imaginary part of first complex number + b_real: Real part of second complex number + b_imag: Imaginary part of second complex number + + Returns: + Dictionary with real and imaginary parts of the result + """ + return {"real": a_real + b_real, "imaginary": a_imag + b_imag} + + +@mcp.tool() +def complex_multiply( + a_real: float, a_imag: float, b_real: float, b_imag: float +) -> Dict[str, float]: + """Multiply two complex numbers. + + Args: + a_real: Real part of first complex number + a_imag: Imaginary part of first complex number + b_real: Real part of second complex number + b_imag: Imaginary part of second complex number + + Returns: + Dictionary with real and imaginary parts of the result + """ + real_part = a_real * b_real - a_imag * b_imag + imag_part = a_real * b_imag + a_imag * b_real + + return {"real": real_part, "imaginary": imag_part} + + +# Unit conversion tools +@mcp.tool() +def convert_temperature(value: float, from_unit: str, to_unit: str) -> float: + """Convert temperature between Celsius, Fahrenheit, and Kelvin. + + Args: + value: The temperature value to convert + from_unit: The source unit ('celsius', 'fahrenheit', or 'kelvin') + to_unit: The target unit ('celsius', 'fahrenheit', or 'kelvin') + + Returns: + The converted temperature value + + Raises: + ValueError: If units are not recognized + """ + from_unit = from_unit.lower() + to_unit = to_unit.lower() + + valid_units = {"celsius", "fahrenheit", "kelvin"} + if from_unit not in valid_units or to_unit not in valid_units: + raise ValueError(f"Units must be one of: {', '.join(valid_units)}") + + # Convert to Celsius first (as intermediate step) + if from_unit == "celsius": + celsius = value + elif from_unit == "fahrenheit": + celsius = (value - 32) * 5 / 9 + else: # kelvin + celsius = value - 273.15 + + # Convert from Celsius to target unit + if to_unit == "celsius": + return celsius + elif to_unit == "fahrenheit": + return celsius * 9 / 5 + 32 + else: # kelvin + return celsius + 273.15 + + +@mcp.tool() +def solve_quadratic(a: float, b: float, c: float) -> Dict[str, Any]: + """Solve a quadratic equation of the form ax² + bx + c = 0. + + Args: + a: Coefficient of x² + b: Coefficient of x + c: Constant term + + Returns: + Dictionary with solution information + + Raises: + ValueError: If a is zero (not a quadratic equation) + """ + if a == 0: + raise ValueError("Coefficient 'a' cannot be zero for a quadratic equation") + + discriminant = b**2 - 4 * a * c + + result = {"discriminant": discriminant, "equation": f"{a}x² + {b}x + {c} = 0"} + + if discriminant > 0: + # Two real solutions + x1 = (-b + math.sqrt(discriminant)) / (2 * a) + x2 = (-b - math.sqrt(discriminant)) / (2 * a) + result["solution_type"] = "two real solutions" + result["solutions"] = [x1, x2] + elif discriminant == 0: + # One real solution (repeated) + x = -b / (2 * a) + result["solution_type"] = "one real solution (repeated)" + result["solutions"] = [x] + else: + # Complex solutions + real_part = -b / (2 * a) + imag_part = math.sqrt(abs(discriminant)) / (2 * a) + result["solution_type"] = "two complex solutions" + result["solutions"] = [ + {"real": real_part, "imaginary": imag_part}, + {"real": real_part, "imaginary": -imag_part}, + ] + + return result + + +# Constants +@mcp.tool() +def get_constants() -> Dict[str, float]: + """Return a dictionary of common mathematical constants. + + Returns: + Dictionary of mathematical constants with their values + """ + return { + "pi": math.pi, + "e": math.e, + "tau": math.tau, # 2π + "phi": (1 + math.sqrt(5)) / 2, # Golden ratio + "euler_mascheroni": 0.57721566490153286, + "sqrt2": math.sqrt(2), + "sqrt3": math.sqrt(3), + "ln2": math.log(2), + "ln10": math.log(10), + } + + +# Run the server when the script is executed +if __name__ == "__main__": + mcp.run() diff --git a/testcases/ground-to-cloud/test.py b/testcases/ground-to-cloud/test.py new file mode 100644 index 0000000..7fc67bf --- /dev/null +++ b/testcases/ground-to-cloud/test.py @@ -0,0 +1,76 @@ +import asyncio +import os +import sys + +from dotenv import load_dotenv +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from retry import retry + + +def get_required_env_var(name: str) -> str: + """Get required environment variable or raise an error if not set.""" + value = os.getenv(name) + if not value: + raise ValueError(f"Required environment variable {name} is not set") + return value + +@retry(tries=3, delay=2, backoff=2) +async def call_add_tool(): + # Load configuration from environment variables + base_url = get_required_env_var("BASE_URL") + folder_key = get_required_env_var("UIPATH_FOLDER_KEY") + token = get_required_env_var("UIPATH_ACCESS_TOKEN") + mcp_server_name = get_required_env_var("MCP_SERVER_NAME") + + # Construct the MCP server URL + mcp_url = f"{base_url}/agenthub_/mcp/{folder_key}/{mcp_server_name}" + + try: + # Use streamable HTTP client to connect to the MCP server + async with streamablehttp_client(mcp_url, headers={ 'Authorization': f'Bearer {token}' }) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the session + await session.initialize() + + # List available tools + tools_result = await session.list_tools() + available_tools = [tool.name for tool in tools_result.tools] + expected_available_tools = [ + "add", "subtract", "multiply", "divide", "power", "square_root", "nth_root", + "sin", "cos", "tan", "log10", "natural_log", "log_base", "mean", "median", "standard_deviation", + "complex_add", "complex_multiply", "convert_temperature", "solve_quadratic", "get_constants" + ] + + print (f"Available tools: {available_tools}") + + if set(available_tools) != set(expected_available_tools): + raise AssertionError(f"Tool sets don't match. Expected: {set(expected_available_tools)}, Got: {set(available_tools)}") + + # Call the add tool directly + call_tool_result = await session.call_tool(name="add", arguments={"a": 7, "b": 5}) + + expected_result = "12.0" + actual_result = call_tool_result.content[0].text if call_tool_result.content else None + + if actual_result != expected_result: + raise AssertionError(f"Expected {expected_result}, got {actual_result}") + + print("Test completed successfully") + except Exception as e: + print(f"Unexpected error connecting to MCP server: {e}") + raise AssertionError(f"Connection error, {e}") from e + +async def main(): + """Main async function to run the test.""" + try: + load_dotenv() + + await call_add_tool() + except Exception as e: + print(f"Test failed with error: {e}") + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/testcases/ground-to-cloud/uipath.json b/testcases/ground-to-cloud/uipath.json new file mode 100644 index 0000000..c3b292e --- /dev/null +++ b/testcases/ground-to-cloud/uipath.json @@ -0,0 +1,11 @@ +{ + "entryPoints": [ + { + "filePath": "mathmcp-PRNUMBER", + "uniqueId": "163f06b8-31e6-4639-aa31-ae4a88968a92", + "type": "mcpserver", + "input": {}, + "output": {} + } + ] +} \ No newline at end of file