diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..543d1b6 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +SANDBOX_API_KEY="" + +AUTH0_DOMAIN="" +AUTH0_AUDIENCE="" +AUTH0_CLIENT_ID="" +AUTH0_CLIENT_SECRET="" \ No newline at end of file diff --git a/.github/workflows/deploy_mcp_docker.yml b/.github/workflows/deploy_mcp_docker.yml new file mode 100644 index 0000000..5a5b769 --- /dev/null +++ b/.github/workflows/deploy_mcp_docker.yml @@ -0,0 +1,60 @@ +name: Deploy MCP Docker + +on: + push: + branches: + - main + - development + +jobs: + set_environment: + runs-on: ubuntu-latest + + steps: + - name: Set up environment based on branch + id: environment_check + run: | + SIMPLE_REF=$(echo ${GITHUB_REF#refs/heads/} | tr / -) + + echo "env_name=${SIMPLE_REF}" >> $GITHUB_OUTPUT + + outputs: + env_name: ${{ steps.environment_check.outputs.env_name }} + + build: + runs-on: ubuntu-latest + needs: [set_environment] + permissions: + id-token: write + contents: read + + environment: + name: ${{ needs.set_environment.outputs.env_name }} + + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.GH_OIDC_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Login into ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag, and push docker image to Amazon ECR + working-directory: ./mcp-server + env: + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + REPOSITORY: ${{ vars.MCP_ECR_REPOSITORY_NAME }} + IMAGE_TAG: latest + run: | + docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG . + docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG + + - name: Deploy to Amazon ECS + uses: brunocascio/ecs-deploy@v2.2.0 + with: + args: deploy ${{ vars.MCP_ECS_CLUSTER }} ${{ vars.MCP_ECS_SERVICE }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b7c201 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +venv +*/venv +*/.venv +**/.env + +# Mac/OSX +.DS_Store + +# Windows +Thumbs.db + +# Editors +.vscode/ +.idea/ +*.iml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fc0a530 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim + +ADD . /app +WORKDIR /app + +RUN pip install -r requirements.txt + +CMD ["python", "src/sandbox_api_mcp_server/server.py"] \ No newline at end of file diff --git a/README.md b/README.md index acf582a..66a86f8 100644 --- a/README.md +++ b/README.md @@ -1 +1,252 @@ -# sandbox-mcp-server \ No newline at end of file +# Sandbox API MCP Server + +## Overview + +This project provides a Model Context Protocol (MCP) server for interacting with the [Neo4j Sandbox API](https://sandbox.neo4j.com/). It allows language models or other MCP clients to easily launch, list, query, and perform other actions on Neo4j Sandbox instances using a standardized tool interface. + +The server is built as a [FastAPI](https://fastapi.tiangolo.com/) application and uses the [FastAPI-MCP](https://fastapi-mcp.tadata.com/getting-started/welcome) library to expose its endpoints as MCP tools. Authentication with the Sandbox API is handled via Auth0, and the necessary Auth0 credentials must be configured through environment variables. + +## Environment Variables + +The server requires the following environment variables to be set for Auth0 authentication, which is used to secure the MCP tools and interact with the Sandbox API on behalf of the user: + +* `AUTH0_DOMAIN`: Your Auth0 tenant domain (e.g., `your-tenant.auth0.com`). +* `AUTH0_AUDIENCE`: The Audience for your Auth0 API (e.g., `https://your-tenant.auth0.com/api/v2/`). +* `AUTH0_CLIENT_ID`: The Client ID of your Auth0 Application. +* `AUTH0_CLIENT_SECRET`: The Client Secret of your Auth0 Application. +* `SANDBOX_API_KEY`: Your Neo4j Sandbox API key. This is used by the underlying `neo4j-sandbox-api-client`. + +You can set these variables directly in your environment or place them in a `.env` file in the project root. + +## Running the Server + +1. **Install dependencies:** + It's recommended to use a virtual environment. + ```bash + python -m venv .venv + source .venv/bin/activate # On Windows use `.venv\\Scripts\\activate` + pip install -r requirements.txt + # Or using uv + # uv pip install -r requirements.txt + ``` + +2. **Set environment variables:** + Ensure the `.env` file is present in the `mcp-server` directory and populated with your Auth0 and Sandbox API credentials as described above. + +3. **Run the FastAPI application:** + The server can be started using Uvicorn: + ```bash + uvicorn src.sandbox_api_mcp_server.server:run --factory --host 0.0.0.0 --port 9100 + ``` + Alternatively, if you have `src` in your `PYTHONPATH` or are in the `mcp-server` directory: + ```bash + python src/sandbox_api_mcp_server/server.py + ``` + This will typically start the server on `http://0.0.0.0:9100`. The MCP endpoint will be available at `http://0.0.0.0:9100/sse` (as configured in `server.py`). + +## Using with MCP Clients (e.g., Claude Desktop) + +To use this MCP server with an MCP client, you need to configure the client to connect to the running FastAPI server. Given the OAuth2 flow used for authentication, **it is highly recommended to use `mcp-remote`** to bridge the connection. `mcp-remote` will handle the browser-based login and token passing to the MCP server. + +### Step 1: Install `mcp-remote` (if not already installed) + +If you don't have `mcp-remote` (part of the `mcp-cli` package) installed globally, you can use `npx` to run it directly or install it: +```bash +npm install -g mcp-remote +``` + +### Step 2: Run your FastAPI MCP Server + +Ensure your FastAPI MCP server is running locally (e.g., on `http://localhost:9100` with the MCP endpoint at `http://localhost:9100/sse`): +```bash +python src/sandbox_api_mcp_server/server.py +``` +Or using uvicorn directly: +```bash +uvicorn src.sandbox_api_mcp_server.server:run --factory --host 0.0.0.0 --port 9100 +``` + + +### Step 3: Run `mcp-remote` + +In a new terminal, start `mcp-remote`, pointing it to your local MCP server's `/sse` endpoint and choosing a local port for `mcp-remote` to listen on (e.g., `8080`): + +```bash +# If mcp-cli is installed globally +mcp-remote http://localhost:9100/sse 8080 + +# Or using npx +npx -y mcp-remote http://localhost:9100/sse 8080 +``` +`mcp-remote` will now listen on `localhost:8080` and proxy requests to your actual MCP server, handling the OAuth flow. + +### Step 4: Configure Claude Desktop + +1. **Locate Claude Desktop Configuration:** + * **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` + * **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + If the file doesn't exist, create it. + +2. **Configure the MCP Server in Claude Desktop:** + Edit `claude_desktop_config.json` to point to the local port where `mcp-remote` is listening (e.g., `8080`). + + ```json + { + "mcpServers": { + "neo4j-sandbox-mcp-via-remote": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "http://localhost:9100/sse", + "8080" + ] + } + } + } + ``` + **Note:** With `mcp-remote` handling the connection to your actual server and its authentication, the Claude Desktop configuration becomes simpler, primarily needing to know where `mcp-remote` is accessible. + +3. **Restart Claude Desktop:** + Quit and reopen Claude Desktop to load the new configuration. + +4. **Authenticate via Browser:** + When you first try to use a tool, `mcp-remote` should open a browser window for you to complete the Auth0 login. After successful authentication, Claude Desktop will be able to use the tools. + +5. **Verify Connection:** + Look for the MCP tools icon in Claude Desktop to confirm connection. + +## Available MCP Tools + +The following tools are exposed, derived from the FastAPI application's endpoints. The `operation_id` of each FastAPI route becomes the tool name. + +--- + +### `list_sandboxes_for_user` +- **Description**: List all running sandbox instances for the authenticated user. +- **Input**: None +- **Output**: `Dict` (JSON object containing a list of sandboxes) + +--- + +### `start_new_sandbox` +- **Description**: Starts a new sandbox instance for a specified use case. +- **Input**: + - `usecase` (str): The name of the use case for the sandbox (e.g., 'movies', 'blank'). +- **Output**: `Dict` (JSON object representing the newly started sandbox) + +--- + +### `terminate_sandbox` +- **Description**: Stops/terminates a specific sandbox instance. +- **Input**: + - `sandbox_hash_key` (str): The unique hash key identifying the sandbox. +- **Output**: `Dict` (Typically an empty JSON object `{}` on success, or an error object) + +--- + +### `extend_sandbox_lifetime` +- **Description**: Extends the lifetime of a sandbox or all sandboxes for the user. +- **Input**: + - `sandbox_hash_key` (Optional[str]): Specific sandbox to extend. If None, all user's sandboxes are extended. +- **Output**: `Dict` (JSON object with status of the extension) + +--- + +### `get_sandbox_connection_details` +- **Description**: Gets connection details for a specific sandbox. +- **Input**: + - `sandbox_hash_key` (str, path parameter): The unique hash key identifying the sandbox. + - `verify_connect` (Optional[bool], query parameter, default: `False`): If true, verifies connection to the sandbox. +- **Output**: `Dict` (JSON object containing connection details for the sandbox) + +--- + +### `request_sandbox_backup` +- **Description**: Requests a backup for a specific sandbox. +- **Input**: + - `sandbox_hash_key` (str, path parameter): The unique hash key identifying the sandbox. +- **Output**: `Dict` (JSON object containing details of the backup request, possibly including a result ID) + +--- + +### `get_backup_result` +- **Description**: Retrieves the result of a specific backup task. +- **Input**: + - `result_id` (str, path parameter): The ID of the backup/upload task result. +- **Output**: `Dict` (JSON object containing the status and details of the backup task) + +--- + +### `list_sandbox_backups` +- **Description**: Lists available backups for a specific sandbox. +- **Input**: + - `sandbox_hash_key` (str, path parameter): The unique hash key identifying the sandbox. +- **Output**: `Dict` (JSON object containing a list of available backups) + +--- + +### `get_sandbox_backup_download_url` +- **Description**: Gets a download URL for a specific sandbox backup file. +- **Input**: + - `sandbox_hash_key` (str, path parameter): The unique hash key identifying the sandbox. + - `key` (str, in request body): The S3 key of the backup file to download. +- **Output**: `Dict` (JSON object containing the download URL) + +--- + +### `upload_sandbox_to_aura` +- **Description**: Uploads a sandbox backup to an Aura instance. +- **Input**: + - `sandbox_hash_key` (str): The unique hash key identifying the sandbox backup to upload. + - `aura_uri` (str): The Aura instance URI (e.g., neo4j+s://xxxx.databases.neo4j.io). + - `aura_password` (str): Password for the Aura instance. + - `aura_username` (Optional[str], default: `'neo4j'`): Username for the Aura instance. +- **Output**: `Dict` (JSON object containing details of the upload task, possibly including a result ID) + +--- + +### `get_aura_upload_result` +- **Description**: Retrieves the result of a specific Aura upload task. +- **Input**: + - `result_id` (str, path parameter): The ID of the Aura upload task result. +- **Output**: `Dict` (JSON object containing the status and details of the Aura upload task) + +--- + +### `get_schema` +- **Description**: Retrieves the schema for a specific sandbox. +- **Input**: + - `hash_key` (str, query parameter): The hash key of the sandbox. +- **Output**: `Dict` (JSON object containing the schema information) + +--- + +### `read_query` +- **Description**: Executes a read-only Cypher query on a sandbox. +- **Input**: + - `hash_key` (str): The hash key of the sandbox to query. + - `query` (str): The Read Cypher query to execute. + - `params` (Optional[Dict[str, Any]]): Optional parameters for the Cypher query. +- **Output**: `Dict` (JSON object containing the query results) + +--- + +### `write_query` +- **Description**: Executes a write Cypher query on a sandbox. +- **Input**: + - `hash_key` (str): The hash key of the sandbox to query. + - `query` (str): The Write Cypher query to execute. + - `params` (Optional[Dict[str, Any]]): Optional parameters for the Cypher query. +- **Output**: `Dict` (JSON object, typically empty or with summary information) + +--- + +## Development + +* The main FastAPI application logic is in `src/sandbox_api_mcp_server/server.py`. +* API routes (which become MCP tools) are defined in `src/sandbox_api_mcp_server/sandbox/routes.py`. +* Request/response models are primarily in `src/sandbox_api_mcp_server/sandbox/models.py` and `src/sandbox_api_mcp_server/models.py`. +* Authentication logic is in `src/sandbox_api_mcp_server/auth.py`. +* The project uses `uv` for dependency management (see `uv.lock`) and `pip` for installation (`requirements.txt`). +* Consider using `hatch` or `poetry` for more robust dependency management and packaging if distributing this server. (The `pyproject.toml` suggests `hatch` might be intended for future use). \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5b1df82 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +packages = ["src/sandbox_api_mcp_server"] + +[project] +name = "sandbox-api-mcp-server" +version = "0.1.0" +description = "Sandbox API tool for FastMCP" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + { name = "Rafal Janicki", email = "rafal.janicki@neo4j.com" } +] +dependencies = [ + "fastmcp>=2.5.1", + "python-dotenv>=1.1.0", + "httpx>=0.28.1", + "pydantic>=2.11.4", +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.scripts] +sandbox-api-mcp-server = "sandbox_api_mcp_server:server.run" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8e2facf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +cryptography~=45.0.0 +fastapi-mcp>=0.3.3 +httpx>=0.28.1,<1.0.0 +PyJWT>=2.10.1,<3.0.0 +python-dotenv>=1.1.0,<2.0.0 +pydantic>=2.11.4,<3.0.0 \ No newline at end of file diff --git a/src/sandbox_api_mcp_server/__init__.py b/src/sandbox_api_mcp_server/__init__.py new file mode 100644 index 0000000..dac062b --- /dev/null +++ b/src/sandbox_api_mcp_server/__init__.py @@ -0,0 +1,10 @@ +from . import server + + +def main(): + """Main entry point for the package, returns a coroutine to be awaited by the caller.""" + return server.run() + + +# Optionally expose other important items at package level +__all__ = ['server'] diff --git a/src/sandbox_api_mcp_server/auth.py b/src/sandbox_api_mcp_server/auth.py new file mode 100644 index 0000000..2831d80 --- /dev/null +++ b/src/sandbox_api_mcp_server/auth.py @@ -0,0 +1,112 @@ +import httpx +import json +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from cryptography.hazmat.primitives import serialization +from helpers import get_logger +from jwt.algorithms import RSAAlgorithm +from fastapi import Request, HTTPException, status +from typing import Any +from models import Auth0Settings + +logger = get_logger(__name__) + + +async def verify_auth(request: Request) -> dict[str, Any]: + """ + Verify the authentication token from the request headers. + + Parameters + ---------- + request: Request + The incoming request object + + Returns + ------- + dict[str, Any] + The decoded JWT payload and the token + + { + "claims": dict[str, Any], + "token": str + } + """ + + try: + import jwt + + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization header") + + token = " ".join(auth_header.split(" ")[1:]) + if token.startswith("ApiKey "): + return {"claims": {"sub": "api_key"}, "token": token, "type": "api_key"} + + header = jwt.get_unverified_header(token) + + # Check if this is a JWE token (encrypted token) + if header.get("alg") == "dir" and header.get("enc") == "A256GCM": + raise ValueError( + "Token is encrypted, offline validation not possible. " + "This is usually due to not specifying the audience when requesting the token." + ) + + # Otherwise, it's a JWT, we can validate it offline + if header.get("alg") in ["RS256", "HS256"]: + claims = jwt.decode( + token, + request.app.state.jwks_public_key, + algorithms=["RS256", "HS256"], + audience=Auth0Settings().auth0_audience, + issuer=f"https://{Auth0Settings().auth0_domain}/", + options={"verify_signature": True}, + ) + logger.info(f"Verified auth: {claims}") + return {"claims": claims, "token": token, "type": "jwt"} + + except Exception as e: + logger.error(f"Auth error: {str(e)}") + + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") + + +async def fetch_jwks_public_key(url: str) -> str: + """ + Fetch JWKS from a given URL and extract the primary public key in PEM format. + + Parameters + ---------- + url: str + The JWKS URL to fetch from + + Returns + ------- + str + PEM-formatted public key as a string + """ + logger.info(f"Fetching JWKS from: {url}") + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + jwks_data = response.json() + + if not jwks_data or "keys" not in jwks_data or not jwks_data["keys"]: + logger.error("Invalid JWKS data format: missing or empty 'keys' array") + raise ValueError("Invalid JWKS data format: missing or empty 'keys' array") + + # Just use the first key in the set + jwk = json.dumps(jwks_data["keys"][0]) + + # Convert JWK to PEM format + public_key = RSAAlgorithm.from_jwk(jwk) + if isinstance(public_key, RSAPublicKey): + pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + pem_str = pem.decode("utf-8") + logger.info("Successfully extracted public key from JWKS") + return pem_str + else: + logger.error("Invalid JWKS data format: expected RSA public key") + raise ValueError("Invalid JWKS data format: expected RSA public key") diff --git a/src/sandbox_api_mcp_server/helpers.py b/src/sandbox_api_mcp_server/helpers.py new file mode 100644 index 0000000..1fd7e23 --- /dev/null +++ b/src/sandbox_api_mcp_server/helpers.py @@ -0,0 +1,11 @@ +import logging + + +def get_logger(name: str) -> logging.Logger: + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + handler = logging.StreamHandler() + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + return logger diff --git a/src/sandbox_api_mcp_server/models.py b/src/sandbox_api_mcp_server/models.py new file mode 100644 index 0000000..a3d4a75 --- /dev/null +++ b/src/sandbox_api_mcp_server/models.py @@ -0,0 +1,27 @@ +from pydantic import Field +from pydantic_settings import BaseSettings + + +class Auth0Settings(BaseSettings): + """ + For this to work, you need an .env file in the root of the project with the following variables: + AUTH0_DOMAIN=your-tenant.auth0.com + AUTH0_AUDIENCE=https://your-tenant.auth0.com/api/v2/ + AUTH0_CLIENT_ID=your-client-id + AUTH0_CLIENT_SECRET=your-client-secret + """ + + auth0_domain: str = Field(default="", validation_alias="AUTH0_DOMAIN") + auth0_audience: str = Field( + default="", validation_alias="AUTH0_AUDIENCE" + ) + auth0_client_id: str = Field(default="", validation_alias="AUTH0_CLIENT_ID") + auth0_client_secret: str = Field(default="none", validation_alias="AUTH0_CLIENT_SECRET") + + @property + def auth0_jwks_url(self): + return f"https://{self.auth0_domain}/.well-known/jwks.json" + + @property + def auth0_oauth_metadata_url(self): + return f"https://{self.auth0_domain}/.well-known/openid-configuration" diff --git a/src/sandbox_api_mcp_server/sandbox/models.py b/src/sandbox_api_mcp_server/sandbox/models.py new file mode 100644 index 0000000..5fcd98a --- /dev/null +++ b/src/sandbox_api_mcp_server/sandbox/models.py @@ -0,0 +1,90 @@ +from typing import Annotated, Optional, Any +from pydantic import BaseModel, Field + + +USECASE_DESCRIPTION = ( + "The name of the use case for the sandbox, possible values are: " + "blank-sandbox,bloom,citations,contact-tracing,cybersecurity,entity-resolution,fincen," + "fraud-detection,graph-data-science,graph-data-science-blank-sandbox,healthcare-analytics," + "icij-offshoreleaks,icij-paradise-papers,legis-graph,movies,network-management," + "openstreetmap,pole,recommendations,twitch,twitter-trolls,wwc2019,yelp,twitter-v2" +) + + +class StartSandboxBody(BaseModel): + usecase: Annotated[str, Field(description=USECASE_DESCRIPTION)] + + +class StopSandboxBody(BaseModel): + sandbox_hash_key: Annotated[str, Field(description="The unique hash key identifying the sandbox.")] + + +class ExtendSandboxBody(BaseModel): + sandbox_hash_key: Annotated[Optional[str], Field(description="Specific sandbox to extend. If None, all user's sandboxes are extended.")] = None + + +class AuraUploadBody(BaseModel): + sandbox_hash_key: Annotated[str, Field(description="The unique hash key identifying the sandbox backup to upload.")] + aura_uri: Annotated[str, Field(description="The Aura instance URI (e.g., neo4j+s://xxxx.databases.neo4j.io).")] + aura_password: Annotated[str, Field(description="Password for the Aura instance.")] + aura_username: Annotated[Optional[str], Field(description="Username for the Aura instance (defaults to 'neo4j').")] = "neo4j" + + +class BackupDownloadUrlBody(BaseModel): + key: Annotated[str, Field(description="The S3 key of the backup file to download.")] + + +class FastApiCypherQueryBody(BaseModel): + """ + Base request model for Cypher queries. + """ + + hash_key: str = Field( + ..., + description="The hash key of the sandbox to query.", + json_schema_extra={"examples": ["abcdef1234567890"]}, + ) + params: Optional[dict[str, Any]] = Field( + None, + description="Optional parameters to pass to the Cypher query.", + json_schema_extra={"examples": [{"name": "John"}]}, + ) + + +class FastApiReadCypherQueryBody(FastApiCypherQueryBody): + """ + Request model for Read Cypher queries. + """ + + query: str = Field( + ..., + description="The Read Cypher query to execute.", + json_schema_extra={"examples": ["MATCH (n: Person {name: $name}) RETURN n.name as name, n.age as age"]}, + ) + + +class FastApiWriteCypherQueryBody(FastApiCypherQueryBody): + """ + Request model for Write Cypher queries. + """ + + query: str = Field( + ..., + description="The Write Cypher query to execute.", + json_schema_extra={"examples": ["MERGE (n: Person {name: $name})"]}, + ) + + +class FastApiReadCypherQueryResponse(BaseModel): + """ + Response for a Read Cypher query. + """ + + data: list[dict[str, Any]] = Field( + ..., + description="The results of the Read Cypher query.", + json_schema_extra={"examples": [{"name": "John", "age": 30}]}, + ) + count: int = Field( + ..., description="The number of rows returned by the query.", json_schema_extra={"examples": [1]} + ) diff --git a/src/sandbox_api_mcp_server/sandbox/routes.py b/src/sandbox_api_mcp_server/sandbox/routes.py new file mode 100644 index 0000000..3cad4f2 --- /dev/null +++ b/src/sandbox_api_mcp_server/sandbox/routes.py @@ -0,0 +1,191 @@ +from fastapi import APIRouter +from typing import Annotated, Dict, Optional +from fastapi import Depends +from fastapi import Query, Path, status +from fastapi.responses import PlainTextResponse + +from .models import StartSandboxBody, StopSandboxBody, ExtendSandboxBody, AuraUploadBody, BackupDownloadUrlBody, FastApiReadCypherQueryBody, FastApiWriteCypherQueryBody +from helpers import get_logger +from .service import call_sandbox_api, SandboxApiClient, get_sandbox_client + +logger = get_logger(__name__) + + +def get_sandbox_api_router() -> APIRouter: + router = APIRouter() + + # Each operation_id will be the MCP tool name as per fastapi-mcp docs. + @router.get("/list-sandboxes", operation_id="list_sandboxes_for_user", tags=["Sandbox"], response_model=Dict) + async def list_sandboxes( + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """List all running sandbox instances for the authenticated user.""" + # timezone: Optional[str] = Query(None, description="User's timezone for accurate expiration calculation.") + # The original tool didn't expose timezone, keeping it simple here. + try: + return await call_sandbox_api("list_sandboxes_for_user", client) + except Exception as e: + logger.error(f"Error listing sandboxes: {e}") + raise e + + @router.post("/start-sandbox", operation_id="start_new_sandbox", tags=["Sandbox"], response_model=Dict, status_code=status.HTTP_201_CREATED) + async def start_sandbox( + body: StartSandboxBody, + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Starts a new sandbox instance for a specified use case.""" + try: + return await call_sandbox_api("start_sandbox", client, usecase=body.usecase) + except Exception as e: + logger.error(f"Error starting sandbox: {e}") + raise e + + @router.post("/terminate-sandbox", operation_id="terminate_sandbox", tags=["Sandbox"], response_model=Dict) + async def terminate_sandbox( + body: StopSandboxBody, + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Stops/terminates a specific sandbox instance.""" + try: + return await call_sandbox_api("stop_sandbox", client, sandbox_hash_key=body.sandbox_hash_key) + except Exception as e: + logger.error(f"Error stopping sandbox: {e}") + raise e + + @router.post("/extend-sandbox", operation_id="extend_sandbox_lifetime", tags=["Sandbox"], response_model=Dict) + async def extend_sandbox( + body: ExtendSandboxBody, + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Extends the lifetime of a sandbox or all sandboxes for the user.""" + try: + return await call_sandbox_api("extend_sandbox", client, sandbox_hash_key=body.sandbox_hash_key) + except Exception as e: + logger.error(f"Error extending sandbox: {e}") + raise e + + @router.get("/get-sandbox-details/{sandbox_hash_key}", operation_id="get_sandbox_connection_details", tags=["Sandbox"], response_model=Dict) + async def get_sandbox_details( + sandbox_hash_key: Annotated[str, Path(description="The unique hash key identifying the sandbox.")], + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + *, # Make subsequent parameters keyword-only + verify_connect: Annotated[Optional[bool], Query(description="If true, verifies connection to the sandbox.")] = False + ): + """Gets connection details for a specific sandbox.""" + try: + return await call_sandbox_api("get_sandbox_details", client, sandbox_hash_key=sandbox_hash_key, verify_connect=verify_connect) + except Exception as e: + logger.error(f"Error getting sandbox details: {e}") + raise e + + # --- Backup Related Endpoints --- + @router.post("/request-backup/{sandbox_hash_key}", operation_id="request_sandbox_backup", tags=["Backup"], response_model=Dict) + async def request_backup_ep( + sandbox_hash_key: Annotated[str, Path(description="The unique hash key identifying the sandbox.")], + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Requests a backup for a specific sandbox.""" + try: + return await call_sandbox_api("request_backup", client, sandbox_hash_key=sandbox_hash_key) + except Exception as e: + logger.error(f"Error requesting backup: {e}") + raise e + + @router.get("/backups/result/{result_id}", operation_id="get_backup_result", tags=["Backup"], response_model=Dict) + async def get_backup_result_ep( + result_id: Annotated[str, Path(description="The ID of the backup/upload task result.")], + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Retrieves the result of a specific backup task.""" + try: + return await call_sandbox_api("get_backup_result", client, result_id=result_id) + except Exception as e: + logger.error(f"Error getting backup result: {e}") + raise e + + @router.get("/list-backups/{sandbox_hash_key}", operation_id="list_sandbox_backups", tags=["Backup"], response_model=Dict) + async def list_backups_ep( + sandbox_hash_key: Annotated[str, Path(description="The unique hash key identifying the sandbox.")], + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Lists available backups for a specific sandbox.""" + try: + return await call_sandbox_api("list_backups", client, sandbox_hash_key=sandbox_hash_key) + except Exception as e: + logger.error(f"Error listing backups: {e}") + raise e + + @router.post("/get-backup-download-url/{sandbox_hash_key}", operation_id="get_sandbox_backup_download_url", tags=["Backup"], response_model=Dict) + async def get_backup_download_url_ep( + sandbox_hash_key: Annotated[str, Path(description="The unique hash key identifying the sandbox.")], + body: BackupDownloadUrlBody, + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Gets a download URL for a specific sandbox backup file.""" + try: + return await call_sandbox_api("get_backup_download_url", client, sandbox_hash_key=sandbox_hash_key, key=body.key) + except Exception as e: + logger.error(f"Error getting backup download URL: {e}") + raise e + + # --- Aura Upload Related Endpoints --- + @router.post("/upload-to-aura", operation_id="upload_sandbox_to_aura", tags=["Aura"], response_model=Dict) + async def upload_to_aura_ep( + body: AuraUploadBody, + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Uploads a sandbox backup to an Aura instance.""" + try: + return await call_sandbox_api( + "upload_to_aura", + client, + sandbox_hash_key=body.sandbox_hash_key, + aura_uri=body.aura_uri, + aura_password=body.aura_password, + aura_username=body.aura_username, + ) + except Exception as e: + logger.error(f"Error uploading to Aura: {e}") + raise e + + @router.get("/aura-upload/result/{result_id}", operation_id="get_aura_upload_result", tags=["Aura"], response_model=Dict) + async def get_aura_upload_result_ep( + result_id: Annotated[str, Path(description="The ID of the Aura upload task result.")], + client: Annotated[SandboxApiClient, Depends(get_sandbox_client)], + ): + """Retrieves the result of a specific Aura upload task.""" + try: + return await call_sandbox_api("get_aura_upload_result", client, result_id=result_id) + except Exception as e: + logger.error(f"Error getting Aura upload result: {e}") + raise e + + @router.get("/query/schema", operation_id="get_schema", tags=["Query"], response_model=Dict) + async def get_schema(hash_key: str, client: Annotated[SandboxApiClient, Depends(get_sandbox_client)]): + try: + return await call_sandbox_api("get_schema", client, hash_key=hash_key) + except Exception as e: + logger.error(f"Error getting schema: {e}") + raise e + + @router.post("/query/read", operation_id="read_query", tags=["Query"], response_model=Dict) + async def read(cypher_query: FastApiReadCypherQueryBody, client: Annotated[SandboxApiClient, Depends(get_sandbox_client)]): + try: + return await call_sandbox_api("read_query", client, hash_key=cypher_query.hash_key, query=cypher_query.query, params=cypher_query.params) + except Exception as e: + logger.error(f"Error reading query: {e}") + raise e + + @router.post("/query/write", operation_id="write_query", tags=["Query"], response_model=Dict) + async def write(cypher_query: FastApiWriteCypherQueryBody, client: Annotated[SandboxApiClient, Depends(get_sandbox_client)]): + try: + return await call_sandbox_api("write_query", client, hash_key=cypher_query.hash_key, query=cypher_query.query, params=cypher_query.params) + except Exception as e: + logger.error(f"Error writing query: {e}") + raise e + + @router.get("/health", tags=["Management"], operation_id="health_check") + async def health_check_endpoint() -> PlainTextResponse: + return PlainTextResponse("Ok", status_code=200) + + return router diff --git a/src/sandbox_api_mcp_server/sandbox/service.py b/src/sandbox_api_mcp_server/sandbox/service.py new file mode 100644 index 0000000..ddd0004 --- /dev/null +++ b/src/sandbox_api_mcp_server/sandbox/service.py @@ -0,0 +1,322 @@ +import asyncio +import httpx +import os +import random +from typing import Annotated, Optional, Dict, Any +from fastapi import HTTPException, status, Depends + +from auth import verify_auth +from helpers import get_logger +from .models import FastApiReadCypherQueryResponse + +MAX_RETRIES = 3 +BASE_BACKOFF_DELAY = 1.0 +logger = get_logger(__name__) + + +class SandboxApiClientError(Exception): + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code + + +class SandboxApiClient: + def __init__(self, access_token: str): + if not access_token: + raise ValueError("access_token cannot be empty.") + hostname = os.getenv("SANDBOX_API_HOSTNAME", "https://api.sandbox.neo4j.com") + self.access_token = access_token + self.client = httpx.AsyncClient(base_url=hostname, timeout=30.0) + self.headers = { + "Authorization": self.access_token, + "Accept": "application/json", + "Content-Type": "application/json" + } + + async def _request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None) -> Any: + logger.info(f"Requesting {method} {endpoint} with params: {params} and json_data: {json_data}") + try: + response = await self.client.request(method, endpoint, params=params, json=json_data, headers=self.headers) + response.raise_for_status() + if response.status_code == 204 or response.status_code == 202: + return None # Or an empty dict, depending on desired non-content response + return response.json() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}") + try: + error_details = e.response.json() + err_msg = error_details.get("error") or error_details.get("Error") or error_details.get("errorString") or str(error_details.get("errors", {})) + raise SandboxApiClientError(f"Sandbox API Error ({e.response.status_code}): {err_msg}", status_code=e.response.status_code) from e + except Exception: + raise SandboxApiClientError(f"Sandbox API Error ({e.response.status_code}): {e.response.text}", status_code=e.response.status_code) from e + except httpx.RequestError as e: + logger.error(f"Request error: {e}") + raise SandboxApiClientError(f"Request failed: {e}", status_code=503) from e # Service Unavailable + except Exception as e: + logger.error(f"Unexpected error in API client: {e}", exc_info=True) + raise SandboxApiClientError(f"An unexpected error occurred: {e}", status_code=500) from e + + async def close(self): + await self.client.aclose() + + async def list_sandboxes_for_user(self, timezone: Optional[str] = None) -> Dict[str, Any]: + """ + Retrieves details of all running sandbox instances for the authenticated user. + Corresponds to GET /SandboxGetRunningInstancesForUser in swagger. + + Args: + timezone (Optional[str]): User's timezone for accurate expiration calculation (e.g., 'America/New_York'). + + Returns: + Dict[str, Any]: A list of the user's running sandbox instances. + See #/components/schemas/RunningInstances in swagger. + """ + params: Dict[str, Any] = {} + if timezone is not None: + params["timezone"] = timezone + + return { + "sandboxes": await self._request("GET", "/SandboxGetRunningInstancesForUser", params=params), + } + + async def start_sandbox(self, usecase: str) -> Dict[str, Any]: + """ + Creates and deploys a new sandbox instance or returns an existing one if duplicates are not allowed and one exists. + Corresponds to POST /SandboxRunInstance in swagger. + + Args: + usecase (str): The name of the use case for the sandbox. Possible values are: + blank-sandbox,bloom,citations,contact-tracing,cybersecurity,entity-resolution,fincen, + fraud-detection,graph-data-science,graph-data-science-blank-sandbox,healthcare-analytics, + icij-offshoreleaks,icij-paradise-papers,legis-graph,movies,network-management, + openstreetmap,pole,recommendations,twitch,twitter-trolls,wwc2019,yelp,twitter-v2 + + Returns: + Dict[str, Any]: Sandbox instance details or draft confirmation. + See #/components/schemas/RunInstanceResponse in swagger. + """ + return await self._request("POST", "/SandboxRunInstance", json_data={"usecase": usecase}) + + async def stop_sandbox(self, sandbox_hash_key: str) -> Optional[Dict[str, Any]]: + """ + Stops a running sandbox instance. + Corresponds to POST /SandboxStopInstance in swagger. + + Args: + sandbox_hash_key (str): The unique hash key identifying the sandbox to stop. + + Returns: + Optional[Dict[str, Any]]: Successfully stopped the instance (often an empty object or None if 204/202) + or no running tasks found. See #/components/schemas/StopInstanceResponse. + """ + json_data = {"sandboxHashKey": sandbox_hash_key} + return await self._request("POST", "/SandboxStopInstance", json_data=json_data) + + async def extend_sandbox(self, sandbox_hash_key: Optional[str] = None) -> Dict[str, Any]: + """ + Extends the lifetime of a user's sandbox(es). User profile details can be submitted with this request. + Corresponds to POST /SandboxExtend in swagger. + + Args: + sandbox_hash_key (Optional[str]): Specific sandbox to extend. If not provided, all user's sandboxes are extended. + + Returns: + Dict[str, Any]: Sandbox lifetime extension status. See #/components/schemas/ExtendResponse. + """ + json_data: Dict[str, Any] = {} + if sandbox_hash_key: + json_data["sandboxHashKey"] = sandbox_hash_key + return await self._request("POST", "/SandboxExtend", json_data=json_data) + + async def get_sandbox_details(self, sandbox_hash_key: str, verify_connect: Optional[bool] = False) -> Dict[str, Any]: + """ + Retrieves details of a specific sandbox instance for the authenticated user. + Corresponds to GET /SandboxAuthdGetInstanceByHashKey in swagger. + + Args: + sandbox_hash_key (str): The unique hash key identifying the sandbox. + verify_connect (Optional[bool]): If true, verifies connection to the sandbox. (default: false) + + Returns: + Dict[str, Any]: Sandbox details. See #/components/schemas/AuthdGetInstanceByHashKeyResponse. + Can also be plain text 'ip:port'. + """ + params = {"sandboxHashKey": sandbox_hash_key} + if verify_connect is not None: + params["verifyConnect"] = verify_connect + return await self._request("GET", "/SandboxAuthdGetInstanceByHashKey", params=params) + + async def request_backup(self, sandbox_hash_key: str) -> Dict[str, Any]: + """ + Initiates a backup process for a specific sandbox. + Corresponds to POST /SandboxBackup/request/{sandboxHashKey} in swagger. + + Args: + sandbox_hash_key (str): The unique hash key identifying the sandbox to back up. + + Returns: + Dict[str, Any]: Backup task initiation status. See #/components/schemas/BackupTaskStatus. + """ + endpoint = f"/SandboxBackup/request/{sandbox_hash_key}" + return await self._request("POST", endpoint) + + async def get_backup_result(self, result_id: str) -> Dict[str, Any]: + """ + Retrieves the result of a specific backup task. + Corresponds to GET /SandboxBackup/result/{result_id} in swagger. + + Args: + result_id (str): The ID of the backup task. + + Returns: + Dict[str, Any]: Backup task status and result (if completed). + See #/components/schemas/BackupResultResponse. + """ + endpoint = f"/SandboxBackup/result/{result_id}" + return await self._request("GET", endpoint) + + async def list_backups(self, sandbox_hash_key: str) -> Dict[str, Any]: + """ + Retrieves a list of available backups for a specific sandbox. + Corresponds to GET /SandboxBackup/{sandboxHashKey} in swagger. + + Args: + sandbox_hash_key (str): The unique hash key identifying the sandbox. + + Returns: + Dict[str, Any]: A list of backup files. See #/components/schemas/BackupListResponse. + Returns an empty list if no backups or sandbox not accessible. + """ + endpoint = f"/SandboxBackup/{sandbox_hash_key}" + return await self._request("GET", endpoint) + + async def get_backup_download_url(self, sandbox_hash_key: str, key: str) -> Dict[str, Any]: + """ + Generates a pre-signed download URL for a specific backup file. + Corresponds to POST /SandboxBackup/{sandboxHashKey} in swagger. + + Args: + sandbox_hash_key (str): The unique hash key identifying the sandbox owning the backup. + key (str): The S3 key of the backup file. + + Returns: + Dict[str, Any]: Pre-signed download URL. See #/components/schemas/BackupDownloadUrlResponse. + """ + endpoint = f"/SandboxBackup/{sandbox_hash_key}" + json_data = {"key": key} + return await self._request("POST", endpoint, json_data=json_data) + + async def upload_to_aura(self, sandbox_hash_key: str, aura_uri: str, aura_password: str, aura_username: Optional[str] = "neo4j") -> Dict[str, Any]: + """ + Initiates an upload of a sandbox backup to an Aura instance. + Corresponds to POST /SandboxAuraUpload/request/{sandboxHashKey} in swagger. + + Args: + sandbox_hash_key (str): The unique hash key identifying the sandbox backup to upload. + aura_uri (str): The Aura instance URI. + aura_password (str): Password for the Aura instance. + aura_username (Optional[str]): Username for the Aura instance (default: 'neo4j'). + + Returns: + Dict[str, Any]: Aura upload task initiation status. See #/components/schemas/AuraUploadTaskStatus. + """ + endpoint = f"/SandboxAuraUpload/request/{sandbox_hash_key}" + json_data = { + "aura_uri": aura_uri, + "aura_password": aura_password, + "aura_username": aura_username + } + return await self._request("POST", endpoint, json_data=json_data) + + async def get_aura_upload_result(self, result_id: str) -> Dict[str, Any]: + """ + Retrieves the result of a specific Aura upload task. + Corresponds to GET /SandboxAuraUpload/result/{result_id} in swagger. + + Args: + result_id (str): The ID of the Aura upload task. + + Returns: + Dict[str, Any]: Aura upload task status and result (if completed). + See #/components/schemas/AuraUploadResultResponse. + """ + endpoint = f"/SandboxAuraUpload/result/{result_id}" + return await self._request("GET", endpoint) + + async def get_schema(self, hash_key: str) -> FastApiReadCypherQueryResponse: + """ + Retrieves the schema of the Neo4j database. + Corresponds to POST /SandboxQuery in swagger. + """ + schema_query = ( + "call apoc.meta.data() yield label, property, type, other, unique, index, elementType " + "where elementType = 'node' and not label starts with '_' " + "with label, collect(case when type <> 'RELATIONSHIP' " + "then [property, type + case when unique then ' unique' else '' end + " + "case when index then ' indexed' else '' end] end) as attributes, " + "collect(case when type = 'RELATIONSHIP' then [property, head(other)] end) as relationships " + "return label, apoc.map.fromPairs(attributes) as attributes, " + "apoc.map.fromPairs(relationships) as relationships" + ) + return await self.read_query(hash_key, schema_query) + + async def read_query(self, hash_key: str, query: str, params: Optional[Dict[str, Any]] = None) -> FastApiReadCypherQueryResponse: + """ + Executes a read query on the Neo4j database. + Corresponds to POST /SandboxQuery in swagger. + """ + return await self._request("POST", "/SandboxRunQuery", json_data={"hash_key": hash_key, "statement": query, "params": params, "accessMode": "Read"}) + + async def write_query(self, hash_key: str, query: str, params: Optional[Dict[str, Any]] = None) -> FastApiReadCypherQueryResponse: + """ + Executes a write query on the Neo4j database. + Corresponds to POST /SandboxQuery in swagger. + """ + return await self._request("POST", "/SandboxRunQuery", json_data={"hash_key": hash_key, "statement": query, "params": params}) + + +def get_sandbox_client(user: Annotated[Dict[str, Any], Depends(verify_auth)]) -> SandboxApiClient: + return SandboxApiClient(user["token"]) + + +async def call_sandbox_api(api_method_name: str, client: SandboxApiClient, **kwargs): + logger.info(f"Calling {api_method_name} with kwargs: {kwargs}") + + retries = 0 + last_exception = None + method_to_call = getattr(client, api_method_name) + + while retries < MAX_RETRIES: + try: + result = await method_to_call(**kwargs) + return result if result is not None else {} # Ensure consistent empty dict for 204/202 + except SandboxApiClientError as e: + last_exception = e + # Check for rate limit or specific retryable errors (e.g., 503) + # This is a simplified check; real rate limit headers (X-Rate-Limit-Reset) should be handled if available + is_retryable = e.status_code == 429 or (e.status_code and 500 <= e.status_code < 600) + if is_retryable: + retries += 1 + if retries >= MAX_RETRIES: + logger.error(f"API call {api_method_name} failed after {MAX_RETRIES} retries. Last error: {e}") + raise HTTPException(status_code=e.status_code or 503, detail=str(e)) + + wait_time = BASE_BACKOFF_DELAY * (2 ** (retries - 1)) + random.uniform(0, 0.5) + logger.warning(f"API call {api_method_name} failed with {e.status_code}. Retrying in {wait_time:.2f}s. Attempt {retries}/{MAX_RETRIES}.") + await asyncio.sleep(wait_time) + else: + # Non-retryable SandboxApiClientError + logger.error(f"API call {api_method_name} failed with non-retryable error: {e}") + raise HTTPException(status_code=e.status_code or 500, detail=str(e)) + except Exception as e: + # Unexpected errors not from SandboxApiClientError + logger.exception(f"Unexpected error calling Sandbox API method {api_method_name}: {e}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected internal server error occurred.") + + # Should not be reached if MAX_RETRIES > 0 and an exception was always raised + logger.info(f"Last exception: {last_exception}") + if last_exception: + raise HTTPException(status_code=last_exception.status_code or 500, detail=str(last_exception)) + + logger.error("API call failed after exhausting retries.") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="API call failed after exhausting retries.") diff --git a/src/sandbox_api_mcp_server/server.py b/src/sandbox_api_mcp_server/server.py new file mode 100644 index 0000000..7b5c4c2 --- /dev/null +++ b/src/sandbox_api_mcp_server/server.py @@ -0,0 +1,99 @@ +import os +import uvicorn + +from contextlib import asynccontextmanager +from dotenv import load_dotenv +from fastapi import FastAPI, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi_mcp import AuthConfig, FastApiMCP +from uvicorn._types import ASGI3Application, ASGIReceiveCallable, ASGISendCallable, Scope +from auth import fetch_jwks_public_key, verify_auth +from models import Auth0Settings +from sandbox.routes import get_sandbox_api_router +from helpers import get_logger + +logger = get_logger(__name__) + +load_dotenv() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.jwks_public_key = await fetch_jwks_public_key(Auth0Settings().auth0_jwks_url) + yield + + +class ProxyHeadersMiddleware: + def __init__(self, app: ASGI3Application) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: + if scope["type"] == "lifespan": + return await self.app(scope, receive, send) + + if scope["server"][0] == "127.0.0.1" or scope["server"][0] == "localhost": + return await self.app(scope, receive, send) + + headers = dict(scope["headers"]) + if b"x-forwarded-proto" in headers: + x_forwarded_proto = headers[b"x-forwarded-proto"].decode("latin1").strip() + + if x_forwarded_proto in {"http", "https", "ws", "wss"}: + if scope["type"] == "websocket": + scope["scheme"] = x_forwarded_proto.replace("http", "ws") + else: + scope["scheme"] = x_forwarded_proto + + if b"x-forwarded-for" in headers: + x_forwarded_for = headers[b"x-forwarded-for"].decode("latin1") + + if x_forwarded_for: + # If the x-forwarded-for header is empty then host is an empty string. + # Only set the client if we actually got something usable. + # See: https://github.com/encode/uvicorn/issues/1068 + + # We've lost the connecting client's port information by now, + # so only include the host. + port = 0 + scope["client"] = (x_forwarded_for, port) + + return await self.app(scope, receive, send) + + +def run(): + app = FastAPI(title="SandboxApiMCP", lifespan=lifespan) + app.include_router(get_sandbox_api_router()) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.add_middleware(ProxyHeadersMiddleware) + fastapi_mcp = FastApiMCP( + app, + name="Neo4j Sandbox API MCP Server", + description="Neo4j Sandbox API MCP Server.", + exclude_operations=["health_check"], + auth_config=AuthConfig( + issuer=f"https://{Auth0Settings().auth0_domain}/", + authorize_url=f"https://{Auth0Settings().auth0_domain}/authorize", + oauth_metadata_url=Auth0Settings().auth0_oauth_metadata_url, + audience=Auth0Settings().auth0_audience, + default_scope="read:account-info openid email profile user_metadata", + client_id=Auth0Settings().auth0_client_id, + client_secret=Auth0Settings().auth0_client_secret, + dependencies=[Depends(verify_auth)], + setup_proxies=True, + ), + ) + + # Mount the MCP server + fastapi_mcp.mount(mount_path="/sse") + + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 9100))) + + +if __name__ == "__main__": + run() diff --git a/swagger.yaml b/swagger.yaml new file mode 100644 index 0000000..2296024 --- /dev/null +++ b/swagger.yaml @@ -0,0 +1,1347 @@ +openapi: 3.0.0 +info: + version: '1.0' + title: Neo4j Sandbox API + description: API for managing Neo4j Sandbox instances and related resources. + contact: + name: Neo4j Developer Relations + url: https://neo4j.com/developer/ + + +servers: + - url: 'https://api.sandbox.neo4j.com/' + description: Sandbox API endpoint + variables: + stage: + default: development + description: The deployment stage (e.g., development, main) + - url: 'http://localhost:3000/{basePath}' + description: Local development server + variables: + basePath: + default: main # Or the appropriate local base path + description: Base path for local serverless offline + - url: 'https://efz1cnte2e.execute-api.us-east-1.amazonaws.com/main' + description: Staging/Production + +paths: + /SandboxGetUsecases: + get: + summary: Get Use Cases + description: Retrieves a list of available sandbox use cases. + operationId: getUseCases + parameters: + - name: additionalUc + in: query + description: JSON string array of additional use cases to include. e.g., '["uc1", "uc2"]' + required: false + schema: + type: string # Should be parsed as JSON array server-side + responses: + '200': + description: A list of use cases. + content: + application/json: + schema: + $ref: '#/components/schemas/UseCases' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxRunInstance: + post: + summary: Run Sandbox Instance + description: Creates and deploys a new sandbox instance or returns an existing one if duplicates are not allowed and one exists. + operationId: runInstance + security: + - Auth0Jwt: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunInstanceRequest" + responses: + '200': + description: Sandbox instance details or draft confirmation. + content: + application/json: + schema: + $ref: "#/components/schemas/RunInstanceResponse" + '400': + description: Bad Request (e.g., invalid use case, or sandbox already exists and duplicates disabled). + content: + application/json: + schema: + $ref: "#/components/schemas/RunInstanceResponse" # Contains errorString + # '401': # Implicit via security scheme + # description: Unauthorized + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxTwitterAuth: + get: + summary: Get Twitter Auth URL (Deprecated) + description: '[DEPRECATED] Initiates Twitter OAuth1.0a flow by providing an authorization URL.' + operationId: getTwitterAuthUrl + parameters: + - name: redirect_url + in: query + description: URL to redirect to after Twitter auth. Defaults to a preconfigured URL if not provided. + required: false + schema: + type: string + format: url + responses: + '200': + description: Twitter authorization redirect URL. + content: + application/json: + schema: + $ref: "#/components/schemas/TwitterAuthResponse" + '500': + description: Failed to get authorization URL. + content: + text/plain: + schema: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxTwitterExchangeToken: + get: + summary: Exchange Twitter Token (Deprecated) + description: '[DEPRECATED] Exchanges Twitter OAuth token and verifier for an access token.' + operationId: exchangeTwitterToken + parameters: + - name: oauth_token + in: query + required: true + schema: + type: string + - name: oauth_verifier + in: query + required: true + schema: + type: string + responses: + '200': + description: Twitter access token. + content: + application/json: + schema: + $ref: "#/components/schemas/TwitterExchangeResponse" + '500': + description: Failed to get access token. + content: + text/plain: + schema: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxStopInstance: + post: + summary: Stop Sandbox Instance + description: Stops a running sandbox instance. + operationId: stopInstance + security: + - Auth0Jwt: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StopInstanceRequest" + responses: + '200': + description: Successfully stopped the instance or no running tasks found. + content: + application/json: # Empty object + schema: + $ref: "#/components/schemas/StopInstanceResponse" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxExtend: + post: + summary: Extend Sandbox Lifetime + description: Extends the lifetime of a user's sandbox(es). User profile details can be submitted with this request. + operationId: extendSandbox + security: + - Auth0Jwt: [] + requestBody: + required: true # Even if body is empty, it is expected. + content: + application/json: + schema: + $ref: "#/components/schemas/ExtendRequest" + responses: + '200': + description: Sandbox lifetime extended successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/ExtendResponse" + '500': # Based on current lambda logic for failures + description: Failed to extend sandbox (e.g., user has no profile, or other error). + content: + application/json: + schema: + $ref: "#/components/schemas/ExtendResponse" # Contains status: FAIL and error + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxAuthdGetInstanceByHashKey: + get: + summary: Get Sandbox Instance (Authenticated) + description: Retrieves details of a specific sandbox instance for the authenticated user. + operationId: getAuthdInstanceByHashKey + security: + - Auth0Jwt: [] + parameters: + - name: sandboxHashKey + in: query + required: true + schema: + type: string + - name: verifyConnect + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + description: Sandbox details. Can be JSON or plain text 'ip:port'. + content: + application/json: + schema: + $ref: "#/components/schemas/AuthdGetInstanceByHashKeyResponse" + text/plain: + schema: + type: string + example: "127.0.0.1:7687" + '404': + description: Sandbox not found or connection verification failed. + content: + application/json: + schema: + $ref: "#/components/schemas/AuthdGetInstanceByHashKeyResponse" # Can also return partial record on verify fail + text/plain: # For "Sandbox not found" or "Found sandbox, but no ip" + schema: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxGetInstanceByHashKey: + get: + summary: Get Sandbox Instance (Public) + description: Retrieves details of a specific sandbox instance. Does not require authentication. + operationId: getInstanceByHashKey + parameters: + - name: sandboxHashKey + in: query + required: true + schema: + type: string + - name: verifyConnect + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + description: Sandbox details. Can be JSON or plain text 'ip:port'. + content: + application/json: + schema: + $ref: "#/components/schemas/AuthdGetInstanceByHashKeyResponse" # Same response schema + text/plain: + schema: + type: string + example: "127.0.0.1:7687" + '400': # If sandboxHashKey is missing + description: Invalid parameters. + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Sandbox not found or connection verification failed. + content: + application/json: + schema: + $ref: "#/components/schemas/AuthdGetInstanceByHashKeyResponse" # Can also return partial record on verify fail + text/plain: + schema: + type: string # "Sandbox not found" or "Found sandbox, but no ip" + '417': # EXPECTATION_FAILED if no IP found after checks + description: Expectation Failed (e.g. sandbox found but no IP). + content: + text/plain: + schema: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxGetRunningInstancesForUser: + get: + summary: Get User's Running Instances + description: Retrieves details of all running sandbox instances for the authenticated user. + operationId: getRunningInstancesForUser + security: + - Auth0Jwt: [] + parameters: + - name: timezone + in: query + required: false + description: User's timezone for accurate expiration calculation (e.g., 'America/New_York'). + schema: + type: string + responses: + '200': + description: A list of the user's running sandbox instances. + content: + application/json: + schema: + $ref: "#/components/schemas/RunningInstances" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxConditionalAddLead: + post: + summary: Add/Update Lead Information + description: Adds or updates lead information for the authenticated user based on marketing parameters. + operationId: conditionalAddLead + security: + - Auth0Jwt: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AddLeadRequest" + responses: + '200': + description: Lead status updated. + content: + application/json: + schema: + $ref: "#/components/schemas/AddLeadResponse" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxShare: + post: + summary: Share Sandbox + description: Invites another user to share one of the authenticated user's sandboxes. + operationId: shareSandbox + security: + - Auth0Jwt: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ShareRequest" + responses: + '200': + description: Invitation successfully created. + content: + application/json: + schema: + $ref: "#/components/schemas/ShareResponse" + default: # Consider specific error for failed share + description: Unexpected error or failed to share + content: + application/json: + schema: + $ref: '#/components/schemas/ShareResponse' # Or a more generic error + /SandboxShareAccept: + post: + summary: Accept Sandbox Share Invitation + description: Accepts an invitation to share a sandbox using an invitation token. + operationId: acceptSandboxShare + security: + - Auth0Jwt: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ShareAcceptRequest" + responses: + '200': + description: Invitation accepted successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/ShareAcceptResponse" # Define based on actual result + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxGetPendingInvitesForUser: + get: + summary: Get Pending Sent Invitations + description: Retrieves a list of pending sandbox invitations sent by the authenticated user. + operationId: getPendingInvitesForUser + security: + - Auth0Jwt: [] + responses: + '200': + description: A list of pending invitations. + content: + application/json: + schema: + $ref: "#/components/schemas/PendingInvitesResponse" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxBackup/request/{sandboxHashKey}: + post: + summary: Request Sandbox Backup + description: Initiates a backup process for a specific sandbox. + operationId: requestSandboxBackup + security: + - Auth0Jwt: [] + parameters: + - name: sandboxHashKey + in: path + required: true + schema: + type: string + responses: + '200': + description: Backup task initiated. + content: + application/json: + schema: + $ref: "#/components/schemas/BackupTaskStatus" + '404': + description: Sandbox not found for the user. + content: + application/json: + schema: + type: object + properties: + message: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxBackup/result/{result_id}: + get: + summary: Get Sandbox Backup Result + description: Retrieves the result of a specific backup task. + operationId: getSandboxBackupResult + security: + - Auth0Jwt: [] + parameters: + - name: result_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Backup task status and result (if completed). + content: + application/json: + schema: + $ref: "#/components/schemas/BackupResultResponse" + '401': + description: Unauthorized to view this result. + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Backup task ID not found. + content: + application/json: + schema: + type: object + properties: + message: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxBackup/{sandboxHashKey}: # GET and POST have different operations on the same path + get: + summary: List Sandbox Backups + description: Retrieves a list of available backups for a specific sandbox. + operationId: listSandboxBackups + security: + - Auth0Jwt: [] + parameters: + - name: sandboxHashKey + in: path + required: true + schema: + type: string + responses: + '200': + description: A list of backup files. + content: + application/json: + schema: + $ref: "#/components/schemas/BackupListResponse" + '404': # If no backups found or sandbox not found + description: No backups found or sandbox not accessible. + content: + application/json: # Returns empty array [] + schema: + type: array + items: {} + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Get Backup Download URL + description: Generates a pre-signed download URL for a specific backup file. + operationId: getBackupDownloadUrl + security: + - Auth0Jwt: [] + parameters: + - name: sandboxHashKey + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BackupDownloadUrlRequest" + responses: + '200': + description: Pre-signed download URL. + content: + application/json: + schema: + $ref: "#/components/schemas/BackupDownloadUrlResponse" + '403': + description: Forbidden to access the specified file. + content: + application/json: + schema: + type: object + properties: + message: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxAuraUpload/request/{sandboxHashKey}: + post: + summary: Request Sandbox to Aura Upload + description: Initiates an upload of a sandbox backup to an Aura instance. + operationId: requestAuraUpload + security: + - Auth0Jwt: [] + parameters: + - name: sandboxHashKey + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AuraUploadRequest" + responses: + '200': + description: Aura upload task initiated. + content: + application/json: + schema: + $ref: "#/components/schemas/AuraUploadTaskStatus" + '404': + description: Sandbox not found for the user. + content: + application/json: + schema: + type: object + properties: + message: + type: string + '422': # UNPROCESSABLE_ENTITY + description: Missing Aura URI or password. + content: + application/json: + schema: + type: object + properties: + message: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxAuraUpload/result/{result_id}: + get: + summary: Get Aura Upload Result + description: Retrieves the result of a specific Aura upload task. + operationId: getAuraUploadResult + security: + - Auth0Jwt: [] + parameters: + - name: result_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Aura upload task status and result (if completed). + content: + application/json: + schema: + $ref: "#/components/schemas/AuraUploadResultResponse" + '401': + description: Unauthorized to view this result. + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Aura upload task ID not found. + content: + application/json: + schema: + type: object + properties: + message: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxGetTraefikConfig: + get: + summary: Get Traefik Configuration + description: Retrieves the Traefik dynamic configuration for routing to active sandboxes. + operationId: getTraefikConfig + responses: + '200': + description: Traefik configuration in YAML format. + content: + application/x-yaml: + schema: + type: string # Representing YAML content + default: + description: Unexpected error + content: + application/json: # Assuming errors are still JSON + schema: + $ref: '#/components/schemas/Error' + /SandboxResendEmailVerification: + post: + summary: Resend Email Verification + description: Requests Auth0 to resend the email verification for the authenticated user. + operationId: resendEmailVerification + security: + - Auth0Jwt: [] + responses: + '204': + description: Email verification sent successfully. No content. + '500': + description: An error occurred while trying to resend the verification email. + content: + application/json: + schema: + type: object + properties: + message: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxCreateGraphAcademyUser: + post: + summary: Create/Merge GraphAcademy User + description: Merges GraphAcademy user details for the authenticated user. + operationId: createGraphAcademyUser + security: + - Auth0Jwt: [] + responses: + '200': + description: User details updated/merged successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/GraphAcademyUserResponse" + '500': + description: Error updating user details. + content: + text/plain: + schema: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxScheduleWorkshop: + post: + summary: Schedule Workshop (Slack) + description: Schedules a workshop, typically triggered via a Slack command. Expects application/x-www-form-urlencoded data. + operationId: scheduleWorkshop + # No Auth0Jwt as it's likely authenticated by Slack's own mechanisms. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/ScheduleWorkshopRequest" + responses: + '200': + description: Response for Slack indicating success or failure of scheduling. + content: + application/json: # Slack expects JSON + schema: + $ref: "#/components/schemas/ScheduleWorkshopResponse" + default: # Should also be a Slack-compatible response for errors + description: Unexpected error + content: + application/json: + schema: # Generic error for Slack + type: object + properties: + response_type: + type: string + example: ephemeral + text: + type: string + example: "An unexpected error occurred." + /SandboxSaveUserInfo: + post: + summary: Save User Information + description: Saves or updates user information (company, name) for the authenticated user. + operationId: saveUserInfo + security: + - Auth0Jwt: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SaveUserInfoRequest" + responses: + '200': + description: User information saved successfully. Returns current user info. + content: + application/json: + schema: + $ref: "#/components/schemas/UserInfoResponse" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxGetUserInfo: + get: + summary: Get User Information + description: Retrieves user information for the authenticated user. + operationId: getUserInfo + security: + - Auth0Jwt: [] + responses: + '200': + description: User information. + content: + application/json: + schema: + $ref: "#/components/schemas/UserInfoResponse" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxGenerateApiKey: + post: + summary: Generate API Key + description: Generates, saves (encrypted), and returns a new API key for the authenticated user. + operationId: generateApiKey + security: + - Auth0Jwt: [] + responses: + '200': + description: API key generated successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxGenerateApiKeyResponse" + '401': + description: Unauthorized (if Auth0 token is invalid or missing). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error (e.g., KMS configuration issue, encryption failure, database save failure). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /SandboxRunQuery: + post: + summary: Run Cypher Query + description: Executes a Cypher query on a specified sandbox instance. + operationId: runQuery + security: + - Auth0Jwt: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunQueryRequest" + responses: + '200': + description: Query executed successfully. Returns query results. + content: + application/json: + schema: + type: object # Define based on expected query result structure + # Example: Will vary based on query + # properties: + # records: + # type: array + # items: + # type: object + # summary: + # type: object + '400': + description: Bad Request (e.g., missing hash_key or statement). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' # Reusing generic error, or could be more specific + '404': + description: Sandbox not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + +components: + securitySchemes: + Auth0Jwt: + type: http + scheme: bearer + bearerFormat: JWT + description: Auth0 JWT for securing API endpoints. + + schemas: + Error: + type: object + properties: + errorString: + type: string + description: A message describing the error. + status: + type: string + description: Status of the operation, usually FAILED or an error code. + message: # For general error messages + type: string + UseCase: + type: object + properties: + name: + type: string + title: + type: string + description: + type: string + image: + type: string + enabled: + type: boolean + # Add other relevant use case properties + UseCases: + type: array + items: + $ref: '#/components/schemas/UseCase' + RunInstanceRequest: + type: object + required: + - usecase + properties: + usecase: + type: string + description: The name of the use case for the sandbox. + auth_token: + type: string + description: Optional authentication token for certain flows. + draft_only: + type: boolean + default: false + description: If true, only a draft record is created, and the instance is not deployed. + cease_emails: + type: boolean + default: false + description: If true, no emails will be sent regarding this sandbox. + SandboxInstanceDetails: # Base for various instance responses + type: object + properties: + sandboxHashKey: + type: string + status: + type: string + description: Current status of the sandbox (e.g., PENDING, RUNNING, STOPPED, FAILED, SandboxDraft). + password: + type: string + format: password + description: Password for the Neo4j instance (if applicable). + sbType: + type: string + description: Type of the sandbox (e.g., Sandbox, SandboxDraft). + usecase: + type: string + ip: + type: string + nullable: true + privip: + type: string + nullable: true + expires: + type: integer # Assuming epoch milliseconds + format: int64 + nullable: true + hasExtended: + type: boolean + nullable: true + version: + type: integer + nullable: true + sandboxId: + type: integer + nullable: true + RunInstanceResponse: + allOf: + - $ref: '#/components/schemas/SandboxInstanceDetails' + - type: object + properties: + errorString: # For specific errors like "Sandbox already exists" + type: string + nullable: true + TwitterAuthResponse: + type: object + properties: + redirect_url: + type: string + format: url + TwitterExchangeResponse: + type: object + properties: + at: + type: object + properties: + key: + type: string + secret: + type: string + StopInstanceRequest: + type: object + required: + - sandboxHashKey + properties: + sandboxHashKey: + type: string + StopInstanceResponse: # Empty on success + type: object + ExtendRequest: + type: object + properties: + sandboxHashKey: + type: string + description: Specific sandbox to extend. If not provided, all user's sandboxes are extended. + email: + type: string + format: email + description: User's email, required if providing profile information. + company: + type: string + country: + type: string + industry: + type: string + telephone: + type: string + jobrole: + type: string + whyneo4j: + type: string + ExtendResponse: + type: object + properties: + status: + type: string + enum: [SUCCESS, FAIL] + extendedByDays: + type: integer + error: + type: string + nullable: true + AuthdGetInstanceByHashKeyResponse: + $ref: '#/components/schemas/SandboxInstanceDetails' + # SandboxGetInstanceByHashKeyResponse is the same as AuthdGetInstanceByHashKeyResponse + RunningInstances: + type: array + items: + $ref: '#/components/schemas/SandboxInstanceDetails' + AddLeadRequest: + type: object + properties: + marketoCookie: + type: string + utmCampaign: + type: string + utmSource: + type: string + utmMedium: + type: string + utmAdGroup: + type: string + utmTerm: + type: string + utmContent: + type: string + utmProgram: + type: string + utmOriginal: + type: string + gclid: + type: string + AddLeadResponse: + type: object + properties: + lead_status: + type: string + ShareRequest: + type: object + required: + - sandboxHashKey + - email + - message + properties: + sandboxHashKey: + type: string + email: + type: string + message: + type: string + ShareResponse: + type: object + properties: + status: + type: string + enum: [SUCCESS, FAIL] # Assuming success or fail + token: + type: string + nullable: true + inviteEmail: + type: string + sandboxHashKey: + type: string + ShareAcceptRequest: + type: object + required: + - auth_token + properties: + auth_token: + type: string + ShareAcceptResponse: # Structure based on sblambda.sandbox.SandboxManager().accept_sandbox_invitation + type: object # Can be more specific if the exact structure is known + properties: + status: # Example, actual properties depend on the result + type: string + PendingInvite: + type: object + # Define properties of a pending invite object based on AuthHelper.get_pending_sent_invites + properties: + # Example properties, adjust based on actual data + token: + type: string + invite_email: + type: string + format: email + sandbox_hash_key: + type: string + created_at: + type: string + format: date-time + PendingInvitesResponse: + type: array + items: + $ref: '#/components/schemas/PendingInvite' + BackupRequestPath: + type: object + properties: + sandboxHashKey: + type: string + BackupTaskStatus: + type: object + properties: + id: + type: string + description: The ID of the backup task. + status: + type: string + enum: [PENDING, SUCCESS, FAILED] # Or other relevant statuses + BackupResultResponse: + allOf: + - $ref: '#/components/schemas/BackupTaskStatus' + - type: object + properties: + result: # Present if status is SUCCESS + type: object # Define the structure of the backup result if known + properties: + # Example: + # file_key: + # type: string + # file_size: + # type: integer + pass: # From existing swagger, seems to be a boolean + type: boolean + errorCode: + type: string + nullable: true + BackupListItem: + type: object + properties: + key: + type: string + size: + type: integer + LastModified: + type: string + BackupListResponse: + type: array + items: + $ref: '#/components/schemas/BackupListItem' + BackupDownloadUrlRequest: + type: object + required: + - key + properties: + key: + type: string + description: The S3 key of the backup file. + BackupDownloadUrlResponse: + type: object + properties: + downloadUrl: + type: string + format: url + AuraUploadRequest: + type: object + required: + - aura_uri + - aura_password + properties: + aura_uri: + type: string + description: The Aura instance URI. + aura_username: + type: string + default: 'neo4j' + description: Username for the Aura instance. + aura_password: + type: string + format: password + description: Password for the Aura instance. + AuraUploadTaskStatus: # Similar to BackupTaskStatus + $ref: '#/components/schemas/BackupTaskStatus' + AuraUploadResultResponse: + allOf: + - $ref: '#/components/schemas/AuraUploadTaskStatus' + - type: object + properties: + errorCode: # Specific to Aura upload failure + type: string + nullable: true + # Traefik config is YAML, not easily represented as a fixed schema + # ResendEmailVerificationResponse - 204 No Content, no schema needed for success + GraphAcademyUserResponse: # Based on AuthHelper.merge_graphacademy_user + type: object + properties: + # Define properties based on actual user details returned + # Example: + # auth0_key: + # type: string + # email: + # type: string + # graph_academy_profile_merged: + # type: boolean + pass: # from existing schema + type: boolean + ScheduleWorkshopRequest: # application/x-www-form-urlencoded + type: object + properties: + text: + type: string + description: "Workshop details: [type] [no_seats] [start_time_utc] [end_time_utc]" + user_id: + type: string + description: Slack user ID of the requester. + ScheduleWorkshopResponse: # Slack response format + type: object + properties: + response_type: + type: string + enum: [in_channel, ephemeral] + text: + type: string + SaveUserInfoRequest: + type: object + properties: + user_metadata: + type: object # Can be more specific if structure is known + additionalProperties: true + company: + type: string + first_name: + type: string + last_name: + type: string + UserInfoResponse: # Structure from AuthHelper.get_user_details + type: object + properties: + # Define properties based on actual user details returned + # Examples: + # auth0_key: + # type: string + # email: + # type: string + # name: + # type: string + # company: + # type: string + # first_name: + # type: string + # last_name: + # type: string + api_key: # New property + type: string + nullable: true + description: The user's decrypted API key, if one exists and decryption is successful. Null otherwise. + pass: # from existing schema + type: boolean + RunQueryRequest: + type: object + required: + - hash_key + - statement + properties: + hash_key: + type: string + description: The hash key of the sandbox instance. + statement: + type: string + description: The Cypher query statement to execute. + params: + type: object + description: Optional parameters for the Cypher query. + additionalProperties: true + accessMode: + type: string + description: Access mode for the query. Defaults to "Write". + enum: [Read, Write] + default: Write + SandboxGenerateApiKeyResponse: + type: object + required: + - api_key + properties: + api_key: + type: string + description: The newly generated raw (unencrypted) API key. The user should save this securely. + + +security: + - Auth0Jwt: [] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..98c777d --- /dev/null +++ b/uv.lock @@ -0,0 +1,524 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "fastmcp" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typer" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/cc/37ff3a96338234a697df31d2c70b50a1d0f5e20f045d9b7cbba052be36af/fastmcp-2.5.1.tar.gz", hash = "sha256:0d10ec65a362ae4f78bdf3b639faf35b36cc0a1c8f5461a54fac906fe821b84d", size = 1035613, upload-time = "2025-05-24T11:48:27.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/4f/e7ec7b63eadcd5b10978dbc472fc3c36de3fc8c91f60ad7642192ed78836/fastmcp-2.5.1-py3-none-any.whl", hash = "sha256:a6fe50693954a6aed89fc6e43f227dcd66e112e3d3a1d633ee22b4f435ee8aed", size = 105789, upload-time = "2025-05-24T11:48:26.371Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "mcp" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/bc/54aec2c334698cc575ca3b3481eed627125fb66544152fa1af927b1a495c/mcp-1.9.1.tar.gz", hash = "sha256:19879cd6dde3d763297617242888c2f695a95dfa854386a6a68676a646ce75e4", size = 316247, upload-time = "2025-05-22T15:52:21.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/c0/4ac795585a22a0a2d09cd2b1187b0252d2afcdebd01e10a68bbac4d34890/mcp-1.9.1-py3-none-any.whl", hash = "sha256:2900ded8ffafc3c8a7bfcfe8bc5204037e988e753ec398f371663e6a06ecd9a9", size = 130261, upload-time = "2025-05-22T15:52:19.702Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "sandbox-api-mcp-server" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastmcp" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastmcp", specifier = ">=2.2.6" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.11.4" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511, upload-time = "2025-05-12T18:23:52.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233, upload-time = "2025-05-12T18:23:50.722Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "typer" +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/89/c527e6c848739be8ceb5c44eb8208c52ea3515c6cf6406aa61932887bf58/typer-0.15.4.tar.gz", hash = "sha256:89507b104f9b6a0730354f27c39fae5b63ccd0c95b1ce1f1a6ba0cfd329997c3", size = 101559, upload-time = "2025-05-14T16:34:57.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/62/d4ba7afe2096d5659ec3db8b15d8665bdcb92a3c6ff0b95e99895b335a9c/typer-0.15.4-py3-none-any.whl", hash = "sha256:eb0651654dcdea706780c466cf06d8f174405a659ffff8f163cfbfee98c0e173", size = 45258, upload-time = "2025-05-14T16:34:55.583Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload-time = "2025-04-19T06:02:50.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload-time = "2025-04-19T06:02:48.42Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +]