You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@amoro.apache.org by cz...@apache.org on 2026/08/10 06:19:34 UTC
(amoro) branch master updated: [AMORO-4266] Introduce Amoro MCP server (#4285)
This is an automated email from the ASF dual-hosted git repository.
czy006 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git
The following commit(s) were added to refs/heads/master by this push:
new c26fd6e78 [AMORO-4266] Introduce Amoro MCP server (#4285)
c26fd6e78 is described below
commit c26fd6e787dc4cdfc5e54601426d9595320923ad
Author: Xu Bai <to...@gmail.com>
AuthorDate: Mon Aug 10 14:19:28 2026 +0800
[AMORO-4266] Introduce Amoro MCP server (#4285)
(cherry picked from commit 5a0f93774e09a10cbb3585e395335fab363540df)
Co-authored-by: ConradJam <ja...@gmail.com>
---
.github/workflows/mcp-server-ci.yml | 77 ++
.gitignore | 3 +
amoro-mcp-server/README.md | 319 ++++++
amoro-mcp-server/pyproject.toml | 68 ++
amoro-mcp-server/src/amoro_mcp_server/__init__.py | 20 +
amoro-mcp-server/src/amoro_mcp_server/actions.py | 235 ++++
amoro-mcp-server/src/amoro_mcp_server/auth.py | 388 +++++++
amoro-mcp-server/src/amoro_mcp_server/client.py | 439 ++++++++
.../src/amoro_mcp_server/compatibility.py | 55 +
amoro-mcp-server/src/amoro_mcp_server/config.py | 121 ++
.../src/amoro_mcp_server/logging_config.py | 74 ++
amoro-mcp-server/src/amoro_mcp_server/models.py | 83 ++
.../src/amoro_mcp_server/request_context.py | 62 ++
amoro-mcp-server/src/amoro_mcp_server/sanitize.py | 198 ++++
amoro-mcp-server/src/amoro_mcp_server/server.py | 140 +++
.../src/amoro_mcp_server/tools/__init__.py | 57 +
.../tools/check_ams_connectivity.py | 40 +
.../src/amoro_mcp_server/tools/common.py | 33 +
.../amoro_mcp_server/tools/get_table_details.py | 38 +
.../src/amoro_mcp_server/tools/list_catalogs.py | 33 +
.../src/amoro_mcp_server/tools/list_databases.py | 39 +
.../src/amoro_mcp_server/tools/list_optimizers.py | 45 +
.../tools/list_optimizing_processes.py | 58 +
.../tools/list_optimizing_tables.py | 48 +
.../tools/list_optimizing_tasks.py | 58 +
.../amoro_mcp_server/tools/list_resource_groups.py | 33 +
.../amoro_mcp_server/tools/list_table_snapshots.py | 64 ++
.../src/amoro_mcp_server/tools/list_tables.py | 46 +
amoro-mcp-server/tests/conftest.py | 31 +
amoro-mcp-server/tests/test_actions.py | 295 +++++
amoro-mcp-server/tests/test_auth.py | 172 +++
amoro-mcp-server/tests/test_client.py | 279 +++++
amoro-mcp-server/tests/test_compatibility.py | 156 +++
amoro-mcp-server/tests/test_logging.py | 30 +
amoro-mcp-server/tests/test_sanitize.py | 132 +++
amoro-mcp-server/tests/test_server.py | 340 ++++++
amoro-mcp-server/uv.lock | 1159 ++++++++++++++++++++
pom.xml | 1 +
38 files changed, 5469 insertions(+)
diff --git a/.github/workflows/mcp-server-ci.yml b/.github/workflows/mcp-server-ci.yml
new file mode 100644
index 000000000..f4e5d9b39
--- /dev/null
+++ b/.github/workflows/mcp-server-ci.yml
@@ -0,0 +1,77 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: MCP Server CI
+
+on:
+ pull_request:
+ paths:
+ - 'amoro-mcp-server/**'
+ - '.github/workflows/mcp-server-ci.yml'
+ - '.gitignore'
+ - 'pom.xml'
+ push:
+ paths:
+ - 'amoro-mcp-server/**'
+ - '.github/workflows/mcp-server-ci.yml'
+ - '.gitignore'
+ - 'pom.xml'
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: amoro-mcp-server
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ version: 0.11.28
+ enable-cache: true
+ cache-dependency-glob: amoro-mcp-server/uv.lock
+
+ - name: Install Python and dependencies
+ run: uv sync --locked
+
+ - name: Run tests and static checks
+ run: |
+ uv lock --check
+ uv run pytest
+ uv run ruff check .
+ uv run ruff format --check .
+ uv run mypy
+
+ license:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: maven
+
+ - name: Check Apache license headers
+ run: ./mvnw apache-rat:check -DskipTests
diff --git a/.gitignore b/.gitignore
index 58055849d..a88a820d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@ gradle/wrapper/gradle-wrapper.jar
__pycache__/
*.py[cod]
+.venv/
.eggs/
.tox/
env/
@@ -47,6 +48,8 @@ metastore_db/
# Python stuff
python/.mypy_cache/
+.mypy_cache/
+.ruff_cache/
# logs
logs/
diff --git a/amoro-mcp-server/README.md b/amoro-mcp-server/README.md
new file mode 100644
index 000000000..e9ff7dcd6
--- /dev/null
+++ b/amoro-mcp-server/README.md
@@ -0,0 +1,319 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Apache Amoro MCP Server
+
+The Apache Amoro MCP server provides access to the Amoro Management Service (AMS) over
+the Model Context Protocol (MCP). It is implemented with Python and FastMCP, uses the
+stateless Streamable HTTP transport, and exposes its MCP endpoint at `/mcp`.
+
+The server exposes eleven dedicated tools for catalog, table, snapshot, optimizing,
+optimizer, and resource-group queries. Additional capabilities can be added as dedicated tool
+modules without changing the transport or deployment model. There is no generic action executor
+or arbitrary URL access.
+
+## Architecture
+
+```text
+ MCP clients
+ |
+ | Streamable HTTP: /mcp
+ | X-Amoro-API-Key + X-Amoro-API-Secret
+ v
++-----------------------------+
+| inbound auth mode dispatch |
+| request-scoped credentials |
+| strict MCP input validation |
++-----------------------------+
+ |
+ v
++-----------------------------+
+| Dedicated MCP tools |
+| one Python module per tool |
++-----------------------------+
+ |
+ v
++-----------------------------+
+| ActionService |
+| static GET path allowlist |
+| path encoding and projection|
++-----------------------------+
+ |
+ v
++-----------------------------+ HTTPS / HTTP Basic or
+| Async AmoroClient | ----> client-owned signature ----> AMS
+| timeout, retry, size limit |
++-----------------------------+
+```
+
+In `ams-api-key` authentication mode, each MCP client's Amoro credentials validate the MCP request and
+sign that client's subsequent AMS requests. The secret is held only in request-local context and
+is not stored in FastMCP access-token claims or authentication caches. In `none` mode, the MCP
+endpoint does not authenticate clients and AMS uses the server's upstream configuration.
+
+Before a tool accesses AMS, a compatibility layer checks the AMS version and blocks versions
+older than the supported baseline. The same cached check is included in readiness.
+
+## AMS compatibility
+
+The minimum supported AMS version is `0.8.0-incubating`. The initial compatibility matrix is:
+
+| AMS version | Status | Notes |
+| --- | --- | --- |
+| 0.7.x and earlier | Unsupported | These releases use the older `/ams/v1` REST prefix |
+| 0.8.x | Supported | Minimum line supported by this MCP server |
+| 0.9.x | Supported | Optimizing-process requests include `processCategory=optimizing` |
+| Later versions | Unverified | A warning is logged, but requests are allowed |
+
+The server reads `GET /api/ams/v1/versionInfo`, normalizes versions such as
+`0.8.0-incubating(...)` and `0.9.0-SNAPSHOT(...)` to `0.8.0` and `0.9.0`, and never returns the
+commit suffix. Results are cached for 300 seconds and concurrent checks are coalesced so AMS is
+not queried once per tool call. An unsupported version makes readiness return HTTP 503 and blocks
+tool execution. An unparseable version is treated as unverified and does not stop service.
+
+Each MCP release will declare its AMS compatibility matrix. Removal of a previously supported
+AMS line follows the Apache Amoro deprecation process instead of implicitly tracking only the
+latest AMS release.
+
+## Tools
+
+| Tool | Parameters | Result |
+| --- | --- | --- |
+| `list_catalogs` | None | Catalog names and types, without catalog configuration |
+| `list_databases` | `catalog`, optional `keywords` | Databases in a catalog |
+| `list_tables` | `catalog`, `database`, optional `keywords` | Tables and formats in a database |
+| `get_table_details` | `catalog`, `database`, `table` | Schema, partitioning, summary, and safe table metadata |
+| `list_table_snapshots` | Table identity, optional `ref` and `commit_type`, `page`, `page_size` | Snapshot commits and sanitized record/file/size metrics |
+| `list_optimizing_processes` | Table identity, optional `type` and `status`, `page`, `page_size` | Paginated optimizing processes |
+| `list_optimizing_tasks` | Table identity, `process_id`, `page`, `page_size` | Task state, timing, failure reason, and aggregate input/output file statistics |
+| `list_optimizers` | Optional `optimizer_group`, `page`, `page_size` | Runtime state, resources, container, start time, and heartbeat time |
+| `list_optimizing_tables` | `optimizer_group`, `page`, `page_size` | Group table state, duration, quota occupation, and file metrics |
+| `list_resource_groups` | None | Resource groups, occupied core/memory, container, and sanitized properties |
+| `check_ams_connectivity` | None | Sanitized AMS API reachability and health classification |
+
+`page` starts at 1. `page_size` defaults to 20 and must be between 1 and 100. The default optimizer
+group is `all`.
+
+`list_optimizing_tasks` never returns optimizer credentials or individual input/output file
+records. `list_optimizers` never returns optimizer tokens. System settings and properties whose
+keys indicate configuration or credentials are removed recursively.
+
+`list_table_snapshots` accepts `commit_type` values `all`, `optimizing`, and `non-optimizing`.
+Snapshots whose `producer` is `OPTIMIZE` were produced by optimizing commits. Snapshot file detail,
+partition-file, and table-operation endpoints remain unavailable because they enumerate files or
+expose a larger operational surface.
+
+To compare adjacent commits, use total metrics such as `total-records` and `total-files-size`
+together with the corresponding `added-*` and `removed-*` values in `summary`. The server does not
+derive cross-page deltas.
+
+Cross-catalog `search_tables` and aggregate `get_table_health_summary` diagnostics are planned
+follow-up tools. They are intentionally not part of this initial eleven-tool API.
+
+## Source layout
+
+```text
+amoro-mcp-server/
+├── pyproject.toml
+├── uv.lock
+├── src/amoro_mcp_server/
+│ ├── server.py # FastMCP, middleware, transport, and health routes
+│ ├── auth.py # none/ams-api-key authentication and request middleware
+│ ├── request_context.py # isolated per-request Amoro credentials
+│ ├── config.py # AMORO_MCP_* environment settings
+│ ├── client.py # bounded async GET client and AMS signing
+│ ├── compatibility.py # AMS version normalization and support policy
+│ ├── actions.py # internal action allowlist and result projection
+│ ├── sanitize.py # sensitive/configuration field filtering
+│ └── tools/ # one public MCP tool per Python module
+└── tests/
+```
+
+## Configuration
+
+Settings are loaded from environment variables with the `AMORO_MCP_` prefix. Nested settings use
+double underscores. Lists and maps must be JSON strings.
+
+### Server settings
+
+| Environment variable | Default | Description |
+| --- | --- | --- |
+| `AMORO_MCP_HOST` | `0.0.0.0` | Bind address when using the Python entry point |
+| `AMORO_MCP_PORT` | `8000` | Bind port when using the Python entry point |
+| `AMORO_MCP_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` |
+| `AMORO_MCP_RESPONSE_MAX_BYTES` | `1048576` | Maximum upstream and projected response size |
+| `AMORO_MCP_ALLOWED_HOSTS` | `[]` | JSON host allowlist for Host/Origin protection |
+| `AMORO_MCP_ALLOWED_ORIGINS` | `[]` | JSON origin allowlist |
+
+### MCP client authentication
+
+Select one core authentication mode:
+
+```shell
+# No MCP client authentication; use only for local development or behind a trusted gateway.
+export AMORO_MCP_AUTH__MODE=none
+
+# Each MCP client authenticates with its own AMS OpenAPI credentials.
+export AMORO_MCP_AUTH__MODE=ams-api-key
+```
+
+In `ams-api-key` mode, clients send `X-Amoro-API-Key` and `X-Amoro-API-Secret` on every MCP request. The
+server validates them with a signed `GET /api/ams/v1/catalogs`, then uses the same credentials for
+tool calls. AMS API keys identify OpenAPI clients, not users or groups, and the current AMS token
+model does not provide expiry or per-tool authorization. The client secret must therefore be sent
+only over HTTPS to a trusted MCP server. `/health/live` and `/health/ready` remain unauthenticated.
+
+### AMS connection and authentication
+
+The AMS API host and port are configured together in `AMORO_MCP_AMORO__BASE_URL`:
+
+```shell
+export AMORO_MCP_AMORO__BASE_URL='http://ams.example.internal:1630'
+```
+
+Do not append `/api/ams/v1`; the client adds allowlisted API paths. The URL may be HTTP or HTTPS
+and must not contain a query or fragment.
+
+In `none` mode, supported server-owned upstream authentication modes are:
+
+```shell
+# No upstream authentication
+export AMORO_MCP_AMORO__AUTH_TYPE=none
+
+# HTTP Basic
+export AMORO_MCP_AMORO__AUTH_TYPE=basic
+export AMORO_MCP_AMORO__USERNAME='<username>'
+export AMORO_MCP_AMORO__PASSWORD='<password>'
+
+# Amoro apiKey + apiSecret query signing
+export AMORO_MCP_AMORO__AUTH_TYPE=token
+export AMORO_MCP_AMORO__API_KEY='<ams-api-key>'
+export AMORO_MCP_AMORO__API_SECRET='<ams-api-secret>'
+export AMORO_MCP_AMORO__SIGNATURE_TIMEZONE=UTC
+```
+
+The signature timezone must match the AMS JVM timezone. The defaults are a 5-second connect
+timeout, 30-second read timeout, 2-second readiness timeout, and two retries for connection errors
+and HTTP 429/502/503/504.
+
+When `AMORO_MCP_AUTH__MODE=ams-api-key`, set `AMORO_MCP_AMORO__AUTH_TYPE=none`. Server-owned Basic or
+token credentials are rejected in this mode so client requests can never fall back to a shared
+privileged identity.
+
+TLS verification is enabled by default. To use a private CA, mount the CA file and set:
+
+```shell
+export AMORO_MCP_AMORO__TLS_VERIFY=true
+export AMORO_MCP_AMORO__TLS_CA_FILE=/etc/amoro-mcp/tls/ca.crt
+```
+
+## Local development
+
+Install [uv](https://docs.astral.sh/uv/), then run from this directory:
+
+```shell
+uv sync --locked
+
+export AMORO_MCP_AUTH__MODE=none
+export AMORO_MCP_AMORO__BASE_URL='http://127.0.0.1:1630'
+export AMORO_MCP_AMORO__AUTH_TYPE=none
+
+uv run amoro-mcp-server
+```
+
+The endpoints are:
+
+- MCP: `http://127.0.0.1:8000/mcp`
+- Liveness: `http://127.0.0.1:8000/health/live`
+- Readiness: `http://127.0.0.1:8000/health/ready`
+
+Liveness checks only the server process. Readiness makes bounded requests to the AMS health and
+version endpoints. Its JSON response contains `status`, normalized `amsVersion` (or `null`), and
+`compatibility` (`supported`, `unsupported`, or `unverified`). It returns HTTP 503 when AMS is
+unavailable or the version is unsupported. Only the normalized semantic version is returned;
+commit hashes and the raw version response are never exposed.
+
+## Configure Codex
+
+The Codex desktop app reads MCP servers from `~/.codex/config.toml`. For `ams-api-key` mode, configure
+the user's own AMS OpenAPI credentials directly in that file.
+
+Add the Streamable HTTP server to `~/.codex/config.toml`:
+
+```toml
+[mcp_servers.amoro-local]
+url = "http://127.0.0.1:8000/mcp"
+http_headers = { "X-Amoro-API-Key" = "<ams-api-key>", "X-Amoro-API-Secret" = "<ams-api-secret>" }
+enabled = true
+tool_timeout_sec = 60
+```
+
+`http_headers` sends both values on every MCP request. This stores the raw secret in plaintext, so
+do not commit or share `~/.codex/config.toml`, ensure it is readable only by the local user, and use
+HTTPS for remote MCP connections. Omit `http_headers` entirely when the server runs in `none` mode.
+
+In the Codex desktop app:
+
+1. Open **Settings** and select **MCP servers**.
+2. Confirm that `amoro-local` is listed and enabled as a Streamable HTTP server.
+3. Select **Restart**, or fully quit and reopen Codex after changing `config.toml`.
+4. Start a new task so Codex refreshes the MCP tool inventory.
+
+Do not configure an `Authorization` header for this server.
+
+Example prompts for verifying the connection include:
+
+- `List all Amoro catalogs.`
+- `Show details for catalog.database.table.`
+- `Show the latest optimizing processes for catalog.database.table, then list the tasks for the latest process.`
+- `Check optimizer and resource-group status.`
+
+## Logging and operations
+
+The service writes structured JSON logs to stderr with Python logging and FastMCP structured
+logging middleware. Logs may contain request ID, client ID, tool/action name, duration, status, and
+error type. They do not contain request parameter values, response bodies, headers, or credentials;
+HTTP client URL logging is suppressed because signed AMS URLs contain authentication material.
+
+Use a process supervisor or logging agent to route stderr to the preferred logging backend.
+Do not enable raw HTTP access logging around this service unless the proxy is configured to redact
+Authorization, API-key, and signed-query values.
+
+## Test and verify
+
+Run Python checks from `amoro-mcp-server`:
+
+```shell
+uv sync --locked
+uv run pytest
+uv run ruff check .
+uv run ruff format --check .
+uv run mypy
+```
+
+Run the Apache license check from the repository root:
+
+```shell
+./mvnw -DskipTests apache-rat:check
+```
+
+The test suite covers the GET allowlist, path encoding, pagination, response filtering, signature
+algorithm, retry/timeout behavior, request-scoped Amoro authentication, credential isolation,
+Authorization-header rejection, version compatibility and caching, MCP tool registration and
+annotations, logging redaction, and the process-to-task/optimizer/resource-group flows.
diff --git a/amoro-mcp-server/pyproject.toml b/amoro-mcp-server/pyproject.toml
new file mode 100644
index 000000000..bc2b6409e
--- /dev/null
+++ b/amoro-mcp-server/pyproject.toml
@@ -0,0 +1,68 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[project]
+name = "amoro-mcp-server"
+version = "0.1.0"
+description = "Model Context Protocol server for Apache Amoro"
+readme = "README.md"
+requires-python = ">=3.12,<3.13"
+license = "Apache-2.0"
+dependencies = [
+ "fastmcp==3.4.3",
+ "httpx~=0.28.1",
+ "pydantic-settings~=2.14.2",
+ "tzdata~=2026.2",
+ "uvicorn~=0.50.2",
+]
+
+[dependency-groups]
+dev = [
+ "mypy>=1.16,<2",
+ "pytest>=8.4,<10",
+ "pytest-asyncio>=1.0,<2",
+ "respx>=0.22,<1",
+ "ruff>=0.12,<1",
+]
+
+[project.scripts]
+amoro-mcp-server = "amoro_mcp_server.server:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/amoro_mcp_server"]
+
+[tool.pytest.ini_options]
+addopts = "-ra"
+asyncio_mode = "auto"
+testpaths = ["tests"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py312"
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
+ignore = ["S101"]
+
+[tool.mypy]
+python_version = "3.12"
+strict = true
+files = ["src"]
diff --git a/amoro-mcp-server/src/amoro_mcp_server/__init__.py b/amoro-mcp-server/src/amoro_mcp_server/__init__.py
new file mode 100644
index 000000000..2bbbfef50
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/__init__.py
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Model Context Protocol server for Apache Amoro."""
+
+__version__ = "0.1.0"
diff --git a/amoro-mcp-server/src/amoro_mcp_server/actions.py b/amoro-mcp-server/src/amoro_mcp_server/actions.py
new file mode 100644
index 000000000..c8818f777
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/actions.py
@@ -0,0 +1,235 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import json
+import logging
+import time
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from typing import Any
+from urllib.parse import quote
+
+from fastmcp.exceptions import ToolError
+from fastmcp.server.dependencies import get_access_token
+from pydantic import BaseModel, ValidationError
+
+from amoro_mcp_server.client import AmoroClient
+from amoro_mcp_server.models import (
+ CatalogSearchParams,
+ EmptyParams,
+ OptimizerParams,
+ OptimizingProcessParams,
+ OptimizingTableParams,
+ OptimizingTaskParams,
+ SnapshotParams,
+ TableListParams,
+ TableParams,
+)
+from amoro_mcp_server.sanitize import (
+ project_catalogs,
+ project_optimizers,
+ project_optimizing_tables,
+ project_resource_groups,
+ project_safe,
+ project_snapshots,
+ project_tasks,
+)
+
+LOGGER = logging.getLogger(__name__)
+Projector = Callable[[Any], Any]
+
+
+@dataclass(frozen=True)
+class ActionSpec:
+ action_id: str
+ path: str
+ params_model: type[BaseModel]
+ query_fields: dict[str, str] = field(default_factory=dict)
+ fixed_query: tuple[tuple[str, str], ...] = ()
+ projector: Projector = project_safe
+
+
+def _action(
+ action_id: str,
+ path: str,
+ params_model: type[BaseModel],
+ *,
+ query_fields: dict[str, str] | None = None,
+ fixed_query: tuple[tuple[str, str], ...] = (),
+ projector: Projector = project_safe,
+) -> ActionSpec:
+ return ActionSpec(
+ action_id=action_id,
+ path=path,
+ params_model=params_model,
+ query_fields=query_fields or {},
+ fixed_query=fixed_query,
+ projector=projector,
+ )
+
+
+TABLE_PATH = "/api/ams/v1/tables/catalogs/{catalog}/dbs/{database}/tables/{table}"
+
+ACTION_SPECS = (
+ _action(
+ "list_catalogs",
+ "/api/ams/v1/catalogs",
+ EmptyParams,
+ projector=project_catalogs,
+ ),
+ _action(
+ "list_databases",
+ "/api/ams/v1/catalogs/{catalog}/databases",
+ CatalogSearchParams,
+ query_fields={"keywords": "keywords"},
+ ),
+ _action(
+ "list_tables",
+ "/api/ams/v1/catalogs/{catalog}/databases/{database}/tables",
+ TableListParams,
+ query_fields={"keywords": "keywords"},
+ ),
+ _action(
+ "get_table_details",
+ f"{TABLE_PATH}/details",
+ TableParams,
+ ),
+ _action(
+ "list_table_snapshots",
+ f"{TABLE_PATH}/snapshots",
+ SnapshotParams,
+ query_fields={
+ "ref": "ref",
+ "commit_type": "operation",
+ "page": "page",
+ "page_size": "pageSize",
+ },
+ projector=project_snapshots,
+ ),
+ _action(
+ "list_optimizing_processes",
+ f"{TABLE_PATH}/optimizing-processes",
+ OptimizingProcessParams,
+ query_fields={"type": "type", "status": "status", "page": "page", "page_size": "pageSize"},
+ fixed_query=(("processCategory", "optimizing"),),
+ ),
+ _action(
+ "list_optimizing_tasks",
+ f"{TABLE_PATH}/optimizing-processes/{{process_id}}/tasks",
+ OptimizingTaskParams,
+ query_fields={"page": "page", "page_size": "pageSize"},
+ projector=project_tasks,
+ ),
+ _action(
+ "list_optimizers",
+ "/api/ams/v1/optimize/optimizerGroups/{optimizer_group}/optimizers",
+ OptimizerParams,
+ query_fields={"page": "page", "page_size": "pageSize"},
+ projector=project_optimizers,
+ ),
+ _action(
+ "list_optimizing_tables",
+ "/api/ams/v1/optimize/optimizerGroups/{optimizer_group}/tables",
+ OptimizingTableParams,
+ query_fields={"page": "page", "page_size": "pageSize"},
+ projector=project_optimizing_tables,
+ ),
+ _action(
+ "list_resource_groups",
+ "/api/ams/v1/optimize/resourceGroups",
+ EmptyParams,
+ projector=project_resource_groups,
+ ),
+)
+
+ACTIONS = {spec.action_id: spec for spec in ACTION_SPECS}
+
+
+class ActionService:
+ def __init__(self, client: AmoroClient, response_max_bytes: int) -> None:
+ self.client = client
+ self.response_max_bytes = response_max_bytes
+
+ @staticmethod
+ def _request_parts(spec: ActionSpec, params: BaseModel) -> tuple[str, list[tuple[str, str]]]:
+ values = params.model_dump(exclude_none=True)
+ path = spec.path
+ for field_name, value in values.items():
+ placeholder = "{" + field_name + "}"
+ if placeholder in path:
+ path = path.replace(placeholder, quote(str(value), safe=""))
+
+ query = list(spec.fixed_query)
+ for field_name, query_name in spec.query_fields.items():
+ if field_name not in values:
+ continue
+ value = values[field_name]
+ if isinstance(value, list):
+ query.extend((query_name, str(item)) for item in value)
+ else:
+ query.append((query_name, str(value)))
+ if "{" in path or "}" in path:
+ raise ToolError("Action parameters did not resolve the allowlisted path")
+ return path, query
+
+ async def execute(self, action_id: str, parameters: dict[str, Any]) -> dict[str, Any]:
+ spec = ACTIONS.get(action_id)
+ if spec is None:
+ raise ToolError(f"Unknown action '{action_id}'")
+ try:
+ validated = spec.params_model.model_validate(parameters)
+ except ValidationError as exc:
+ issues = "; ".join(
+ f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}"
+ for error in exc.errors(include_input=False)
+ )
+ raise ToolError(f"Invalid parameters for {action_id}: {issues}") from exc
+
+ path, query = self._request_parts(spec, validated)
+ token = get_access_token()
+ client_id = token.client_id if token is not None else "unauthenticated"
+ started = time.perf_counter()
+ try:
+ await self.client.ensure_compatible()
+ result = await self.client.get_json(path, query)
+ projected = spec.projector(result)
+ response = {"action": action_id, "data": projected}
+ encoded = json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode()
+ if len(encoded) > self.response_max_bytes:
+ raise ToolError("Projected MCP result exceeds the configured size limit")
+ LOGGER.info(
+ "amoro_action_complete",
+ extra={
+ "action": action_id,
+ "client_id": client_id,
+ "duration_ms": round((time.perf_counter() - started) * 1000, 2),
+ },
+ )
+ return response
+ except Exception as exc:
+ LOGGER.error(
+ "amoro_action_failed",
+ extra={
+ "action": action_id,
+ "client_id": client_id,
+ "duration_ms": round((time.perf_counter() - started) * 1000, 2),
+ "error_type": type(exc).__name__,
+ },
+ )
+ raise
diff --git a/amoro-mcp-server/src/amoro_mcp_server/auth.py b/amoro-mcp-server/src/amoro_mcp_server/auth.py
new file mode 100644
index 000000000..932d3ea9d
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/auth.py
@@ -0,0 +1,388 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import hmac
+import secrets
+import time
+from abc import ABC, abstractmethod
+from collections import OrderedDict
+from dataclasses import dataclass
+from typing import Any, TypeVar
+
+from fastmcp.server.auth import AccessToken, TokenVerifier
+from starlette.responses import JSONResponse
+from starlette.types import ASGIApp, Receive, Scope, Send
+
+from amoro_mcp_server.client import AmoroClient, AmoroCredentialValidationError
+from amoro_mcp_server.config import InboundAuthSettings
+from amoro_mcp_server.request_context import (
+ AmoroRequestCredentials,
+ RequestAuthentication,
+ get_request_authentication,
+ reset_request_authentication,
+ set_request_authentication,
+)
+
+POSITIVE_CACHE_TTL_SECONDS = 300.0
+NEGATIVE_CACHE_TTL_SECONDS = 5.0
+AUTH_CACHE_MAX_ENTRIES = 10_000
+
+_AUTHORIZATION = b"authorization"
+_API_KEY = b"x-api-key"
+_AMORO_API_KEY = b"x-amoro-api-key"
+_AMORO_API_SECRET = b"x-amoro-api-secret"
+_AUTH_HEADER_NAMES = {
+ _AUTHORIZATION,
+ _API_KEY,
+ _AMORO_API_KEY,
+ _AMORO_API_SECRET,
+}
+
+
+class AuthenticationProviderUnavailable(RuntimeError):
+ """The configured identity provider could not make an authentication decision."""
+
+
+@dataclass(frozen=True)
+class AuthIdentity:
+ client_id: str
+ subject: str
+ claims: dict[str, Any]
+
+
+@dataclass(frozen=True)
+class _CacheEntry:
+ expires_at: float
+ identity: AuthIdentity | None
+
+
+class _AuthenticationCache:
+ def __init__(self, max_entries: int = AUTH_CACHE_MAX_ENTRIES) -> None:
+ self._max_entries = max_entries
+ self._entries: OrderedDict[str, _CacheEntry] = OrderedDict()
+
+ def get(self, key: str) -> tuple[bool, AuthIdentity | None]:
+ entry = self._entries.get(key)
+ if entry is None:
+ return False, None
+ if entry.expires_at <= time.monotonic():
+ del self._entries[key]
+ return False, None
+ self._entries.move_to_end(key)
+ return True, entry.identity
+
+ def set(self, key: str, identity: AuthIdentity | None) -> None:
+ ttl = POSITIVE_CACHE_TTL_SECONDS if identity is not None else NEGATIVE_CACHE_TTL_SECONDS
+ self._entries[key] = _CacheEntry(time.monotonic() + ttl, identity)
+ self._entries.move_to_end(key)
+ while len(self._entries) > self._max_entries:
+ self._entries.popitem(last=False)
+
+
+CredentialT = TypeVar("CredentialT")
+
+
+class CachedAuthProvider[CredentialT](ABC):
+ """Cache authentication decisions by credential digest and coalesce concurrent checks."""
+
+ def __init__(self) -> None:
+ self._cache = _AuthenticationCache()
+ self._lock = asyncio.Lock()
+ self._inflight: dict[str, asyncio.Task[AuthIdentity | None]] = {}
+
+ async def __aenter__(self) -> CachedAuthProvider[CredentialT]:
+ return self
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ async def authenticate(self, credential: CredentialT) -> AuthIdentity | None:
+ cache_key = self._cache_key(credential)
+ async with self._lock:
+ found, cached = self._cache.get(cache_key)
+ if found:
+ return cached
+ task = self._inflight.get(cache_key)
+ if task is None:
+ task = asyncio.create_task(self._validate_and_cache(cache_key, credential))
+ self._inflight[cache_key] = task
+
+ try:
+ return await asyncio.shield(task)
+ finally:
+ if task.done():
+ async with self._lock:
+ if self._inflight.get(cache_key) is task:
+ del self._inflight[cache_key]
+
+ async def _validate_and_cache(
+ self,
+ cache_key: str,
+ credential: CredentialT,
+ ) -> AuthIdentity | None:
+ identity = await self._validate(credential)
+ async with self._lock:
+ self._cache.set(cache_key, identity)
+ return identity
+
+ @abstractmethod
+ def _cache_key(self, credential: CredentialT) -> str:
+ raise NotImplementedError
+
+ @abstractmethod
+ async def _validate(self, credential: CredentialT) -> AuthIdentity | None:
+ raise NotImplementedError
+
+
+class AmoroAuthProvider(CachedAuthProvider[AmoroRequestCredentials]):
+ def __init__(self, client: AmoroClient) -> None:
+ super().__init__()
+ self._client = client
+
+ def _cache_key(self, credential: AmoroRequestCredentials) -> str:
+ material = f"amoro\0{credential.api_key}\0{credential.api_secret}".encode()
+ return hashlib.sha256(material).hexdigest()
+
+ async def _validate(self, credential: AmoroRequestCredentials) -> AuthIdentity | None:
+ try:
+ valid = await self._client.validate_credentials(credential)
+ except AmoroCredentialValidationError as exc:
+ raise AuthenticationProviderUnavailable from exc
+ if not valid:
+ return None
+ return AuthIdentity(
+ client_id=f"amoro:{credential.api_key}",
+ subject=credential.api_key,
+ claims={"auth_method": "amoro", "provider": "amoro"},
+ )
+
+
+class ContextTokenVerifier(TokenVerifier):
+ """Expose a request-local, non-secret token to FastMCP's authentication layer."""
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ async def verify_token(self, token: str) -> AccessToken | None:
+ authentication = get_request_authentication()
+ if authentication is None or not hmac.compare_digest(
+ token,
+ authentication.internal_token,
+ ):
+ return None
+ return AccessToken(
+ token=authentication.internal_token,
+ client_id=authentication.client_id,
+ subject=authentication.subject,
+ scopes=[],
+ claims=authentication.claims,
+ )
+
+
+class AuthenticationRuntime:
+ def __init__(
+ self,
+ settings: InboundAuthSettings,
+ provider: CachedAuthProvider[Any] | None,
+ ) -> None:
+ self.settings = settings
+ self.provider = provider
+ self.verifier = ContextTokenVerifier() if provider is not None else None
+
+ async def __aenter__(self) -> AuthenticationRuntime:
+ if self.provider is not None:
+ await self.provider.__aenter__()
+ return self
+
+ async def __aexit__(self, *_args: object) -> None:
+ if self.provider is not None:
+ await self.provider.__aexit__(*_args)
+
+ async def authenticate(self, credential: object) -> AuthIdentity | None:
+ if self.provider is None:
+ raise RuntimeError("authentication provider is not configured")
+ return await self.provider.authenticate(credential)
+
+
+def build_auth_runtime(
+ settings: InboundAuthSettings,
+ amoro_client: AmoroClient,
+) -> AuthenticationRuntime:
+ if settings.mode == "none":
+ provider: CachedAuthProvider[Any] | None = None
+ elif settings.mode == "ams-api-key":
+ provider = AmoroAuthProvider(amoro_client)
+ else:
+ provider = AmoroAuthProvider(amoro_client)
+ return AuthenticationRuntime(settings, provider)
+
+
+class InboundAuthenticationMiddleware:
+ """Validate configured credential headers before requests reach FastMCP."""
+
+ def __init__(self, app: ASGIApp, runtime: AuthenticationRuntime) -> None:
+ self.app = app
+ self.runtime = runtime
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http" or str(scope.get("path", "")).startswith("/health/"):
+ await self.app(scope, receive, send)
+ return
+
+ header_values = self._authentication_headers(scope)
+ if header_values is None:
+ await self._respond(
+ scope,
+ receive,
+ send,
+ 400,
+ "duplicate_authentication_header",
+ "Authentication headers must not be repeated",
+ )
+ return
+ if header_values[_AUTHORIZATION]:
+ await self._respond(
+ scope,
+ receive,
+ send,
+ 400,
+ "unsupported_authorization",
+ "Authorization is not supported for the configured authentication mode",
+ )
+ return
+
+ mode = self.runtime.settings.mode
+ if mode == "none":
+ if any(header_values[name] for name in _AUTH_HEADER_NAMES - {_AUTHORIZATION}):
+ await self._respond(
+ scope,
+ receive,
+ send,
+ 400,
+ "unexpected_authentication_header",
+ "Authentication headers are not accepted in none mode",
+ )
+ return
+ await self.app(scope, receive, send)
+ return
+
+ if header_values[_API_KEY]:
+ await self._mixed_headers(scope, receive, send)
+ return
+ api_key = header_values[_AMORO_API_KEY]
+ api_secret = header_values[_AMORO_API_SECRET]
+ if bool(api_key) != bool(api_secret):
+ await self._respond(
+ scope,
+ receive,
+ send,
+ 400,
+ "incomplete_amoro_credentials",
+ "Both X-Amoro-API-Key and X-Amoro-API-Secret are required",
+ )
+ return
+ if not api_key or not api_secret:
+ await self._unauthorized(scope, receive, send)
+ return
+ amoro_credentials = AmoroRequestCredentials(api_key=api_key, api_secret=api_secret)
+
+ try:
+ identity = await self.runtime.authenticate(amoro_credentials)
+ except AuthenticationProviderUnavailable:
+ await self._respond(
+ scope,
+ receive,
+ send,
+ 503,
+ "authentication_unavailable",
+ "The authentication provider is temporarily unavailable",
+ )
+ return
+ if identity is None:
+ await self._unauthorized(scope, receive, send)
+ return
+
+ internal_token = secrets.token_urlsafe(32)
+ authentication = RequestAuthentication(
+ internal_token=internal_token,
+ client_id=identity.client_id,
+ subject=identity.subject,
+ claims=identity.claims,
+ amoro_credentials=amoro_credentials,
+ )
+ context_token = set_request_authentication(authentication)
+ rewritten = dict(scope)
+ rewritten["headers"] = [
+ (name, value)
+ for name, value in scope.get("headers", [])
+ if name.lower() not in _AUTH_HEADER_NAMES
+ ]
+ rewritten["headers"].append((_AUTHORIZATION, f"Bearer {internal_token}".encode("latin-1")))
+ try:
+ await self.app(rewritten, receive, send)
+ finally:
+ reset_request_authentication(context_token)
+
+ @staticmethod
+ def _authentication_headers(scope: Scope) -> dict[bytes, str] | None:
+ found: dict[bytes, list[str]] = {name: [] for name in _AUTH_HEADER_NAMES}
+ for name, value in scope.get("headers", []):
+ normalized = name.lower()
+ if normalized in found:
+ found[normalized].append(value.decode("latin-1"))
+ if any(len(values) > 1 for values in found.values()):
+ return None
+ return {name: values[0] if values else "" for name, values in found.items()}
+
+ async def _mixed_headers(self, scope: Scope, receive: Receive, send: Send) -> None:
+ await self._respond(
+ scope,
+ receive,
+ send,
+ 400,
+ "mixed_authentication_headers",
+ "Credential headers for different authentication modes must not be mixed",
+ )
+
+ async def _unauthorized(self, scope: Scope, receive: Receive, send: Send) -> None:
+ await self._respond(
+ scope,
+ receive,
+ send,
+ 401,
+ "invalid_token",
+ "Authentication required",
+ )
+
+ @staticmethod
+ async def _respond(
+ scope: Scope,
+ receive: Receive,
+ send: Send,
+ status_code: int,
+ error: str,
+ description: str,
+ ) -> None:
+ response = JSONResponse(
+ {"error": error, "error_description": description},
+ status_code=status_code,
+ )
+ await response(scope, receive, send)
diff --git a/amoro-mcp-server/src/amoro_mcp_server/client.py b/amoro-mcp-server/src/amoro_mcp_server/client.py
new file mode 100644
index 000000000..23cb5c6f1
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/client.py
@@ -0,0 +1,439 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import logging
+import ssl
+import time
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+from urllib.parse import unquote_plus
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+import httpx
+from fastmcp.exceptions import ToolError
+
+from amoro_mcp_server.compatibility import AmsCompatibility, classify_ams_version
+from amoro_mcp_server.config import AmoroSettings
+from amoro_mcp_server.request_context import (
+ AmoroRequestCredentials,
+ get_amoro_request_credentials,
+)
+
+RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
+COMPATIBILITY_CACHE_TTL_SECONDS = 300.0
+LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class Readiness:
+ ready: bool
+ status: str
+ ams_version: str | None
+ compatibility: str
+
+
+class ResponseTooLargeError(RuntimeError):
+ pass
+
+
+class AmoroCredentialValidationError(RuntimeError):
+ """Raised when AMS cannot determine whether request credentials are valid."""
+
+
+def calculate_amoro_signature(
+ api_key: str,
+ api_secret: str,
+ params: list[tuple[str, str]],
+ *,
+ now: datetime | None = None,
+ timezone_name: str = "UTC",
+) -> str:
+ """Replicate AMS ParamSignatureCalculator for query authentication."""
+
+ grouped: dict[str, list[str]] = {}
+ for key, value in params:
+ if key in {"apiKey", "signature"}:
+ continue
+ grouped.setdefault(key, []).append(value)
+
+ components: list[str] = []
+ for key in sorted(grouped):
+ values = sorted(value for value in grouped[key] if value and not value.isspace())
+ if values:
+ components.append(f"{key}{unquote_plus(values[0])}")
+
+ if components:
+ encrypted_string = "".join(components)
+ else:
+ try:
+ timezone = ZoneInfo(timezone_name)
+ except ZoneInfoNotFoundError as exc:
+ raise ValueError(f"unknown signature timezone: {timezone_name}") from exc
+ current = now.astimezone(timezone) if now else datetime.now(timezone)
+ encrypted_string = current.strftime("%Y%m%d")
+
+ plain_text = f"{api_key}{encrypted_string}{api_secret}"
+ return hashlib.md5(plain_text.encode("utf-8"), usedforsecurity=False).hexdigest() # noqa: S324
+
+
+class AmoroClient:
+ """Bounded, retrying, HTTP client for AMS."""
+
+ def __init__(
+ self,
+ settings: AmoroSettings,
+ response_max_bytes: int,
+ *,
+ require_request_credentials: bool = False,
+ transport: httpx.AsyncBaseTransport | None = None,
+ ) -> None:
+ self.settings = settings
+ self.response_max_bytes = response_max_bytes
+ self.require_request_credentials = require_request_credentials
+ self._transport = transport
+ self._client: httpx.AsyncClient | None = None
+ self._compatibility: AmsCompatibility | None = None
+ self._compatibility_expires_at = 0.0
+ self._compatibility_lock = asyncio.Lock()
+
+ async def __aenter__(self) -> AmoroClient:
+ verify: bool | ssl.SSLContext = self.settings.tls_verify
+ if self.settings.tls_ca_file:
+ verify = ssl.create_default_context(cafile=self.settings.tls_ca_file)
+ timeout = httpx.Timeout(
+ connect=self.settings.connect_timeout_seconds,
+ read=self.settings.read_timeout_seconds,
+ write=self.settings.read_timeout_seconds,
+ pool=self.settings.connect_timeout_seconds,
+ )
+ auth: httpx.Auth | None = None
+ if self.settings.auth_type == "basic":
+ assert self.settings.username is not None and self.settings.password is not None
+ auth = httpx.BasicAuth(
+ self.settings.username,
+ self.settings.password.get_secret_value(),
+ )
+ self._client = httpx.AsyncClient(
+ base_url=self.settings.base_url,
+ timeout=timeout,
+ follow_redirects=False,
+ verify=verify,
+ auth=auth,
+ transport=self._transport,
+ limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
+ )
+ return self
+
+ async def __aexit__(self, *_args: object) -> None:
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+
+ def _authenticated_params(
+ self,
+ params: list[tuple[str, str]],
+ *,
+ credentials: AmoroRequestCredentials | None = None,
+ allow_without_request_credentials: bool = False,
+ ) -> list[tuple[str, str]]:
+ resolved = credentials or get_amoro_request_credentials()
+ if resolved is not None:
+ signature = calculate_amoro_signature(
+ resolved.api_key,
+ resolved.api_secret,
+ params,
+ timezone_name=self.settings.signature_timezone,
+ )
+ return [*params, ("apiKey", resolved.api_key), ("signature", signature)]
+
+ if self.require_request_credentials and not allow_without_request_credentials:
+ raise ToolError("Request-scoped AMS credentials are required")
+
+ if self.settings.auth_type != "token":
+ return params
+ assert self.settings.api_key is not None and self.settings.api_secret is not None
+ signature = calculate_amoro_signature(
+ self.settings.api_key,
+ self.settings.api_secret.get_secret_value(),
+ params,
+ timezone_name=self.settings.signature_timezone,
+ )
+ return [*params, ("apiKey", self.settings.api_key), ("signature", signature)]
+
+ async def _request_once(
+ self,
+ path: str,
+ params: list[tuple[str, str]],
+ *,
+ credentials: AmoroRequestCredentials | None = None,
+ allow_without_request_credentials: bool = False,
+ request_timeout: httpx.Timeout | None = None,
+ ) -> tuple[int, bytes, dict[str, str]]:
+ if self._client is None:
+ raise RuntimeError("AmoroClient must be used as an async context manager")
+ authenticated = self._authenticated_params(
+ params,
+ credentials=credentials,
+ allow_without_request_credentials=allow_without_request_credentials,
+ )
+ query_values: list[tuple[str, str | int | float | bool | None]] = list(authenticated)
+ query = httpx.QueryParams(query_values)
+ async with self._client.stream(
+ "GET", path, params=query, timeout=request_timeout
+ ) as response:
+ content_length = response.headers.get("content-length")
+ if content_length:
+ try:
+ if int(content_length) > self.response_max_bytes:
+ raise ResponseTooLargeError(
+ "AMS response exceeds the configured size limit"
+ )
+ except ValueError:
+ pass
+ chunks: list[bytes] = []
+ size = 0
+ async for chunk in response.aiter_bytes():
+ size += len(chunk)
+ if size > self.response_max_bytes:
+ raise ResponseTooLargeError("AMS response exceeds the configured size limit")
+ chunks.append(chunk)
+ return response.status_code, b"".join(chunks), dict(response.headers)
+
+ async def get_json(self, path: str, params: list[tuple[str, str]]) -> Any:
+ last_error: Exception | None = None
+ for attempt in range(self.settings.max_retries + 1):
+ try:
+ status_code, body, headers = await self._request_once(path, params)
+ if status_code in RETRYABLE_STATUS_CODES and attempt < self.settings.max_retries:
+ retry_after = headers.get("retry-after")
+ delay = 0.25 * (2**attempt)
+ if retry_after:
+ try:
+ delay = min(max(float(retry_after), 0.0), 5.0)
+ except ValueError:
+ pass
+ await asyncio.sleep(delay)
+ continue
+ if status_code < 200 or status_code >= 300:
+ raise ToolError(f"AMS request failed with HTTP status {status_code}")
+ try:
+ payload = json.loads(body)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise ToolError("AMS returned an invalid JSON response") from exc
+ if not isinstance(payload, dict):
+ raise ToolError("AMS returned an unexpected response envelope")
+ code = payload.get("code")
+ if code != 200:
+ message = str(payload.get("message") or "AMS request failed")[:500]
+ raise ToolError(f"AMS error {code}: {message}")
+ return payload.get("result")
+ except (httpx.TransportError, httpx.TimeoutException) as exc:
+ last_error = exc
+ if attempt >= self.settings.max_retries:
+ break
+ await asyncio.sleep(0.25 * (2**attempt))
+ except ResponseTooLargeError as exc:
+ raise ToolError(str(exc)) from exc
+ raise ToolError("AMS request failed after retries") from last_error
+
+ async def validate_credentials(self, credentials: AmoroRequestCredentials) -> bool:
+ """Validate client-owned credentials against a protected AMS GET endpoint."""
+
+ timeout = httpx.Timeout(connect=2.0, read=5.0, write=5.0, pool=2.0)
+ last_error: Exception | None = None
+ for attempt in range(2):
+ try:
+ status_code, body, headers = await self._request_once(
+ "/api/ams/v1/catalogs",
+ [],
+ credentials=credentials,
+ request_timeout=timeout,
+ )
+ if (status_code == 429 or 500 <= status_code < 600) and attempt == 0:
+ retry_after = headers.get("retry-after")
+ delay = 0.25
+ if retry_after:
+ try:
+ delay = min(max(float(retry_after), 0.0), 5.0)
+ except ValueError:
+ pass
+ await asyncio.sleep(delay)
+ continue
+ if status_code in {401, 403}:
+ return False
+ if status_code < 200 or status_code >= 300:
+ raise AmoroCredentialValidationError(
+ f"AMS credential validation returned HTTP status {status_code}"
+ )
+ try:
+ payload = json.loads(body)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise AmoroCredentialValidationError(
+ "AMS credential validation returned invalid JSON"
+ ) from exc
+ if not isinstance(payload, dict):
+ raise AmoroCredentialValidationError(
+ "AMS credential validation returned an unexpected envelope"
+ )
+ code = payload.get("code")
+ if code == 200:
+ return True
+ if code in {401, 403}:
+ return False
+ raise AmoroCredentialValidationError(
+ f"AMS credential validation returned error code {code}"
+ )
+ except (httpx.TransportError, httpx.TimeoutException) as exc:
+ last_error = exc
+ if attempt == 0:
+ await asyncio.sleep(0.25)
+ continue
+ except ResponseTooLargeError as exc:
+ raise AmoroCredentialValidationError(
+ "AMS credential validation response exceeded the size limit"
+ ) from exc
+ except AmoroCredentialValidationError:
+ raise
+ raise AmoroCredentialValidationError(
+ "AMS credential validation failed after retries"
+ ) from last_error
+
+ async def _fetch_compatibility(self) -> AmsCompatibility:
+ try:
+ status_code, body, _headers = await self._request_once(
+ "/api/ams/v1/versionInfo",
+ [],
+ allow_without_request_credentials=True,
+ request_timeout=httpx.Timeout(self.settings.readiness_timeout_seconds),
+ )
+ if status_code != 200:
+ compatibility = AmsCompatibility(None, "unverified")
+ else:
+ payload = json.loads(body)
+ result = payload.get("result") if isinstance(payload, dict) else None
+ raw_version = result.get("version") if isinstance(result, dict) else None
+ compatibility = (
+ classify_ams_version(raw_version)
+ if isinstance(payload, dict) and payload.get("code") == 200
+ else AmsCompatibility(None, "unverified")
+ )
+ except Exception:
+ compatibility = AmsCompatibility(None, "unverified")
+
+ if compatibility.status == "unverified":
+ LOGGER.warning(
+ "ams_version_unverified",
+ extra={
+ "ams_version": compatibility.normalized_version,
+ "compatibility": compatibility.status,
+ },
+ )
+ return compatibility
+
+ async def get_compatibility(self) -> AmsCompatibility:
+ now = time.monotonic()
+ if self._compatibility is not None and now < self._compatibility_expires_at:
+ return self._compatibility
+ async with self._compatibility_lock:
+ now = time.monotonic()
+ if self._compatibility is not None and now < self._compatibility_expires_at:
+ return self._compatibility
+ compatibility = await self._fetch_compatibility()
+ self._compatibility = compatibility
+ self._compatibility_expires_at = time.monotonic() + COMPATIBILITY_CACHE_TTL_SECONDS
+ return compatibility
+
+ async def ensure_compatible(self) -> None:
+ compatibility = await self.get_compatibility()
+ if compatibility.status == "unsupported":
+ raise ToolError(
+ f"AMS version {compatibility.normalized_version} is unsupported; "
+ "minimum supported version is 0.8.0"
+ )
+
+ async def readiness(self) -> Readiness:
+ healthy = False
+ try:
+ status_code, body, _headers = await self._request_once(
+ "/api/ams/v1/health/status",
+ [],
+ allow_without_request_credentials=True,
+ request_timeout=httpx.Timeout(self.settings.readiness_timeout_seconds),
+ )
+ payload = json.loads(body)
+ healthy = (
+ status_code == 200 and isinstance(payload, dict) and payload.get("code") == 200
+ )
+ except Exception as exc:
+ LOGGER.warning(
+ "ams_health_check_failed",
+ extra={"error_type": type(exc).__name__},
+ )
+
+ compatibility = await self.get_compatibility()
+ ready = healthy and compatibility.status != "unsupported"
+ return Readiness(
+ ready=ready,
+ status="ready" if ready else "not_ready",
+ ams_version=compatibility.normalized_version,
+ compatibility=compatibility.status,
+ )
+
+ async def check_connectivity(self) -> dict[str, Any]:
+ """Return a sanitized AMS health classification without response details."""
+
+ try:
+ status_code, body, _headers = await self._request_once(
+ "/api/ams/v1/health/status",
+ [],
+ allow_without_request_credentials=True,
+ request_timeout=httpx.Timeout(self.settings.readiness_timeout_seconds),
+ )
+ except (httpx.TransportError, httpx.TimeoutException):
+ return {"reachable": False, "amsHealth": "unknown", "failureClass": "network"}
+ except Exception:
+ return {"reachable": False, "amsHealth": "unknown", "failureClass": "unknown"}
+
+ if status_code in {401, 403}:
+ return {
+ "reachable": True,
+ "amsHealth": "unknown",
+ "failureClass": "auth",
+ "httpStatus": status_code,
+ }
+ if status_code < 200 or status_code >= 300:
+ return {
+ "reachable": True,
+ "amsHealth": "unhealthy",
+ "failureClass": "server",
+ "httpStatus": status_code,
+ }
+ try:
+ payload = json.loads(body)
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return {"reachable": True, "amsHealth": "unknown", "failureClass": "unknown"}
+ if not isinstance(payload, dict):
+ return {"reachable": True, "amsHealth": "unknown", "failureClass": "unknown"}
+ if payload.get("code") == 200:
+ return {"reachable": True, "amsHealth": "healthy", "failureClass": None}
+ return {"reachable": True, "amsHealth": "unhealthy", "failureClass": "server"}
diff --git a/amoro-mcp-server/src/amoro_mcp_server/compatibility.py b/amoro-mcp-server/src/amoro_mcp_server/compatibility.py
new file mode 100644
index 000000000..fd4b5243d
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/compatibility.py
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Literal
+
+CompatibilityStatus = Literal["supported", "unsupported", "unverified"]
+
+MINIMUM_AMS_VERSION = (0, 8, 0)
+SUPPORTED_AMS_LINES = {(0, 8), (0, 9)}
+_VERSION_PATTERN = re.compile(
+ r"^\s*(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
+ r"(?:[-+][0-9A-Za-z.-]+)?(?:\([^)]*\))?\s*$"
+)
+
+
+@dataclass(frozen=True)
+class AmsCompatibility:
+ normalized_version: str | None
+ status: CompatibilityStatus
+
+
+def classify_ams_version(raw_version: object) -> AmsCompatibility:
+ """Normalize the public version while discarding suffixes and commit metadata."""
+
+ if not isinstance(raw_version, str):
+ return AmsCompatibility(None, "unverified")
+ match = _VERSION_PATTERN.fullmatch(raw_version)
+ if match is None:
+ return AmsCompatibility(None, "unverified")
+
+ version = tuple(int(match.group(part)) for part in ("major", "minor", "patch"))
+ normalized = ".".join(str(part) for part in version)
+ if version < MINIMUM_AMS_VERSION:
+ return AmsCompatibility(normalized, "unsupported")
+ if version[:2] in SUPPORTED_AMS_LINES:
+ return AmsCompatibility(normalized, "supported")
+ return AmsCompatibility(normalized, "unverified")
diff --git a/amoro-mcp-server/src/amoro_mcp_server/config.py b/amoro-mcp-server/src/amoro_mcp_server/config.py
new file mode 100644
index 000000000..1267b9dd4
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/config.py
@@ -0,0 +1,121 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import Literal
+from urllib.parse import urlparse
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+def _validate_http_url(value: str, *, field_name: str) -> str:
+ parsed = urlparse(value)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ raise ValueError(f"{field_name} must be an absolute HTTP(S) URL")
+ if parsed.query or parsed.fragment:
+ raise ValueError(f"{field_name} must not contain a query string or fragment")
+ return value.rstrip("/")
+
+
+class InboundAuthSettings(BaseModel):
+ """Authentication settings for clients connecting to the MCP endpoint."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ mode: Literal["none", "ams-api-key"]
+
+
+class AmoroSettings(BaseModel):
+ """Connection settings for the upstream Amoro Management Service."""
+
+ base_url: str = "http://127.0.0.1:1630"
+ auth_type: Literal["basic", "token", "none"] = "none"
+ username: str | None = None
+ password: SecretStr | None = None
+ api_key: str | None = None
+ api_secret: SecretStr | None = None
+ signature_timezone: str = "UTC"
+ connect_timeout_seconds: float = Field(default=5.0, gt=0, le=60)
+ read_timeout_seconds: float = Field(default=30.0, gt=0, le=300)
+ readiness_timeout_seconds: float = Field(default=2.0, gt=0, le=30)
+ max_retries: int = Field(default=2, ge=0, le=5)
+ tls_verify: bool = True
+ tls_ca_file: str | None = None
+
+ @field_validator("base_url")
+ @classmethod
+ def validate_base_url(cls, value: str) -> str:
+ return _validate_http_url(value, field_name="base_url")
+
+ @field_validator("signature_timezone")
+ @classmethod
+ def validate_signature_timezone(cls, value: str) -> str:
+ try:
+ ZoneInfo(value)
+ except ZoneInfoNotFoundError as exc:
+ raise ValueError("signature_timezone must be a valid IANA timezone") from exc
+ return value
+
+ @model_validator(mode="after")
+ def validate_credentials(self) -> AmoroSettings:
+ if self.auth_type == "basic" and not (self.username and self.password):
+ raise ValueError("basic auth requires username and password")
+ if self.auth_type == "token" and not (self.api_key and self.api_secret):
+ raise ValueError("token auth requires api_key and api_secret")
+ return self
+
+
+class Settings(BaseSettings):
+ """Application settings loaded from AMORO_MCP_* environment variables."""
+
+ model_config = SettingsConfigDict(
+ env_prefix="AMORO_MCP_",
+ env_nested_delimiter="__",
+ case_sensitive=False,
+ extra="ignore",
+ )
+
+ host: str = "0.0.0.0" # noqa: S104 - intentional server bind address
+ port: int = Field(default=8000, ge=1, le=65535)
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
+ response_max_bytes: int = Field(default=1_048_576, ge=1024, le=10_485_760)
+ allowed_hosts: list[str] = Field(default_factory=list)
+ allowed_origins: list[str] = Field(default_factory=list)
+ auth: InboundAuthSettings
+ amoro: AmoroSettings = Field(default_factory=AmoroSettings)
+
+ @model_validator(mode="after")
+ def validate_auth_mode_with_amoro(self) -> Settings:
+ if self.auth.mode == "ams-api-key":
+ configured_credentials = any(
+ value is not None
+ for value in (
+ self.amoro.username,
+ self.amoro.password,
+ self.amoro.api_key,
+ self.amoro.api_secret,
+ )
+ )
+ if self.amoro.auth_type != "none" or configured_credentials:
+ raise ValueError(
+ "ams-api-key inbound authentication uses request credentials; "
+ "server-wide AMS authentication and credentials must be absent"
+ )
+ return self
diff --git a/amoro-mcp-server/src/amoro_mcp_server/logging_config.py b/amoro-mcp-server/src/amoro_mcp_server/logging_config.py
new file mode 100644
index 000000000..dfee71baa
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/logging_config.py
@@ -0,0 +1,74 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import json
+import logging
+import sys
+from datetime import UTC, datetime
+from typing import Any
+
+SAFE_EXTRA_FIELDS = {
+ "action",
+ "client_id",
+ "duration_ms",
+ "error_type",
+ "event",
+ "method",
+ "request_id",
+ "source",
+ "status_code",
+}
+
+
+class JsonFormatter(logging.Formatter):
+ def format(self, record: logging.LogRecord) -> str:
+ message = record.getMessage()
+ payload: dict[str, Any] = {
+ "timestamp": datetime.now(UTC).isoformat(),
+ "level": record.levelname,
+ "logger": record.name,
+ }
+ try:
+ structured_message = json.loads(message)
+ except json.JSONDecodeError:
+ payload["message"] = message
+ else:
+ if isinstance(structured_message, dict):
+ for field in SAFE_EXTRA_FIELDS:
+ if field in structured_message:
+ payload[field] = structured_message[field]
+ else:
+ payload["message"] = message
+ for field in SAFE_EXTRA_FIELDS:
+ if hasattr(record, field):
+ payload[field] = getattr(record, field)
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
+
+
+def configure_logging(level: str) -> logging.Logger:
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setFormatter(JsonFormatter())
+ root = logging.getLogger()
+ root.handlers.clear()
+ root.addHandler(handler)
+ root.setLevel(level)
+ # httpx logs full request URLs, which would disclose AMS query signatures.
+ logging.getLogger("httpx").setLevel(logging.WARNING)
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
+ return logging.getLogger("amoro_mcp_server.protocol")
diff --git a/amoro-mcp-server/src/amoro_mcp_server/models.py b/amoro-mcp-server/src/amoro_mcp_server/models.py
new file mode 100644
index 000000000..bda9ecd45
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/models.py
@@ -0,0 +1,83 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+Identifier = str
+Page = int
+PageSize = int
+
+
+class StrictParams(BaseModel):
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
+
+
+class EmptyParams(StrictParams):
+ pass
+
+
+class CatalogParams(StrictParams):
+ catalog: Identifier = Field(min_length=1, max_length=255)
+
+
+class CatalogSearchParams(CatalogParams):
+ keywords: str | None = Field(default=None, max_length=255)
+
+
+class TableListParams(CatalogSearchParams):
+ database: Identifier = Field(min_length=1, max_length=255)
+
+
+class TableParams(StrictParams):
+ catalog: Identifier = Field(min_length=1, max_length=255)
+ database: Identifier = Field(min_length=1, max_length=255)
+ table: Identifier = Field(min_length=1, max_length=255)
+
+
+class PageParams(StrictParams):
+ page: Page = Field(default=1, ge=1)
+ page_size: PageSize = Field(default=20, ge=1, le=100)
+
+
+class TablePageParams(TableParams, PageParams):
+ pass
+
+
+class OptimizingProcessParams(TablePageParams):
+ type: str | None = Field(default=None, max_length=128)
+ status: str | None = Field(default=None, max_length=128)
+
+
+class OptimizingTaskParams(TablePageParams):
+ process_id: Identifier = Field(min_length=1, max_length=255)
+
+
+class SnapshotParams(TablePageParams):
+ ref: Identifier | None = Field(default=None, min_length=1, max_length=255)
+ commit_type: Literal["all", "optimizing", "non-optimizing"] = "all"
+
+
+class OptimizerParams(PageParams):
+ optimizer_group: Identifier = Field(default="all", min_length=1, max_length=50)
+
+
+class OptimizingTableParams(PageParams):
+ optimizer_group: Identifier = Field(min_length=1, max_length=50)
diff --git a/amoro-mcp-server/src/amoro_mcp_server/request_context.py b/amoro-mcp-server/src/amoro_mcp_server/request_context.py
new file mode 100644
index 000000000..962d26fbc
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/request_context.py
@@ -0,0 +1,62 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from contextvars import ContextVar, Token
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class AmoroRequestCredentials:
+ api_key: str
+ api_secret: str
+
+
+@dataclass(frozen=True)
+class RequestAuthentication:
+ internal_token: str
+ client_id: str
+ subject: str
+ claims: dict[str, Any]
+ amoro_credentials: AmoroRequestCredentials | None = None
+
+
+_REQUEST_AUTH: ContextVar[RequestAuthentication | None] = ContextVar(
+ "amoro_mcp_request_auth",
+ default=None,
+)
+
+
+def get_request_authentication() -> RequestAuthentication | None:
+ return _REQUEST_AUTH.get()
+
+
+def get_amoro_request_credentials() -> AmoroRequestCredentials | None:
+ authentication = get_request_authentication()
+ return authentication.amoro_credentials if authentication is not None else None
+
+
+def set_request_authentication(
+ authentication: RequestAuthentication,
+) -> Token[RequestAuthentication | None]:
+ return _REQUEST_AUTH.set(authentication)
+
+
+def reset_request_authentication(token: Token[RequestAuthentication | None]) -> None:
+ _REQUEST_AUTH.reset(token)
diff --git a/amoro-mcp-server/src/amoro_mcp_server/sanitize.py b/amoro-mcp-server/src/amoro_mcp_server/sanitize.py
new file mode 100644
index 000000000..cd5cfdd5e
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/sanitize.py
@@ -0,0 +1,198 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from typing import Any
+
+SENSITIVE_KEY = re.compile(
+ r"(?:password|passwd|secret|token|credential|access.?key|private.?key|"
+ r"authorization|cookie|signature|api.?key)",
+ re.IGNORECASE,
+)
+CONFIGURATION_KEY = re.compile(r"(?:config(?:uration)?s?|settings?)", re.IGNORECASE)
+FILE_STAT_FIELDS = {"fileCnt", "totalSize", "averageSize"}
+
+
+def _is_blocked_key(key: str) -> bool:
+ return bool(SENSITIVE_KEY.search(key) or CONFIGURATION_KEY.search(key))
+
+
+def project_safe(value: Any) -> Any:
+ """Recursively retain business fields while removing credentials and configuration."""
+
+ if isinstance(value, list):
+ return [project_safe(item) for item in value]
+ if not isinstance(value, Mapping):
+ return value
+
+ projected: dict[str, Any] = {}
+ for key, item in value.items():
+ key_string = str(key)
+ if _is_blocked_key(key_string):
+ continue
+ projected[key_string] = project_safe(item)
+ return projected
+
+
+def _project_file_statistics(value: Any) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ return {}
+ return {
+ key: project_safe(item)
+ for key, item in value.items()
+ if key in FILE_STAT_FIELDS and not _is_blocked_key(key)
+ }
+
+
+def project_catalogs(value: Any) -> list[dict[str, Any]]:
+ if not isinstance(value, list):
+ return []
+ return [
+ {
+ key: item[key]
+ for key in ("catalogName", "catalogType")
+ if isinstance(item, Mapping) and key in item
+ }
+ for item in value
+ if isinstance(item, Mapping)
+ ]
+
+
+def _project_page(value: Any, item_projector: Any) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ return {"list": [], "total": 0}
+ raw_list = value.get("list")
+ return {
+ "list": [item_projector(item) for item in raw_list] if isinstance(raw_list, list) else [],
+ "total": value.get("total", 0),
+ }
+
+
+def project_snapshots(value: Any) -> dict[str, Any]:
+ """Preserve safe snapshot metrics while enforcing the paginated response shape."""
+
+ return _project_page(value, project_safe)
+
+
+def project_optimizers(value: Any) -> dict[str, Any]:
+ allowed = {
+ "startTime",
+ "touchTime",
+ "jobId",
+ "groupName",
+ "coreNumber",
+ "memory",
+ "jobStatus",
+ "container",
+ }
+
+ def item_projector(item: Any) -> dict[str, Any]:
+ if not isinstance(item, Mapping):
+ return {}
+ return {key: project_safe(data) for key, data in item.items() if key in allowed}
+
+ return _project_page(value, item_projector)
+
+
+def project_optimizing_tables(value: Any) -> dict[str, Any]:
+ """Return only table scheduling and capacity fields used by AIOps diagnosis."""
+
+ allowed = {
+ "tableIdentifier",
+ "catalogName",
+ "databaseName",
+ "tableName",
+ "optimizeStatus",
+ "optimizingStatus",
+ "duration",
+ "fileCount",
+ "fileSize",
+ "quota",
+ "quotaOccupation",
+ "groupName",
+ }
+
+ def item_projector(item: Any) -> dict[str, Any]:
+ if not isinstance(item, Mapping):
+ return {}
+ return {key: project_safe(data) for key, data in item.items() if key in allowed}
+
+ return _project_page(value, item_projector)
+
+
+def project_tasks(value: Any) -> dict[str, Any]:
+ allowed = {
+ "tableId",
+ "processId",
+ "taskId",
+ "partitionData",
+ "status",
+ "retryNum",
+ "threadId",
+ "startTime",
+ "endTime",
+ "costTime",
+ "failReason",
+ "inputFiles",
+ "outputFiles",
+ "summary",
+ "properties",
+ }
+
+ def item_projector(item: Any) -> dict[str, Any]:
+ if not isinstance(item, Mapping):
+ return {}
+ result: dict[str, Any] = {}
+ for key, data in item.items():
+ if key not in allowed or _is_blocked_key(key):
+ continue
+ result[key] = (
+ _project_file_statistics(data)
+ if key in {"inputFiles", "outputFiles"}
+ else project_safe(data)
+ )
+ return result
+
+ return _project_page(value, item_projector)
+
+
+def project_resource_groups(value: Any) -> list[dict[str, Any]]:
+ if not isinstance(value, list):
+ return []
+ projected: list[dict[str, Any]] = []
+ for item in value:
+ if not isinstance(item, Mapping):
+ continue
+ resource = item.get("resourceGroup")
+ safe_resource: dict[str, Any] = {}
+ if isinstance(resource, Mapping):
+ for key in ("name", "container", "properties"):
+ if key in resource:
+ safe_resource[key] = (
+ project_safe(resource[key]) if key == "properties" else resource[key]
+ )
+ projected.append(
+ {
+ "resourceGroup": safe_resource,
+ "occupationCore": item.get("occupationCore", 0),
+ "occupationMemory": item.get("occupationMemory", 0),
+ }
+ )
+ return projected
diff --git a/amoro-mcp-server/src/amoro_mcp_server/server.py b/amoro-mcp-server/src/amoro_mcp_server/server.py
new file mode 100644
index 000000000..f1ff1b75a
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/server.py
@@ -0,0 +1,140 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from typing import Any
+
+import httpx
+import uvicorn
+from fastmcp import FastMCP
+from fastmcp.server.middleware.logging import StructuredLoggingMiddleware
+from starlette.responses import JSONResponse
+from starlette.types import ASGIApp
+
+from amoro_mcp_server import __version__
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.auth import (
+ AuthenticationRuntime,
+ InboundAuthenticationMiddleware,
+ build_auth_runtime,
+)
+from amoro_mcp_server.client import AmoroClient
+from amoro_mcp_server.config import Settings
+from amoro_mcp_server.logging_config import configure_logging
+from amoro_mcp_server.tools import register_tools
+
+
+def _build_server_components(
+ settings: Settings,
+ *,
+ transport: httpx.AsyncBaseTransport | None = None,
+) -> tuple[FastMCP, AuthenticationRuntime]:
+ protocol_logger = configure_logging(settings.log_level)
+ client = AmoroClient(
+ settings.amoro,
+ settings.response_max_bytes,
+ require_request_credentials=settings.auth.mode == "ams-api-key",
+ transport=transport,
+ )
+ auth_runtime = build_auth_runtime(settings.auth, client)
+ service = ActionService(client, settings.response_max_bytes)
+
+ @asynccontextmanager
+ async def lifespan(_server: FastMCP) -> AsyncIterator[None]:
+ async with client, auth_runtime:
+ yield
+
+ mcp = FastMCP(
+ name="Apache Amoro",
+ version=__version__,
+ auth=auth_runtime.verifier,
+ lifespan=lifespan,
+ middleware=[
+ StructuredLoggingMiddleware(
+ logger=protocol_logger,
+ include_payloads=False,
+ include_payload_length=False,
+ )
+ ],
+ mask_error_details=True,
+ strict_input_validation=True,
+ )
+ register_tools(mcp, service)
+
+ @mcp.custom_route("/health/live", methods=["GET"])
+ async def health_live(_request: Any) -> JSONResponse:
+ return JSONResponse({"status": "ok", "service": "amoro-mcp-server"})
+
+ @mcp.custom_route("/health/ready", methods=["GET"])
+ async def health_ready(_request: Any) -> JSONResponse:
+ readiness = await client.readiness()
+ return JSONResponse(
+ {
+ "status": readiness.status,
+ "amsVersion": readiness.ams_version,
+ "compatibility": readiness.compatibility,
+ },
+ status_code=200 if readiness.ready else 503,
+ )
+
+ return mcp, auth_runtime
+
+
+def build_server(
+ settings: Settings,
+ *,
+ transport: httpx.AsyncBaseTransport | None = None,
+) -> FastMCP:
+ mcp, _auth_runtime = _build_server_components(settings, transport=transport)
+ return mcp
+
+
+def create_app(
+ settings: Settings | None = None,
+ *,
+ transport: httpx.AsyncBaseTransport | None = None,
+) -> ASGIApp:
+ resolved = settings or Settings() # type: ignore[call-arg]
+ mcp, auth_runtime = _build_server_components(resolved, transport=transport)
+ app = mcp.http_app(
+ path="/mcp",
+ stateless_http=True,
+ json_response=True,
+ host_origin_protection=True,
+ allowed_hosts=resolved.allowed_hosts or ["127.0.0.1:*", "localhost:*"],
+ allowed_origins=resolved.allowed_origins,
+ )
+ return InboundAuthenticationMiddleware(app, auth_runtime)
+
+
+def main() -> None:
+ settings = Settings() # type: ignore[call-arg]
+ uvicorn.run(
+ "amoro_mcp_server.server:create_app",
+ factory=True,
+ host=settings.host,
+ port=settings.port,
+ log_level=settings.log_level.lower(),
+ access_log=False,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/__init__.py b/amoro-mcp-server/src/amoro_mcp_server/tools/__init__.py
new file mode 100644
index 000000000..f282811a1
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/__init__.py
@@ -0,0 +1,57 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from collections.abc import Callable
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.check_ams_connectivity import register as register_ams_connectivity
+from amoro_mcp_server.tools.get_table_details import register as register_table_details
+from amoro_mcp_server.tools.list_catalogs import register as register_catalogs
+from amoro_mcp_server.tools.list_databases import register as register_databases
+from amoro_mcp_server.tools.list_optimizers import register as register_optimizers
+from amoro_mcp_server.tools.list_optimizing_processes import (
+ register as register_optimizing_processes,
+)
+from amoro_mcp_server.tools.list_optimizing_tables import register as register_optimizing_tables
+from amoro_mcp_server.tools.list_optimizing_tasks import register as register_optimizing_tasks
+from amoro_mcp_server.tools.list_resource_groups import register as register_resource_groups
+from amoro_mcp_server.tools.list_table_snapshots import register as register_table_snapshots
+from amoro_mcp_server.tools.list_tables import register as register_tables
+
+ToolRegistrar = Callable[[FastMCP, ActionService], None]
+
+TOOL_REGISTRARS: tuple[ToolRegistrar, ...] = (
+ register_catalogs,
+ register_databases,
+ register_tables,
+ register_table_details,
+ register_table_snapshots,
+ register_optimizing_processes,
+ register_optimizing_tasks,
+ register_optimizers,
+ register_optimizing_tables,
+ register_resource_groups,
+ register_ams_connectivity,
+)
+
+
+def register_tools(mcp: FastMCP, service: ActionService) -> None:
+ """Register every public Amoro MCP tool on the server instance."""
+ for register in TOOL_REGISTRARS:
+ register(mcp, service)
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/check_ams_connectivity.py b/amoro-mcp-server/src/amoro_mcp_server/tools/check_ams_connectivity.py
new file mode 100644
index 000000000..df588a245
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/check_ams_connectivity.py
@@ -0,0 +1,40 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import READ_ONLY_ANNOTATIONS
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="Check AMS connectivity",
+ description=(
+ "Check sanitized AMS API reachability and health classification. This does not "
+ "prove that AMS or optimizer object-store IAM credentials are valid."
+ ),
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def check_ams_connectivity() -> dict[str, Any]:
+ await service.client.ensure_compatible()
+ return {
+ "action": "check_ams_connectivity",
+ "data": await service.client.check_connectivity(),
+ }
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/common.py b/amoro-mcp-server/src/amoro_mcp_server/tools/common.py
new file mode 100644
index 000000000..52b98e717
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/common.py
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Annotated
+
+from pydantic import Field
+
+READ_ONLY_ANNOTATIONS = {
+ "readOnlyHint": True,
+ "destructiveHint": False,
+ "idempotentHint": True,
+ "openWorldHint": True,
+}
+
+NameArg = Annotated[str, Field(min_length=1, max_length=255)]
+GroupArg = Annotated[str, Field(min_length=1, max_length=50)]
+OptionalKeywordArg = Annotated[str | None, Field(max_length=255)]
+PageArg = Annotated[int, Field(ge=1)]
+PageSizeArg = Annotated[int, Field(ge=1, le=100)]
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/get_table_details.py b/amoro-mcp-server/src/amoro_mcp_server/tools/get_table_details.py
new file mode 100644
index 000000000..40e677028
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/get_table_details.py
@@ -0,0 +1,38 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import READ_ONLY_ANNOTATIONS, NameArg
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="Get Amoro table details",
+ description="Get schema, partitioning, summary, and safe metadata for a table.",
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def get_table_details(
+ catalog: NameArg, database: NameArg, table: NameArg
+ ) -> dict[str, Any]:
+ return await service.execute(
+ "get_table_details",
+ {"catalog": catalog, "database": database, "table": table},
+ )
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_catalogs.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_catalogs.py
new file mode 100644
index 000000000..a38bf8d9b
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_catalogs.py
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import READ_ONLY_ANNOTATIONS
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List Amoro catalogs",
+ description="List catalog names and types without returning catalog configuration.",
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_catalogs() -> dict[str, Any]:
+ return await service.execute("list_catalogs", {})
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_databases.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_databases.py
new file mode 100644
index 000000000..729583c29
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_databases.py
@@ -0,0 +1,39 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import (
+ READ_ONLY_ANNOTATIONS,
+ NameArg,
+ OptionalKeywordArg,
+)
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List Amoro databases",
+ description="List databases in a catalog, optionally filtered by keyword.",
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_databases(
+ catalog: NameArg, keywords: OptionalKeywordArg = None
+ ) -> dict[str, Any]:
+ return await service.execute("list_databases", {"catalog": catalog, "keywords": keywords})
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizers.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizers.py
new file mode 100644
index 000000000..e367c22b5
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizers.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import (
+ READ_ONLY_ANNOTATIONS,
+ GroupArg,
+ PageArg,
+ PageSizeArg,
+)
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List optimizer instances",
+ description="List paginated optimizer runtime metadata without optimizer tokens.",
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_optimizers(
+ optimizer_group: GroupArg = "all",
+ page: PageArg = 1,
+ page_size: PageSizeArg = 20,
+ ) -> dict[str, Any]:
+ return await service.execute(
+ "list_optimizers",
+ {"optimizer_group": optimizer_group, "page": page, "page_size": page_size},
+ )
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_processes.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_processes.py
new file mode 100644
index 000000000..d0c9b7862
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_processes.py
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Annotated, Any
+
+from fastmcp import FastMCP
+from pydantic import Field
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import (
+ READ_ONLY_ANNOTATIONS,
+ NameArg,
+ PageArg,
+ PageSizeArg,
+)
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List table optimizing processes",
+ description="List paginated optimizing processes with optional type and status filters.",
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_optimizing_processes(
+ catalog: NameArg,
+ database: NameArg,
+ table: NameArg,
+ type: Annotated[str | None, Field(max_length=128)] = None,
+ status: Annotated[str | None, Field(max_length=128)] = None,
+ page: PageArg = 1,
+ page_size: PageSizeArg = 20,
+ ) -> dict[str, Any]:
+ return await service.execute(
+ "list_optimizing_processes",
+ {
+ "catalog": catalog,
+ "database": database,
+ "table": table,
+ "type": type,
+ "status": status,
+ "page": page,
+ "page_size": page_size,
+ },
+ )
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_tables.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_tables.py
new file mode 100644
index 000000000..e023ec797
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_tables.py
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import (
+ READ_ONLY_ANNOTATIONS,
+ GroupArg,
+ PageArg,
+ PageSizeArg,
+)
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List optimizing tables",
+ description=(
+ "List paginated tables assigned to an optimizer group with scheduling state, "
+ "duration, quota occupation, and aggregate file metrics."
+ ),
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_optimizing_tables(
+ optimizer_group: GroupArg,
+ page: PageArg = 1,
+ page_size: PageSizeArg = 20,
+ ) -> dict[str, Any]:
+ return await service.execute(
+ "list_optimizing_tables",
+ {"optimizer_group": optimizer_group, "page": page, "page_size": page_size},
+ )
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_tasks.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_tasks.py
new file mode 100644
index 000000000..074861a1c
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_optimizing_tasks.py
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import (
+ READ_ONLY_ANNOTATIONS,
+ NameArg,
+ PageArg,
+ PageSizeArg,
+)
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List optimizing process tasks",
+ description=(
+ "List sanitized tasks for an optimizing process. Returns task timing, status, failure "
+ "reason, and aggregate file statistics; never returns optimizer credentials."
+ ),
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_optimizing_tasks(
+ catalog: NameArg,
+ database: NameArg,
+ table: NameArg,
+ process_id: NameArg,
+ page: PageArg = 1,
+ page_size: PageSizeArg = 20,
+ ) -> dict[str, Any]:
+ return await service.execute(
+ "list_optimizing_tasks",
+ {
+ "catalog": catalog,
+ "database": database,
+ "table": table,
+ "process_id": process_id,
+ "page": page,
+ "page_size": page_size,
+ },
+ )
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_resource_groups.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_resource_groups.py
new file mode 100644
index 000000000..f3950c010
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_resource_groups.py
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import READ_ONLY_ANNOTATIONS
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List optimizer resource groups",
+ description="List resource groups with occupied resources and sanitized properties.",
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_resource_groups() -> dict[str, Any]:
+ return await service.execute("list_resource_groups", {})
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_table_snapshots.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_table_snapshots.py
new file mode 100644
index 000000000..9f3f784cd
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_table_snapshots.py
@@ -0,0 +1,64 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Annotated, Any, Literal
+
+from fastmcp import FastMCP
+from pydantic import Field
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import (
+ READ_ONLY_ANNOTATIONS,
+ NameArg,
+ PageArg,
+ PageSizeArg,
+)
+
+CommitTypeArg = Literal["all", "optimizing", "non-optimizing"]
+OptionalRefArg = Annotated[str | None, Field(min_length=1, max_length=255)]
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List table snapshots",
+ description=(
+ "List paginated table snapshots for a branch or tag. Returns sanitized commit, "
+ "record, file, and size metrics; producer=OPTIMIZE identifies optimizing commits."
+ ),
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_table_snapshots(
+ catalog: NameArg,
+ database: NameArg,
+ table: NameArg,
+ ref: OptionalRefArg = None,
+ commit_type: CommitTypeArg = "all",
+ page: PageArg = 1,
+ page_size: PageSizeArg = 20,
+ ) -> dict[str, Any]:
+ return await service.execute(
+ "list_table_snapshots",
+ {
+ "catalog": catalog,
+ "database": database,
+ "table": table,
+ "ref": ref,
+ "commit_type": commit_type,
+ "page": page,
+ "page_size": page_size,
+ },
+ )
diff --git a/amoro-mcp-server/src/amoro_mcp_server/tools/list_tables.py b/amoro-mcp-server/src/amoro_mcp_server/tools/list_tables.py
new file mode 100644
index 000000000..395da0067
--- /dev/null
+++ b/amoro-mcp-server/src/amoro_mcp_server/tools/list_tables.py
@@ -0,0 +1,46 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+from fastmcp import FastMCP
+
+from amoro_mcp_server.actions import ActionService
+from amoro_mcp_server.tools.common import (
+ READ_ONLY_ANNOTATIONS,
+ NameArg,
+ OptionalKeywordArg,
+)
+
+
+def register(mcp: FastMCP, service: ActionService) -> None:
+ @mcp.tool(
+ title="List Amoro tables",
+ description=(
+ "List tables and formats in a catalog database, optionally filtered by keyword."
+ ),
+ annotations=READ_ONLY_ANNOTATIONS,
+ )
+ async def list_tables(
+ catalog: NameArg,
+ database: NameArg,
+ keywords: OptionalKeywordArg = None,
+ ) -> dict[str, Any]:
+ return await service.execute(
+ "list_tables",
+ {"catalog": catalog, "database": database, "keywords": keywords},
+ )
diff --git a/amoro-mcp-server/tests/conftest.py b/amoro-mcp-server/tests/conftest.py
new file mode 100644
index 000000000..df2d4111e
--- /dev/null
+++ b/amoro-mcp-server/tests/conftest.py
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import pytest
+
+from amoro_mcp_server.config import AmoroSettings, InboundAuthSettings, Settings
+
+
+@pytest.fixture
+def settings() -> Settings:
+ return Settings(
+ auth=InboundAuthSettings(mode="none"),
+ amoro=AmoroSettings(base_url="https://amoro.test", auth_type="none"),
+ allowed_hosts=["testserver"],
+ )
diff --git a/amoro-mcp-server/tests/test_actions.py b/amoro-mcp-server/tests/test_actions.py
new file mode 100644
index 000000000..ed3d096e7
--- /dev/null
+++ b/amoro-mcp-server/tests/test_actions.py
@@ -0,0 +1,295 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from fastmcp.exceptions import ToolError
+
+from amoro_mcp_server.actions import ACTION_SPECS, ACTIONS, ActionService
+
+PROMOTED_ACTIONS = {
+ "list_catalogs",
+ "list_databases",
+ "list_tables",
+ "get_table_details",
+ "list_table_snapshots",
+ "list_optimizing_processes",
+ "list_optimizing_tasks",
+ "list_optimizers",
+ "list_optimizing_tables",
+ "list_resource_groups",
+}
+
+
+class FakeClient:
+ def __init__(self, result: Any) -> None:
+ self.result = result
+ self.calls: list[tuple[str, list[tuple[str, str]]]] = []
+ self.compatibility_checks = 0
+
+ async def ensure_compatible(self) -> None:
+ self.compatibility_checks += 1
+
+ async def get_json(self, path: str, params: list[tuple[str, str]]) -> Any:
+ self.calls.append((path, params))
+ return self.result
+
+
+def test_action_catalog_is_get_only_and_excludes_unsafe_routes() -> None:
+ excluded = (
+ "/files",
+ "/operations",
+ "/settings",
+ "/terminal",
+ "/login",
+ "/config/",
+ )
+ assert len(ACTIONS) == len(ACTION_SPECS)
+ assert set(ACTIONS) == PROMOTED_ACTIONS
+ assert all(spec.path.startswith("/api/ams/v1/") for spec in ACTION_SPECS)
+ assert not any(fragment in spec.path for fragment in excluded for spec in ACTION_SPECS)
+ assert {spec.path for spec in ACTION_SPECS if "/snapshots" in spec.path} == {
+ "/api/ams/v1/tables/catalogs/{catalog}/dbs/{database}/tables/{table}/snapshots"
+ }
+
+
+@pytest.mark.asyncio
+async def test_execute_encodes_path_and_maps_query_parameters() -> None:
+ client = FakeClient({"list": [], "total": 0})
+ service = ActionService(client, 1_048_576) # type: ignore[arg-type]
+
+ await service.execute(
+ "list_optimizing_tasks",
+ {
+ "catalog": "prod/catalog",
+ "database": "db name",
+ "table": "orders",
+ "process_id": "process/1",
+ "page": 2,
+ "page_size": 25,
+ },
+ )
+
+ path, query = client.calls[0]
+ assert "prod%2Fcatalog" in path
+ assert "db%20name" in path
+ assert "process%2F1" in path
+ assert query == [("page", "2"), ("pageSize", "25")]
+ assert client.compatibility_checks == 1
+
+
+@pytest.mark.asyncio
+async def test_optimizing_processes_adds_compatibility_category_without_public_parameter() -> None:
+ client = FakeClient({"list": [], "total": 0})
+ service = ActionService(client, 1_048_576) # type: ignore[arg-type]
+
+ await service.execute(
+ "list_optimizing_processes",
+ {
+ "catalog": "prod",
+ "database": "sales",
+ "table": "orders",
+ "page": 1,
+ "page_size": 20,
+ },
+ )
+
+ assert client.calls == [
+ (
+ "/api/ams/v1/tables/catalogs/prod/dbs/sales/tables/orders/optimizing-processes",
+ [("processCategory", "optimizing"), ("page", "1"), ("pageSize", "20")],
+ )
+ ]
+ with pytest.raises(ToolError, match="Extra inputs are not permitted"):
+ await service.execute(
+ "list_optimizing_processes",
+ {
+ "catalog": "prod",
+ "database": "sales",
+ "table": "orders",
+ "processCategory": "other",
+ },
+ )
+
+
+@pytest.mark.asyncio
+async def test_snapshot_action_maps_filters_and_preserves_safe_new_metrics() -> None:
+ client = FakeClient(
+ {
+ "list": [
+ {
+ "snapshotId": "123",
+ "producer": "OPTIMIZE",
+ "futureMetric": 42,
+ "summary": {
+ "total-records": "100",
+ "new-iceberg-metric": "7",
+ "optimizerToken": "never-return",
+ "storageConfiguration": {"endpoint": "internal"},
+ },
+ }
+ ],
+ "total": 1,
+ }
+ )
+ service = ActionService(client, 1_048_576) # type: ignore[arg-type]
+
+ result = await service.execute(
+ "list_table_snapshots",
+ {
+ "catalog": "prod/catalog",
+ "database": "db name",
+ "table": "orders",
+ "ref": "release/v1",
+ "commit_type": "optimizing",
+ "page": 2,
+ "page_size": 10,
+ },
+ )
+
+ path, query = client.calls[0]
+ assert path.endswith("/prod%2Fcatalog/dbs/db%20name/tables/orders/snapshots")
+ assert query == [
+ ("ref", "release/v1"),
+ ("operation", "optimizing"),
+ ("page", "2"),
+ ("pageSize", "10"),
+ ]
+ snapshot = result["data"]["list"][0]
+ assert snapshot["producer"] == "OPTIMIZE"
+ assert snapshot["futureMetric"] == 42
+ assert snapshot["summary"] == {
+ "total-records": "100",
+ "new-iceberg-metric": "7",
+ }
+
+
+@pytest.mark.asyncio
+async def test_optimizing_tables_action_maps_group_and_projects_capacity_fields() -> None:
+ client = FakeClient(
+ {
+ "list": [
+ {
+ "tableIdentifier": "prod.sales.orders",
+ "optimizeStatus": "PENDING",
+ "duration": 10_800_000,
+ "fileCount": 900,
+ "fileSize": 4096,
+ "quota": 0.1,
+ "quotaOccupation": 0.1,
+ "groupName": "external-prod",
+ "optimizerToken": "never-return",
+ "configuration": {"secret": "never-return"},
+ }
+ ],
+ "total": 1,
+ }
+ )
+ service = ActionService(client, 1_048_576) # type: ignore[arg-type]
+
+ result = await service.execute(
+ "list_optimizing_tables",
+ {"optimizer_group": "external/prod", "page": 2, "page_size": 25},
+ )
+
+ assert client.calls == [
+ (
+ "/api/ams/v1/optimize/optimizerGroups/external%2Fprod/tables",
+ [("page", "2"), ("pageSize", "25")],
+ )
+ ]
+ assert result["data"] == {
+ "list": [
+ {
+ "tableIdentifier": "prod.sales.orders",
+ "optimizeStatus": "PENDING",
+ "duration": 10_800_000,
+ "fileCount": 900,
+ "fileSize": 4096,
+ "quota": 0.1,
+ "quotaOccupation": 0.1,
+ "groupName": "external-prod",
+ }
+ ],
+ "total": 1,
+ }
+
+
+@pytest.mark.asyncio
+async def test_default_projection_keeps_new_metrics_but_drops_configuration() -> None:
+ client = FakeClient(
+ {
+ "tableName": "orders",
+ "icebergMetrics": {"positionDeleteRecords": 17},
+ "storageConfig": {"endpoint": "internal"},
+ }
+ )
+ service = ActionService(client, 1_048_576) # type: ignore[arg-type]
+
+ result = await service.execute(
+ "get_table_details",
+ {"catalog": "prod", "database": "sales", "table": "orders"},
+ )
+
+ assert result["data"] == {
+ "tableName": "orders",
+ "icebergMetrics": {"positionDeleteRecords": 17},
+ }
+
+
+@pytest.mark.asyncio
+async def test_unknown_action_and_invalid_pagination_are_rejected() -> None:
+ service = ActionService(FakeClient(None), 1_048_576) # type: ignore[arg-type]
+ with pytest.raises(ToolError, match="Unknown action"):
+ await service.execute("get_any_url", {})
+ with pytest.raises(ToolError, match="less than or equal to 100"):
+ await service.execute(
+ "list_optimizers", {"optimizer_group": "all", "page": 1, "page_size": 101}
+ )
+ with pytest.raises(ToolError, match="Input should be"):
+ await service.execute(
+ "list_table_snapshots",
+ {
+ "catalog": "prod",
+ "database": "sales",
+ "table": "orders",
+ "commit_type": "rewrite",
+ },
+ )
+ with pytest.raises(ToolError, match="greater than or equal to 1"):
+ await service.execute(
+ "list_table_snapshots",
+ {
+ "catalog": "prod",
+ "database": "sales",
+ "table": "orders",
+ "page": 0,
+ },
+ )
+ with pytest.raises(ToolError, match="Extra inputs are not permitted"):
+ await service.execute(
+ "list_table_snapshots",
+ {
+ "catalog": "prod",
+ "database": "sales",
+ "table": "orders",
+ "snapshot_id": "123",
+ },
+ )
diff --git a/amoro-mcp-server/tests/test_auth.py b/amoro-mcp-server/tests/test_auth.py
new file mode 100644
index 000000000..5d8e5b220
--- /dev/null
+++ b/amoro-mcp-server/tests/test_auth.py
@@ -0,0 +1,172 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+from pydantic import SecretStr, ValidationError
+from starlette.applications import Starlette
+from starlette.responses import JSONResponse
+from starlette.routing import Route
+from starlette.testclient import TestClient
+
+import amoro_mcp_server.auth as auth_module
+from amoro_mcp_server.auth import (
+ AuthIdentity,
+ CachedAuthProvider,
+ ContextTokenVerifier,
+ InboundAuthenticationMiddleware,
+ build_auth_runtime,
+)
+from amoro_mcp_server.client import AmoroClient
+from amoro_mcp_server.config import (
+ AmoroSettings,
+ InboundAuthSettings,
+ Settings,
+)
+from amoro_mcp_server.request_context import (
+ RequestAuthentication,
+ reset_request_authentication,
+ set_request_authentication,
+)
+
+
+class _CountingProvider(CachedAuthProvider[str]):
+ def __init__(self, result: AuthIdentity | None) -> None:
+ super().__init__()
+ self.result = result
+ self.calls = 0
+
+ def _cache_key(self, credential: str) -> str:
+ return credential
+
+ async def _validate(self, credential: str) -> AuthIdentity | None:
+ self.calls += 1
+ await asyncio.sleep(0)
+ return self.result
+
+
+def test_auth_modes_are_strictly_validated(monkeypatch: pytest.MonkeyPatch) -> None:
+ assert InboundAuthSettings(mode="none").mode == "none"
+ assert InboundAuthSettings(mode="ams-api-key").mode == "ams-api-key"
+
+ with pytest.raises(ValidationError):
+ InboundAuthSettings.model_validate({"mode": "amoro"})
+
+ with pytest.raises(ValidationError):
+ InboundAuthSettings.model_validate({"mode": "udp"})
+ with pytest.raises(ValidationError):
+ InboundAuthSettings.model_validate(
+ {"mode": "none", "udp": {"user_info_url": "https://udp.test/user"}}
+ )
+ with pytest.raises(ValidationError):
+ InboundAuthSettings.model_validate(
+ {"mode": "none", "api_keys": {"legacy": {"sha256": "0" * 64}}}
+ )
+ with pytest.raises(ValidationError):
+ InboundAuthSettings.model_validate({"mode": "none", "allow_unauthenticated": True})
+
+ monkeypatch.setenv("AMORO_MCP_AUTH__MODE", "udp")
+ with pytest.raises(ValidationError):
+ Settings() # type: ignore[call-arg]
+
+ monkeypatch.setenv("AMORO_MCP_AUTH__MODE", "none")
+ monkeypatch.setenv("AMORO_MCP_AUTH__API_KEYS", "{}")
+ with pytest.raises(ValidationError):
+ Settings() # type: ignore[call-arg]
+
+
+def test_amoro_mode_rejects_server_wide_upstream_credentials() -> None:
+ with pytest.raises(ValidationError, match="request credentials"):
+ Settings(
+ auth=InboundAuthSettings(mode="ams-api-key"),
+ amoro=AmoroSettings(
+ base_url="https://amoro.test",
+ auth_type="token",
+ api_key="server-key",
+ api_secret=SecretStr("server-secret"),
+ ),
+ )
+
+ with pytest.raises(ValidationError, match="IANA timezone"):
+ AmoroSettings(signature_timezone="Not/A-Timezone")
+
+
+@pytest.mark.asyncio
+async def test_context_verifier_exposes_identity_without_client_secret() -> None:
+ request_auth = RequestAuthentication(
+ internal_token="internal-only-token", # noqa: S106 - synthetic test value
+ client_id="amoro:client-key",
+ subject="client-key",
+ claims={"auth_method": "amoro", "provider": "amoro"},
+ )
+ context_token = set_request_authentication(request_auth)
+ try:
+ verifier = ContextTokenVerifier()
+ access = await verifier.verify_token("internal-only-token")
+ assert access is not None
+ assert access.client_id == "amoro:client-key"
+ assert access.subject == "client-key"
+ assert access.scopes == []
+ assert await verifier.verify_token("wrong") is None
+ finally:
+ reset_request_authentication(context_token)
+
+
+@pytest.mark.asyncio
+async def test_cached_provider_coalesces_concurrent_checks_and_expires_results(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ clock = [0.0]
+ monkeypatch.setattr(auth_module.time, "monotonic", lambda: clock[0])
+ identity = AuthIdentity("amoro:key", "key", {"auth_method": "amoro"})
+ positive = _CountingProvider(identity)
+ results = await asyncio.gather(*(positive.authenticate("same") for _ in range(10)))
+ assert results == [identity] * 10
+ assert positive.calls == 1
+ assert await positive.authenticate("same") == identity
+ assert positive.calls == 1
+ clock[0] = 301.0
+ assert await positive.authenticate("same") == identity
+ assert positive.calls == 2
+
+ negative = _CountingProvider(None)
+ assert await negative.authenticate("bad") is None
+ assert await negative.authenticate("bad") is None
+ assert negative.calls == 1
+ clock[0] += 6.0
+ assert await negative.authenticate("bad") is None
+ assert negative.calls == 2
+
+
+def test_none_mode_rejects_misleading_authentication_headers() -> None:
+ async def ok(_request: object) -> JSONResponse:
+ return JSONResponse({"ok": True})
+
+ client = AmoroClient(AmoroSettings(base_url="https://amoro.test"), 1024)
+ runtime = build_auth_runtime(InboundAuthSettings(mode="none"), client)
+ app = InboundAuthenticationMiddleware(
+ Starlette(routes=[Route("/mcp", ok)]),
+ runtime,
+ )
+ with TestClient(app) as test_client:
+ assert test_client.get("/mcp").status_code == 200
+ assert test_client.get("/mcp", headers={"X-API-Key": "ignored"}).status_code == 400
+ assert test_client.get("/mcp", headers={"Authorization": "Bearer token"}).status_code == 400
+ assert test_client.get("/health/live", headers={"X-API-Key": "ignored"}).status_code == 404
diff --git a/amoro-mcp-server/tests/test_client.py b/amoro-mcp-server/tests/test_client.py
new file mode 100644
index 000000000..a14cdba40
--- /dev/null
+++ b/amoro-mcp-server/tests/test_client.py
@@ -0,0 +1,279 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+from datetime import UTC, datetime
+
+import httpx
+import pytest
+from fastmcp.exceptions import ToolError
+from pydantic import SecretStr
+
+from amoro_mcp_server.client import AmoroClient, calculate_amoro_signature
+from amoro_mcp_server.config import AmoroSettings
+from amoro_mcp_server.request_context import (
+ AmoroRequestCredentials,
+ RequestAuthentication,
+ reset_request_authentication,
+ set_request_authentication,
+)
+
+
+def test_signature_matches_ams_parameter_rules() -> None:
+ params = [("page", "2"), ("actions[]", "b"), ("actions[]", "a"), ("blank", "")]
+ expected = hashlib.md5( # noqa: S324
+ b"keyactions[]apage2secret", usedforsecurity=False
+ ).hexdigest()
+ assert calculate_amoro_signature("key", "secret", params) == expected
+
+ expected_date = hashlib.md5(b"key20260710secret", usedforsecurity=False).hexdigest() # noqa: S324
+ assert (
+ calculate_amoro_signature(
+ "key",
+ "secret",
+ [],
+ now=datetime(2026, 7, 9, 23, 0, tzinfo=UTC),
+ timezone_name="Asia/Shanghai",
+ )
+ == expected_date
+ )
+
+
+@pytest.mark.asyncio
+async def test_token_auth_is_added_and_response_envelope_is_unwrapped() -> None:
+ captured_url: httpx.URL | None = None
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ nonlocal captured_url
+ captured_url = request.url
+ return httpx.Response(200, json={"code": 200, "message": "success", "result": ["db"]})
+
+ settings = AmoroSettings(
+ base_url="https://amoro.test",
+ auth_type="token",
+ api_key="key",
+ api_secret=SecretStr("secret"),
+ )
+ client = AmoroClient(settings, 1024, transport=httpx.MockTransport(handler))
+ async with client:
+ assert await client.get_json("/api/ams/v1/catalogs/c/databases", []) == ["db"]
+
+ assert captured_url is not None
+ assert captured_url.params["apiKey"] == "key"
+ assert len(captured_url.params["signature"]) == 32
+
+
+@pytest.mark.asyncio
+async def test_basic_auth_error_envelope_and_retry() -> None:
+ attempts = 0
+
+ async def retrying_handler(request: httpx.Request) -> httpx.Response:
+ nonlocal attempts
+ attempts += 1
+ assert request.headers["authorization"].startswith("Basic ")
+ if attempts == 1:
+ return httpx.Response(503, json={"code": 503, "message": "busy"})
+ return httpx.Response(200, json={"code": 403, "message": "denied"})
+
+ settings = AmoroSettings(
+ base_url="https://amoro.test",
+ auth_type="basic",
+ username="admin",
+ password=SecretStr("password"),
+ max_retries=1,
+ )
+ client = AmoroClient(settings, 1024, transport=httpx.MockTransport(retrying_handler))
+ async with client:
+ with pytest.raises(ToolError, match="AMS error 403: denied"):
+ await client.get_json("/api/ams/v1/catalogs", [])
+ assert attempts == 2
+
+
+@pytest.mark.asyncio
+async def test_response_size_limit_is_enforced() -> None:
+ transport = httpx.MockTransport(
+ lambda _request: httpx.Response(
+ 200,
+ content=b'{"code":200,"result":"' + b"x" * 1024 + b'"}',
+ )
+ )
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test"),
+ 128,
+ transport=transport,
+ )
+ async with client:
+ with pytest.raises(ToolError, match="size limit"):
+ await client.get_json("/api/ams/v1/catalogs", [])
+
+
+@pytest.mark.asyncio
+async def test_request_scoped_credentials_are_isolated_across_concurrent_calls() -> None:
+ captured: list[tuple[str, str]] = []
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ api_key = request.url.params["apiKey"]
+ signature = request.url.params["signature"]
+ secret = {"client-a": "secret-a", "client-b": "secret-b"}[api_key]
+ assert signature == calculate_amoro_signature(api_key, secret, [])
+ assert secret not in str(request.url)
+ captured.append((api_key, signature))
+ return httpx.Response(200, json={"code": 200, "result": api_key})
+
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test"),
+ 1024,
+ require_request_credentials=True,
+ transport=httpx.MockTransport(handler),
+ )
+
+ async def call(api_key: str, api_secret: str) -> str:
+ context_token = set_request_authentication(
+ RequestAuthentication(
+ internal_token=f"internal-{api_key}",
+ client_id=f"amoro:{api_key}",
+ subject=api_key,
+ claims={"auth_method": "amoro"},
+ amoro_credentials=AmoroRequestCredentials(api_key, api_secret),
+ )
+ )
+ try:
+ return await client.get_json("/api/ams/v1/catalogs", [])
+ finally:
+ reset_request_authentication(context_token)
+
+ async with client:
+ results = await asyncio.gather(
+ call("client-a", "secret-a"),
+ call("client-b", "secret-b"),
+ )
+ assert results == ["client-a", "client-b"]
+ assert {item[0] for item in captured} == {"client-a", "client-b"}
+
+
+@pytest.mark.asyncio
+async def test_amoro_mode_never_falls_back_to_server_credentials() -> None:
+ settings = AmoroSettings(
+ base_url="https://amoro.test",
+ auth_type="token",
+ api_key="server-key",
+ api_secret=SecretStr("server-secret"),
+ )
+ client = AmoroClient(
+ settings,
+ 1024,
+ require_request_credentials=True,
+ transport=httpx.MockTransport(
+ lambda _request: httpx.Response(200, json={"code": 200, "result": []})
+ ),
+ )
+ async with client:
+ with pytest.raises(ToolError, match="Request-scoped AMS credentials"):
+ await client.get_json("/api/ams/v1/catalogs", [])
+
+
+@pytest.mark.asyncio
+async def test_amoro_credentials_are_validated_with_protected_catalog_get() -> None:
+ requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ if request.url.params.get("apiKey") != "client-key":
+ return httpx.Response(200, json={"code": 403, "message": "denied"})
+ expected = calculate_amoro_signature("client-key", "client-secret", [])
+ if request.url.params.get("signature") != expected:
+ return httpx.Response(200, json={"code": 403, "message": "denied"})
+ return httpx.Response(200, json={"code": 200, "result": ["catalog"]})
+
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test"),
+ 1024,
+ require_request_credentials=True,
+ transport=httpx.MockTransport(handler),
+ )
+ async with client:
+ assert await client.validate_credentials(
+ AmoroRequestCredentials("client-key", "client-secret")
+ )
+ assert not await client.validate_credentials(
+ AmoroRequestCredentials("client-key", "wrong-secret")
+ )
+ assert all(request.method == "GET" for request in requests)
+ assert all(request.url.path == "/api/ams/v1/catalogs" for request in requests)
+ assert all("client-secret" not in str(request.url) for request in requests)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("response", "expected"),
+ [
+ (
+ httpx.Response(200, json={"code": 200, "result": {"status": "ok"}}),
+ {"reachable": True, "amsHealth": "healthy", "failureClass": None},
+ ),
+ (
+ httpx.Response(403),
+ {
+ "reachable": True,
+ "amsHealth": "unknown",
+ "failureClass": "auth",
+ "httpStatus": 403,
+ },
+ ),
+ (
+ httpx.Response(503),
+ {
+ "reachable": True,
+ "amsHealth": "unhealthy",
+ "failureClass": "server",
+ "httpStatus": 503,
+ },
+ ),
+ ],
+)
+async def test_check_connectivity_returns_sanitized_classification(
+ response: httpx.Response,
+ expected: dict[str, object],
+) -> None:
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test", max_retries=0),
+ 1024,
+ transport=httpx.MockTransport(lambda _request: response),
+ )
+ async with client:
+ assert await client.check_connectivity() == expected
+
+
+@pytest.mark.asyncio
+async def test_check_connectivity_classifies_network_failure_without_details() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectError("secret internal hostname", request=request)
+
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test", max_retries=0),
+ 1024,
+ transport=httpx.MockTransport(handler),
+ )
+ async with client:
+ assert await client.check_connectivity() == {
+ "reachable": False,
+ "amsHealth": "unknown",
+ "failureClass": "network",
+ }
diff --git a/amoro-mcp-server/tests/test_compatibility.py b/amoro-mcp-server/tests/test_compatibility.py
new file mode 100644
index 000000000..2782642cb
--- /dev/null
+++ b/amoro-mcp-server/tests/test_compatibility.py
@@ -0,0 +1,156 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import asyncio
+
+import httpx
+import pytest
+from fastmcp.exceptions import ToolError
+
+from amoro_mcp_server.client import AmoroClient
+from amoro_mcp_server.compatibility import AmsCompatibility, classify_ams_version
+from amoro_mcp_server.config import AmoroSettings
+
+
+@pytest.mark.parametrize(
+ ("raw_version", "expected"),
+ [
+ ("0.7.0-incubating(abcdef)", AmsCompatibility("0.7.0", "unsupported")),
+ ("0.8.0-incubating(abcdef)", AmsCompatibility("0.8.0", "supported")),
+ ("0.9.0-SNAPSHOT(abcdef)", AmsCompatibility("0.9.0", "supported")),
+ ("1.0.0", AmsCompatibility("1.0.0", "unverified")),
+ ("UNKNOWN(commit)", AmsCompatibility(None, "unverified")),
+ (None, AmsCompatibility(None, "unverified")),
+ ],
+)
+def test_classify_ams_version_discards_build_and_commit_metadata(
+ raw_version: object,
+ expected: AmsCompatibility,
+) -> None:
+ assert classify_ams_version(raw_version) == expected
+
+
+@pytest.mark.asyncio
+async def test_compatibility_check_is_cached_and_concurrent_requests_are_coalesced() -> None:
+ requests = 0
+
+ async def handler(_request: httpx.Request) -> httpx.Response:
+ nonlocal requests
+ requests += 1
+ await asyncio.sleep(0)
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "result": {"version": "0.9.0-SNAPSHOT(commit-must-not-leak)"},
+ },
+ )
+
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test"),
+ 1024,
+ transport=httpx.MockTransport(handler),
+ )
+ async with client:
+ results = await asyncio.gather(*(client.get_compatibility() for _ in range(10)))
+ assert results == [AmsCompatibility("0.9.0", "supported")] * 10
+ assert requests == 1
+
+ client._compatibility_expires_at = 0.0
+ assert await client.get_compatibility() == AmsCompatibility("0.9.0", "supported")
+ assert requests == 2
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("version", "expected_ready", "expected_status", "expected_version"),
+ [
+ ("0.7.0-incubating(commit)", False, "unsupported", "0.7.0"),
+ ("0.8.0-incubating(commit)", True, "supported", "0.8.0"),
+ ("0.9.0-SNAPSHOT(commit)", True, "supported", "0.9.0"),
+ ("1.0.0", True, "unverified", "1.0.0"),
+ ("UNKNOWN(commit)", True, "unverified", None),
+ ],
+)
+async def test_readiness_combines_health_and_normalized_version(
+ version: str,
+ expected_ready: bool,
+ expected_status: str,
+ expected_version: str | None,
+) -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith("/health/status"):
+ return httpx.Response(200, json={"code": 200, "result": {"status": "ok"}})
+ return httpx.Response(
+ 200,
+ json={"code": 200, "result": {"version": version, "commitTime": "private"}},
+ )
+
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test"),
+ 1024,
+ transport=httpx.MockTransport(handler),
+ )
+ async with client:
+ readiness = await client.readiness()
+
+ assert readiness.ready is expected_ready
+ assert readiness.status == ("ready" if expected_ready else "not_ready")
+ assert readiness.compatibility == expected_status
+ assert readiness.ams_version == expected_version
+ assert "commit" not in repr(readiness)
+ assert "private" not in repr(readiness)
+
+
+@pytest.mark.asyncio
+async def test_readiness_is_not_ready_when_ams_is_unreachable() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectError("private upstream address", request=request)
+
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test"),
+ 1024,
+ transport=httpx.MockTransport(handler),
+ )
+ async with client:
+ readiness = await client.readiness()
+
+ assert readiness.ready is False
+ assert readiness.status == "not_ready"
+ assert readiness.ams_version is None
+ assert readiness.compatibility == "unverified"
+
+
+@pytest.mark.asyncio
+async def test_unsupported_version_blocks_tool_requests() -> None:
+ client = AmoroClient(
+ AmoroSettings(base_url="https://amoro.test"),
+ 1024,
+ transport=httpx.MockTransport(
+ lambda _request: httpx.Response(
+ 200,
+ json={"code": 200, "result": {"version": "0.7.0-incubating(secret-commit)"}},
+ )
+ ),
+ )
+ async with client:
+ with pytest.raises(ToolError, match=r"AMS version 0\.7\.0 is unsupported") as exc_info:
+ await client.ensure_compatible()
+
+ assert "secret-commit" not in str(exc_info.value)
diff --git a/amoro-mcp-server/tests/test_logging.py b/amoro-mcp-server/tests/test_logging.py
new file mode 100644
index 000000000..e969bf790
--- /dev/null
+++ b/amoro-mcp-server/tests/test_logging.py
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import logging
+
+from amoro_mcp_server.logging_config import configure_logging
+
+
+def test_http_client_info_logs_cannot_disclose_signed_urls(capsys: object) -> None:
+ configure_logging("INFO")
+ logging.getLogger("httpx").info("GET https://amoro.test/path?apiKey=secret&signature=secret")
+ captured = capsys.readouterr() # type: ignore[attr-defined]
+ assert "apiKey" not in captured.err
+ assert "signature" not in captured.err
diff --git a/amoro-mcp-server/tests/test_sanitize.py b/amoro-mcp-server/tests/test_sanitize.py
new file mode 100644
index 000000000..dad6d8a37
--- /dev/null
+++ b/amoro-mcp-server/tests/test_sanitize.py
@@ -0,0 +1,132 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from amoro_mcp_server.sanitize import (
+ project_catalogs,
+ project_optimizers,
+ project_resource_groups,
+ project_safe,
+ project_tasks,
+)
+
+
+def test_catalog_projection_drops_configuration_and_unknown_fields() -> None:
+ catalogs = project_catalogs(
+ [
+ {
+ "catalogName": "prod",
+ "catalogType": "hive",
+ "authConfigs": {"password": "leak"},
+ "storageConfigs": {"secret": "leak"},
+ }
+ ]
+ )
+ assert catalogs == [{"catalogName": "prod", "catalogType": "hive"}]
+
+
+def test_optimizer_task_and_resource_group_projection_is_fail_closed() -> None:
+ optimizer = project_optimizers(
+ {
+ "list": [
+ {
+ "token": "optimizer-secret",
+ "jobId": "job-1",
+ "groupName": "default",
+ "coreNumber": 2,
+ "memory": 1024,
+ "jobStatus": "RUNNING",
+ "container": "flink",
+ "unexpected": "drop-me",
+ }
+ ],
+ "total": 1,
+ }
+ )
+ assert optimizer["list"][0]["jobId"] == "job-1"
+ assert "token" not in optimizer["list"][0]
+ assert "unexpected" not in optimizer["list"][0]
+
+ tasks = project_tasks(
+ {
+ "list": [
+ {
+ "taskId": 1,
+ "processId": "p1",
+ "optimizerToken": "optimizer-secret",
+ "summary": {"retry-count": "0", "optimizer.token": "secret"},
+ "properties": {"safe": "yes", "password": "secret"},
+ "inputFiles": {"fileCnt": 2, "totalSize": 10, "path": "/secret"},
+ }
+ ],
+ "total": 1,
+ }
+ )
+ task = tasks["list"][0]
+ assert "optimizerToken" not in task
+ assert task["summary"] == {"retry-count": "0"}
+ assert task["properties"] == {"safe": "yes"}
+ assert task["inputFiles"] == {"fileCnt": 2, "totalSize": 10}
+
+ groups = project_resource_groups(
+ [
+ {
+ "resourceGroup": {
+ "name": "default",
+ "container": "flink",
+ "properties": {"parallelism": "2", "secretKey": "secret"},
+ },
+ "occupationCore": 2,
+ "occupationMemory": 1024,
+ "unknown": "drop",
+ }
+ ]
+ )
+ assert groups == [
+ {
+ "resourceGroup": {
+ "name": "default",
+ "container": "flink",
+ "properties": {"parallelism": "2"},
+ },
+ "occupationCore": 2,
+ "occupationMemory": 1024,
+ }
+ ]
+
+
+def test_global_projection_preserves_new_metrics_and_drops_sensitive_configuration() -> None:
+ assert project_safe(
+ {
+ "tableName": "orders",
+ "properties": {"owner": "analytics", "credential": "secret"},
+ "icebergMetrics": {
+ "positionDeleteRecords": 17,
+ "newNestedMetric": {"value": 4, "apiToken": "secret"},
+ },
+ "systemSettings": {"server.port": 1234},
+ "storageConfig": {"endpoint": "internal"},
+ }
+ ) == {
+ "tableName": "orders",
+ "properties": {"owner": "analytics"},
+ "icebergMetrics": {
+ "positionDeleteRecords": 17,
+ "newNestedMetric": {"value": 4},
+ },
+ }
diff --git a/amoro-mcp-server/tests/test_server.py b/amoro-mcp-server/tests/test_server.py
new file mode 100644
index 000000000..75db4e909
--- /dev/null
+++ b/amoro-mcp-server/tests/test_server.py
@@ -0,0 +1,340 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import httpx
+import pytest
+from fastmcp import Client
+from fastmcp.exceptions import ToolError
+from starlette.testclient import TestClient
+
+from amoro_mcp_server.client import calculate_amoro_signature
+from amoro_mcp_server.config import InboundAuthSettings, Settings
+from amoro_mcp_server.server import build_server, create_app
+from amoro_mcp_server.tools import TOOL_REGISTRARS
+
+EXPECTED_TOOLS = {
+ "list_catalogs",
+ "list_databases",
+ "list_tables",
+ "get_table_details",
+ "list_table_snapshots",
+ "list_optimizing_processes",
+ "list_optimizing_tasks",
+ "list_optimizers",
+ "list_optimizing_tables",
+ "list_resource_groups",
+ "check_ams_connectivity",
+}
+
+
+@pytest.mark.asyncio
+async def test_server_lists_promoted_read_only_tools_and_returns_structured_output(
+ settings: Settings,
+) -> None:
+ assert len(TOOL_REGISTRARS) == len(EXPECTED_TOOLS)
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith("/versionInfo"):
+ result = {"version": "0.9.0-SNAPSHOT(commit-must-not-leak)", "commitTime": "secret"}
+ elif request.url.path.endswith("/tasks"):
+ result = {
+ "list": [
+ {
+ "taskId": 7,
+ "processId": "p1",
+ "status": "SUCCESS",
+ "optimizerToken": "never-return",
+ "summary": {"optimizer.token": "never-return", "retry-count": "0"},
+ }
+ ],
+ "total": 1,
+ }
+ elif request.url.path.endswith("/snapshots"):
+ result = {
+ "list": [
+ {
+ "snapshotId": "123",
+ "commitTime": 1_735_689_600_000,
+ "operation": "replace",
+ "producer": "OPTIMIZE",
+ "records": 100,
+ "fileCount": 4,
+ "originalFileSize": 4096,
+ "summary": {
+ "total-records": "100",
+ "new-iceberg-metric": "7",
+ "apiSecret": "never-return",
+ },
+ }
+ ],
+ "total": 1,
+ }
+ else:
+ result = []
+ return httpx.Response(200, json={"code": 200, "message": "success", "result": result})
+
+ mcp = build_server(settings, transport=httpx.MockTransport(handler))
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+ assert {tool.name for tool in tools} == EXPECTED_TOOLS
+ for tool in tools:
+ assert tool.annotations is not None
+ assert tool.annotations.readOnlyHint is True
+ assert tool.annotations.destructiveHint is False
+ assert tool.annotations.idempotentHint is True
+ assert tool.annotations.openWorldHint is True
+
+ result = await client.call_tool(
+ "list_optimizing_tasks",
+ {
+ "catalog": "prod",
+ "database": "sales",
+ "table": "orders",
+ "process_id": "p1",
+ },
+ )
+ assert result.structured_content is not None
+ task = result.structured_content["data"]["list"][0]
+ assert task["taskId"] == 7
+ assert "optimizerToken" not in task
+ assert "optimizer.token" not in task["summary"]
+ assert result.content
+
+ snapshot_result = await client.call_tool(
+ "list_table_snapshots",
+ {
+ "catalog": "prod",
+ "database": "sales",
+ "table": "orders",
+ "commit_type": "optimizing",
+ },
+ )
+ assert snapshot_result.structured_content is not None
+ snapshot = snapshot_result.structured_content["data"]["list"][0]
+ assert snapshot["producer"] == "OPTIMIZE"
+ assert snapshot["records"] == 100
+ assert snapshot["fileCount"] == 4
+ assert snapshot["originalFileSize"] == 4096
+ assert snapshot["summary"]["new-iceberg-metric"] == "7"
+ assert "apiSecret" not in snapshot["summary"]
+
+
+@pytest.mark.asyncio
+async def test_unsupported_ams_version_blocks_tool_execution(settings: Settings) -> None:
+ requested_paths: list[str] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requested_paths.append(request.url.path)
+ assert request.url.path == "/api/ams/v1/versionInfo"
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "result": {"version": "0.7.0-incubating(commit-must-not-leak)"},
+ },
+ )
+
+ mcp = build_server(settings, transport=httpx.MockTransport(handler))
+ async with Client(mcp) as client:
+ with pytest.raises(ToolError, match=r"AMS version 0\.7\.0 is unsupported") as exc_info:
+ await client.call_tool("list_catalogs", {})
+
+ assert requested_paths == ["/api/ams/v1/versionInfo"]
+ assert "commit-must-not-leak" not in str(exc_info.value)
+
+
+INITIALIZE_REQUEST = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2025-06-18",
+ "capabilities": {},
+ "clientInfo": {"name": "test", "version": "1.0"},
+ },
+}
+MCP_HEADERS = {
+ "Accept": "application/json, text/event-stream",
+ "Content-Type": "application/json",
+}
+
+
+def test_http_app_none_mode_and_health(settings: Settings) -> None:
+ app = create_app(settings, transport=httpx.MockTransport(lambda _request: httpx.Response(500)))
+ with TestClient(app) as client:
+ assert client.get("/health/live").status_code == 200
+ assert (
+ client.post(
+ "/mcp",
+ json=INITIALIZE_REQUEST,
+ headers=MCP_HEADERS,
+ ).status_code
+ == 200
+ )
+ assert (
+ client.post(
+ "/mcp",
+ json=INITIALIZE_REQUEST,
+ headers={**MCP_HEADERS, "X-API-Key": "misleading"},
+ ).status_code
+ == 400
+ )
+
+
+@pytest.mark.parametrize(
+ ("version", "expected_status", "expected_body"),
+ [
+ (
+ "0.8.0-incubating(commit-must-not-leak)",
+ 200,
+ {"status": "ready", "amsVersion": "0.8.0", "compatibility": "supported"},
+ ),
+ (
+ "0.7.0-incubating(commit-must-not-leak)",
+ 503,
+ {"status": "not_ready", "amsVersion": "0.7.0", "compatibility": "unsupported"},
+ ),
+ (
+ "UNKNOWN(commit-must-not-leak)",
+ 200,
+ {"status": "ready", "amsVersion": None, "compatibility": "unverified"},
+ ),
+ ],
+)
+def test_readiness_exposes_only_normalized_compatibility(
+ settings: Settings,
+ version: str,
+ expected_status: int,
+ expected_body: dict[str, object],
+) -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith("/health/status"):
+ result: object = {"status": "ok"}
+ else:
+ assert request.url.path.endswith("/versionInfo")
+ result = {"version": version, "commitTime": "must-not-leak"}
+ return httpx.Response(200, json={"code": 200, "result": result})
+
+ app = create_app(settings, transport=httpx.MockTransport(handler))
+ with TestClient(app) as client:
+ response = client.get("/health/ready")
+
+ assert response.status_code == expected_status
+ assert response.json() == expected_body
+ assert "commit" not in response.text
+ assert "must-not-leak" not in response.text
+
+
+def test_http_app_amoro_credentials_authenticate_and_reject_mixed_headers(
+ settings: Settings,
+ capsys: object,
+) -> None:
+ api_key = "client-key"
+ api_secret = "client-secret" # noqa: S105 - synthetic test value
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ expected = calculate_amoro_signature(api_key, api_secret, [])
+ code = (
+ 200
+ if request.url.params.get("apiKey") == api_key
+ and request.url.params.get("signature") == expected
+ else 403
+ )
+ result: object = []
+ if request.url.path == "/api/ams/v1/versionInfo":
+ result = {"version": "0.9.0-SNAPSHOT(commit-must-not-leak)"}
+ else:
+ assert request.url.path == "/api/ams/v1/catalogs"
+ return httpx.Response(200, json={"code": code, "result": result})
+
+ secured = settings.model_copy(update={"auth": InboundAuthSettings(mode="ams-api-key")})
+ app = create_app(secured, transport=httpx.MockTransport(handler))
+ with TestClient(app) as client:
+ assert client.get("/health/live").status_code == 200
+ assert client.post("/mcp", json=INITIALIZE_REQUEST, headers=MCP_HEADERS).status_code == 401
+ assert (
+ client.post(
+ "/mcp",
+ json=INITIALIZE_REQUEST,
+ headers={**MCP_HEADERS, "X-Amoro-API-Key": api_key},
+ ).status_code
+ == 400
+ )
+ assert (
+ client.post(
+ "/mcp",
+ json=INITIALIZE_REQUEST,
+ headers={
+ **MCP_HEADERS,
+ "X-Amoro-API-Key": api_key,
+ "X-Amoro-API-Secret": "wrong-secret",
+ },
+ ).status_code
+ == 401
+ )
+ authenticated = client.post(
+ "/mcp",
+ json=INITIALIZE_REQUEST,
+ headers={
+ **MCP_HEADERS,
+ "X-Amoro-API-Key": api_key,
+ "X-Amoro-API-Secret": api_secret,
+ },
+ )
+ assert authenticated.status_code == 200
+ tool_response = client.post(
+ "/mcp",
+ json={
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {"name": "list_catalogs", "arguments": {}},
+ },
+ headers={
+ **MCP_HEADERS,
+ "X-Amoro-API-Key": api_key,
+ "X-Amoro-API-Secret": api_secret,
+ },
+ )
+ assert tool_response.status_code == 200
+ assert tool_response.json()["result"]["isError"] is False
+ assert (
+ client.post(
+ "/mcp",
+ json=INITIALIZE_REQUEST,
+ headers={
+ **MCP_HEADERS,
+ "X-API-Key": "udp-token",
+ "X-Amoro-API-Key": api_key,
+ "X-Amoro-API-Secret": api_secret,
+ },
+ ).status_code
+ == 400
+ )
+ assert (
+ client.post(
+ "/mcp",
+ json=INITIALIZE_REQUEST,
+ headers={**MCP_HEADERS, "Authorization": "Bearer token"},
+ ).status_code
+ == 400
+ )
+ captured = capsys.readouterr() # type: ignore[attr-defined]
+ assert api_secret not in captured.err
+ assert "wrong-secret" not in captured.err
diff --git a/amoro-mcp-server/uv.lock b/amoro-mcp-server/uv.lock
new file mode 100644
index 000000000..13cebd259
--- /dev/null
+++ b/amoro-mcp-server/uv.lock
@@ -0,0 +1,1159 @@
+version = 1
+revision = 3
+requires-python = "==3.12.*"
+
+[[package]]
+name = "aiofile"
+version = "3.11.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "caio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" },
+]
+
+[[package]]
+name = "amoro-mcp-server"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+ { name = "fastmcp" },
+ { name = "httpx" },
+ { name = "pydantic-settings" },
+ { name = "tzdata" },
+ { name = "uvicorn" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "mypy" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "respx" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "fastmcp", specifier = "==3.4.3" },
+ { name = "httpx", specifier = "~=0.28.1" },
+ { name = "pydantic-settings", specifier = "~=2.14.2" },
+ { name = "tzdata", specifier = "~=2026.2" },
+ { name = "uvicorn", specifier = "~=0.50.2" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "mypy", specifier = ">=1.16,<2" },
+ { name = "pytest", specifier = ">=8.4,<10" },
+ { name = "pytest-asyncio", specifier = ">=1.0,<2" },
+ { name = "respx", specifier = ">=0.22,<1" },
+ { name = "ruff", specifier = ">=0.12,<1" },
+]
+
+[[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.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "authlib"
+version = "1.7.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "joserfc" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
+]
+
+[[package]]
+name = "beartype"
+version = "0.22.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
+]
+
+[[package]]
+name = "cachetools"
+version = "7.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" },
+]
+
+[[package]]
+name = "caio"
+version = "0.9.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" },
+ { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.6.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
+ { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
+ { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
+ { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[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 = "cryptography"
+version = "49.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
+ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
+ { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
+ { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
+ { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
+ { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
+ { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
+]
+
+[[package]]
+name = "cyclopts"
+version = "4.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "docstring-parser" },
+ { name = "rich" },
+ { name = "rich-rst" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/89/f4c775c651d91f9cd8149f70baec94c902a34e5f17a7a67881881bcfb244/cyclopts-4.20.0.tar.gz", hash = "sha256:1d819de2b12dc6b1c9f17ce0f4937d82922c0b83ac846eb4b3289c9c9f321c9f", size = 190236, upload-time = "2026-06-29T15:04:42.253Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/d4/7243bc65d33c5ff5150569c42c8c1154aadae20b25638afe35f7f568489c/cyclopts-4.20.0-py3-none-any.whl", hash = "sha256:0b4337e9c11303d86b33d3f37c629dc01638f84591681e0e5611286bdd507646", size = 229383, upload-time = "2026-06-29T15:04:40.621Z" },
+]
+
+[[package]]
+name = "dnspython"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
+]
+
+[[package]]
+name = "docstring-parser"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
+]
+
+[[package]]
+name = "email-validator"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dnspython" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "fastmcp"
+version = "3.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fastmcp-slim", extra = ["client", "server"] },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f2/92/cf0e921675af66534eb6dfffffa1871b5062f01650bcea63b62005a12f50/fastmcp-3.4.3.tar.gz", hash = "sha256:31e212ae9ff223876c6d7af2082e73f90bc692460e7a054568dfa88719f075ff", size = 28789252, upload-time = "2026-07-05T23:27:55.153Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/79/64000c2792743877baae152499a951c06a9c14b793309ac4ac06135f1ae7/fastmcp-3.4.3-py3-none-any.whl", hash = "sha256:bdf15277800586a033a6e1e95d20301cd508aaa872df3d508c956a2195de6c72", size = 8019, upload-time = "2026-07-05T23:27:53.241Z" },
+]
+
+[[package]]
+name = "fastmcp-slim"
+version = "3.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "platformdirs" },
+ { name = "pydantic", extra = ["email"] },
+ { name = "pydantic-settings" },
+ { name = "python-dotenv" },
+ { name = "rich" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/fa/500f6362016c34fba01c6412bbfd53435dc75900abd1266d88e111e2e9c2/fastmcp_slim-3.4.3.tar.gz", hash = "sha256:e3e12ac1e87c5195fa59a1c3cd2d31b487a463c269089fb4020b7571cbb57b29", size = 588086, upload-time = "2026-07-05T23:27:33.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/4f/66765dfe4fe65b45e134e54ca46118d93189fb10cd8ecb635cf504b68552/fastmcp_slim-3.4.3-py3-none-any.whl", hash = "sha256:21ad50465494edb141eed76b177dc788e00e705a5df09b53a31efebeb4432a95", size = 761455, upload-time = "2026-07-05T23:27:32.547Z" },
+]
+
+[package.optional-dependencies]
+client = [
+ { name = "authlib" },
+ { name = "exceptiongroup" },
+ { name = "httpx" },
+ { name = "mcp" },
+ { name = "opentelemetry-api" },
+ { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
+ { name = "starlette" },
+]
+server = [
+ { name = "authlib" },
+ { name = "cyclopts" },
+ { name = "exceptiongroup" },
+ { name = "griffelib" },
+ { name = "httpx" },
+ { name = "joserfc" },
+ { name = "jsonref" },
+ { name = "jsonschema-path" },
+ { name = "mcp" },
+ { name = "openapi-pydantic" },
+ { name = "opentelemetry-api" },
+ { name = "packaging" },
+ { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
+ { name = "pyperclip" },
+ { name = "python-multipart" },
+ { name = "pyyaml" },
+ { name = "starlette" },
+ { name = "uncalled-for" },
+ { name = "uvicorn" },
+ { name = "watchfiles" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "griffelib"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" },
+]
+
+[[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.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jaraco-classes"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
+]
+
+[[package]]
+name = "jaraco-context"
+version = "6.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
+]
+
+[[package]]
+name = "jaraco-functools"
+version = "4.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" },
+]
+
+[[package]]
+name = "jeepney"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
+]
+
+[[package]]
+name = "joserfc"
+version = "1.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d4/c6/b1cac0280f8efc57626ea8804866b37099f23cae11b1485a42b213245e31/joserfc-1.7.3.tar.gz", hash = "sha256:116955c2587139dba20621fd0bd7fc9255fa960c9fe7f43c43ebef2e801dcfcf", size = 233821, upload-time = "2026-07-08T12:41:42.66Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/f5/650b59d1b74f5befb7a7a7e7d7c92a26b94256df3541e2b4914152cd177a/joserfc-1.7.3-py3-none-any.whl", hash = "sha256:7c39f3f2c943dbc03122747fa8ebbd8e156e54904cf25651b452f4d2634a6075", size = 70982, upload-time = "2026-07-08T12:41:41.521Z" },
+]
+
+[[package]]
+name = "jsonref"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
+]
+
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-path"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "pathable" },
+ { name = "pyyaml" },
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
+[[package]]
+name = "keyring"
+version = "25.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jaraco-classes" },
+ { name = "jaraco-context" },
+ { name = "jaraco-functools" },
+ { name = "jeepney", marker = "sys_platform == 'linux'" },
+ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
+ { name = "secretstorage", marker = "sys_platform == 'linux'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
+]
+
+[[package]]
+name = "librt"
+version = "0.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" },
+ { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" },
+ { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" },
+ { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" },
+ { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" },
+ { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "mcp"
+version = "1.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpx" },
+ { name = "httpx-sse" },
+ { name = "jsonschema" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "python-multipart" },
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "sse-starlette" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+ { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" },
+]
+
+[[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 = "more-itertools"
+version = "11.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.20.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
+ { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[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 = "opentelemetry-api"
+version = "1.43.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pathable"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "py-key-value-aio"
+version = "0.4.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" },
+]
+
+[package.optional-dependencies]
+filetree = [
+ { name = "aiofile" },
+ { name = "anyio" },
+]
+keyring = [
+ { name = "keyring" },
+]
+memory = [
+ { name = "cachetools" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[package.optional-dependencies]
+email = [
+ { name = "email-validator" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
+]
+
+[package.optional-dependencies]
+crypto = [
+ { name = "cryptography" },
+]
+
+[[package]]
+name = "pyperclip"
+version = "1.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "312"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
+]
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
+[[package]]
+name = "respx"
+version = "0.23.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "rich-rst"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+ { name = "rich" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "2026.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
+ { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
+ { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
+ { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
+ { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
+ { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
+ { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
+ { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
+ { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
+ { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
+ { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
+]
+
+[[package]]
+name = "secretstorage"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "jeepney" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
+]
+
+[[package]]
+name = "sse-starlette"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "starlette" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2026.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
+]
+
+[[package]]
+name = "uncalled-for"
+version = "0.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.50.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/f6/cc9aadc0e481344a42095d222bfa764122fb8cfba708d1922917bd8bfb01/uvicorn-0.50.2.tar.gz", hash = "sha256:b92bf03509b82bcb9d49e7335b4fd364518ad021c2dc18b4e6a2fec8c955a0bb", size = 93716, upload-time = "2026-07-06T10:38:31.984Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/f0/7c228ee10c7ab8fd3a21d06579a6f7c6075c6ce72594a20fb5d2f206ff24/uvicorn-0.50.2-py3-none-any.whl", hash = "sha256:4ae72a385630bcc17a0adb8290f26c993865e0b43a2114c2aab96420172c056a", size = 72846, upload-time = "2026-07-06T10:38:30.543Z" },
+]
+
+[[package]]
+name = "watchfiles"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
+ { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
+ { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
+ { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
+ { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
+]
+
+[[package]]
+name = "websockets"
+version = "16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
+ { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
+ { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
+ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
+ { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
+]
diff --git a/pom.xml b/pom.xml
index 40cc34ecb..3bd16417a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1666,6 +1666,7 @@
<exclude>**/metastore_db/**</exclude>
<exclude>**/unit_test_base_tmp/**</exclude>
<exclude>**/pnpm-lock.yaml</exclude>
+ <exclude>**/uv.lock</exclude>
<exclude>**/components.d.ts</exclude>
<exclude>**/Chart.lock</exclude>
<exclude>dev/deps/**</exclude>