diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 0ffa9c6..ff591ea 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -56,12 +56,6 @@ jobs: uv run python scripts/generate_models.py || echo "Model generation skipped (requires local docs repo)" echo "Model generation complete." - - name: Generate MCP tools - run: | - echo "Starting MCP tool generation..." - uv run --with pyyaml python scripts/generate_mcp_tools.py - echo "MCP tool generation complete." - - name: Generate README SDK Reference run: | echo "Starting README SDK Reference generation..." @@ -72,8 +66,6 @@ jobs: run: | uv run ruff check --fix src/late/resources/_generated/ || true uv run ruff format src/late/resources/_generated/ || true - uv run ruff check --fix src/late/mcp/generated_tools.py || true - uv run ruff format src/late/mcp/generated_tools.py || true - name: Run tests run: uv run pytest tests -v --tb=short @@ -163,7 +155,6 @@ jobs: - name: Publish zernio-sdk to PyPI if: steps.changes.outputs.has_changes == 'true' - continue-on-error: true uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} @@ -189,8 +180,24 @@ jobs: - name: Publish late-sdk to PyPI if: steps.changes.outputs.has_changes == 'true' - continue-on-error: true uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} skip-existing: true + + - name: Notify zernio-mcp after successful SDK publication + if: steps.changes.outputs.has_changes == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.MCP_REPO_TOKEN }} + script: | + await github.rest.repos.createDispatchEvent({ + owner: 'zernio-dev', + repo: 'zernio-mcp', + event_type: 'sdk-released', + client_payload: { + sdk_version: '${{ steps.version.outputs.new_version }}', + sdk_repository: context.repo.owner + '/' + context.repo.repo, + sdk_sha: context.sha, + }, + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b7415b..08083fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,6 +144,23 @@ jobs: with: password: ${{ secrets.PYPI_API_TOKEN }} + - name: Notify zernio-mcp after successful SDK publication + if: steps.check_tag.outputs.exists == 'false' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.MCP_REPO_TOKEN }} + script: | + await github.rest.repos.createDispatchEvent({ + owner: 'zernio-dev', + repo: 'zernio-mcp', + event_type: 'sdk-released', + client_payload: { + sdk_version: '${{ steps.version.outputs.version }}', + sdk_repository: context.repo.owner + '/' + context.repo.repo, + sdk_sha: context.sha, + }, + }); + - name: Post-release Summary if: steps.check_tag.outputs.exists == 'false' run: | diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index edbe073..0000000 --- a/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -# Late MCP HTTP Server - Railway Deployment -# Uses uv for fast, reliable dependency management - -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - -WORKDIR /app - -# Copy dependency files -COPY pyproject.toml uv.lock README.md ./ -COPY src ./src - -# Install dependencies (no dev dependencies in production) -RUN uv sync --frozen --no-dev --extra mcp - -# Expose port (Railway will set PORT env var) -EXPOSE 8080 - -# Health check for Railway monitoring (Railway will use /health endpoint) - -# Run HTTP server (Railway sets PORT env var automatically) -CMD ["sh", "-c", "uv run late-mcp-http --host 0.0.0.0 --port ${PORT:-8080}"] \ No newline at end of file diff --git a/Dockerfile.docker b/Dockerfile.docker deleted file mode 100644 index 4436914..0000000 --- a/Dockerfile.docker +++ /dev/null @@ -1,22 +0,0 @@ -# Late MCP HTTP Server - Railway Deployment -# Uses uv for fast, reliable dependency management - -FROM ghcr.io/astral-sh/uv:python3.12-slim - -WORKDIR /app - -# Copy dependency files -COPY pyproject.toml uv.lock README.md ./ -COPY src ./src - -# Install dependencies (no dev dependencies in production) -RUN --mount=type=cache,id=uv-cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev --extra mcp - -# Expose port (Railway will set PORT env var) -EXPOSE 8080 - -# Health check for Railway monitoring (Railway will use /health endpoint) - -# Run HTTP server (Railway sets PORT env var automatically) -CMD ["sh", "-c", "uv run late-mcp-http --host 0.0.0.0 --port ${PORT:-8080}"] \ No newline at end of file diff --git a/README.md b/README.md index aef8367..b52311c 100644 --- a/README.md +++ b/README.md @@ -857,10 +857,10 @@ Both `from zernio import ...` and `from late import ...` work identically. The ` ## MCP Server (Claude Desktop) -The SDK includes a Model Context Protocol (MCP) server for integration with Claude Desktop. See [MCP documentation](https://docs.zernio.com/resources/mcp) for setup instructions. +The Model Context Protocol server now lives in the standalone [zernio-mcp](https://github.com/zernio-dev/zernio-mcp) project. It consumes this SDK as a versioned dependency and is synchronized only after a successful SDK release. ```bash -pip install zernio-sdk[mcp] +uvx zernio-mcp ``` ## Requirements diff --git a/claude-desktop-config.json b/claude-desktop-config.json deleted file mode 100644 index bb23e5b..0000000 --- a/claude-desktop-config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "mcpServers": { - "zernio": { - "command": "uvx", - "args": [ - "--from", - "zernio-sdk[mcp]", - "zernio-mcp" - ], - "env": { - "ZERNIO_API_KEY": "YOUR_API_KEY_HERE" - } - } - } -} diff --git a/docs/HTTP_DEPLOYMENT.md b/docs/HTTP_DEPLOYMENT.md deleted file mode 100644 index 38fd463..0000000 --- a/docs/HTTP_DEPLOYMENT.md +++ /dev/null @@ -1,127 +0,0 @@ -# HTTP Deployment Guide - -## Overview - -The Zernio MCP server can be deployed over HTTP, allowing remote access from any MCP client. Each user provides their own Zernio API key when connecting. - -The server's primary transport is **Streamable HTTP** (`POST /mcp`), the modern MCP transport supported by all current clients. Each request/response is self-contained (with optional chunked streaming), so it survives proxies and bridges that drop idle connections. The legacy SSE transport (`GET /sse` + `POST /messages/`) is kept for backward compatibility. - -## Quick Start - -### Local Testing - -1. Install dependencies: -```bash -uv sync --extra mcp -``` - -2. Run HTTP server: -```bash -uv run zernio-mcp-http -``` - -3. Test the server: -```bash -# Health check (no auth needed) -curl http://localhost:8080/health - -# Server info (no auth needed) -curl http://localhost:8080/ - -# Streamable HTTP endpoint (requires your Zernio API key) -curl -H "Authorization: Bearer your_zernio_api_key" \ - -H "Accept: application/json, text/event-stream" \ - -X POST http://localhost:8080/mcp \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' -``` - -## Railway Deployment - -### Using Dockerfile - -1. Push code to GitHub -2. Create new Railway project from repo -3. Railway auto-detects Dockerfile and deploys -4. No environment variables needed! (users provide their own API keys) - -### Environment Variables - -The server doesn't require any environment variables. Users authenticate by providing their Zernio API key when connecting. - -Optional variables: -- `HOST` (default: 0.0.0.0) -- `PORT` (default: 8080, Railway sets this automatically) -- `MCP_PUBLIC_URL` (default: https://mcp.zernio.com) — the server's canonical public URL, used as the OAuth resource identifier in the discovery metadata. Set it for non-prod deployments (e.g. a Railway preview URL). -- `MCP_ALLOWED_ORIGINS` — comma-separated hostnames appended to the browser Origin allowlist (DNS-rebinding protection). - -## Connecting Clients - -### Claude Code CLI (recommended: Streamable HTTP) - -```bash -# Add the MCP server using the modern Streamable HTTP transport -claude mcp add --transport http zernio https://your-app.railway.app/mcp \ - --header "Authorization: Bearer your_zernio_api_key_here" -``` - -Configuration in MCP settings: -```json -{ - "zernio": { - "url": "https://your-app.railway.app/mcp", - "headers": { - "Authorization": "Bearer your_zernio_api_key_here" - } - } -} -``` - -### Python Client (Streamable HTTP) - -```python -from mcp.client.streamable_http import streamablehttp_client - -headers = { - "Authorization": "Bearer your_zernio_api_key_here" -} - -async with streamablehttp_client( - "https://your-app.railway.app/mcp", - headers=headers, -) as (read, write, _): - # Use MCP client - pass -``` - -## Authentication - -Each user must provide their own Zernio API key when connecting using the standard HTTP Authorization header: - -``` -Authorization: Bearer YOUR_ZERNIO_API_KEY -``` - -Example (Streamable HTTP): -```bash -curl -H "Authorization: Bearer sk_your_api_key_here" \ - -H "Accept: application/json, text/event-stream" \ - -X POST https://your-app.railway.app/mcp \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -``` - -The server validates the API key by making a test request to the Zernio API. If valid, the request is processed and the API key is used for all operations within that request. - -The server is an OAuth 2.0 **resource server** (FastMCP's resource-server model): both plain Zernio API keys and OAuth access tokens issued by zernio.com are accepted as the bearer. Clients without a token (e.g. Claude's connector) receive a `401` whose `WWW-Authenticate` challenge points at the RFC 9728 discovery document at `/.well-known/oauth-protected-resource/mcp`, from which they discover the zernio.com authorization server and run the OAuth flow. The pre-FastMCP discovery path (`/.well-known/oauth-protected-resource`) permanently redirects to the canonical document, so clients holding the old URL keep working. - -## Security - -- Each user's bearer (API key or OAuth token) is validated against the Zernio API on every request -- The validated bearer is scoped to its request via FastMCP's access-token context (Streamable HTTP runs in stateless mode, so credentials never leak across requests) -- No shared credentials or server-wide API keys -- Browser requests are checked against an Origin allowlist for DNS-rebinding protection (extend with `MCP_ALLOWED_ORIGINS`) -- Health check, server info, and OAuth discovery endpoints are public (no auth required) -- The MCP and legacy SSE endpoints require authentication - -## Get Your Zernio API Key - -Visit https://zernio.com to sign up and get your API key. diff --git a/docs/MCP.md b/docs/MCP.md deleted file mode 100644 index 8a88ce9..0000000 --- a/docs/MCP.md +++ /dev/null @@ -1,97 +0,0 @@ -# MCP Server (Claude Desktop Integration) - -Schedule posts directly from Claude Desktop using natural language with the Late MCP server. - -## Quick Setup - -### 1. Install uv (package manager) - -```bash -# macOS / Linux -curl -LsSf https://astral.sh/uv/install.sh | sh - -# Windows (PowerShell) -powershell -c "irm https://astral.sh/uv/install.ps1 | iex" -``` - -### 2. Add to Claude Desktop config - -Open the config file: -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -Add this configuration: - -```json -{ - "mcpServers": { - "late": { - "command": "uvx", - "args": ["--from", "getlate[mcp]", "late-mcp"], - "env": { - "LATE_API_KEY": "your_api_key_here" - } - } - } -} -``` - -> Get your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys) - -### 3. Restart Claude Desktop - -Done! Ask Claude things like: -- *"Post 'Hello world!' to Twitter"* -- *"Schedule a LinkedIn post for tomorrow at 9am"* -- *"Show my connected accounts"* - -## Alternative: Using pip - -If you prefer pip over uvx: - -```bash -pip install getlate[mcp] -``` - -```json -{ - "mcpServers": { - "late": { - "command": "python", - "args": ["-m", "late.mcp"], - "env": { - "LATE_API_KEY": "your_api_key_here" - } - } - } -} -``` - -## Uploading Media - -Since Claude can't access local files, use the browser upload flow: - -1. Ask Claude: *"I want to post an image to Instagram"* -2. Claude gives you an upload link -> open it in your browser -3. Upload your file and tell Claude *"done"* -4. Claude creates the post with your media - -## Available Commands - -| Command | Description | -|---------|-------------| -| `accounts_list` | Show connected social accounts | -| `posts_create` | Create scheduled, immediate, or draft post | -| `posts_publish_now` | Publish immediately | -| `posts_cross_post` | Post to multiple platforms | -| `posts_list` | Show your posts | -| `posts_retry` | Retry a failed post | -| `media_generate_upload_link` | Get link to upload media | - -These core commands are always visible. The full Zernio API catalog (~400 tools) is reachable on demand through tool search: `search_tools` finds a capability by description and `call_tool` invokes it — and every underlying tool also stays directly callable by name. - -## Remote Access (HTTP) - -The remote MCP server speaks **Streamable HTTP** (`POST /mcp`) — the modern single-endpoint transport supported by all current MCP clients. It survives idle timeouts on proxies and bridges like `mcp-remote`. The legacy SSE transport (`GET /sse`) is kept for backward compatibility. - -For deployment and client setup, see the [HTTP Deployment Guide](HTTP_DEPLOYMENT.md). diff --git a/pyproject.toml b/pyproject.toml index 5be5c35..0ecd7cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,35 +59,18 @@ anthropic = [ all = [ "openai>=1.0.0", "anthropic>=0.40.0", - "mcp>=1.8.0", -] -mcp = [ - "fastmcp>=3.1.0", - "mcp>=1.8.0", - "starlette>=0.42.0", - "uvicorn[standard]>=0.32.0", ] dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "pytest-cov>=6.0.0", "respx>=0.21.0", - # Needed so tests can import late.mcp.generated_tools (formatter regression test). - "mcp>=1.8.0", "ruff>=0.8.0", "mypy>=1.13.0", "datamodel-code-generator>=0.26.0", "pyyaml>=6.0.0", ] -[project.scripts] -# New Zernio-branded entry points -zernio-mcp = "late.mcp.server:main" -zernio-mcp-http = "late.mcp.http_server:main" -# Backward-compatible entry points (kept for existing users) -late-mcp = "late.mcp.server:main" -late-mcp-http = "late.mcp.http_server:main" - [project.urls] Homepage = "https://zernio.com" Documentation = "https://docs.zernio.com" @@ -159,7 +142,6 @@ plugins = ["pydantic.mypy"] module = [ "late.models._generated.*", "late.ai.*", - "late.mcp.*", "late.resources.*", "late.pipelines.*", ] diff --git a/railway.toml b/railway.toml deleted file mode 100644 index 4ec1d67..0000000 --- a/railway.toml +++ /dev/null @@ -1,9 +0,0 @@ -[build] -builder = "DOCKERFILE" -dockerfilePath = "Dockerfile" - -[deploy] -healthcheckPath = "/health" -healthcheckTimeout = 100 -restartPolicyType = "on_failure" -restartPolicyMaxRetries = 3 diff --git a/scripts/generate_mcp_docs.py b/scripts/generate_mcp_docs.py deleted file mode 100644 index 2dd3f76..0000000 --- a/scripts/generate_mcp_docs.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate MCP documentation from tool definitions. - -Usage: - python scripts/generate_mcp_docs.py - -This script generates MDX documentation from the centralized tool definitions -in src/late/mcp/tool_definitions.py - -The generated output can be copied into the docs site. -""" - -import sys -from pathlib import Path - -# Add src to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from late.mcp.tool_definitions import TOOL_DEFINITIONS, generate_mdx_tools_reference - - -def main() -> None: - """Generate and print MDX documentation.""" - print("=" * 60) - print("MCP Tool Documentation (generated from tool_definitions.py)") - print("=" * 60) - print() - print(generate_mdx_tools_reference()) - print() - print("=" * 60) - print(f"Total tools: {len(TOOL_DEFINITIONS)}") - print("Copy the above into claude-mcp.mdx under '## Tool Reference'") - print("=" * 60) - - -if __name__ == "__main__": - main() diff --git a/scripts/generate_mcp_tools.py b/scripts/generate_mcp_tools.py deleted file mode 100644 index 894a755..0000000 --- a/scripts/generate_mcp_tools.py +++ /dev/null @@ -1,656 +0,0 @@ -#!/usr/bin/env python3 -""" -Auto-generates MCP tool handlers from OpenAPI spec. - -This script parses the OpenAPI spec and generates complete MCP tool handlers -that wrap the SDK resources. The generated code can be imported directly -into server.py. - -Usage: - python scripts/generate_mcp_tools.py - # or - uv run python scripts/generate_mcp_tools.py -""" - -from __future__ import annotations - -import keyword -import re -import sys -from pathlib import Path -from typing import Any - -import yaml - -# Map OpenAPI tags to SDK resource names -TAG_TO_RESOURCE: dict[str, str] = { - "Posts": "posts", - "Accounts": "accounts", - "Profiles": "profiles", - "Analytics": "analytics", - "Account Groups": "account_groups", - "Queue": "queue", - "Webhooks": "webhooks", - "API Keys": "api_keys", - "Media": "media", - "Tools": "tools", - "Users": "users", - "Usage": "usage", - "Logs": "logs", - "Connect": "connect", - "Reddit Search": "reddit", - "Invites": "invites", - "GMB Reviews": "accounts", - "GMB Food Menus": "accounts", - "GMB Location Details": "accounts", - "GMB Media": "accounts", - "GMB Attributes": "accounts", - "GMB Place Actions": "accounts", - "LinkedIn Mentions": "accounts", - "Validate": "validate", -} - -# Operations to SKIP (not useful for MCP) -SKIP_OPERATIONS = { - # OAuth redirect endpoints - "connectPlatform", - "startBlueskyConnect", - "completeTiktokAuth", - "startSnapchatConnect", - # Internal endpoints - "deleteUser", - "deleteTeam", - # Already have custom implementations - # Note: createPost is intentionally NOT skipped anymore. The simplified - # posts_create wrapper in tool_definitions.py is friendlier for single- - # account flows, but power users (agencies cross-posting with per-target - # customContent / scheduledFor / platformSpecificData) need the full - # nested-array form. Both are exposed; LLMs pick whichever fits. - "retryPost", - "generateMediaUploadToken", - "checkMediaUploadToken", -} - - -def camel_to_snake(name: str) -> str: - """Convert camelCase to snake_case.""" - name = name.replace("-", "_") - name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) - name = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", name) - result = name.lower() - # Reserved keywords (e.g. `from`) can't be Python identifiers; suffix with - # `_` per PEP 8. The matching SDK method param is sanitized the same way, so - # the generated `from_=from_` keyword call lines up. - if keyword.iskeyword(result): - result += "_" - return result - - -def _resolve_ref(ref: str, spec: dict[str, Any]) -> dict[str, Any]: - """Resolve a `#/components/schemas/Foo` style JSON-pointer against spec. - - Returns an empty dict if any segment is missing, so callers can keep - walking without raising on a malformed/external ref. - """ - parts = ref.lstrip("#/").split("/") - node: Any = spec - for p in parts: - if not isinstance(node, dict): - return {} - node = node.get(p, {}) - return node if isinstance(node, dict) else {} - - -def _resolve_schema(schema: dict[str, Any], spec: dict[str, Any] | None) -> dict[str, Any]: - """Dereference a property schema into something with a concrete `type`. - - Handles three composite forms commonly used in our OpenAPI spec: - - - `{"$ref": "..."}` -> look up the target schema. - - `{"allOf": [{...}, ...]}` -> return the first branch with a concrete - `type` (we mostly see single-element allOf as a "ref + description" - pattern, but this generalises safely). - - `{"oneOf"|"anyOf": [...]}` -> if every branch resolves to the same - scalar/object/array `type`, return that branch; otherwise leave the - schema alone so the caller falls back to `str`. A deliberately - over-broad type is worse than no type for the LLM. - - Without spec access (spec=None) the function is a no-op so unit tests - that don't load the full spec still work. - """ - if not schema or not isinstance(schema, dict) or spec is None: - return schema - - if "$ref" in schema: - return _resolve_schema(_resolve_ref(schema["$ref"], spec), spec) - - if "allOf" in schema and isinstance(schema["allOf"], list): - for branch in schema["allOf"]: - resolved = _resolve_schema(branch, spec) - if resolved.get("type") in ("string", "integer", "number", "boolean", "array", "object"): - return resolved - - for combinator in ("oneOf", "anyOf"): - if combinator in schema and isinstance(schema[combinator], list): - resolved_branches = [_resolve_schema(b, spec) for b in schema[combinator]] - types = {b.get("type") for b in resolved_branches if isinstance(b, dict)} - types.discard(None) - if len(types) == 1: - only = types.pop() - for b in resolved_branches: - if b.get("type") == only: - return b - - return schema - - -def get_python_type( - schema: dict[str, Any], - required: bool = True, - spec: dict[str, Any] | None = None, -) -> tuple[str, str]: - """Convert OpenAPI schema to a Python type annotation and default literal. - - For scalar types (string/integer/number/boolean) we map to the obvious - Python type with a sensible default ('', 0, 0.0, False). - - For complex types (array / object) we emit proper container types with a - `None` default. Previously these were collapsed to `str = ""` with the - intent that callers would pass comma-separated strings, but the - generated wrapper never parsed them back - it forwarded the literal - string straight into the SDK, which then sent it to the API expecting - an array/object and got rejected with `Expected array, received string`. - - Emitting the correct container type (e.g. `list[str] | None`) means: - - FastMCP's JSON-schema introspection now declares the right shape - to the LLM, so the model passes a real array. - - The SDK's `_build_payload` already filters `None` values, so leaving - an unused param at `None` is a no-op upstream. - - Item types for arrays are inferred from `items.type`; $ref/allOf wrappers - on either the top-level schema or the items schema are dereferenced via - `_resolve_schema`. Unknown item types fall back to `Any`. - """ - if not schema: - return "str", '""' - - # Dereference $ref / allOf / single-type oneOf|anyOf so we can read the - # underlying `type` consistently. Without this, properties like - # `createPost.tiktokSettings` (a $ref to an object schema) would fall - # through to the `str` branch and the wrapper would forward a string - # where the API expects an object. - schema = _resolve_schema(schema, spec) - - schema_type = schema.get("type") - default = schema.get("default") - - # Scalars: respect the OpenAPI-declared default when present, otherwise - # use `None` so the SDK's `_build_payload` can drop the field before it - # reaches the API. Previously optional scalars defaulted to zero values - # (0/0.0/"" / False) which got forwarded literally and tripped Zod - # validators server-side - e.g. `bidAmount=0` fails - # `z.number().positive().optional()` even though the field is optional, - # surfacing as the cryptic "Number must be greater than 0" with no - # field name attached. - # - # Required scalars keep their bare type (no `| None`) regardless - the - # function-signature renderer drops the default, and the LLM is - # expected to always provide a value. The `| None` widening only - # applies when we're emitting a `= None` default. - if schema_type == "string": - if default is not None: - type_str = "str" - default_str = f'"{default}"' - elif required: - type_str = "str" - default_str = "" # placeholder; not rendered for required - else: - type_str = "str | None" - default_str = "None" - elif schema_type == "integer": - if default is not None: - type_str = "int" - default_str = str(default) - elif required: - type_str = "int" - default_str = "" - else: - type_str = "int | None" - default_str = "None" - elif schema_type == "number": - if default is not None: - type_str = "float" - default_str = str(default) - elif required: - type_str = "float" - default_str = "" - else: - type_str = "float | None" - default_str = "None" - elif schema_type == "boolean": - if default is not None: - type_str = "bool" - default_str = str(default) - elif required: - type_str = "bool" - default_str = "" - else: - type_str = "bool | None" - default_str = "None" - elif schema_type == "array": - # Inspect items.type so the LLM gets the right inner schema. Most - # array fields in the spec hold strings (countries, keywords, etc.) - # or objects (cities, interests, creatives, plus any $ref-wrapped - # object). Items themselves may be $ref/allOf/oneOf so resolve - # before reading the type. - items_schema = _resolve_schema(schema.get("items", {}) or {}, spec) - items_type = items_schema.get("type") - if items_type == "string": - inner = "str" - elif items_type == "integer": - inner = "int" - elif items_type == "number": - inner = "float" - elif items_type == "boolean": - inner = "bool" - elif items_type == "object": - inner = "dict[str, Any]" - else: - inner = "Any" - type_str = f"list[{inner}] | None" - default_str = "None" - elif schema_type == "object": - type_str = "dict[str, Any] | None" - default_str = "None" - else: - # Fallback for schemas we couldn't classify (mixed-type oneOf, - # unknown shapes). Type as str, but default to None when optional - # so the SDK can drop the field instead of forwarding an empty - # string that may fail enum/min-length/email validators. - if required: - type_str = "str" - default_str = "" - else: - type_str = "str | None" - default_str = "None" - - if not required: - return type_str, default_str - return type_str, "" - - -def extract_parameters( - operation: dict[str, Any], - spec: dict[str, Any] | None = None, -) -> list[dict[str, Any]]: - """Extract parameters from operation. - - `spec` is threaded through so `get_python_type` can dereference - `$ref`/`allOf` schemas to read the underlying type. - - Dedupes by Python name: when the same parameter appears in both the path - and the request body (e.g. `accountId` as a path param and also in the - body schema), Python can't accept duplicate kwargs in a function - signature, so we keep the first occurrence (path > query > body) and - drop subsequent ones. This prevents SyntaxErrors in generated_tools.py. - """ - params: list[dict[str, Any]] = [] - seen_names: set[str] = set() - - def add_param(entry: dict[str, Any]) -> None: - if entry["name"] in seen_names: - return - seen_names.add(entry["name"]) - params.append(entry) - - for param in operation.get("parameters", []): - if "$ref" in param: - ref = param["$ref"] - if "PageParam" in ref: - add_param({ - "name": "page", - "type": "int", - "required": False, - "default": "1", - "description": "Page number", - "sdk_name": "page", - }) - elif "LimitParam" in ref: - add_param({ - "name": "limit", - "type": "int", - "required": False, - "default": "10", - "description": "Results per page", - "sdk_name": "limit", - }) - continue - - if "name" not in param: - continue - - # Skip header parameters - they're handled differently in SDK - if param.get("in") == "header": - continue - - py_name = camel_to_snake(param["name"]) - # SDK name must also be valid Python identifier - sdk_name = camel_to_snake(param["name"]).replace("-", "_") - # MCP doesn't allow params starting with underscore - if py_name.startswith("_"): - py_name = py_name.lstrip("_") + "_id" if py_name == "_id" else py_name.lstrip("_") - - type_str, default_str = get_python_type( - param.get("schema", {}), - param.get("required", False), - spec, - ) - - add_param({ - "name": py_name, - "type": type_str, - "required": param.get("required", False), - "default": default_str, - "description": param.get("description", ""), - "sdk_name": sdk_name, - }) - - # Request body - request_body = operation.get("requestBody", {}) - if request_body: - content = request_body.get("content", {}) - json_content = content.get("application/json", {}) - schema = json_content.get("schema", {}) - properties = schema.get("properties", {}) - required_props = schema.get("required", []) - - for prop_name, prop_schema in properties.items(): - py_name = camel_to_snake(prop_name) - # MCP doesn't allow params starting with underscore - if py_name.startswith("_"): - py_name = py_name.lstrip("_") - if not py_name: # Was just "_" - continue - is_required = prop_name in required_props - type_str, default_str = get_python_type(prop_schema, is_required, spec) - - add_param({ - "name": py_name, - "type": type_str, - "required": is_required, - "default": default_str, - "description": prop_schema.get("description", ""), - "sdk_name": py_name, - }) - - return params - - -def generate_tool_handler( - tool_name: str, - resource: str, - sdk_method: str, - summary: str, - params: list[dict[str, Any]], - read_only: bool, - title: str, -) -> str: - """Generate a complete tool handler function.""" - lines = [] - - # Sort params: required first, then optional - required = [p for p in params if p["required"]] - optional = [p for p in params if not p["required"]] - - # Build function signature - sig_params = [] - for p in required: - sig_params.append(f"{p['name']}: {p['type']}") - for p in optional: - sig_params.append(f"{p['name']}: {p['type']} = {p['default']}") - - sig = ", ".join(sig_params) - - # Docstring - strip trailing whitespace from all lines - doc_lines = [summary.rstrip()] - if params: - doc_lines.append("") - doc_lines.append("Args:") - for p in params: - req = " (required)" if p["required"] else "" - desc = p['description'] if p['description'] else "" - # Strip trailing whitespace from each line of multiline descriptions - desc = "\n".join(line.rstrip() for line in desc.split("\n")) - # Avoid trailing whitespace when description is empty - if desc: - doc_lines.append(f" {p['name']}: {desc}{req}") - else: - doc_lines.append(f" {p['name']}:{req}" if req else f" {p['name']}") - - # Strip trailing whitespace from all docstring lines - docstring = "\n ".join(line.rstrip() for line in doc_lines) - - # Tool annotation (required by the Anthropic Connectors Directory AND the - # ChatGPT Apps submission, which needs all three hints set explicitly). - # title is rendered via repr() so quotes/specials in the summary are - # escaped safely. openWorldHint mirrors read_only: write tools act on - # external social platforms (publish/send/etc.) so they touch the open - # world; GET tools only read, so they don't change external state. - annotation = ( - f"annotations=ToolAnnotations(title={title!r}, " - f"readOnlyHint={read_only}, destructiveHint={not read_only}, " - f"openWorldHint={not read_only})" - ) - - lines.append("") - lines.append("") - lines.append(f"@mcp.tool({annotation})") - lines.append(f"def {tool_name}({sig}) -> str:") - lines.append(f' """{docstring}"""') - lines.append(" client = _get_client()") - - # Build SDK call - always use keyword args for clarity - sdk_args = [] - for p in params: - sdk_name = p.get("sdk_name", p["name"]) - sdk_args.append(f"{sdk_name}={p['name']}") - - lines.append(" try:") - lines.append(f" response = client.{resource}.{sdk_method}({', '.join(sdk_args)})") - lines.append(" return _format_response(response)") - lines.append(" except Exception as e:") - lines.append(" return f'Error: {e}'") - - return "\n".join(lines) - - -def main() -> int: - """Main entry point.""" - project_root = Path(__file__).parent.parent - openapi_path = project_root / "openapi.yaml" - - if not openapi_path.exists(): - print(f"Error: OpenAPI spec not found at {openapi_path}") - return 1 - - with openapi_path.open() as f: - spec = yaml.safe_load(f) - - # Collect operations - operations = [] - - # Track tool_names already emitted. Two different (path, method) pairs can - # produce the same tool_name when their operationIds snake-case to the - # same string, or when the spec has a duplicate operation. Either way, - # emitting two @mcp.tool() functions with the same name causes an F811 - # redefinition lint failure, so we skip collisions with a warning. - seen_tool_names: set[str] = set() - - for path, path_item in spec.get("paths", {}).items(): - for method, operation in path_item.items(): - if method not in ("get", "post", "put", "patch", "delete"): - continue - - operation_id = operation.get("operationId") - if not operation_id or operation_id in SKIP_OPERATIONS: - continue - - tags = operation.get("tags", ["Other"]) - resource = TAG_TO_RESOURCE.get(tags[0], tags[0].lower().replace(" ", "_")) - - # Generate tool name from operationId - sdk_method = camel_to_snake(operation_id) - tool_name = f"{resource}_{sdk_method}" - # Clean up redundant prefixes - tool_name = re.sub(rf"^{resource}_{resource}_", f"{resource}_", tool_name) - - if tool_name in seen_tool_names: - print( - f"Warning: duplicate MCP tool_name '{tool_name}' at " - f"{method.upper()} {path} (operationId={operation_id}); skipping.", - file=sys.stderr, - ) - continue - seen_tool_names.add(tool_name) - - summary = operation.get("summary", operation_id) - operations.append({ - "tool_name": tool_name, - "resource": resource, - "sdk_method": sdk_method, - "summary": summary, - # HTTP method drives the tool annotation: GET is read-only, - # everything else (POST/PUT/PATCH/DELETE) is treated as a - # destructive write. Required by Anthropic's Connectors - # Directory (every tool must carry readOnlyHint or - # destructiveHint). - "read_only": method == "get", - # Human-readable title for the annotation. First line of the - # OpenAPI summary, capped so it stays a short label. - "title": summary.split("\n")[0].strip()[:80], - "params": extract_parameters(operation, spec), - }) - - # Generate output file - lines = [ - '"""', - "Auto-generated MCP tool handlers.", - "", - "DO NOT EDIT - Run `python scripts/generate_mcp_tools.py` to regenerate.", - '"""', - "", - "from __future__ import annotations", - "", - "from typing import Any", - "", - "from mcp.types import ToolAnnotations", - "", - "", - "def _enum_str(value: Any) -> str:", - ' """Extract string value from an enum or return as-is if already a string.', - "", - ' The auto-generated models use plain Enum classes (Platform5, etc.) whose', - " str() returns 'Platform5.TWITTER' instead of 'twitter'. This helper", - ' normalises any enum value to its underlying string.', - ' """', - " if value is None:", - ' return ""', - " if isinstance(value, str):", - " return value", - " if hasattr(value, 'value'):", - " return str(value.value)", - " return str(value)", - "", - "", - "def _format_response(response: Any) -> str:", - ' """Format SDK response for MCP output."""', - " if response is None:", - ' return "Success"', - " if hasattr(response, '__dict__'):", - " # Handle response objects", - " if hasattr(response, 'posts') and response.posts:", - " posts = response.posts", - ' lines = [f"Found {len(posts)} post(s):"]', - " for p in posts[:10]:", - " content = str(getattr(p, 'content', ''))[:50]", - " status = _enum_str(getattr(p, 'status', 'unknown'))", - ' lines.append(f"- [{status}] {content}...")', - ' return "\\n".join(lines)', - " if hasattr(response, 'accounts') and hasattr(response, 'stats'):", - " # Follower-stats: lossless structured output for the LLM. Must precede the", - " # generic 'accounts' branch (which would print only 'platform: username' and", - " # drop currentFollowers/growth and the daily series). 'stats' attr is unique", - " # to FollowerStatsResponse, so no other tool's response matches.", - " return response.model_dump_json(by_alias=True, exclude_none=True)", - " if hasattr(response, 'accounts') and response.accounts:", - " accs = response.accounts", - ' lines = [f"Found {len(accs)} account(s):"]', - " for a in accs[:10]:", - " platform = _enum_str(getattr(a, 'platform', '?'))", - " username = getattr(a, 'username', None) or getattr(a, 'displayName', '?')", - ' lines.append(f"- {platform}: {username}")', - ' return "\\n".join(lines)', - " if hasattr(response, 'profiles') and response.profiles:", - " profiles = response.profiles", - ' lines = [f"Found {len(profiles)} profile(s):"]', - " for p in profiles[:10]:", - " name = getattr(p, 'name', 'Unnamed')", - ' lines.append(f"- {name}")', - ' return "\\n".join(lines)', - " if hasattr(response, 'post') and response.post:", - " p = response.post", - ' return f"Post ID: {getattr(p, \'field_id\', \'N/A\')}\\nStatus: {_enum_str(getattr(p, \'status\', \'N/A\'))}"', - " if hasattr(response, 'profile') and response.profile:", - " p = response.profile", - ' return f"Profile: {getattr(p, \'name\', \'N/A\')} (ID: {getattr(p, \'field_id\', \'N/A\')})"', - " return str(response)", - "", - "", - "def register_generated_tools(mcp, _get_client):", - ' """Register all auto-generated tools with the MCP server."""', - ] - - # Group by resource for organization - by_resource: dict[str, list] = {} - for op in operations: - res = op["resource"] - if res not in by_resource: - by_resource[res] = [] - by_resource[res].append(op) - - # Generate handlers inside register function - for resource, ops in sorted(by_resource.items()): - lines.append("") - lines.append(f" # {resource.upper()}") - - for op in ops: - handler = generate_tool_handler( - op["tool_name"], - op["resource"], - op["sdk_method"], - op["summary"], - op["params"], - op["read_only"], - op["title"], - ) - # Indent for being inside register function - handler_lines = handler.split("\n") - for hl in handler_lines: - if hl.strip(): - lines.append(f" {hl}") - else: - lines.append("") - - # Output - output_file = project_root / "src" / "late" / "mcp" / "generated_tools.py" - output_file.write_text("\n".join(lines) + "\n") - - print(f"Generated {output_file}") - print(f"Total tools: {len(operations)}") - print("\nTo use: import and call register_generated_tools(mcp, _get_client) in server.py") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/smoketest_streamable_http.py b/scripts/smoketest_streamable_http.py deleted file mode 100644 index a3b2cb6..0000000 --- a/scripts/smoketest_streamable_http.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -End-to-end smoke test for the Streamable HTTP transport. - -Boots the HTTP server in-process with auth verification stubbed out, then -drives it with the official MCP Python client over Streamable HTTP. Covers: - - 1. /mcp routing reaches our auth wrapper (no 307, no signature mismatch) - 2. Streamable HTTP protocol handshake (initialize) - 3. tools/list returns the registered tool catalog - 4. ContextVar API-key propagation: a tool that reads the key sees it - 5. SSE legacy transport endpoint still responds (no regression) -""" - -from __future__ import annotations - -import asyncio -import threading -import time -from collections.abc import Iterator -from contextlib import contextmanager - -import httpx -import uvicorn - -# --- Stub auth BEFORE importing the server modules so the patched function -# is what gets bound into the routes module. --- -import late.mcp.auth as auth_module - -_ORIG_VERIFY = auth_module.verify_late_api_key - - -async def _always_valid(api_key: str) -> bool: # noqa: D401 - stub - """Stub: accept any non-empty key. Bypasses the network call to zernio.com.""" - return bool(api_key) - - -auth_module.verify_late_api_key = _always_valid -# Also patch the already-imported reference inside routes (it imports by name). -import late.mcp.routes as routes_module # noqa: E402 - -routes_module.verify_late_api_key = _always_valid - -from mcp import ClientSession # noqa: E402 -from mcp.client.streamable_http import streamablehttp_client # noqa: E402 - -from late.mcp.http_server import create_app # noqa: E402 -from late.mcp.server import _get_client, mcp # noqa: E402 - -PORT = 8766 -BASE = f"http://127.0.0.1:{PORT}" -FAKE_KEY = "test_key_smoke" - - -@contextmanager -def run_server() -> Iterator[None]: - """Start uvicorn in a daemon thread; yield once it's accepting connections.""" - app = create_app(mcp._mcp_server, debug=False) - config = uvicorn.Config(app, host="127.0.0.1", port=PORT, log_level="warning") - server = uvicorn.Server(config) - - thread = threading.Thread(target=server.run, daemon=True) - thread.start() - - # Wait for the server to come up. - for _ in range(50): - try: - r = httpx.get(f"{BASE}/health", timeout=0.5) - if r.status_code == 200: - break - except Exception: - time.sleep(0.1) - else: - raise RuntimeError("server failed to start") - - try: - yield - finally: - server.should_exit = True - thread.join(timeout=5) - - -async def run_streamable_http_checks() -> None: - """Drive the server with the real MCP client over Streamable HTTP.""" - headers = {"Authorization": f"Bearer {FAKE_KEY}"} - url = f"{BASE}/mcp" - - print(f"[client] connecting to {url} via streamablehttp_client") - async with streamablehttp_client(url, headers=headers) as (read, write, _get_session_id): - async with ClientSession(read, write) as session: - print("[client] initialize()") - init = await session.initialize() - assert init.serverInfo.name == "Zernio", init.serverInfo - print(f"[ok] initialize -> serverInfo.name={init.serverInfo.name!r}") - - print("[client] list_tools()") - tools = await session.list_tools() - names = [t.name for t in tools.tools] - assert names, "expected non-empty tool list" - assert any(n.startswith("posts_") for n in names), names - print(f"[ok] list_tools -> {len(names)} tools (sample: {names[:5]})") - - # The real ContextVar test: invoke a tool and observe the error. - # - If plumbing is BROKEN: tool errors with "API key is required" - # (the ValueError raised by _get_client when ContextVar is unset). - # - If plumbing WORKS: the key reaches the Late client, which then - # makes a real call to zernio.com that fails with 401/auth error - # because our fake key isn't real. Either of those proves the - # key is being propagated through anyio's task group context. - print("[client] call_tool('accounts_list') to verify API-key propagation") - result = await session.call_tool("accounts_list", {}) - text_content = " ".join(getattr(c, "text", "") for c in result.content) - print(f"[debug] tool response (truncated): {text_content[:200]!r}") - - broken_marker = "Zernio API key is required" - assert broken_marker not in text_content, ( - f"ContextVar propagation FAILED — tool could not see the API key.\n" - f"Full response: {text_content}" - ) - print("[ok] API key reached the tool (no 'API key required' error)") - - -def check_no_redirect() -> None: - """Verify POST /mcp does not 307 to /mcp/ — the bug we fixed.""" - print("[raw] POST /mcp without auth") - r = httpx.post( - f"{BASE}/mcp", - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, - follow_redirects=False, - ) - assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}" - assert "Missing API key" in r.text, r.text - print(f"[ok] no-auth -> 401 (no 307 redirect)") - - -def check_sse_still_works() -> None: - """Verify the legacy SSE endpoint still gates auth (no regression).""" - print("[raw] GET /sse without auth") - r = httpx.get(f"{BASE}/sse", timeout=5.0) - assert r.status_code == 401, f"expected 401, got {r.status_code}" - print(f"[ok] /sse no-auth -> 401") - - -def check_root_advertises_both() -> None: - print("[raw] GET / (root info)") - r = httpx.get(f"{BASE}/") - body = r.json() - assert "mcp" in body["endpoints"], body["endpoints"] - assert "sse" in body["endpoints"], body["endpoints"] - assert body["transport"] == "sse+streamable-http", body - print(f"[ok] root advertises both transports: {body['transport']}") - - -def main() -> None: - with run_server(): - check_no_redirect() - check_sse_still_works() - check_root_advertises_both() - asyncio.run(run_streamable_http_checks()) - print("\nALL CHECKS PASSED") - - -if __name__ == "__main__": - main() diff --git a/server.json b/server.json deleted file mode 100644 index 8d489ce..0000000 --- a/server.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "com.zernio/zernio", - "title": "Zernio", - "description": "Schedule, publish, and analyze social media across 15+ platforms, plus inbox, ads, and analytics.", - "version": "1.0.0", - "repository": { - "url": "https://github.com/zernio-dev/zernio-python", - "source": "github" - }, - "remotes": [ - { - "type": "streamable-http", - "url": "https://mcp.zernio.com/mcp" - } - ] -} diff --git a/src/late/mcp/__init__.py b/src/late/mcp/__init__.py deleted file mode 100644 index 47ca0c0..0000000 --- a/src/late/mcp/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Late MCP Server - Interact with Late API through Model Context Protocol.""" - -from .server import mcp - -__all__ = ["mcp"] diff --git a/src/late/mcp/__main__.py b/src/late/mcp/__main__.py deleted file mode 100644 index 7960190..0000000 --- a/src/late/mcp/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Allow running MCP server with: python -m late.mcp""" - -from late.mcp.server import mcp - -if __name__ == "__main__": - mcp.run() diff --git a/src/late/mcp/auth.py b/src/late/mcp/auth.py deleted file mode 100644 index 59a7f80..0000000 --- a/src/late/mcp/auth.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Authentication module for Zernio MCP HTTP server.""" - -import os -from urllib.parse import urlparse - -import httpx -from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier -from starlette.requests import Request - -from late.mcp.constants import ( - DOCS_URL, - MCP_PUBLIC_URL, - OAUTH_AUTHORIZATION_SERVER, - OAUTH_SCOPES, - SERVICE_NAME, -) - -# Origin allowlist for DNS-rebinding protection (MCP spec / Anthropic Connectors -# Directory requirement). Matched as exact host or subdomain suffix. Extend at -# runtime with MCP_ALLOWED_ORIGINS (comma-separated hostnames). -_DEFAULT_ALLOWED_ORIGIN_SUFFIXES = ( - "claude.ai", - "claude.com", - "anthropic.com", - "chatgpt.com", - "openai.com", - "localhost", - "127.0.0.1", -) - - -def _allowed_origin_suffixes() -> tuple[str, ...]: - """Return the configured Origin allowlist (defaults + MCP_ALLOWED_ORIGINS).""" - extra = os.getenv("MCP_ALLOWED_ORIGINS", "") - extras = tuple(h.strip().lower() for h in extra.split(",") if h.strip()) - return _DEFAULT_ALLOWED_ORIGIN_SUFFIXES + extras - - -def is_allowed_origin(request: Request) -> bool: - """Validate the request's Origin header to prevent DNS-rebinding attacks. - - The threat is a malicious web page in a browser scripting requests to the - MCP server; browsers always attach an Origin header to such cross-site - requests. Non-browser callers (native MCP clients, mcp-remote, and - server-to-server callers like Anthropic's connector backend) send no Origin - and are allowed through — they can't be driven by a hostile web page. - - Returns True when there is no Origin header or when the Origin host matches - an allowlisted domain (exact or subdomain); False for a present but - unrecognised browser Origin. - """ - origin = request.headers.get("Origin") - if not origin: - return True - host = (urlparse(origin).hostname or "").lower() - if not host: - return False - return any(host == s or host.endswith("." + s) for s in _allowed_origin_suffixes()) - - -async def verify_late_api_key(api_key: str) -> bool: - """ - Verify Zernio API key by making a test request to Zernio API. - - Function name kept as verify_late_api_key for backwards compatibility. - - Args: - api_key: The Zernio API key to verify. - - Returns: - True if API key is valid, False otherwise. - """ - try: - async with httpx.AsyncClient() as client: - response = await client.get( - "https://zernio.com/api/v1/accounts", - headers={"Authorization": f"Bearer {api_key}"}, - timeout=5.0, - ) - return response.status_code == 200 - except Exception: - return False - - -class ZernioTokenVerifier(TokenVerifier): - """Resource-server token verification for the Zernio MCP server. - - Accepts BOTH plain Zernio API keys and OAuth access tokens: both arrive as - the same bearer string and are validated the same way — a live GET to the - Zernio API (verify_late_api_key). HTTP 200 => valid. Deliberately - format-agnostic (no JWT decode), which is why a static API key works here - just as well as an issued OAuth token. - """ - - async def verify_token(self, token: str) -> AccessToken | None: - if not await verify_late_api_key(token): - return None - return AccessToken(token=token, client_id="zernio", scopes=list(OAUTH_SCOPES)) - - -def build_auth_provider() -> RemoteAuthProvider: - """Build the FastMCP resource-server auth provider. - - RemoteAuthProvider makes this server an OAuth 2.0 resource server: it - auto-serves /.well-known/oauth-protected-resource (RFC 9728), emits the 401 - WWW-Authenticate challenge pointing clients back at that document, and - delegates token validation to ZernioTokenVerifier. The authorization server - itself (token / authorize / register) lives at zernio.com. - """ - return RemoteAuthProvider( - token_verifier=ZernioTokenVerifier(), - authorization_servers=[OAUTH_AUTHORIZATION_SERVER], - base_url=MCP_PUBLIC_URL, - scopes_supported=list(OAUTH_SCOPES), - resource_name=SERVICE_NAME, - resource_documentation=DOCS_URL, - ) diff --git a/src/late/mcp/config.py b/src/late/mcp/config.py deleted file mode 100644 index b664a39..0000000 --- a/src/late/mcp/config.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Configuration management for Zernio MCP HTTP server.""" - -import os -from dataclasses import dataclass - -from late.mcp.constants import ( - DEFAULT_HOST, - DEFAULT_PORT, - ENV_HOST, - ENV_PORT, -) - - -@dataclass -class ServerConfig: - """Server configuration.""" - - host: str - port: int - debug: bool = False - - @classmethod - def from_env(cls, host: str | None = None, port: int | None = None, debug: bool = False) -> "ServerConfig": - """ - Create configuration from environment variables. - - Args: - host: Override host from environment - port: Override port from environment - debug: Enable debug mode - - Returns: - ServerConfig instance - """ - return cls( - host=host or os.getenv(ENV_HOST, DEFAULT_HOST), - port=port or int(os.getenv(ENV_PORT, str(DEFAULT_PORT))), - debug=debug, - ) - - -def validate_environment() -> None: - """ - Validate required environment variables are set. - - Note: No environment variables are required for the server. - Users provide their Zernio API keys via request headers. - """ - # No validation needed - users provide API keys in headers - pass diff --git a/src/late/mcp/constants.py b/src/late/mcp/constants.py deleted file mode 100644 index a7cf7cd..0000000 --- a/src/late/mcp/constants.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Constants for Zernio MCP HTTP server.""" - -import os - -# Server information -SERVICE_NAME = "Zernio MCP Server" -SERVICE_VERSION = "1.2.0" -# Streamable HTTP is the primary transport (single endpoint, request/response -# with optional chunked streaming — no long-idle connection for proxies or -# bridges like mcp-remote to kill). Legacy SSE is still served for backwards -# compatibility: production continues to receive GET /sse connections from -# older client configs. New clients should use Streamable HTTP. -TRANSPORT_TYPE = "sse+streamable-http" - -# Default server configuration -DEFAULT_HOST = "0.0.0.0" -DEFAULT_PORT = 8080 - -# Environment variable names -ENV_HOST = "HOST" -ENV_PORT = "PORT" - -# Endpoints -ENDPOINT_ROOT = "/" -ENDPOINT_HEALTH = "/health" -ENDPOINT_MCP = "/mcp" -# Legacy SSE transport (two-endpoint protocol: GET /sse + POST /messages/), -# deprecated but kept for backwards compatibility with older client configs. -ENDPOINT_SSE = "/sse" -ENDPOINT_MESSAGES = "/messages/" - -# --- OAuth 2.0 protected-resource discovery (RFC 9728 / MCP authorization spec) --- -# This MCP server is a *resource server*: it does not mint tokens, it accepts -# bearer tokens issued by the Zernio authorization server at zernio.com (and -# also accepts plain Zernio API keys — see verify_late_api_key). A spec- -# compliant client (e.g. Claude's connector) handed only the /mcp URL discovers -# the authorization server by: -# 1. reading the `resource_metadata` parameter on the 401 WWW-Authenticate -# challenge (emitted by FastMCP's RemoteAuthProvider), or -# 2. fetching the path-inserted discovery document that FastMCP serves at -# /.well-known/oauth-protected-resource/mcp on THIS origin. -# The pre-FastMCP server served the document at the root well-known path below; -# it is kept as a permanent redirect to the canonical document so clients still -# holding the old URL keep working (see http_server). -ENDPOINT_OAUTH_PROTECTED_RESOURCE = "/.well-known/oauth-protected-resource" - -# Public URL of this MCP server — the OAuth `resource` identifier. Must be the -# canonical URL clients actually connect to; per RFC 8707/9728 strict clients -# reject metadata whose `resource` doesn't match the server they're talking to. -# Overridable for non-prod deployments (e.g. a Railway preview URL). -MCP_PUBLIC_URL = os.getenv("MCP_PUBLIC_URL", "https://mcp.zernio.com") - -# The Zernio authorization server that issues OAuth tokens for this resource. -OAUTH_AUTHORIZATION_SERVER = "https://zernio.com" - -# OAuth scopes advertised in protected-resource metadata. Mirrors the set the -# authorization server defines at zernio.com/.well-known/oauth-authorization-server -# (kept in sync with libs/oauth/scopes.ts in the main app). Informational only: -# the client requests scopes from the authorization-server metadata, not from -# here, and scopes are not enforced per-endpoint today. -OAUTH_SCOPES = [ - "posts:read", - "posts:write", - "accounts:read", - "accounts:write", - "analytics:read", - "ads:write", - "messaging:write", - "automations:write", -] - -# Documentation -DOCS_URL = "https://docs.zernio.com" diff --git a/src/late/mcp/generated_tools.py b/src/late/mcp/generated_tools.py deleted file mode 100644 index 30c38f4..0000000 --- a/src/late/mcp/generated_tools.py +++ /dev/null @@ -1,14724 +0,0 @@ -""" -Auto-generated MCP tool handlers. - -DO NOT EDIT - Run `python scripts/generate_mcp_tools.py` to regenerate. -""" - -from __future__ import annotations - -from typing import Any - -from mcp.types import ToolAnnotations - - -def _enum_str(value: Any) -> str: - """Extract string value from an enum or return as-is if already a string. - - The auto-generated models use plain Enum classes (Platform5, etc.) whose - str() returns 'Platform5.TWITTER' instead of 'twitter'. This helper - normalises any enum value to its underlying string. - """ - if value is None: - return "" - if isinstance(value, str): - return value - if hasattr(value, "value"): - return str(value.value) - return str(value) - - -def _format_response(response: Any) -> str: - """Format SDK response for MCP output.""" - if response is None: - return "Success" - if hasattr(response, "__dict__"): - # Handle response objects - if hasattr(response, "posts") and response.posts: - posts = response.posts - lines = [f"Found {len(posts)} post(s):"] - for p in posts[:10]: - content = str(getattr(p, "content", ""))[:50] - status = _enum_str(getattr(p, "status", "unknown")) - lines.append(f"- [{status}] {content}...") - return "\n".join(lines) - if hasattr(response, "accounts") and hasattr(response, "stats"): - # Follower-stats: lossless structured output for the LLM. Must precede the - # generic 'accounts' branch (which would print only 'platform: username' and - # drop currentFollowers/growth and the daily series). 'stats' attr is unique - # to FollowerStatsResponse, so no other tool's response matches. - return response.model_dump_json(by_alias=True, exclude_none=True) - if hasattr(response, "accounts") and response.accounts: - accs = response.accounts - lines = [f"Found {len(accs)} account(s):"] - for a in accs[:10]: - platform = _enum_str(getattr(a, "platform", "?")) - username = getattr(a, "username", None) or getattr( - a, "displayName", "?" - ) - lines.append(f"- {platform}: {username}") - return "\n".join(lines) - if hasattr(response, "profiles") and response.profiles: - profiles = response.profiles - lines = [f"Found {len(profiles)} profile(s):"] - for p in profiles[:10]: - name = getattr(p, "name", "Unnamed") - lines.append(f"- {name}") - return "\n".join(lines) - if hasattr(response, "post") and response.post: - p = response.post - return f"Post ID: {getattr(p, 'field_id', 'N/A')}\nStatus: {_enum_str(getattr(p, 'status', 'N/A'))}" - if hasattr(response, "profile") and response.profile: - p = response.profile - return f"Profile: {getattr(p, 'name', 'N/A')} (ID: {getattr(p, 'field_id', 'N/A')})" - return str(response) - - -def register_generated_tools(mcp, _get_client): - """Register all auto-generated tools with the MCP server.""" - - # ACCOUNT_GROUPS - - @mcp.tool( - annotations=ToolAnnotations( - title="List groups", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def account_groups_list_account_groups() -> str: - """List groups""" - client = _get_client() - try: - response = client.account_groups.list_account_groups() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create group", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_groups_create_account_group( - name: str, account_ids: list[str] | None, profile_id: str | None = None - ) -> str: - """Create group - - Args: - name: (required) - account_ids: (required) - profile_id: Deprecated. Accepted for backward compatibility but ignored. - Groups are no longer scoped to a single profile.""" - client = _get_client() - try: - response = client.account_groups.create_account_group( - name=name, account_ids=account_ids, profile_id=profile_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update group", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_groups_update_account_group( - group_id: str, name: str | None = None, account_ids: list[str] | None = None - ) -> str: - """Update group - - Args: - group_id: (required) - name - account_ids""" - client = _get_client() - try: - response = client.account_groups.update_account_group( - group_id=group_id, name=name, account_ids=account_ids - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete group", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_groups_delete_account_group(group_id: str) -> str: - """Delete group - - Args: - group_id: (required)""" - client = _get_client() - try: - response = client.account_groups.delete_account_group(group_id=group_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # ACCOUNT_SETTINGS - - @mcp.tool( - annotations=ToolAnnotations( - title="Get FB persistent menu", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def account_settings_get_messenger_menu(account_id: str) -> str: - """Get FB persistent menu - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.account_settings.get_messenger_menu(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set FB persistent menu", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_settings_set_messenger_menu( - account_id: str, persistent_menu: list[dict[str, Any]] | None - ) -> str: - """Set FB persistent menu - - Args: - account_id: (required) - persistent_menu: Persistent menu configuration array (Meta format) (required)""" - client = _get_client() - try: - response = client.account_settings.set_messenger_menu( - account_id=account_id, persistent_menu=persistent_menu - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete FB persistent menu", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_settings_delete_messenger_menu(account_id: str) -> str: - """Delete FB persistent menu - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.account_settings.delete_messenger_menu( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get IG ice breakers", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def account_settings_get_instagram_ice_breakers(account_id: str) -> str: - """Get IG ice breakers - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.account_settings.get_instagram_ice_breakers( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set IG ice breakers", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_settings_set_instagram_ice_breakers( - account_id: str, ice_breakers: list[dict[str, Any]] | None - ) -> str: - """Set IG ice breakers - - Args: - account_id: (required) - ice_breakers: (required)""" - client = _get_client() - try: - response = client.account_settings.set_instagram_ice_breakers( - account_id=account_id, ice_breakers=ice_breakers - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete IG ice breakers", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_settings_delete_instagram_ice_breakers(account_id: str) -> str: - """Delete IG ice breakers - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.account_settings.delete_instagram_ice_breakers( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get TG bot commands", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def account_settings_get_telegram_commands(account_id: str) -> str: - """Get TG bot commands - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.account_settings.get_telegram_commands( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set TG bot commands", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_settings_set_telegram_commands( - account_id: str, commands: list[dict[str, Any]] | None - ) -> str: - """Set TG bot commands - - Args: - account_id: (required) - commands: (required)""" - client = _get_client() - try: - response = client.account_settings.set_telegram_commands( - account_id=account_id, commands=commands - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete TG bot commands", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def account_settings_delete_telegram_commands(account_id: str) -> str: - """Delete TG bot commands - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.account_settings.delete_telegram_commands( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # ACCOUNTS - - @mcp.tool( - annotations=ToolAnnotations( - title="List accounts", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_list_accounts( - profile_id: str | None = None, - platform: str | None = None, - status: str | None = None, - include_over_limit: bool = False, - page: int | None = None, - limit: int | None = None, - ) -> str: - """List accounts - - Args: - profile_id: Filter accounts by profile ID - platform: Filter accounts by platform (e.g. "instagram", "twitter"). - status: Filter accounts by connection status. `connected` returns healthy accounts; `disconnected` returns accounts that need reconnection (per the same reconnection check surfaced in the dashboard). Omit to return accounts in any status. When combined with page/limit, pagination totals reflect the filtered result set. - include_over_limit: When true, includes accounts from over-limit profiles. - page: Page number (1-based). When provided with limit, enables server-side pagination. Omit for all accounts. - limit: Page size. Required alongside page for pagination.""" - client = _get_client() - try: - response = client.accounts.list_accounts( - profile_id=profile_id, - platform=platform, - status=status, - include_over_limit=include_over_limit, - page=page, - limit=limit, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get follower stats", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_follower_stats( - account_ids: str | None = None, - profile_id: str | None = None, - from_date: str | None = None, - to_date: str | None = None, - granularity: str = "daily", - ) -> str: - """Get follower stats - - Args: - account_ids: Comma-separated list of account IDs (optional, defaults to all user's accounts) - profile_id: Filter by profile ID - from_date: Start date in YYYY-MM-DD format (defaults to 30 days ago) - to_date: End date in YYYY-MM-DD format (defaults to today) - granularity: Data aggregation level""" - client = _get_client() - try: - response = client.accounts.get_follower_stats( - account_ids=account_ids, - profile_id=profile_id, - from_date=from_date, - to_date=to_date, - granularity=granularity, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update account", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_update_account( - account_id: str, - username: str | None = None, - display_name: str | None = None, - x_capabilities: dict[str, Any] | None = None, - ) -> str: - """Update account - - Args: - account_id: (required) - username - display_name - x_capabilities: X/Twitter only. Per-account opt-in toggles for background API - operations that incur X API pass-through costs. Each call is - billed via Metronome at the X tier rate. Either field can be - sent independently; omitted fields are unchanged.""" - client = _get_client() - try: - response = client.accounts.update_account( - account_id=account_id, - username=username, - display_name=display_name, - x_capabilities=x_capabilities, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Move account to another profile", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_move_account_to_profile(account_id: str, profile_id: str) -> str: - """Move account to another profile - - Args: - account_id: (required) - profile_id: Target profile ID (must be a valid ObjectId and owned by the same user as the account). (required)""" - client = _get_client() - try: - response = client.accounts.move_account_to_profile( - account_id=account_id, profile_id=profile_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Disconnect account", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_delete_account(account_id: str) -> str: - """Disconnect account - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.accounts.delete_account(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check accounts health", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_all_accounts_health( - profile_id: str | None = None, - platform: str | None = None, - status: str | None = None, - ) -> str: - """Check accounts health - - Args: - profile_id: Filter by profile ID - platform: Filter by platform - status: Filter by health status""" - client = _get_client() - try: - response = client.accounts.get_all_accounts_health( - profile_id=profile_id, platform=platform, status=status - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check account health", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_account_health(account_id: str) -> str: - """Check account health - - Args: - account_id: The account ID to check (required)""" - client = _get_client() - try: - response = client.accounts.get_account_health(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get TikTok creator info", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_tik_tok_creator_info( - account_id: str, media_type: str = "video" - ) -> str: - """Get TikTok creator info - - Args: - account_id: The TikTok account ID (required) - media_type: The media type to get creator info for (affects available interaction settings)""" - client = _get_client() - try: - response = client.accounts.get_tik_tok_creator_info( - account_id=account_id, media_type=media_type - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get reviews", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_google_business_reviews( - account_id: str, - location_id: str | None = None, - page_size: int = 50, - page_token: str | None = None, - ) -> str: - """Get reviews - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - page_size: Number of reviews to fetch per page (max 50) - page_token: Pagination token from previous response""" - client = _get_client() - try: - response = client.accounts.get_google_business_reviews( - account_id=account_id, - location_id=location_id, - page_size=page_size, - page_token=page_token, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get food menus", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_google_business_food_menus( - account_id: str, location_id: str | None = None - ) -> str: - """Get food menus - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.""" - client = _get_client() - try: - response = client.accounts.get_google_business_food_menus( - account_id=account_id, location_id=location_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update food menus", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_update_google_business_food_menus( - account_id: str, - menus: list[dict[str, Any]] | None, - location_id: str | None = None, - update_mask: str | None = None, - ) -> str: - """Update food menus - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - menus: Array of food menus to set (required) - update_mask: Field mask for partial updates (e.g. "menus")""" - client = _get_client() - try: - response = client.accounts.update_google_business_food_menus( - account_id=account_id, - location_id=location_id, - menus=menus, - update_mask=update_mask, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get location details", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_google_business_location_details( - account_id: str, location_id: str | None = None, read_mask: str | None = None - ) -> str: - """Get location details - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - read_mask: Comma-separated fields to return. Available: name, title, phoneNumbers, categories, storefrontAddress, websiteUri, regularHours, specialHours, serviceArea, serviceItems, profile, openInfo, metadata, moreHours. - `title` and `metadata` are always included in the response so the `location` summary block can be populated, even if you omit them here. - Note: `location` is a derived response field, not a Google readMask value, passing it returns 400.""" - client = _get_client() - try: - response = client.accounts.get_google_business_location_details( - account_id=account_id, location_id=location_id, read_mask=read_mask - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update location details", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_update_google_business_location_details( - account_id: str, - update_mask: str, - location_id: str | None = None, - regular_hours: dict[str, Any] | None = None, - special_hours: dict[str, Any] | None = None, - profile: dict[str, Any] | None = None, - website_uri: str | None = None, - phone_numbers: dict[str, Any] | None = None, - categories: dict[str, Any] | None = None, - service_items: list[dict[str, Any]] | None = None, - ) -> str: - """Update location details - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - update_mask: Required. Comma-separated fields to update (e.g. 'regularHours', 'specialHours', 'profile.description', 'categories', 'serviceItems'). Any valid Google Business Information API updateMask field is supported. (required) - regular_hours - special_hours - profile - website_uri - phone_numbers - categories: Primary and additional business categories. Use updateMask='categories' to update. - service_items: Services offered by the business. Use updateMask='serviceItems' to update.""" - client = _get_client() - try: - response = client.accounts.update_google_business_location_details( - account_id=account_id, - location_id=location_id, - update_mask=update_mask, - regular_hours=regular_hours, - special_hours=special_hours, - profile=profile, - website_uri=website_uri, - phone_numbers=phone_numbers, - categories=categories, - service_items=service_items, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List media", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_list_google_business_media( - account_id: str, - location_id: str | None = None, - page_size: int = 100, - page_token: str | None = None, - ) -> str: - """List media - - Args: - account_id: (required) - location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - page_size: Number of items to return (max 100) - page_token: Pagination token from previous response""" - client = _get_client() - try: - response = client.accounts.list_google_business_media( - account_id=account_id, - location_id=location_id, - page_size=page_size, - page_token=page_token, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Upload photo", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_create_google_business_media( - account_id: str, - source_url: str, - location_id: str | None = None, - media_format: str = "PHOTO", - description: str | None = None, - category: str | None = None, - ) -> str: - """Upload photo - - Args: - account_id: (required) - location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - source_url: Publicly accessible image URL (required) - media_format - description: Photo description - category: Where the photo appears on the listing""" - client = _get_client() - try: - response = client.accounts.create_google_business_media( - account_id=account_id, - location_id=location_id, - source_url=source_url, - media_format=media_format, - description=description, - category=category, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete photo", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_delete_google_business_media( - account_id: str, media_id: str, location_id: str | None = None - ) -> str: - """Delete photo - - Args: - account_id: (required) - location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - media_id: The media item ID to delete (required)""" - client = _get_client() - try: - response = client.accounts.delete_google_business_media( - account_id=account_id, location_id=location_id, media_id=media_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get attribute metadata", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_gmb_attribute_metadata( - account_id: str, - location_id: str | None = None, - category_name: str | None = None, - region_code: str | None = None, - language_code: str | None = None, - page_size: int | None = None, - page_token: str | None = None, - ) -> str: - """Get attribute metadata - - Args: - account_id: (required) - location_id: GBP location ID (e.g. "6257659026299438786"). If omitted, uses the account's stored selectedLocationId. Mutually exclusive with categoryName. - category_name: Category resource name, must start with "categories/" (e.g. "categories/gcid:plumber"). Required together with regionCode. Mutually exclusive with locationId. - region_code: BCP-47 region code (e.g. "US", "ES"). Required when categoryName is provided. - language_code: BCP-47 language code for display names (e.g. "en", "es"). Optional when categoryName is provided. Omitted from the Google call when not supplied. - page_size: Maximum number of attribute metadata items to return. Google defaults to 200. - page_token: Pagination token from a previous response's nextPageToken field.""" - client = _get_client() - try: - response = client.accounts.get_gmb_attribute_metadata( - account_id=account_id, - location_id=location_id, - category_name=category_name, - region_code=region_code, - language_code=language_code, - page_size=page_size, - page_token=page_token, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get attributes", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_google_business_attributes( - account_id: str, location_id: str | None = None - ) -> str: - """Get attributes - - Args: - account_id: (required) - location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.""" - client = _get_client() - try: - response = client.accounts.get_google_business_attributes( - account_id=account_id, location_id=location_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update attributes", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_update_google_business_attributes( - account_id: str, - attributes: list[dict[str, Any]] | None, - attribute_mask: str, - location_id: str | None = None, - ) -> str: - """Update attributes - - Args: - account_id: (required) - location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - attributes: (required) - attribute_mask: Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout') (required)""" - client = _get_client() - try: - response = client.accounts.update_google_business_attributes( - account_id=account_id, - location_id=location_id, - attributes=attributes, - attribute_mask=attribute_mask, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List action links", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_list_google_business_place_actions( - account_id: str, - location_id: str | None = None, - page_size: int = 100, - page_token: str | None = None, - ) -> str: - """List action links - - Args: - account_id: (required) - location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - page_size - page_token""" - client = _get_client() - try: - response = client.accounts.list_google_business_place_actions( - account_id=account_id, - location_id=location_id, - page_size=page_size, - page_token=page_token, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create action link", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_create_google_business_place_action( - account_id: str, - uri: str, - place_action_type: str, - location_id: str | None = None, - ) -> str: - """Create action link - - Args: - account_id: (required) - location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - uri: The action URL (required) - place_action_type: Type of action (required)""" - client = _get_client() - try: - response = client.accounts.create_google_business_place_action( - account_id=account_id, - location_id=location_id, - uri=uri, - place_action_type=place_action_type, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete action link", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_delete_google_business_place_action( - account_id: str, name: str, location_id: str | None = None - ) -> str: - """Delete action link - - Args: - account_id: (required) - location_id: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs. - name: The resource name of the place action link (e.g. locations/123/placeActionLinks/456) (required)""" - client = _get_client() - try: - response = client.accounts.delete_google_business_place_action( - account_id=account_id, location_id=location_id, name=name - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update action link", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_update_google_business_place_action( - account_id: str, - name: str, - location_id: str | None = None, - uri: str | None = None, - place_action_type: str | None = None, - ) -> str: - """Update action link - - Args: - account_id: (required) - location_id: Override which location to target. If omitted, uses the account's selected location. - name: Resource name of the place action link (e.g. locations/123/placeActionLinks/456) (required) - uri: New action URL - place_action_type: New action type""" - client = _get_client() - try: - response = client.accounts.update_google_business_place_action( - account_id=account_id, - location_id=location_id, - name=name, - uri=uri, - place_action_type=place_action_type, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Batch get reviews", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_batch_get_google_business_reviews( - account_id: str, - location_names: list[str] | None, - page_size: int = 50, - page_token: str | None = None, - ) -> str: - """Batch get reviews - - Args: - account_id: (required) - location_names: Array of full location resource names (e.g. ['accounts/123/locations/456']) (required) - page_size: Number of reviews per page (max 50) - page_token: Pagination token from previous response""" - client = _get_client() - try: - response = client.accounts.batch_get_google_business_reviews( - account_id=account_id, - location_names=location_names, - page_size=page_size, - page_token=page_token, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Reply to a review", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_reply_to_google_business_review( - account_id: str, review_id: str, comment: str - ) -> str: - """Reply to a review - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - review_id: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name (required) - comment: The reply text to post on the review. Must be non-empty. (required)""" - client = _get_client() - try: - response = client.accounts.reply_to_google_business_review( - account_id=account_id, review_id=review_id, comment=comment - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete a review reply", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def accounts_delete_google_business_review_reply( - account_id: str, review_id: str - ) -> str: - """Delete a review reply - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - review_id: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name (required)""" - client = _get_client() - try: - response = client.accounts.delete_google_business_review_reply( - account_id=account_id, review_id=review_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Resolve LinkedIn mention", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def accounts_get_linked_in_mentions( - account_id: str, url: str, display_name: str | None = None - ) -> str: - """Resolve LinkedIn mention - - Args: - account_id: The LinkedIn account ID (required) - url: LinkedIn profile URL, company URL, or vanity name. (required) - display_name: Exact display name as shown on LinkedIn. Required for person mentions to be clickable. Optional for org mentions.""" - client = _get_client() - try: - response = client.accounts.get_linked_in_mentions( - account_id=account_id, url=url, display_name=display_name - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # AD_AUDIENCES - - @mcp.tool( - annotations=ToolAnnotations( - title="List custom audiences", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ad_audiences_list_ad_audiences( - account_id: str, - ad_account_id: str, - platform: str | None = None, - type: str | None = None, - ) -> str: - """List custom audiences - - Args: - account_id: Social account ID (required) - ad_account_id: Platform ad account ID (required) - platform - type: Filter to one audience type. `saved_targeting` returns stored TargetingSpec audiences (each item carries a `spec`); the other types return uploaded/derived audiences.""" - client = _get_client() - try: - response = client.ad_audiences.list_ad_audiences( - account_id=account_id, - ad_account_id=ad_account_id, - platform=platform, - type=type, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create custom audience", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_audiences_create_ad_audience() -> str: - """Create custom audience""" - client = _get_client() - try: - response = client.ad_audiences.create_ad_audience() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get audience details", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ad_audiences_get_ad_audience(audience_id: str) -> str: - """Get audience details - - Args: - audience_id: (required)""" - client = _get_client() - try: - response = client.ad_audiences.get_ad_audience(audience_id=audience_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update saved targeting audience", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_audiences_update_ad_audience( - audience_id: str, - name: str | None = None, - description: str | None = None, - spec: dict[str, Any] | None = None, - ) -> str: - """Update saved targeting audience - - Args: - audience_id: (required) - name - description - spec: Full replacement for the stored targeting spec.""" - client = _get_client() - try: - response = client.ad_audiences.update_ad_audience( - audience_id=audience_id, name=name, description=description, spec=spec - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete custom audience", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_audiences_delete_ad_audience(audience_id: str) -> str: - """Delete custom audience - - Args: - audience_id: (required)""" - client = _get_client() - try: - response = client.ad_audiences.delete_ad_audience(audience_id=audience_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Add users to audience", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_audiences_add_users_to_ad_audience( - audience_id: str, users: list[dict[str, Any]] | None - ) -> str: - """Add users to audience - - Args: - audience_id: (required) - users: (required)""" - client = _get_client() - try: - response = client.ad_audiences.add_users_to_ad_audience( - audience_id=audience_id, users=users - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # AD_CAMPAIGNS - - @mcp.tool( - annotations=ToolAnnotations( - title="List campaigns", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ad_campaigns_list_ad_campaigns( - page: int = 1, - limit: int = 20, - source: str = "all", - platform: str | None = None, - status: str | None = None, - ad_account_id: str | None = None, - account_id: str | None = None, - profile_id: str | None = None, - from_date: str | None = None, - to_date: str | None = None, - ) -> str: - """List campaigns - - Args: - page: Page number - limit - source: `all` (default) returns both Zernio-created ads and those discovered from the platform's ad manager — matches the web UI's default view. Pass `zernio` to restrict to isExternal=false only. Status is NOT filtered by default — use the `status` param for that. - platform - status: Filter by derived campaign status (post-aggregation) - ad_account_id: Platform ad account ID (e.g. act_123 for Meta) - account_id: Social account ID - profile_id: Profile ID - from_date: Start of metrics date range (YYYY-MM-DD, inclusive). Defaults to 90 days ago when both date params are omitted. - to_date: End of metrics date range (YYYY-MM-DD, inclusive). Defaults to today. Max 730-day range.""" - client = _get_client() - try: - response = client.ad_campaigns.list_ad_campaigns( - page=page, - limit=limit, - source=source, - platform=platform, - status=status, - ad_account_id=ad_account_id, - account_id=account_id, - profile_id=profile_id, - from_date=from_date, - to_date=to_date, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pause or resume a campaign", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_campaigns_update_ad_campaign_status( - campaign_id: str, status: str, platform: str - ) -> str: - """Pause or resume a campaign - - Args: - campaign_id: Platform campaign ID (required) - status: (required) - platform: (required)""" - client = _get_client() - try: - response = client.ad_campaigns.update_ad_campaign_status( - campaign_id=campaign_id, status=status, platform=platform - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update a campaign", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_campaigns_update_ad_campaign( - campaign_id: str, - platform: str, - budget: dict[str, Any] | None = None, - bid_strategy: str | None = None, - name: str | None = None, - ) -> str: - """Update a campaign - - Args: - campaign_id: Platform campaign ID (required) - platform: (required) - budget - bid_strategy: Campaign-level default. Ad sets inherit this unless they override. - name: Rename the campaign (Meta only; other platforms return 501). At least one of budget/bidStrategy/name is required.""" - client = _get_client() - try: - response = client.ad_campaigns.update_ad_campaign( - campaign_id=campaign_id, - platform=platform, - budget=budget, - bid_strategy=bid_strategy, - name=name, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete a campaign", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_campaigns_delete_ad_campaign(campaign_id: str, platform: str) -> str: - """Delete a campaign - - Args: - campaign_id: Platform campaign ID (required) - platform: (required)""" - client = _get_client() - try: - response = client.ad_campaigns.delete_ad_campaign( - campaign_id=campaign_id, platform=platform - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pause or resume many campaigns", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_campaigns_bulk_update_ad_campaign_status( - status: str, campaigns: list[dict[str, Any]] | None - ) -> str: - """Pause or resume many campaigns - - Args: - status: (required) - campaigns: (required)""" - client = _get_client() - try: - response = client.ad_campaigns.bulk_update_ad_campaign_status( - status=status, campaigns=campaigns - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Duplicate a campaign", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_campaigns_duplicate_ad_campaign( - campaign_id: str, - platform: str, - deep_copy: bool = True, - status_option: str = "PAUSED", - start_time: str | None = None, - end_time: str | None = None, - rename_strategy: str | None = None, - rename_prefix: str | None = None, - rename_suffix: str | None = None, - sync_after: bool = True, - ) -> str: - """Duplicate a campaign - - Args: - campaign_id: Source platform campaign ID (required) - platform: (required) - deep_copy: Copy child ad sets + ads + creatives + targeting - status_option - start_time: Reschedule the copied hierarchy's start time - end_time - rename_strategy - rename_prefix - rename_suffix - sync_after: Trigger ads discovery on the owning account after the copy succeeds""" - client = _get_client() - try: - response = client.ad_campaigns.duplicate_ad_campaign( - campaign_id=campaign_id, - platform=platform, - deep_copy=deep_copy, - status_option=status_option, - start_time=start_time, - end_time=end_time, - rename_strategy=rename_strategy, - rename_prefix=rename_prefix, - rename_suffix=rename_suffix, - sync_after=sync_after, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update an ad set", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_campaigns_update_ad_set( - ad_set_id: str, - platform: str, - budget: dict[str, Any] | None = None, - status: str | None = None, - name: str | None = None, - bid_strategy: str | None = None, - bid_amount: float | None = None, - roas_average_floor: float | None = None, - ) -> str: - """Update an ad set - - Args: - ad_set_id: Platform ad set ID (required) - platform: (required) - budget: Omit if not updating budget - status: Omit if not toggling delivery state - name: Rename the ad set (Meta only; other platforms return 501). At least one of budget/status/bidStrategy/name is required. - bid_strategy: Ad-set-level bid strategy. Overrides the campaign-level default. - Supported on Meta (facebook, instagram) and TikTok. On TikTok the - Meta-style enum is mapped to bid_type / bid_price / deep_bid_type - automatically. Other platforms (linkedin, pinterest, google, twitter) - return 501 Not Implemented when bidStrategy is set. - bid_amount: Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when - bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. Internally converted to Meta's - smallest-denomination integer. - roas_average_floor: Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Required when bidStrategy is - LOWEST_COST_WITH_MIN_ROAS. Sent to Meta as `bid_constraints.roas_average_floor` × 10000.""" - client = _get_client() - try: - response = client.ad_campaigns.update_ad_set( - ad_set_id=ad_set_id, - platform=platform, - budget=budget, - status=status, - name=name, - bid_strategy=bid_strategy, - bid_amount=bid_amount, - roas_average_floor=roas_average_floor, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pause or resume a single ad set", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ad_campaigns_update_ad_set_status( - ad_set_id: str, status: str, platform: str - ) -> str: - """Pause or resume a single ad set - - Args: - ad_set_id: Platform ad set ID (required) - status: (required) - platform: (required)""" - client = _get_client() - try: - response = client.ad_campaigns.update_ad_set_status( - ad_set_id=ad_set_id, status=status, platform=platform - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get campaign tree", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ad_campaigns_get_ad_tree( - page: int = 1, - limit: int = 20, - source: str = "all", - platform: str | None = None, - status: str | None = None, - ad_account_id: str | None = None, - account_id: str | None = None, - profile_id: str | None = None, - campaign_id: str | None = None, - from_date: str | None = None, - to_date: str | None = None, - sort: str = "newest", - time_increment: int | None = None, - daily_level: str = "campaign", - ) -> str: - """Get campaign tree - - Args: - page: Page number - limit: Campaigns per page - source: `all` (default) returns both Zernio-created ads and those discovered from the platform's ad manager — matches the web UI's default view. Pass `zernio` to restrict to isExternal=false only. Status is NOT filtered by default — use the `status` param for that. - platform - status: Filter by derived campaign status (post-aggregation) - ad_account_id: Platform ad account ID - account_id: Social account ID - profile_id: Profile ID - campaign_id: Restrict the tree to a single campaign by its platform campaign id (the id the platform assigns, e.g. Meta's numeric campaign id). Filters the campaign set itself, so it works regardless of account size and pagination — pass this when you already hold a campaign id instead of paging the tree to find it. Mirrors the `campaignId` filter on GET /v1/ads. - from_date: Start of the METRICS date range (YYYY-MM-DD). Affects only the spend/impression numbers overlaid on each node, NOT which campaigns are returned. Defaults to 90 days ago. - to_date: End of metrics date range (YYYY-MM-DD). Defaults to today. Max 730-day range. - sort: Campaign-level sort order. `newest` (default) / `oldest` order by the campaign's newest-ad createdAt. `spend_desc` / `spend_asc` order by aggregated spend in the requested date range; campaigns with no spend land at the end. - time_increment: Set to `1` to also return a daily breakdown. Mirrors Meta Insights' `time_increment=1`: each node gains a `daily[]` array of per-day metrics (same fields as the aggregated `metrics`) alongside the range total, so you get per-entity daily trends in ONE call instead of calling the tree once per day. Only `1` (daily) is supported. The daily series covers the same date range and uses the same source data as `metrics`. See `dailyLevel` to control which levels carry it. - daily_level: Which tree levels get the `daily[]` series when `timeIncrement=1`. `campaign` (default) attaches it on campaign nodes only — the common per-campaign-trend case, and the smallest payload. `adset` adds it on ad sets too; `ad` adds it on every ad in `ads[]` as well (heaviest — a long range × up to 100 ads per ad set). Scope with `campaignId` to keep `ad`-level responses small. Ignored when `timeIncrement` is unset.""" - client = _get_client() - try: - response = client.ad_campaigns.get_ad_tree( - page=page, - limit=limit, - source=source, - platform=platform, - status=status, - ad_account_id=ad_account_id, - account_id=account_id, - profile_id=profile_id, - campaign_id=campaign_id, - from_date=from_date, - to_date=to_date, - sort=sort, - time_increment=time_increment, - daily_level=daily_level, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get daily account metrics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ad_campaigns_get_ads_timeline( - account_id: str, - ad_account_id: str | None = None, - from_date: str | None = None, - to_date: str | None = None, - platform: str | None = None, - ) -> str: - """Get daily account metrics - - Args: - account_id: Social account ID. Sibling-expanded to its linked posting↔ads pair. (required) - ad_account_id: Optional platform-native ad account ID (e.g. Meta `act_…`, TikTok advertiser ID). Use when the connection wraps multiple platform ad accounts and the chart should show one only. Note: rows ingested before 2026-05-13 don't carry this column; the recurring 7-day re-sync repopulates them naturally. - from_date: Inclusive start of metrics range (YYYY-MM-DD). Defaults to 90 days ago. - to_date: Inclusive end of metrics range (YYYY-MM-DD). Defaults to today. Max 730-day range. - platform: Restrict to one platform.""" - client = _get_client() - try: - response = client.ad_campaigns.get_ads_timeline( - account_id=account_id, - ad_account_id=ad_account_id, - from_date=from_date, - to_date=to_date, - platform=platform, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # ADS - - @mcp.tool( - annotations=ToolAnnotations( - title="List ads", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_ads( - page: int = 1, - limit: int = 50, - source: str = "all", - status: str | None = None, - platform: str | None = None, - account_id: str | None = None, - ad_account_id: str | None = None, - profile_id: str | None = None, - campaign_id: str | None = None, - platform_ad_id: str | None = None, - effective_object_story_id: str | None = None, - effective_instagram_media_id: str | None = None, - from_date: str | None = None, - to_date: str | None = None, - ) -> str: - """List ads - - Args: - page: Page number - limit - source: all (default) = Zernio-created + platform-discovered ads. zernio = restrict to Zernio-created only. - status - platform - account_id: Social account ID - ad_account_id: Platform ad account ID (e.g. act_123 for Meta). Mirrors the same filter on /v1/ads/campaigns and /v1/ads/tree. - profile_id: Profile ID - campaign_id: Platform campaign ID (filter ads within a campaign) - platform_ad_id: Meta ad ID. Returns the ad with this platform-side ad ID. - effective_object_story_id: Facebook `{pageId}_{postId}` of the post the ad's engagement lives on (Meta `effective_object_story_id`). Use to map a Business-Manager-visible post back to the Zernio ad. - effective_instagram_media_id: Instagram media ID of the boosted post (Meta `effective_instagram_media_id`). Use to map a Business-Manager-visible IG post back to the Zernio ad. - from_date: Start of metrics date range (YYYY-MM-DD). Defaults to 90 days ago. - to_date: End of metrics date range (YYYY-MM-DD). Defaults to today. Max 730-day range.""" - client = _get_client() - try: - response = client.ads.list_ads( - page=page, - limit=limit, - source=source, - status=status, - platform=platform, - account_id=account_id, - ad_account_id=ad_account_id, - profile_id=profile_id, - campaign_id=campaign_id, - platform_ad_id=platform_ad_id, - effective_object_story_id=effective_object_story_id, - effective_instagram_media_id=effective_instagram_media_id, - from_date=from_date, - to_date=to_date, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get ad details", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_ad(ad_id: str) -> str: - """Get ad details - - Args: - ad_id: Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs. See description for details. - (required)""" - client = _get_client() - try: - response = client.ads.get_ad(ad_id=ad_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update ad", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_update_ad( - ad_id: str, - status: str | None = None, - budget: dict[str, Any] | None = None, - targeting: dict[str, Any] | None = None, - creative: dict[str, Any] | None = None, - name: str | None = None, - ) -> str: - """Update ad - - Args: - ad_id: (required) - status - budget - targeting: Meta + TikTok only. Pinterest / X / LinkedIn / Google return 501. - creative: Replace the ad's creative. Meta + TikTok only. - - - **Meta**: requires `headline`, `body`, `callToAction`, `linkUrl`, `imageUrl`. The - ad's existing creative is replaced via a new `/act_X/adcreatives` upload + ad - update. The old creative is retained on the ad account for historical reporting. - - **TikTok**: patch-style. Pass any subset; `headline` is ignored (TikTok creatives - have no headline slot). `body` becomes the in-feed `ad_text`; `linkUrl` becomes - `landing_page_url`; `videoUrl` triggers a fresh upload. - name: Rename the ad. Now propagated to Meta (POST /{ad-id}); non-Meta platforms return 501.""" - client = _get_client() - try: - response = client.ads.update_ad( - ad_id=ad_id, - status=status, - budget=budget, - targeting=targeting, - creative=creative, - name=name, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Cancel an ad", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_delete_ad(ad_id: str) -> str: - """Cancel an ad - - Args: - ad_id: (required)""" - client = _get_client() - try: - response = client.ads.delete_ad(ad_id=ad_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pause or resume a single ad", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_update_ad_status(ad_id: str, status: str) -> str: - """Pause or resume a single ad - - Args: - ad_id: Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs. (required) - status: (required)""" - client = _get_client() - try: - response = client.ads.update_ad_status(ad_id=ad_id, status=status) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get campaign analytics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_campaign_analytics( - campaign_id: str, - platform: str | None = None, - from_date: str | None = None, - to_date: str | None = None, - breakdowns: str | None = None, - ) -> str: - """Get campaign analytics - - Args: - campaign_id: Platform campaign id (platformCampaignId). (required) - platform: Disambiguate when the campaign id exists across platforms (e.g. facebook, instagram). - from_date: Start of date range (YYYY-MM-DD). Defaults to 90 days ago. - to_date: End of date range (YYYY-MM-DD). Defaults to today. Max 730-day range. - breakdowns: Comma-separated breakdown dimensions (Meta only): age, gender, country, publisher_platform, device_platform, region, platform_position, impression_device, video_asset, image_asset, body_asset, title_asset.""" - client = _get_client() - try: - response = client.ads.get_campaign_analytics( - campaign_id=campaign_id, - platform=platform, - from_date=from_date, - to_date=to_date, - breakdowns=breakdowns, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get ad analytics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_ad_analytics( - ad_id: str, - from_date: str | None = None, - to_date: str | None = None, - breakdowns: str | None = None, - ) -> str: - """Get ad analytics - - Args: - ad_id: (required) - from_date: Start of date range (YYYY-MM-DD). Defaults to 90 days ago. - to_date: End of date range (YYYY-MM-DD). Defaults to today. Max 730-day range. - breakdowns: Comma-separated breakdown dimensions. Meta: age, gender, country, publisher_platform, device_platform, region. TikTok: gender, age, country_code, platform, ac, language.""" - client = _get_client() - try: - response = client.ads.get_ad_analytics( - ad_id=ad_id, from_date=from_date, to_date=to_date, breakdowns=breakdowns - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get ad tracking tags", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_ad_tracking_tags(ad_id: str) -> str: - """Get ad tracking tags - - Args: - ad_id: Ad id (hex _id, platformAdId, or effective story/media id). (required)""" - client = _get_client() - try: - response = client.ads.get_ad_tracking_tags(ad_id=ad_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set ad tracking tags", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_update_ad_tracking_tags( - ad_id: str, - url_tags: list[dict[str, Any]] | None = None, - creative: dict[str, Any] | None = None, - tracking_url_template: str | None = None, - final_url_suffix: str | None = None, - dynamic_value_parameters: dict[str, Any] | None = None, - custom_value_parameters: dict[str, Any] | None = None, - ) -> str: - """Set ad tracking tags - - Args: - ad_id: (required) - url_tags: Meta only. Click-URL params appended to a freshly-rebuilt creative. - creative: Meta only. OPTIONAL — omit to preserve the existing creative verbatim (default). Provide it only to rebuild the creative explicitly, or for creatives whose object_story_spec Meta strips. - tracking_url_template: Google only. Full tracking template (must contain {lpurl}). - final_url_suffix: Google only. Parse-only key=value params. - dynamic_value_parameters: LinkedIn only. key -> dynamic value enum (CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, ...). - custom_value_parameters: LinkedIn only. key -> static value.""" - client = _get_client() - try: - response = client.ads.update_ad_tracking_tags( - ad_id=ad_id, - url_tags=url_tags, - creative=creative, - tracking_url_template=tracking_url_template, - final_url_suffix=final_url_suffix, - dynamic_value_parameters=dynamic_value_parameters, - custom_value_parameters=custom_value_parameters, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List comments on an ad", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_ad_comments( - ad_id: str, - placement: str | None = None, - limit: int = 25, - cursor: str | None = None, - ) -> str: - """List comments on an ad - - Args: - ad_id: Internal Zernio ad ID (ObjectId). (required) - placement: Which side of the ad to return comments for. Omit to default to the Instagram side when present, else Facebook. Returns ad_not_commentable if the ad has no such placement. - limit - cursor: Pagination cursor from a previous response.""" - client = _get_client() - try: - response = client.ads.get_ad_comments( - ad_id=ad_id, placement=placement, limit=limit, cursor=cursor - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List TikTok Business Centers", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_ads_business_centers(account_id: str) -> str: - """List TikTok Business Centers - - Args: - account_id: ID of the `tiktokads` (or parent `tiktok` posting) SocialAccount (required)""" - client = _get_client() - try: - response = client.ads.list_ads_business_centers(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List ad accounts", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_ad_accounts( - account_id: str, ad_account_id: str | None = None, limit: int | None = None - ) -> str: - """List ad accounts - - Args: - account_id: Social account ID (required) - ad_account_id: Filter response to a single platform ad account ID (e.g. `act_123` for Meta, advertiser_id for TikTok). Returns at most one item. - limit: Clamp the returned `accounts[]` length. Useful for typeahead pickers on agency tokens with hundreds of advertisers.""" - client = _get_client() - try: - response = client.ads.list_ad_accounts( - account_id=account_id, ad_account_id=ad_account_id, limit=limit - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Boost post as ad", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_boost_post( - account_id: str, - ad_account_id: str, - name: str, - goal: str, - budget: dict[str, Any] | None, - post_id: str | None = None, - platform_post_id: str | None = None, - currency: str | None = None, - schedule: dict[str, Any] | None = None, - targeting: dict[str, Any] | None = None, - bid_strategy: str | None = None, - bid_amount: float | None = None, - roas_average_floor: float | None = None, - tracking: dict[str, Any] | None = None, - special_ad_categories: list[str] | None = None, - link_url: str | None = None, - call_to_action: str | None = None, - spark_auth_code: str | None = None, - dsa_beneficiary: str | None = None, - dsa_payor: str | None = None, - ) -> str: - """Boost post as ad - - Args: - post_id: Zernio post ID (provide this or platformPostId) - platform_post_id: Platform post ID (alternative to postId) - account_id: Social account ID (required) - ad_account_id: Platform ad account ID (required) - name: (required) - goal: Available goals vary by platform. Meta (Facebook/Instagram) and TikTok support all 7. LinkedIn supports all except app_promotion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views. (required) - budget: (required) - currency - schedule - targeting - bid_strategy: Meta bid strategy applied to the ad set. On TikTok, mapped to - `bid_type` / `bid_price` / `deep_bid_type` automatically. - bid_amount: Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when - `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. Backward-compat: providing - `bidAmount` without `bidStrategy` is treated as `LOWEST_COST_WITH_BID_CAP`. - roas_average_floor: Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when - `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as - `bid_constraints.roas_average_floor` × 10000 (Meta uses fixed-point integers). - tracking: Meta only. Tracking specs (pixel, URL tags). - special_ad_categories: Meta only. Required for housing, employment, credit, or political ads. - link_url: TikTok-only. Custom destination URL for the Spark Ad. Without this, TikTok - Spark Ads have no clickable destination — required for traffic / conversion - objectives. Maps to `landing_page_url` on the creative entry of /v2/ad/create/ - (TikTok SDK `AdcreateCreatives.landing_page_url`). Ignored on Meta / LinkedIn / - Pinterest / X / Google (those infer the destination from the boosted post). - call_to_action: TikTok-only. Call-to-action button label on the Spark Ad creative (e.g. - `LEARN_MORE`, `SHOP_NOW`, `DOWNLOAD_NOW`, `SIGN_UP`, `WATCH_NOW`). Maps to - `call_to_action` on the creative entry of /v2/ad/create/. Pass-through — - the platform validates the value. See TikTok's "Enumeration - Call-to-Action" - reference for the full list. - spark_auth_code: TikTok-only. Spark Code (creator's `auth_code`) authorizing cross-creator - Spark Ads — the advertiser can boost a video owned by a DIFFERENT TikTok - account. Without this, boosts are limited to videos owned by the same - account running the ads (same-BC creators only). The creator generates the - code in their TikTok app's Promote settings and shares it with the - advertiser. Maps to `auth_code` on the creative entry of /v2/ad/create/. - dsa_beneficiary: Name of the legal entity benefiting from the ad. - Required by Meta when targeting EU users (DSA Article 26). - Not enforced at schema level; enforced server-side when targeting intersects EU member states. - dsa_payor: Name of the legal entity paying for the ad. - Required by Meta when targeting EU users (DSA Article 26). - Note Meta API spelling: dsa_payor (not dsa_payer).""" - client = _get_client() - try: - response = client.ads.boost_post( - post_id=post_id, - platform_post_id=platform_post_id, - account_id=account_id, - ad_account_id=ad_account_id, - name=name, - goal=goal, - budget=budget, - currency=currency, - schedule=schedule, - targeting=targeting, - bid_strategy=bid_strategy, - bid_amount=bid_amount, - roas_average_floor=roas_average_floor, - tracking=tracking, - special_ad_categories=special_ad_categories, - link_url=link_url, - call_to_action=call_to_action, - spark_auth_code=spark_auth_code, - dsa_beneficiary=dsa_beneficiary, - dsa_payor=dsa_payor, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create standalone ad", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_create_standalone_ad( - account_id: str, - ad_account_id: str, - name: str, - campaign_name: str | None = None, - ad_set_name: str | None = None, - ad_name: str | None = None, - tracking: dict[str, Any] | None = None, - goal: str | None = None, - optimization_goal: str | None = None, - budget_amount: float | None = None, - budget_type: str | None = None, - status: str | None = None, - budget_level: str = "adset", - currency: str | None = None, - headline: str | None = None, - long_headline: str | None = None, - body: str | None = None, - description: str | None = None, - call_to_action: str | None = None, - link_url: str | None = None, - lead_gen_form_id: str | None = None, - image_url: str | None = None, - images: dict[str, Any] | None = None, - video: dict[str, Any] | None = None, - creatives: list[dict[str, Any]] | None = None, - ad_set_id: str | None = None, - existing_campaign_id: str | None = None, - existing_creative_id: str | None = None, - business_name: str | None = None, - board_id: str | None = None, - organization_id: str | None = None, - countries: list[str] | None = None, - cities: list[dict[str, Any]] | None = None, - regions: list[dict[str, Any]] | None = None, - age_min: int | None = None, - age_max: int | None = None, - interests: list[dict[str, Any]] | None = None, - zips: list[dict[str, Any]] | None = None, - metros: list[dict[str, Any]] | None = None, - custom_locations: list[dict[str, Any]] | None = None, - behaviors: list[dict[str, Any]] | None = None, - income_tier: str | None = None, - languages: list[str] | None = None, - placements: dict[str, Any] | None = None, - saved_targeting_id: str | None = None, - raw_targeting: dict[str, Any] | None = None, - special_ad_categories: list[str] | None = None, - end_date: str | None = None, - start_date: str | None = None, - instagram_account_id: str | None = None, - dynamic_creative: dict[str, Any] | None = None, - placement_assets: dict[str, Any] | None = None, - audience_id: str | None = None, - campaign_type: str = "display", - keywords: list[str] | None = None, - additional_headlines: list[str] | None = None, - additional_descriptions: list[str] | None = None, - advantage_audience: int | None = None, - attribution_spec: list[dict[str, Any]] | None = None, - gender: str = "all", - bid_strategy: str | None = None, - bid_amount: float | None = None, - roas_average_floor: float | None = None, - dsa_beneficiary: str | None = None, - dsa_payor: str | None = None, - brand_identity: dict[str, Any] | None = None, - identity_type: str | None = None, - promoted_object: dict[str, Any] | None = None, - ) -> str: - """Create standalone ad - - Args: - account_id: (required) - ad_account_id: (required) - name: (required) - campaign_name: Meta only. Exact campaign name. Overrides the default ` - Campaign`. - ad_set_name: Meta only. Exact ad set name. Overrides the default ` - Ad Set`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry.) - ad_name: Meta only. Exact ad name (the single-creative ad object's name). Overrides the default, which is `name`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry instead.) - tracking: Meta only. Attaches pixel measurement to the ad regardless of the optimization goal (the "Website events" tracking row in Ads Manager). `pixelId` becomes the ad's `tracking_specs` (offsite_conversion + fb_pixel); `urlTags` becomes the ad's `url_tags` (click-tracking query params). Applied on the legacy single-creative shape, every ad of the multi-creative shape, and the attach shape. NOTE: tracking lives on the AD object and is not inherited from the ad set, so pass it on EVERY attach call that should carry the pixel. - goal: Required on legacy + multi-creative shapes. Inherited from the ad set on the attach shape. Available goals vary by platform. Meta-specific: `conversions` (OUTCOME_SALES) requires `promotedObject.pixelId` + `promotedObject.customEventType` (use a commerce event, e.g. PURCHASE, START_TRIAL); `lead_conversion` (OUTCOME_LEADS, website pixel leads) requires the same pixel + event but with a leads-class event (e.g. LEAD, SUBMIT_APPLICATION, SCHEDULE, CONTACT) — these are rejected under `conversions` because Meta gates conversion events by objective; `lead_generation` is OUTCOME_LEADS with instant forms (`leadGenFormId`), distinct from `lead_conversion`'s website pixel optimization; `app_promotion` requires `promotedObject.applicationId` + `promotedObject.objectStoreUrl`; `catalog_sales` (Advantage+ catalog ads, e.g. vehicle inventory) requires `promotedObject.productSetId` + `promotedObject.pixelId` + `promotedObject.customEventType` and builds a catalog TEMPLATE creative from the copy fields (headline/body/description/linkUrl/callToAction, which may carry catalog template tags like {{product.name}} or {{vehicle.make}}) — no imageUrl/video is sent, Meta renders the visuals per catalog item; discover catalogs via GET /v1/ads/catalogs and product sets via GET /v1/ads/catalogs/{catalogId}/product-sets; single shape only (no creatives[]/adSetId/dynamicCreative/placementAssets); `lead_generation` accepts an optional `promotedObject.pageId` (auto-filled from the connected Page when omitted). TikTok-specific: `conversions` (website-conversion ad group) requires `promotedObject.pixelId` (your TikTok Pixel ID) and accepts an optional `promotedObject.customEventType` (a TikTok `optimization_event` code like `ON_WEB_ORDER`, `INITIATE_ORDER`, `ON_WEB_REGISTER`, `FORM`); to inherit a pixel + event from an existing ad group, pass `adSetId` instead. LinkedIn-specific: `engagement`, `traffic`, `awareness`, and `video_views` are supported for standalone ads (creates a Direct Sponsored Content single image or single video ad). `traffic` requires `linkUrl`; `video_views` requires the `video` field. For `lead_generation` / `conversions` on LinkedIn — or to promote an existing post — use `POST /v1/ads/boost`. - optimization_goal: Meta only. Explicit ad-set `optimization_goal` (e.g. `LANDING_PAGE_VIEWS`, `LINK_CLICKS`, `REACH`, `IMPRESSIONS`, `OFFSITE_CONVERSIONS`, `THRUPLAY`, `LEAD_GENERATION`). Overrides the default derived from `goal` (e.g. `traffic` defaults to `LINK_CLICKS`). Forwarded verbatim to Meta, which validates compatibility with the campaign objective and rejects incompatible combinations. - budget_amount: Required on legacy + multi-creative shapes. Inherited on attach. - budget_type: Required on legacy + multi-creative shapes. Inherited on attach. - status: Meta only. Publish state of the created ad set + ad. Omitted or ACTIVE publishes live (default, back-compat); PAUSED creates them paused and skips activation, so you can review before they spend. - budget_level: Meta only. Where the budget lives, which selects the Meta budget model: - - `adset` (default): ABO (Ad-set Budget Optimization). The budget is set on the - ad set. This is the back-compatible behaviour — omit this field to keep it. - - `campaign`: CBO (Campaign Budget Optimization / Advantage Campaign Budget). The - budget AND `bidStrategy` are set on the CAMPAIGN, and Meta distributes spend - across ad sets automatically. - Meta requires the budget at exactly one level, never both. Non-Meta platforms ignore - this field. Ignored on the attach shape (`adSetId`), which inherits the existing budget. - currency - headline: Required for Meta, Google, Pinterest, and LinkedIn on legacy + attach shapes (skip for multi-creative — use `creatives[].headline`). Ignored for TikTok and X/Twitter. Max: Meta=255, Google=30, Pinterest=100, LinkedIn=400. On LinkedIn this is the ad's headline (the bold text on the creative); for traffic ads it's the link card title. - long_headline: Google Display only — defaults to `headline` if omitted. On LinkedIn, reused as the optional secondary description text on traffic (link) ads; omitted if not provided. - body: Required on legacy + attach shapes. For X/Twitter this is the tweet text (max 280 chars including a ~24-char URL when `linkUrl` is set). On LinkedIn this is the post commentary (the intro text shown above the ad). Max: Google=90, Pinterest=500. - description: Meta only (facebook/instagram). Link description — the secondary text shown below the headline (Meta's link_data.description; on video creatives mapped to video_data.link_description). When omitted, Meta auto-pulls the destination URL's OpenGraph description. Applies on legacy, attach, and placementAssets shapes; for multi-creative use creatives[].description (this field is the shared fallback). For multi-text variations use dynamicCreative.descriptions instead. - call_to_action: Required on legacy + attach shapes for Meta. Honoured on TikTok (passes through to the Spark Ad creative's `call_to_action`) and on LinkedIn (the CTA button on the ad; defaults to LEARN_MORE when `linkUrl` is set). LinkedIn accepts: LEARN_MORE, SIGN_UP, DOWNLOAD, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, SHOP_NOW, BUY_NOW. Ignored by Google, Pinterest, and X/Twitter. - link_url: Required on legacy + attach shapes (skip for multi-creative). On LinkedIn it's the ad's destination URL; required for `traffic` ads, optional for `engagement` / `awareness`. NOT required when `goal` is `lead_generation` (the ad opens a Lead Gen form instead of a destination). - lead_gen_form_id: Meta Lead Gen forms only (facebook/instagram). The leadgen_forms ID to attach to the ad's creative — create one via POST /v1/ads/lead-forms. REQUIRED when `goal` is `lead_generation`, and on every ATTACH (`adSetId`) call that targets a lead ad set (the form attaches per-ad; Meta rejects a formless ad in a lead ad set). Ignored otherwise. The ad set's promoted_object.page_id + LEAD_GENERATION optimization + destination_type ON_AD are derived automatically from the goal. Both `placementAssets` (per-placement creative) and `dynamicCreative` (multi-text / multi-asset pool, e.g. multiple headlines and primary texts) ARE supported on instant-form lead ads — the form is attached for you, and for `dynamicCreative` the ad set is created as a Dynamic Creative ad set automatically (Meta requires that for any multi-text feed; there is no non-DCO multi-text path). Send a single `imageUrls` entry plus your text variations to get Meta's "Multiple Text Options" behavior on a lead ad. - image_url: Image creative for Meta/Google/Pinterest/LinkedIn on legacy + attach shapes (mutually exclusive with `video`). Required for LinkedIn ads unless `video` is set. Not required for Google Search campaigns. For TikTok, this field carries the VIDEO URL (the TikTok ads endpoint is video-only; the field retains the `imageUrl` name for cross-platform consistency). Ignored for X/Twitter. For Google Display, treated as the landscape image (alias of `images.landscape`); supply `images.square` alongside or the request is rejected. For LinkedIn the image is uploaded to LinkedIn under the authoring Company Page (see `organizationId`); recommended ratio 1.91:1 (e.g. 1200×627). - images: Google Display (Responsive Display Ads) only. Google RDA requires both a landscape (1.91:1) and a square (1:1) marketing image; sending only one is rejected upstream as 'Too few.' (NOT_ENOUGH_*_MARKETING_IMAGE_ASSET). Supply both URLs here. Either this field or the legacy `imageUrl` can provide the landscape, but `square` has no legacy counterpart so it must be set here for Display. - video: Meta (facebook, instagram) and LinkedIn. When set, creates a VIDEO ad on the legacy (or, for Meta, attach) shape. Mutually exclusive with `imageUrl`. For Meta multi-creative, set `video` per entry inside `creatives[]` instead. For LinkedIn the video is uploaded to LinkedIn under the authoring Company Page (see `organizationId`) and the campaign format is set to SINGLE_VIDEO; LinkedIn ignores `thumbnailUrl` (it auto-generates the poster frame) — supply MP4 H.264/AAC, 3s-30min, 75KB-500MB. - creatives: Meta-only. When present, switches to the multi-creative shape: - creates 1 campaign + 1 ad set + N ads (one per entry here). - Top-level `headline` / `body` / `imageUrl` / `linkUrl` / - `callToAction` are ignored in this mode. Mutually exclusive with `adSetId`. - ad_set_id: Meta-only. When present, switches to the attach shape: adds - one new ad to this existing ad set without creating a new - campaign. Budget, targeting, goal, schedule, AND bid strategy - are inherited from the ad set on Meta — passing `bidStrategy` - in attach mode returns 400. To change an existing ad set's - bid, use `PUT /v1/ads/ad-sets/{adSetId}`. Mutually exclusive - with `creatives[]`. - - The attached ad takes the full single-creative surface: - `headline`/`body`/`description`/`callToAction` plus either - `imageUrl`/`video` OR `placementAssets` (its own per-placement - Feed/Story assets), and `leadGenFormId` when the target is a - lead ad set (the parent must be ON_AD — true for ad sets - created via goal `lead_generation`; Meta rejects a formless ad - there, so pass the form on EVERY attached ad). This is the way - to build N full ads sharing one ad set: create the first ad - via the normal shape, then attach the rest one call each. - - Supported on Meta (facebook, instagram) and TikTok. On TikTok - the `adSetId` is the ad group ID; the new ad inherits the - ad group's bid + budget + targeting. - existing_campaign_id: Meta only. Add the new ad set under this EXISTING campaign - instead of creating a new one (multi-ad-set audience testing). - The new ad set's budget is matched to the campaign's mode - automatically: for a CBO campaign (campaign-level budget) omit - `budgetAmount`/`budgetType` — the campaign owns the budget; for - an ABO campaign pass them (they go on the new ad set). On - failure only the new ad set is cleaned up; the existing campaign - is left untouched and is never (re)activated. Mutually exclusive - with `adSetId` and `creatives[]`. - existing_creative_id: Meta only. Reuse an EXISTING ad creative by id instead of - building a new one from the copy/media fields (which are then - ignored). Combine with `existingCampaignId` to build a - multi-ad-set campaign that shares one creative. Mutually - exclusive with `creatives[]`, `dynamicCreative`, and - `placementAssets`. The creative id used is returned as - `creativeId` on the create response. - business_name: Google Display only - board_id: Pinterest only. Board ID (auto-creates if not provided). - organization_id: LinkedIn only. The Company Page that authors the Direct Sponsored Content ("dark") post backing the ad — accepts a numeric organization ID or a full `urn:li:organization:N` URN. Required unless the resolved `accountId` is a connected LinkedIn Company-Page account (defaults to that page) or the LinkedIn ad account is org-owned (defaults to the account's owning organization). The authenticated member must be an ADMINISTRATOR or DIRECT_SPONSORED_CONTENT_POSTER of this page (and the page must be associated with the ad account), or LinkedIn returns 403. Ignored by every other platform. - countries: ISO 3166-1 alpha-2 country codes (e.g. ['NL']). Defaults to ['US'] when no `cities` or `regions` are provided. (LinkedIn currently honours country-level targeting only.) - cities: Meta-only. City-level geo targeting. Each city is targeted by Meta's opaque `key` (the city ID) which can be looked up via `GET /v1/ads/targeting/search?type=city&q=&country_code=`. Optional `radius` + `distance_unit` extend the targeting beyond the city limits (e.g. radius 25 km around the city center). Both must be set together, or both omitted (Meta defaults to ~16 km when omitted). - - Cannot overlap with the same country in `countries` (Meta returns a "locations overlap" error). Either drop the country or scope it to a different country. - regions: Meta-only. Region-level (state/province) geo targeting. Each region is targeted by Meta's opaque `key` (the region ID) which can be looked up via `GET /v1/ads/targeting/search?type=region&q=&country_code=`. - age_min - age_max - interests: Interest objects from /v1/ads/interests. Each must include id and name. - zips: Postal/ZIP geo targeting. `key` is the platform's postal location ID from /v1/ads/targeting/search?dimension=geo&geoType=zip. Supported on Meta, Google, TikTok, Pinterest, X. - metros: DMA / metro-area geo targeting. `key` is the platform's metro ID from /v1/ads/targeting/search?dimension=geo&geoType=metro. - custom_locations: Point-radius (lat/lng) geo targeting. Meta only (custom_locations). Rejected on platforms without radius support. - behaviors: Behaviour entities from /v1/ads/targeting/search?dimension=behavior. Supported on Meta and TikTok. Each must include id. - income_tier: Normalized household-income tier. Meta and TikTok express all four; Google maps only - `top_10`; rejected on LinkedIn, X, and Pinterest. On Meta, income targeting is incompatible - with housing/employment/credit `specialAdCategories`. - languages: Language codes (e.g. ['en']). Restricts the audience by language. - placements: Meta only. Manual ad placements. Omit for automatic placements (Meta's default, - recommended for most cases — Meta optimises delivery across all eligible surfaces). - When set, restricts delivery to the chosen surfaces, mapped onto the ad set's - `targeting.{publisher_platforms, facebook_positions, instagram_positions, - messenger_positions, audience_network_positions, threads_positions, - whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta - additionally enforces co-selection rules (e.g. some positions require their parent - publisher platform) and returns an actionable error which we surface. Non-Meta - platforms reject this field. - saved_targeting_id: ID of a `saved_targeting` audience (created via POST /v1/ads/audiences). When set, its stored - TargetingSpec is expanded as the base targeting; inline fields on this body merge on top. Lets you - reuse a named targeting preset without re-sending every field. - raw_targeting: Meta only. A raw Meta-native targeting spec passed to the ad set VERBATIM (snake_case: - `geo_locations`, `age_min`, `excluded_custom_audiences`, `flexible_spec`, `targeting_automation`, - business places, etc.) — exactly the shape `GET /v1/ads/{adId}` returns for external ads. Use it to - clone a campaign's targeting EXACTLY, preserving advanced fields the camelCase targeting fields can't - model. Mutually exclusive with the camelCase targeting fields (countries/regions/cities/interests/ - ageMin/...), `audienceId`, and `savedTargetingId` (sending both → 422). Sent as-is; Meta validates and - surfaces any errors. If cloning an EU campaign, also pass `dsaBeneficiary` / `dsaPayor` (those are - separate fields, not part of targeting). - special_ad_categories: Meta only. Declares the ad's special category, required for housing, employment, credit, or - political/social-issue ads (Meta enforces restricted targeting for these). Note: setting a special - category disables income/zip targeting on Meta. - end_date: Required for lifetime budgets - start_date: Meta only. Ad-set start time (ISO 8601, e.g. "2026-06-10T09:00:00Z"), mapped to the - ad set's `start_time`. When omitted the ad starts delivering immediately. For lifetime - budgets Meta also requires `endDate`. (Same `schedule.startDate` semantics already - available on `POST /v1/ads/boost`.) - instagram_account_id: Meta only. Override the Instagram account the ad is delivered as — pass an Instagram - Business Account ID (e.g. 17841...), mapped to the creative's `instagram_user_id`. - When omitted we auto-resolve the IG account linked to the connected Facebook Page - (the existing default). Useful when a Page has more than one eligible IG account. - dynamic_creative: Meta only. Dynamic Creative: supply a POOL of assets and Meta auto-combines and - optimises them into the best-performing variations within a single ad (mapped to the - creative's `asset_feed_spec`). When set, the top-level single-creative fields - (`imageUrl`, `headline`, `body`, `linkUrl`, `callToAction`) are ignored. Mutually - exclusive with the `creatives[]` multi-creative shape. Meta limits: ≤10 images, - ≤5 bodies / titles / descriptions. - placement_assets: Meta only. Placement asset customization: pin a SPECIFIC asset (image OR video) to - each placement group on a SINGLE ad (e.g. a 9:16 on Stories/Reels and a 4:5 on Feed). - The same thing Meta Ads Manager produces with "different creative per placement", - mapped to the creative's `asset_feed_spec` + `asset_customization_rules`. Deterministic - pinning, NOT the auto-optimizing pool of `dynamicCreative` (mutually exclusive). Works - on the legacy single shape AND the attach shape (`adSetId` + placementAssets adds one - placement-customized ad to an existing ad set — the way to build N per-placement ads - sharing one ad set: create the first normally, attach the rest). Cannot be combined - with `creatives[]`. Shared copy (headline, body, link, - CTA) comes from the top-level single-creative fields since only the asset varies by - placement. Each rule's `placements` accepts the same fields as the top-level - `placements` object; Meta enforces co-selection rules and returns an actionable error. - - Note on text rendering: Meta suppresses primary text and headline on fullscreen - placements (Stories and Reels) in actual ad delivery; the fields are accepted and - the ad publishes, but the copy is not shown to users. For visible copy on those - placements, bake the text into the creative image or video itself. - - A block is all-image OR all-video, never mixed (Meta's asset_feed_spec carries one ad - format). Image mode: `defaultImageUrl` + `rules[].imageUrl`. Video mode: - `defaultVideoUrl` + `rules[].videoUrl` (optional `thumbnailUrl`/`defaultThumbnailUrl` - posters; Meta auto-generates when omitted). Exactly one catch-all default is required. - audience_id: Custom audience ID for targeting - campaign_type: Google only - keywords: Google Search only - additional_headlines: Google Search RSA only. Extra headlines. - additional_descriptions: Google Search RSA only. Extra descriptions. - advantage_audience: Meta only. Controls the Advantage audience feature (targeting_automation). 0 = disabled (default), 1 = enabled. Meta Marketing API requires this field on all ad set creation requests. - attribution_spec: Meta only. Conversion attribution window for the ad set — maps 1:1 to Meta's - ad-set `attribution_spec`. Only honored for conversion goals (`conversions`, - `lead_generation`, `app_promotion`); ignored for awareness/traffic/engagement. - Omit to use Meta's default (`7-day click` + `1-day view`). Meta enforces the - valid combinations: `VIEW_THROUGH` only allows `windowDays: 1` (7d/28d view - windows were removed Jan 2026); `ENGAGED_VIDEO_VIEW` only `1` and only alongside - `VIEW_THROUGH: 1`; `CLICK_THROUGH: 28` only on certain objectives. Invalid combos - surface as a Meta 400. - Example: `[{ "eventType": "CLICK_THROUGH", "windowDays": 7 }, { "eventType": "VIEW_THROUGH", "windowDays": 1 }]` - gender: Meta only. Restrict the audience by gender. 'male' targets men only, 'female' targets women only, 'all' (default) targets everyone. Ignored by non-Meta platforms. - bid_strategy: Meta bid strategy applied to the ad set. - bid_amount: Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when - `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. - roas_average_floor: Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when - `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as - `bid_constraints.roas_average_floor` × 10000. - dsa_beneficiary: Name of the legal entity benefiting from the ad. - Required by Meta when targeting EU users (DSA Article 26). - Not enforced at schema level; enforced server-side when targeting intersects EU member states. - dsa_payor: Name of the legal entity paying for the ad. - Required by Meta when targeting EU users (DSA Article 26). - Note Meta API spelling: dsa_payor (not dsa_payer). - brand_identity: TikTok only. Synthetic Brand Identity used when the ad - attributes to a CUSTOMIZED_USER (instead of a real TT_USER - @username). Required on the FIRST CUSTOMIZED_USER ad on a - `tiktokads` SocialAccount with no cached identity; omit on - subsequent ads (the identity is cached on the account after - first creation). Non-TikTok platforms ignore this field. - - Alternative: configure once via `PATCH /v1/connect/tiktok-ads`, - then create ads without this field. - identity_type: TikTok only. Forces the identity attribution on the ad: - - - `TT_USER`: the posting account's open_id (real @username - branding). Requires a connected TikTok posting account - on the same profile. - - `CUSTOMIZED_USER`: synthetic Brand Identity (display - name + avatar). Requires a configured Brand Identity - (cached on the `tiktokads` SocialAccount via - `PATCH /v1/connect/tiktok-ads`) or an inline - `brandIdentity` to create one on the fly. - - When omitted, defaults to `TT_USER` if a posting account is - connected on this profile, else `CUSTOMIZED_USER`. Spark - Ads (`POST /v1/ads/boost`) always use `TT_USER` regardless - of this field — TikTok requires the original organic - post's author identity for Spark. - promoted_object: What the ad optimises against. Behaviour depends on the platform. - - **Meta**: forwarded to the ad set's `promoted_object` (snake-cased). - Required for goals whose ad-set optimization_goal points at a specific - event/page/app (without it Meta rejects the ad-set create with - `error_subcode: 1815430` "Please select a promoted object for your ad set"): - - `goal: conversions` (OFFSITE_CONVERSIONS): requires `pixelId` + `customEventType` - - `goal: app_promotion` (APP_INSTALLS): requires `applicationId` + `objectStoreUrl` - - `goal: lead_generation` (LEAD_GENERATION): `pageId` is auto-filled from the connected Page when omitted - - Other Meta goals (engagement, traffic, awareness, video_views) ignore this field. - - **TikTok**: only `goal: conversions` uses it. - - `pixelId` maps to the ad group's `pixel_id`. Required: a TikTok website-conversion - ad group without a pixel is rejected with `40002: Please select a pixel`. - - `customEventType` maps to the ad group's `optimization_event` (the pixel event to - optimise for). Optional: TikTok accepts a pixel-only auto-bid conversion ad group. - See the `customEventType` field below for the valid TikTok codes. - - The remaining `promotedObject.*` fields are Meta-only. Platforms other than - Meta and TikTok ignore `promotedObject` entirely.""" - client = _get_client() - try: - response = client.ads.create_standalone_ad( - account_id=account_id, - ad_account_id=ad_account_id, - name=name, - campaign_name=campaign_name, - ad_set_name=ad_set_name, - ad_name=ad_name, - tracking=tracking, - goal=goal, - optimization_goal=optimization_goal, - budget_amount=budget_amount, - budget_type=budget_type, - status=status, - budget_level=budget_level, - currency=currency, - headline=headline, - long_headline=long_headline, - body=body, - description=description, - call_to_action=call_to_action, - link_url=link_url, - lead_gen_form_id=lead_gen_form_id, - image_url=image_url, - images=images, - video=video, - creatives=creatives, - ad_set_id=ad_set_id, - existing_campaign_id=existing_campaign_id, - existing_creative_id=existing_creative_id, - business_name=business_name, - board_id=board_id, - organization_id=organization_id, - countries=countries, - cities=cities, - regions=regions, - age_min=age_min, - age_max=age_max, - interests=interests, - zips=zips, - metros=metros, - custom_locations=custom_locations, - behaviors=behaviors, - income_tier=income_tier, - languages=languages, - placements=placements, - saved_targeting_id=saved_targeting_id, - raw_targeting=raw_targeting, - special_ad_categories=special_ad_categories, - end_date=end_date, - start_date=start_date, - instagram_account_id=instagram_account_id, - dynamic_creative=dynamic_creative, - placement_assets=placement_assets, - audience_id=audience_id, - campaign_type=campaign_type, - keywords=keywords, - additional_headlines=additional_headlines, - additional_descriptions=additional_descriptions, - advantage_audience=advantage_audience, - attribution_spec=attribution_spec, - gender=gender, - bid_strategy=bid_strategy, - bid_amount=bid_amount, - roas_average_floor=roas_average_floor, - dsa_beneficiary=dsa_beneficiary, - dsa_payor=dsa_payor, - brand_identity=brand_identity, - identity_type=identity_type, - promoted_object=promoted_object, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List submitted leads", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_leads( - form_id: str | None = None, - account_id: str | None = None, - limit: int = 25, - since: int | None = None, - cursor: str | None = None, - ) -> str: - """List submitted leads - - Args: - form_id: Filter to a single lead form. - account_id: Filter to a single connected account. - limit - since: Unix seconds; only leads created at/after this Meta timestamp. - cursor: Keyset cursor from a previous response's pagination.cursor.""" - client = _get_client() - try: - response = client.ads.list_leads( - form_id=form_id, - account_id=account_id, - limit=limit, - since=since, - cursor=cursor, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List lead forms", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_lead_forms( - account_id: str, limit: int = 25, cursor: str | None = None - ) -> str: - """List lead forms - - Args: - account_id: Connected facebook account id. (required) - limit - cursor""" - client = _get_client() - try: - response = client.ads.list_lead_forms( - account_id=account_id, limit=limit, cursor=cursor - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a lead form", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_create_lead_form( - account_id: str, - name: str, - questions: list[dict[str, Any]] | None, - privacy_policy_url: str, - privacy_policy_link_text: str | None = None, - follow_up_action_url: str | None = None, - locale: str | None = None, - thank_you_title: str | None = None, - thank_you_body: str | None = None, - thank_you_button_text: str | None = None, - thank_you_button_type: str | None = None, - thank_you_website_url: str | None = None, - is_optimized_for_quality: bool | None = None, - ) -> str: - """Create a lead form - - Args: - account_id: (required) - name: (required) - questions: (required) - privacy_policy_url: (required) - privacy_policy_link_text - follow_up_action_url - locale - thank_you_title - thank_you_body - thank_you_button_text - thank_you_button_type - thank_you_website_url - is_optimized_for_quality""" - client = _get_client() - try: - response = client.ads.create_lead_form( - account_id=account_id, - name=name, - questions=questions, - privacy_policy_url=privacy_policy_url, - privacy_policy_link_text=privacy_policy_link_text, - follow_up_action_url=follow_up_action_url, - locale=locale, - thank_you_title=thank_you_title, - thank_you_body=thank_you_body, - thank_you_button_text=thank_you_button_text, - thank_you_button_type=thank_you_button_type, - thank_you_website_url=thank_you_website_url, - is_optimized_for_quality=is_optimized_for_quality, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a lead form", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_lead_form(form_id: str, account_id: str) -> str: - """Get a lead form - - Args: - form_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.ads.get_lead_form(form_id=form_id, account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Archive a lead form", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_archive_lead_form(form_id: str, account_id: str) -> str: - """Archive a lead form - - Args: - form_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.ads.archive_lead_form( - form_id=form_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List leads for a single form", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_form_leads( - form_id: str, - account_id: str, - limit: int = 25, - cursor: str | None = None, - since: int | None = None, - ) -> str: - """List leads for a single form - - Args: - form_id: (required) - account_id: (required) - limit - cursor - since: Unix seconds.""" - client = _get_client() - try: - response = client.ads.list_form_leads( - form_id=form_id, - account_id=account_id, - limit=limit, - cursor=cursor, - since=since, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a test lead", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_create_test_lead( - form_id: str, account_id: str, field_data: list[dict[str, Any]] | None - ) -> str: - """Create a test lead - - Args: - form_id: (required) - account_id: (required) - field_data: (required)""" - client = _get_client() - try: - response = client.ads.create_test_lead( - form_id=form_id, account_id=account_id, field_data=field_data - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Search targeting interests", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_search_ad_interests(q: str, account_id: str) -> str: - """Search targeting interests - - Args: - q: Search query (required) - account_id: Social account ID (required)""" - client = _get_client() - try: - response = client.ads.search_ad_interests(q=q, account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Search targeting options", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_search_ad_targeting( - account_id: str, - q: str, - dimension: str = "interest", - geo_type: str = "city", - country_code: str | None = None, - limit: int = 25, - ) -> str: - """Search targeting options - - Args: - account_id: Social account ID (a connected account on the target ad platform). (required) - q: Search query. For geo, the locality name only (no region/country suffix). (required) - dimension: What to search. `geo` resolves locations (scope further with `geoType`), `interest`/`behavior` resolve audience entities, `income` resolves income-tier options. Defaults to `interest` for backward compatibility with the deprecated /v1/ads/interests alias. - geo_type: Only used when `dimension=geo`. The kind of location to resolve. Defaults to `city`. - country_code: ISO 3166-1 alpha-2 country code (e.g. NL) to scope a geo search. - limit: Maximum results to return.""" - client = _get_client() - try: - response = client.ads.search_ad_targeting( - account_id=account_id, - q=q, - dimension=dimension, - geo_type=geo_type, - country_code=country_code, - limit=limit, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Estimate audience reach", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_estimate_ad_reach( - account_id: str, - ad_account_id: str, - spec: dict[str, Any] | None, - optimization_goal: str | None = None, - ) -> str: - """Estimate audience reach - - Args: - account_id: Zernio social account ID on the target ad platform (the estimate runs against its platform). (required) - ad_account_id: Required. The platform ad-account ID the reach call runs against (Meta act_..., LinkedIn numeric sponsoredAccount ID, Pinterest ad-account ID, X account ID) - every backing reach API is scoped to one ad account. Get it from GET /v1/ads/accounts. (required) - spec: The targeting spec to estimate. Same shape used by POST /v1/ads/create. (required) - optimization_goal: Optional. The optimization goal the estimate should assume (platform's - own vocabulary, e.g. Meta `REACH`, `LINK_CLICKS`, `OFFSITE_CONVERSIONS`). - Some platforms vary the estimate by goal; omit to use the platform default.""" - client = _get_client() - try: - response = client.ads.estimate_ad_reach( - account_id=account_id, - ad_account_id=ad_account_id, - spec=spec, - optimization_goal=optimization_goal, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Meta product catalogs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_ad_catalogs(account_id: str, ad_account_id: str) -> str: - """List Meta product catalogs - - Args: - account_id: A facebook, instagram, or metaads social account ID (required) - ad_account_id: Meta ad account ID (act_...) (required)""" - client = _get_client() - try: - response = client.ads.list_ad_catalogs( - account_id=account_id, ad_account_id=ad_account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List a catalog's product sets", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_ad_catalog_product_sets(catalog_id: str, account_id: str) -> str: - """List a catalog's product sets - - Args: - catalog_id: Meta product catalog ID (from GET /v1/ads/catalogs) (required) - account_id: A facebook, instagram, or metaads social account ID (required)""" - client = _get_client() - try: - response = client.ads.list_ad_catalog_product_sets( - catalog_id=catalog_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Event Match Quality", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_conversions_quality(account_id: str, destination_id: str) -> str: - """Get Event Match Quality - - Args: - account_id: SocialAccount _id (must be a metaads account). (required) - destination_id: Meta pixel/dataset ID. (required)""" - client = _get_client() - try: - response = client.ads.get_conversions_quality( - account_id=account_id, destination_id=destination_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send conversion events", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_send_conversions( - account_id: str, - destination_id: str, - events: list[dict[str, Any]] | None, - test_code: str | None = None, - consent: dict[str, Any] | None = None, - ) -> str: - """Send conversion events - - Args: - account_id: SocialAccount ID (metaads, googleads, linkedinads, or tiktokads). (required) - destination_id: Platform destination identifier. For Meta, the pixel/dataset - ID. For Google, the conversion action resource name. For - LinkedIn, the conversion rule ID or full - `urn:lla:llaPartnerConversion:{id}` URN. - (required) - events: (required) - test_code: Meta `test_event_code` passthrough. Ignored by Google and LinkedIn. - consent: Batch-level user consent. Required by Google for EEA/UK - events under the Feb 2026 restrictions. Ignored by Meta - and LinkedIn.""" - client = _get_client() - try: - response = client.ads.send_conversions( - account_id=account_id, - destination_id=destination_id, - events=events, - test_code=test_code, - consent=consent, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Adjust uploaded conversions", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_adjust_conversions( - account_id: str, destination_id: str, adjustments: list[dict[str, Any]] | None - ) -> str: - """Adjust uploaded conversions - - Args: - account_id: SocialAccount ID. Must be a `googleads` account. (required) - destination_id: Conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`. (required) - adjustments: (required)""" - client = _get_client() - try: - response = client.ads.adjust_conversions( - account_id=account_id, - destination_id=destination_id, - adjustments=adjustments, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List conversion destinations", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_conversion_destinations(account_id: str) -> str: - """List conversion destinations - - Args: - account_id: SocialAccount ID (metaads, googleads, linkedinads, or tiktokads). (required)""" - client = _get_client() - try: - response = client.ads.list_conversion_destinations(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a conversion destination", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_create_conversion_destination( - account_id: str, - ad_account_id: str, - name: str, - type: str, - attribution_type: str | None = None, - post_click_attribution_window_size: int | None = None, - view_through_attribution_window_size: int | None = None, - value_type: str | None = None, - value: dict[str, Any] | None = None, - auto_association_type: str = "ALL_CAMPAIGNS", - counting_type: str | None = None, - primary_for_goal: bool | None = None, - ) -> str: - """Create a conversion destination - - Args: - account_id: SocialAccount ID (linkedinads or googleads). (required) - ad_account_id: Ad account ID. For LinkedIn: numeric (e.g. "5123456") or - full `urn:li:sponsoredAccount:{id}` URN. For Google: numeric - customer ID (e.g. "1234567890") or `customers/{id}` form. - (required) - name: (required) - type: Conversion type. For LinkedIn: a unified standard event name - (e.g. "Purchase", "Lead", "AddToCart") or a LinkedIn rule - type enum (e.g. "PURCHASE", "QUALIFIED_LEAD"). For Google: - a unified standard event name (Purchase, Subscribe, - CompleteRegistration, Lead, Schedule) or a Google - ConversionActionCategory enum value directly (e.g. - "PURCHASE", "SUBSCRIBE_PAID", "SIGNUP", "IMPORTED_LEAD", - "BOOK_APPOINTMENT"). Unknown values pass through to the - platform. - (required) - attribution_type: LinkedIn only. - post_click_attribution_window_size: LinkedIn only. Default 30. 365 only allowed for LEAD, - PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION - rule types; the API rejects other combinations locally. - view_through_attribution_window_size: LinkedIn only. Default 7. Same 365-day-window type - restriction applies as `postClickAttributionWindowSize`. - value_type: LinkedIn only. DYNAMIC (default) uses the per-event `value` - from `sendConversions`. FIXED uses the rule's `value` field. - NO_VALUE drops monetary value entirely. - value: LinkedIn only. Static conversion value. Used when - `valueType=FIXED`. The currency should match the ad - account's currency. - auto_association_type: LinkedIn only. Controls campaign association at rule-creation - time: - - ALL_CAMPAIGNS: associate the rule with every active, - paused, and draft campaign in the ad account - - OBJECTIVE_BASED: associate only campaigns whose - objective matches the rule's type - - NONE: don't auto-associate. Manage associations via - the `/associations` endpoints below. - Note: auto-association runs once at create time; new - campaigns added after the rule still need explicit - association. - counting_type: Google Ads only. Whether to count multiple conversions from - the same click (MANY_PER_CLICK) or at most one - (ONE_PER_CLICK). Defaults to MANY_PER_CLICK if omitted. - primary_for_goal: Google Ads only. When true, the conversion action is marked - as primary and immediately influences Smart Bidding. Defaults - to false (secondary, record-only) to avoid unintentionally - steering the customer's campaigns on creation.""" - client = _get_client() - try: - response = client.ads.create_conversion_destination( - account_id=account_id, - ad_account_id=ad_account_id, - name=name, - type=type, - attribution_type=attribution_type, - post_click_attribution_window_size=post_click_attribution_window_size, - view_through_attribution_window_size=view_through_attribution_window_size, - value_type=value_type, - value=value, - auto_association_type=auto_association_type, - counting_type=counting_type, - primary_for_goal=primary_for_goal, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a conversion destination", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_conversion_destination( - account_id: str, destination_id: str, ad_account_id: str - ) -> str: - """Get a conversion destination - - Args: - account_id: (required) - destination_id: (required) - ad_account_id: Numeric ID or full `urn:li:sponsoredAccount:{id}` URN. (required)""" - client = _get_client() - try: - response = client.ads.get_conversion_destination( - account_id=account_id, - destination_id=destination_id, - ad_account_id=ad_account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update a conversion destination", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_update_conversion_destination( - account_id: str, - destination_id: str, - ad_account_id: str, - name: str | None = None, - enabled: bool | None = None, - attribution_type: str | None = None, - post_click_attribution_window_size: int | None = None, - view_through_attribution_window_size: int | None = None, - value_type: str | None = None, - value: dict[str, Any] | None = None, - ) -> str: - """Update a conversion destination - - Args: - account_id: (required) - destination_id: (required) - ad_account_id: (required) - name - enabled: Setting `false` is equivalent to calling DELETE — the - rule will appear as `inactive` afterwards. - attribution_type - post_click_attribution_window_size: 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, - QUALIFIED_LEAD, SUBMIT_APPLICATION rule types. - view_through_attribution_window_size: 365 only allowed for LEAD, PURCHASE, ADD_TO_CART, - QUALIFIED_LEAD, SUBMIT_APPLICATION rule types. - value_type - value: Used when `valueType=FIXED`.""" - client = _get_client() - try: - response = client.ads.update_conversion_destination( - account_id=account_id, - destination_id=destination_id, - ad_account_id=ad_account_id, - name=name, - enabled=enabled, - attribution_type=attribution_type, - post_click_attribution_window_size=post_click_attribution_window_size, - view_through_attribution_window_size=view_through_attribution_window_size, - value_type=value_type, - value=value, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete a conversion destination", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_delete_conversion_destination( - account_id: str, destination_id: str, ad_account_id: str | None = None - ) -> str: - """Delete a conversion destination - - Args: - account_id: (required) - destination_id: (required) - ad_account_id: Required as query OR in JSON body.""" - client = _get_client() - try: - response = client.ads.delete_conversion_destination( - account_id=account_id, - destination_id=destination_id, - ad_account_id=ad_account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List associated campaigns", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_list_conversion_associations( - account_id: str, destination_id: str, ad_account_id: str - ) -> str: - """List associated campaigns - - Args: - account_id: (required) - destination_id: (required) - ad_account_id: (required)""" - client = _get_client() - try: - response = client.ads.list_conversion_associations( - account_id=account_id, - destination_id=destination_id, - ad_account_id=ad_account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Associate campaigns", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_add_conversion_associations( - account_id: str, - destination_id: str, - ad_account_id: str, - campaign_ids: list[str] | None, - ) -> str: - """Associate campaigns - - Args: - account_id: (required) - destination_id: (required) - ad_account_id: (required) - campaign_ids: (required)""" - client = _get_client() - try: - response = client.ads.add_conversion_associations( - account_id=account_id, - destination_id=destination_id, - ad_account_id=ad_account_id, - campaign_ids=campaign_ids, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Remove associated campaigns", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_remove_conversion_associations( - account_id: str, destination_id: str, ad_account_id: str, campaign_ids: str - ) -> str: - """Remove associated campaigns - - Args: - account_id: (required) - destination_id: (required) - ad_account_id: (required) - campaign_ids: Comma-separated list of campaign IDs. (required)""" - client = _get_client() - try: - response = client.ads.remove_conversion_associations( - account_id=account_id, - destination_id=destination_id, - ad_account_id=ad_account_id, - campaign_ids=campaign_ids, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get attribution metrics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def ads_get_conversion_metrics( - account_id: str, - destination_id: str, - ad_account_id: str, - start_date: str, - end_date: str | None = None, - granularity: str = "DAILY", - ) -> str: - """Get attribution metrics - - Args: - account_id: (required) - destination_id: (required) - ad_account_id: (required) - start_date: (required) - end_date - granularity""" - client = _get_client() - try: - response = client.ads.get_conversion_metrics( - account_id=account_id, - destination_id=destination_id, - ad_account_id=ad_account_id, - start_date=start_date, - end_date=end_date, - granularity=granularity, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create Click-to-WhatsApp ad", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def ads_create_ctwa_ad( - account_id: str, - ad_account_id: str, - name: str, - budget_amount: float, - budget_type: str, - headline: str | None = None, - body: str | None = None, - image_url: str | None = None, - video: dict[str, Any] | None = None, - creatives: list[dict[str, Any]] | None = None, - currency: str | None = None, - end_date: str | None = None, - countries: list[str] | None = None, - cities: list[dict[str, Any]] | None = None, - regions: list[dict[str, Any]] | None = None, - zips: list[dict[str, Any]] | None = None, - metros: list[dict[str, Any]] | None = None, - custom_locations: list[dict[str, Any]] | None = None, - age_min: int | None = None, - age_max: int | None = None, - interests: list[dict[str, Any]] | None = None, - audience_id: str | None = None, - placements: dict[str, Any] | None = None, - advantage_audience: int | None = None, - objective: str | None = None, - bid_strategy: str | None = None, - bid_amount: float | None = None, - roas_average_floor: float | None = None, - dsa_beneficiary: str | None = None, - dsa_payor: str | None = None, - ) -> str: - """Create Click-to-WhatsApp ad - - Args: - account_id: Facebook or Instagram SocialAccount ID. (required) - ad_account_id: Meta ad account ID, e.g. `act_123456789`. (required) - name: Ad display name. Used to derive campaign / ad set names. - On the multi-creative shape, each ad's Meta name gets a - " #N" suffix (1-indexed) so Ads Manager shows them as a - numbered batch. - (required) - headline: Single-creative shape only. Mutually exclusive with - `creatives[]`. - body: Primary text shown above the image / video. Single-creative - shape only. Mutually exclusive with `creatives[]`. - image_url: Image asset for single-creative shape. Mutually exclusive - with `video` and with `creatives[]`. Required on the - single-creative shape if `video` is not supplied. - video: Video creative for single-creative shape. Mutually - exclusive with `imageUrl` and with `creatives[]`. Required - on the single-creative shape if `imageUrl` is not supplied. - creatives: Multi-creative shape: N CTWA ads under one campaign + one - ad set, sharing budget and targeting. Mutually exclusive - with the top-level single-creative fields (`headline` / - `body` / `imageUrl` / `video`). Each entry must supply its - own headline, body, and exactly one of `imageUrl` / - `video`. - budget_amount: Budget amount in the ad account's currency major units - (e.g. dollars for USD, not cents). Must be > 0. - (required) - budget_type: (required) - currency: ISO 4217 currency code matching the ad account's currency - (e.g. `USD`). Optional; Meta infers from the ad account - when omitted. - end_date: ISO 8601 datetime. Required when `budgetType` is `lifetime`. - countries: ISO 3166-1 alpha-2 country codes. Defaults to `["US"]` only - when no other geo (`cities`, `regions`, `zips`, `metros`, - `customLocations`) is supplied. - cities: City-level geo targeting for local CTWA campaigns (e.g. - 25km radius around Milan). Each entry maps to Meta's - TargetingGeoLocationCity. `key` is Meta's city ID - (lookupable via GET /v1/ads/targeting/search). `radius` - and `distance_unit` are coupled: set both or neither. - regions: Region / state-level geo targeting. `key` is Meta's region - ID (lookupable via GET /v1/ads/targeting/search?type=region). - zips: ZIP / postal-code geo targeting. `key` is the platform's - postal id resolved via /v1/ads/targeting/search. - metros: DMA / metro-area geo targeting. `key` is Meta's metro id - (e.g. `DMA:807`). - custom_locations: Point-radius geo (Meta `geo_locations.custom_locations`). - Use for targeting a radius around a specific lat/long when - no Meta city/region key fits. `distanceUnit` is required. - age_min - age_max - interests - audience_id: Custom audience ID to target. - placements: Manual ad placements on the shared ad set. Omit - for automatic placements. When set, restricts delivery to the chosen surfaces, - mapped onto the ad set's `targeting.{publisher_platforms, facebook_positions, instagram_positions, - messenger_positions, audience_network_positions, threads_positions, - whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta - additionally enforces co-selection rules and restricts which - placements are eligible for click-to-WhatsApp ads, returning an actionable - error which we surface. - advantage_audience: Meta's Advantage+ audience expansion. `0` (default) keeps - targeting strict; `1` lets Meta expand beyond the supplied - targeting when its delivery system finds better matches. - Always sent on CREATE (Meta requires it). - objective: Defaults to `OUTCOME_ENGAGEMENT` (the broadly-supported CTWA - objective). `OUTCOME_SALES` and `OUTCOME_LEADS` require - additional account configuration (Dataset linked to the WABA - for sales) and may be rejected by Meta if missing. - bid_strategy: Meta bid strategy applied to the shared ad set. Defaults to - `LOWEST_COST_WITHOUT_CAP` (auto-bid) when omitted. - `LOWEST_COST_WITH_BID_CAP` and `COST_CAP` require - `bidAmount`. `LOWEST_COST_WITH_MIN_ROAS` requires - `roasAverageFloor`. CTWA's `optimization_goal` is fixed to - `CONVERSATIONS`, but the bid strategy is independent. - bid_amount: Whole currency units (e.g. `5` = $5.00 on a USD account). - Required when `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` - or `COST_CAP`; rejected otherwise. - roas_average_floor: Decimal ROAS multiplier (e.g. `2.0` = 2.0× ROAS floor). - Required when `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`; - rejected otherwise. Meta enforces its own upper bound - server-side. - dsa_beneficiary: Name of the legal entity benefiting from the ad. - Required by Meta when targeting EU users (DSA Article 26). - Not enforced at schema level; enforced server-side when targeting intersects EU member states. - dsa_payor: Name of the legal entity paying for the ad. - Required by Meta when targeting EU users (DSA Article 26). - Note Meta API spelling: dsa_payor (not dsa_payer).""" - client = _get_client() - try: - response = client.ads.create_ctwa_ad( - account_id=account_id, - ad_account_id=ad_account_id, - name=name, - headline=headline, - body=body, - image_url=image_url, - video=video, - creatives=creatives, - budget_amount=budget_amount, - budget_type=budget_type, - currency=currency, - end_date=end_date, - countries=countries, - cities=cities, - regions=regions, - zips=zips, - metros=metros, - custom_locations=custom_locations, - age_min=age_min, - age_max=age_max, - interests=interests, - audience_id=audience_id, - placements=placements, - advantage_audience=advantage_audience, - objective=objective, - bid_strategy=bid_strategy, - bid_amount=bid_amount, - roas_average_floor=roas_average_floor, - dsa_beneficiary=dsa_beneficiary, - dsa_payor=dsa_payor, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # ANALYTICS - - @mcp.tool( - annotations=ToolAnnotations( - title="Get post analytics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_analytics( - post_id: str | None = None, - platform: str | None = None, - profile_id: str | None = None, - account_id: str | None = None, - source: str = "all", - from_date: str | None = None, - to_date: str | None = None, - limit: int = 50, - page: int = 1, - sort_by: str = "date", - order: str = "desc", - ) -> str: - """Get post analytics - - Args: - post_id: Returns analytics for a single post. Accepts both Zernio Post IDs and External Post IDs. Zernio IDs are auto-resolved to External Post analytics. - platform: Filter by platform (default "all") - profile_id: Filter by profile ID (default "all") - account_id: Filter by social account ID - source: Filter by post source: late (posted via Zernio API), external (synced from platform), all (default) - from_date: Inclusive lower bound (YYYY-MM-DD). Defaults to 90 days ago if omitted. Max range is 366 days. - to_date: Inclusive upper bound (YYYY-MM-DD). Defaults to today if omitted. - limit: Page size (default 50) - page: Page number (default 1) - sort_by: Sort by date, engagement, or a specific metric - order: Sort order""" - client = _get_client() - try: - response = client.analytics.get_analytics( - post_id=post_id, - platform=platform, - profile_id=profile_id, - account_id=account_id, - source=source, - from_date=from_date, - to_date=to_date, - limit=limit, - page=page, - sort_by=sort_by, - order=order, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get YouTube channel insights", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_you_tube_channel_insights( - account_id: str, - metrics: str | None = None, - since: str | None = None, - until: str | None = None, - metric_type: str = "total_value", - ) -> str: - """Get YouTube channel insights - - Args: - account_id: The Zernio SocialAccount ID for the YouTube account. (required) - metrics: Comma-separated list. Defaults to "views,estimatedMinutesWatched,subscribersGained,subscribersLost". - - Live YouTube Analytics v2 metrics: - - views - - estimatedMinutesWatched - - averageViewDuration (ratio - weighted mean computed across days) - - subscribersGained - - subscribersLost - - Zernio-synthesized from daily follower snapshots (cross-platform parity): - - followers_gained - - followers_lost - since: Start date (YYYY-MM-DD). Defaults to 30 days ago. - until: End date (YYYY-MM-DD). Defaults to today. YouTube Analytics has a 2-3 day delay, - so the fetch is internally clamped to 3 days ago; any requested range extending - beyond that returns zero values for the tail days. The response's dateRange.until - field reflects your requested value. - metric_type: "total_value" (default) returns aggregated totals. - "time_series" returns per-day values in the "values" array.""" - client = _get_client() - try: - response = client.analytics.get_you_tube_channel_insights( - account_id=account_id, - metrics=metrics, - since=since, - until=until, - metric_type=metric_type, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get LinkedIn org analytics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_linked_in_org_aggregate_analytics( - account_id: str, - metrics: str | None = None, - since: str | None = None, - until: str | None = None, - metric_type: str = "total_value", - ) -> str: - """Get LinkedIn org analytics - - Args: - account_id: The Zernio SocialAccount ID for the LinkedIn organization account. (required) - metrics: Comma-separated list. Defaults to - "impressions,clicks,engagement_rate,organic_followers_gained,followers_gained,followers_lost". - - Share statistics (support both total_value and time_series): - - impressions - - unique_impressions - - clicks - - likes - - comments - - shares - - engagement_rate (0..1, LinkedIn-computed) - - Follower-gain statistics (support total_value and time_series): - - organic_followers_gained (per-day organic gains for time_series; sum of organic gains over the range for total_value) - - paid_followers_gained (per-day paid gains for time_series; sum of paid gains over the range for total_value) - - Page-view statistics (total_value ONLY - LinkedIn platform limit): - - page_views_total - - page_views_overview - - page_views_careers - - page_views_jobs - - page_views_life - - Zernio-synthesized from daily follower snapshots: - - followers_gained - - followers_lost - since: Start date (YYYY-MM-DD). Defaults to 30 days ago. - until: End date (YYYY-MM-DD). Defaults to today. - metric_type""" - client = _get_client() - try: - response = client.analytics.get_linked_in_org_aggregate_analytics( - account_id=account_id, - metrics=metrics, - since=since, - until=until, - metric_type=metric_type, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get TikTok account-level insights", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_tik_tok_account_insights( - account_id: str, - metrics: str | None = None, - since: str | None = None, - until: str | None = None, - metric_type: str = "total_value", - ) -> str: - """Get TikTok account-level insights - - Args: - account_id: The Zernio SocialAccount ID for the TikTok account. (required) - metrics: Comma-separated list. Defaults to - "follower_count,likes_count,video_count,followers_gained,followers_lost". - - Live from /v2/user/info/ (requires user.info.stats scope): - - follower_count (cumulative; time series joined from AccountStats) - - following_count (cumulative; time series joined from AccountStats.metadata) - - likes_count (cumulative; time series joined from AccountStats.metadata) - - video_count (cumulative; time series joined from AccountStats.metadata) - - Zernio-synthesized: - - followers_gained (sum of positive daily follower deltas) - - followers_lost (sum of absolute negative daily deltas) - since: Start date (YYYY-MM-DD). Defaults to 30 days ago. - until: End date (YYYY-MM-DD). Defaults to today. - metric_type: "total_value" returns the latest cumulative counter value. - "time_series" returns daily values joined from AccountStats snapshots.""" - client = _get_client() - try: - response = client.analytics.get_tik_tok_account_insights( - account_id=account_id, - metrics=metrics, - since=since, - until=until, - metric_type=metric_type, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get YouTube daily views", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_you_tube_daily_views( - video_id: str, - account_id: str, - start_date: str | None = None, - end_date: str | None = None, - ) -> str: - """Get YouTube daily views - - Args: - video_id: The YouTube video ID (e.g., "dQw4w9WgXcQ") (required) - account_id: The Zernio account ID for the YouTube account (required) - start_date: Start date (YYYY-MM-DD). Defaults to 30 days ago. - end_date: End date (YYYY-MM-DD). Defaults to 3 days ago (YouTube data latency).""" - client = _get_client() - try: - response = client.analytics.get_you_tube_daily_views( - video_id=video_id, - account_id=account_id, - start_date=start_date, - end_date=end_date, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get YouTube video retention curve", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_you_tube_video_retention( - video_id: str, - account_id: str, - start_date: str | None = None, - end_date: str | None = None, - ) -> str: - """Get YouTube video retention curve - - Args: - video_id: The YouTube video ID (e.g., "dQw4w9WgXcQ") (required) - account_id: The Zernio account ID for the YouTube account (required) - start_date: Start date (YYYY-MM-DD). Defaults to the video's publish date (lifetime curve). - end_date: End date (YYYY-MM-DD). Defaults to 3 days ago (YouTube data latency).""" - client = _get_client() - try: - response = client.analytics.get_you_tube_video_retention( - video_id=video_id, - account_id=account_id, - start_date=start_date, - end_date=end_date, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Facebook Page insights", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_facebook_page_insights( - account_id: str, - metrics: str | None = None, - since: str | None = None, - until: str | None = None, - metric_type: str = "total_value", - ) -> str: - """Get Facebook Page insights - - Args: - account_id: The Zernio SocialAccount ID for the connected Facebook Page. (required) - metrics: Comma-separated list of metrics. Defaults to - "page_media_view,page_post_engagements,page_follows,followers_gained,followers_lost". - - Live Meta metrics (current names, post-Nov-2025): - - page_media_view (replaces deprecated page_impressions) - - page_views_total - - page_post_engagements - - page_video_views - - page_video_view_time - - page_follows (replaces deprecated page_fans) - - Zernio-synthesized from daily follower snapshots (filling the Nov-2025 gap - left by the page_fan_adds / page_fan_removes deprecation): - - followers_gained - - followers_lost - since: Start date (YYYY-MM-DD). Defaults to 30 days ago. - until: End date (YYYY-MM-DD). Defaults to today. - metric_type: "total_value" (default) returns aggregated totals only. - "time_series" returns daily values in the "values" array.""" - client = _get_client() - try: - response = client.analytics.get_facebook_page_insights( - account_id=account_id, - metrics=metrics, - since=since, - until=until, - metric_type=metric_type, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Instagram insights", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_instagram_account_insights( - account_id: str, - metrics: str | None = None, - since: str | None = None, - until: str | None = None, - metric_type: str = "total_value", - breakdown: str | None = None, - ) -> str: - """Get Instagram insights - - Args: - account_id: The Zernio SocialAccount ID for the Instagram account (required) - metrics: Comma-separated list of metrics. Defaults to "reach,views,accounts_engaged,total_interactions". - Valid metrics: reach, views, accounts_engaged, total_interactions, comments, likes, saves, shares, - replies, reposts, follows_and_unfollows, profile_links_taps. - Note: only "reach" supports metricType=time_series. All other metrics (including - follows_and_unfollows) are total_value only. This is an Instagram Graph API limitation, - not a Zernio limitation - the IG API does not return time-series data for these metrics. - For a daily running follower count, use /v1/analytics/instagram/follower-history instead. - since: Start date (YYYY-MM-DD). Defaults to 30 days ago. - until: End date (YYYY-MM-DD). Defaults to today. - metric_type: "total_value" (default) returns aggregated totals and supports breakdowns. - "time_series" returns daily values but only works with the "reach" metric. - breakdown: Breakdown dimension (only valid with metricType=total_value). - Valid values depend on the metric: media_product_type, follow_type, follower_type, contact_button_type.""" - client = _get_client() - try: - response = client.analytics.get_instagram_account_insights( - account_id=account_id, - metrics=metrics, - since=since, - until=until, - metric_type=metric_type, - breakdown=breakdown, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Instagram follower history", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_instagram_follower_history( - account_id: str, - metrics: str | None = None, - since: str | None = None, - until: str | None = None, - metric_type: str = "total_value", - ) -> str: - """Get Instagram follower history - - Args: - account_id: The Zernio SocialAccount ID for the Instagram account. (required) - metrics: Comma-separated list. Defaults to "follower_count,followers_gained,followers_lost". - - follower_count : per-day raw follower count - - followers_gained : sum of positive daily deltas - - followers_lost : sum of absolute negative daily deltas - since: Start date (YYYY-MM-DD). Defaults to 30 days ago. - until: End date (YYYY-MM-DD). Defaults to today. - metric_type: "total_value" returns aggregated totals (latest for follower_count, sum for gained/lost). - "time_series" returns per-day values in the "values" array.""" - client = _get_client() - try: - response = client.analytics.get_instagram_follower_history( - account_id=account_id, - metrics=metrics, - since=since, - until=until, - metric_type=metric_type, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Instagram demographics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_instagram_demographics( - account_id: str, - metric: str = "follower_demographics", - breakdown: str | None = None, - timeframe: str = "this_month", - ) -> str: - """Get Instagram demographics - - Args: - account_id: The Zernio SocialAccount ID for the Instagram account (required) - metric: "follower_demographics" for follower audience data, or "engaged_audience_demographics" for engaged viewers. - breakdown: Comma-separated list of demographic dimensions: age, city, country, gender. - Defaults to all four if omitted. - timeframe: Time period for demographic data. Defaults to "this_month".""" - client = _get_client() - try: - response = client.analytics.get_instagram_demographics( - account_id=account_id, - metric=metric, - breakdown=breakdown, - timeframe=timeframe, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get YouTube demographics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_you_tube_demographics( - account_id: str, - breakdown: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - ) -> str: - """Get YouTube demographics - - Args: - account_id: The Zernio SocialAccount ID for the YouTube account (required) - breakdown: Comma-separated list of demographic dimensions: age, gender, country. - Defaults to all three if omitted. - start_date: Start date in YYYY-MM-DD format. Defaults to 90 days ago. - end_date: End date in YYYY-MM-DD format. Defaults to 3 days ago (YouTube data latency).""" - client = _get_client() - try: - response = client.analytics.get_you_tube_demographics( - account_id=account_id, - breakdown=breakdown, - start_date=start_date, - end_date=end_date, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get daily aggregated metrics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_daily_metrics( - platform: str | None = None, - profile_id: str | None = None, - account_id: str | None = None, - from_date: str | None = None, - to_date: str | None = None, - source: str = "all", - attribution: str = "publish", - ) -> str: - """Get daily aggregated metrics - - Args: - platform: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. - profile_id: Filter by profile ID. Omit for all profiles. - account_id: Filter by social account ID - from_date: Inclusive start date (ISO 8601). Defaults to 180 days ago. - to_date: Inclusive end date (ISO 8601). Defaults to now. - source: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms. - attribution: How each post's engagement is attributed to a day. - "publish" (default) sums each post's lifetime total on its publish date. - "received" buckets the per-day increase in engagement by the day it actually arrived (engagement-over-time), so engagement on older posts appears on the day it was gained rather than the post's publish date.""" - client = _get_client() - try: - response = client.analytics.get_daily_metrics( - platform=platform, - profile_id=profile_id, - account_id=account_id, - from_date=from_date, - to_date=to_date, - source=source, - attribution=attribution, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get best times to post", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_best_time_to_post( - platform: str | None = None, - profile_id: str | None = None, - account_id: str | None = None, - source: str = "all", - ) -> str: - """Get best times to post - - Args: - platform: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. - profile_id: Filter by profile ID. Omit for all profiles. - account_id: Filter by social account ID. Omit for all accounts. - source: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.""" - client = _get_client() - try: - response = client.analytics.get_best_time_to_post( - platform=platform, - profile_id=profile_id, - account_id=account_id, - source=source, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get content performance decay", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_content_decay( - platform: str | None = None, - profile_id: str | None = None, - account_id: str | None = None, - source: str = "all", - ) -> str: - """Get content performance decay - - Args: - platform: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. - profile_id: Filter by profile ID. Omit for all profiles. - account_id: Filter by social account ID. Omit for all accounts. - source: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.""" - client = _get_client() - try: - response = client.analytics.get_content_decay( - platform=platform, - profile_id=profile_id, - account_id=account_id, - source=source, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get frequency vs engagement", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_posting_frequency( - platform: str | None = None, - profile_id: str | None = None, - account_id: str | None = None, - source: str = "all", - ) -> str: - """Get frequency vs engagement - - Args: - platform: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms. - profile_id: Filter by profile ID. Omit for all profiles. - account_id: Filter by social account ID. Omit for all accounts. - source: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.""" - client = _get_client() - try: - response = client.analytics.get_posting_frequency( - platform=platform, - profile_id=profile_id, - account_id=account_id, - source=source, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get post analytics timeline", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_post_timeline( - post_id: str, from_date: str | None = None, to_date: str | None = None - ) -> str: - """Get post analytics timeline - - Args: - post_id: The post to fetch timeline for. Accepts an ExternalPost ID, a platformPostId, or a Zernio Post ID. - (required) - from_date: Start of date range (ISO 8601). Defaults to 90 days ago. - to_date: End of date range (ISO 8601). Defaults to now.""" - client = _get_client() - try: - response = client.analytics.get_post_timeline( - post_id=post_id, from_date=from_date, to_date=to_date - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get GBP performance metrics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_google_business_performance( - account_id: str, - metrics: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - ) -> str: - """Get GBP performance metrics - - Args: - account_id: The Zernio SocialAccount ID for the Google Business Profile account. (required) - metrics: Comma-separated metric names. Defaults to all available metrics. - Valid values: BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH, - BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH, - BUSINESS_CONVERSATIONS, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS, - BUSINESS_BOOKINGS, BUSINESS_FOOD_ORDERS, BUSINESS_FOOD_MENU_CLICKS - start_date: Start date (YYYY-MM-DD). Defaults to 30 days ago. Max 18 months back. - end_date: End date (YYYY-MM-DD). Defaults to today.""" - client = _get_client() - try: - response = client.analytics.get_google_business_performance( - account_id=account_id, - metrics=metrics, - start_date=start_date, - end_date=end_date, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get GBP search keywords", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_google_business_search_keywords( - account_id: str, start_month: str | None = None, end_month: str | None = None - ) -> str: - """Get GBP search keywords - - Args: - account_id: The Zernio SocialAccount ID for the Google Business Profile account. (required) - start_month: Start month (YYYY-MM). Defaults to 3 months ago. - end_month: End month (YYYY-MM). Defaults to current month.""" - client = _get_client() - try: - response = client.analytics.get_google_business_search_keywords( - account_id=account_id, start_month=start_month, end_month=end_month - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Sync an external post", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def analytics_sync_external_posts( - account_id: str, url: str | None = None, post_id: str | None = None - ) -> str: - """Sync an external post - - Args: - account_id: SocialAccount ID whose posts to sync. Must be connected to Zernio. (required) - url: The post URL to locate. Optional. Provide `url` or `postId` to return a specific post; omit both to just refresh and return the account's recent posts. - post_id: The platform post/media/video id to locate, as an alternative to `url`. Optional.""" - client = _get_client() - try: - response = client.analytics.sync_external_posts( - account_id=account_id, url=url, post_id=post_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get LinkedIn aggregate stats", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_linked_in_aggregate_analytics( - account_id: str, - aggregation: str = "TOTAL", - start_date: str | None = None, - end_date: str | None = None, - metrics: str | None = None, - ) -> str: - """Get LinkedIn aggregate stats - - Args: - account_id: The ID of the LinkedIn personal account (required) - aggregation: TOTAL (default, lifetime totals) or DAILY (time series). MEMBERS_REACHED not available with DAILY. - start_date: Start date (YYYY-MM-DD). If omitted, returns lifetime analytics. - end_date: End date (YYYY-MM-DD, exclusive). Defaults to today if omitted. - metrics: Comma-separated metrics: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE, POST_SAVE, POST_SEND. Omit for all.""" - client = _get_client() - try: - response = client.analytics.get_linked_in_aggregate_analytics( - account_id=account_id, - aggregation=aggregation, - start_date=start_date, - end_date=end_date, - metrics=metrics, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get LinkedIn post stats", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_linked_in_post_analytics(account_id: str, urn: str) -> str: - """Get LinkedIn post stats - - Args: - account_id: The ID of the LinkedIn account (required) - urn: The LinkedIn post URN (required)""" - client = _get_client() - try: - response = client.analytics.get_linked_in_post_analytics( - account_id=account_id, urn=urn - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get LinkedIn post reactions", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_linked_in_post_reactions( - account_id: str, urn: str, limit: int = 25, cursor: str | None = None - ) -> str: - """Get LinkedIn post reactions - - Args: - account_id: The ID of the LinkedIn organization account (required) - urn: The LinkedIn post URN (required) - limit: Maximum number of reactions to return per page - cursor: Offset-based pagination start index""" - client = _get_client() - try: - response = client.analytics.get_linked_in_post_reactions( - account_id=account_id, urn=urn, limit=limit, cursor=cursor - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Facebook post reactions", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def analytics_get_facebook_post_reactions(account_id: str, post_id: str) -> str: - """Get Facebook post reactions - - Args: - account_id: The ID of the Facebook Page account (required) - post_id: The Facebook post ID (required)""" - client = _get_client() - try: - response = client.analytics.get_facebook_post_reactions( - account_id=account_id, post_id=post_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # API_KEYS - - @mcp.tool( - annotations=ToolAnnotations( - title="List keys", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def api_keys_list_api_keys() -> str: - """List keys""" - client = _get_client() - try: - response = client.api_keys.list_api_keys() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create key", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def api_keys_create_api_key( - name: str, - expires_in: int | None = None, - scope: str = "full", - profile_ids: list[str] | None = None, - permission: str = "read-write", - ) -> str: - """Create key - - Args: - name: (required) - expires_in: Days until expiry - scope: 'full' grants access to all profiles (default), 'profiles' restricts to specific profiles - profile_ids: Profile IDs this key can access. Required when scope is 'profiles'. - permission: 'read-write' allows all operations (default), 'read' restricts to GET requests only""" - client = _get_client() - try: - response = client.api_keys.create_api_key( - name=name, - expires_in=expires_in, - scope=scope, - profile_ids=profile_ids, - permission=permission, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete key", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def api_keys_delete_api_key(key_id: str) -> str: - """Delete key - - Args: - key_id: (required)""" - client = _get_client() - try: - response = client.api_keys.delete_api_key(key_id=key_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # BROADCASTS - - @mcp.tool( - annotations=ToolAnnotations( - title="List broadcasts", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def broadcasts_list_broadcasts( - profile_id: str | None = None, - status: str | None = None, - platform: str | None = None, - limit: int = 50, - skip: int = 0, - ) -> str: - """List broadcasts - - Args: - profile_id: Filter by profile. Omit to list across all profiles - status - platform - limit - skip""" - client = _get_client() - try: - response = client.broadcasts.list_broadcasts( - profile_id=profile_id, - status=status, - platform=platform, - limit=limit, - skip=skip, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create broadcast draft", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def broadcasts_create_broadcast( - profile_id: str, - account_id: str, - platform: str, - name: str, - description: str | None = None, - message: dict[str, Any] | None = None, - template: dict[str, Any] | None = None, - segment_filters: dict[str, Any] | None = None, - ) -> str: - """Create broadcast draft - - Args: - profile_id: (required) - account_id: (required) - platform: (required) - name: (required) - description - message - template: WhatsApp template (required when platform is whatsapp) - segment_filters""" - client = _get_client() - try: - response = client.broadcasts.create_broadcast( - profile_id=profile_id, - account_id=account_id, - platform=platform, - name=name, - description=description, - message=message, - template=template, - segment_filters=segment_filters, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get broadcast details", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def broadcasts_get_broadcast(broadcast_id: str) -> str: - """Get broadcast details - - Args: - broadcast_id: (required)""" - client = _get_client() - try: - response = client.broadcasts.get_broadcast(broadcast_id=broadcast_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update broadcast", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def broadcasts_update_broadcast( - broadcast_id: str, - name: str | None = None, - description: str | None = None, - message: dict[str, Any] | None = None, - template: dict[str, Any] | None = None, - segment_filters: dict[str, Any] | None = None, - ) -> str: - """Update broadcast - - Args: - broadcast_id: (required) - name - description - message: Generic message payload (used for non-WhatsApp platforms). - template: WhatsApp template payload (used when platform is `whatsapp`). - segment_filters: Recipient segment filters (tags, channels, subscription state).""" - client = _get_client() - try: - response = client.broadcasts.update_broadcast( - broadcast_id=broadcast_id, - name=name, - description=description, - message=message, - template=template, - segment_filters=segment_filters, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete broadcast", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def broadcasts_delete_broadcast(broadcast_id: str) -> str: - """Delete broadcast - - Args: - broadcast_id: (required)""" - client = _get_client() - try: - response = client.broadcasts.delete_broadcast(broadcast_id=broadcast_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send broadcast now", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def broadcasts_send_broadcast(broadcast_id: str) -> str: - """Send broadcast now - - Args: - broadcast_id: (required)""" - client = _get_client() - try: - response = client.broadcasts.send_broadcast(broadcast_id=broadcast_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Schedule broadcast for later", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def broadcasts_schedule_broadcast(broadcast_id: str, scheduled_at: str) -> str: - """Schedule broadcast for later - - Args: - broadcast_id: (required) - scheduled_at: (required)""" - client = _get_client() - try: - response = client.broadcasts.schedule_broadcast( - broadcast_id=broadcast_id, scheduled_at=scheduled_at - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Cancel broadcast", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def broadcasts_cancel_broadcast(broadcast_id: str) -> str: - """Cancel broadcast - - Args: - broadcast_id: (required)""" - client = _get_client() - try: - response = client.broadcasts.cancel_broadcast(broadcast_id=broadcast_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List broadcast recipients", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def broadcasts_list_broadcast_recipients( - broadcast_id: str, status: str | None = None, limit: int = 50, skip: int = 0 - ) -> str: - """List broadcast recipients - - Args: - broadcast_id: (required) - status - limit - skip""" - client = _get_client() - try: - response = client.broadcasts.list_broadcast_recipients( - broadcast_id=broadcast_id, status=status, limit=limit, skip=skip - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Add recipients to a broadcast", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def broadcasts_add_broadcast_recipients( - broadcast_id: str, - contact_ids: list[str] | None = None, - phones: list[str] | None = None, - use_segment: bool | None = None, - ) -> str: - """Add recipients to a broadcast - - Args: - broadcast_id: (required) - contact_ids: Specific contact IDs to add - phones: Raw phone numbers (auto-creates contacts). Useful for WhatsApp/Telegram manual entry - use_segment: Auto-populate from broadcast segment filters""" - client = _get_client() - try: - response = client.broadcasts.add_broadcast_recipients( - broadcast_id=broadcast_id, - contact_ids=contact_ids, - phones=phones, - use_segment=use_segment, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # CALLS - - @mcp.tool( - annotations=ToolAnnotations( - title="List all calls (unified history)", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def calls_list_calls( - channel: str | None = None, - status: str | None = None, - direction: str | None = None, - number: str | None = None, - search: str | None = None, - before: str | None = None, - limit: int = 50, - ) -> str: - """List all calls (unified history) - - Args: - channel - status - direction - number: Exact filter: calls involving this number (typically one of YOUR numbers, to scope history to a single line). E.164, leading + optional. - search: Free-text match on the from/to numbers. Non-digits are stripped, so partial queries like `302` or `+1 302` work. - before: Return calls with startedAt strictly before this instant (use the previous page's nextCursor). - limit""" - client = _get_client() - try: - response = client.calls.list_calls( - channel=channel, - status=status, - direction=direction, - number=number, - search=search, - before=before, - limit=limit, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a call (any channel)", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def calls_get_call(id: str) -> str: - """Get a call (any channel) - - Args: - id: (required)""" - client = _get_client() - try: - response = client.calls.get_call(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a call recording", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def calls_get_call_recording(id: str, as_: str | None = None) -> str: - """Get a call recording - - Args: - id: (required) - as_: `json` returns `{ url }` instead of a 302 redirect.""" - client = _get_client() - try: - response = client.calls.get_call_recording(id=id, as_=as_) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # COMMENT_AUTOMATIONS - - @mcp.tool( - annotations=ToolAnnotations( - title="List comment-to-DM automations", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def comment_automations_list_comment_automations( - profile_id: str | None = None, - ) -> str: - """List comment-to-DM automations - - Args: - profile_id: Filter by profile. Omit to list across all profiles""" - client = _get_client() - try: - response = client.comment_automations.list_comment_automations( - profile_id=profile_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create comment-to-DM automation", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comment_automations_create_comment_automation( - profile_id: str, - account_id: str, - name: str, - dm_message: str, - trigger: str = "comment", - platform_post_id: str | None = None, - post_id: str | None = None, - post_title: str | None = None, - keywords: list[str] | None = None, - match_mode: str = "contains", - buttons: list[dict[str, Any]] | None = None, - comment_reply: str | None = None, - link_tracking: bool = True, - click_tag: str | None = None, - ) -> str: - """Create comment-to-DM automation - - Args: - profile_id: (required) - account_id: Instagram or Facebook account ID (required) - trigger: What fires the automation. 'comment' (keyword comment on a post) or 'story_reply' (keyword reply to an Instagram story). For 'story_reply', platformPostId is the story media id (omit for any story). - platform_post_id: Platform media/post ID (or story media id when trigger=story_reply). Omit for an account-wide (any-post / any-story) automation. - post_id: Zernio post ID. Required only when also targeting a specific post via platformPostId. - post_title: Post content snippet for display - name: Automation label (required) - keywords: Trigger keywords (empty = any comment triggers) - match_mode - dm_message: DM text to send to commenter. Max 640 chars when buttons are set, otherwise ~1000. (required) - buttons: Optional inline DM buttons (1-3). Phone buttons are Facebook-only. Omit or pass [] for a plain-text DM. - comment_reply: Optional public reply to the comment - link_tracking: Wrap link buttons in the DM in a tracked redirect so clicks are counted (Link Clicks / CTR). Pass false to send links exactly as written. Defaults to on. - click_tag: Optional tag applied to a contact when they click a tracked link (requires linkTracking). Lets you segment clickers for broadcasts/sequences.""" - client = _get_client() - try: - response = client.comment_automations.create_comment_automation( - profile_id=profile_id, - account_id=account_id, - trigger=trigger, - platform_post_id=platform_post_id, - post_id=post_id, - post_title=post_title, - name=name, - keywords=keywords, - match_mode=match_mode, - dm_message=dm_message, - buttons=buttons, - comment_reply=comment_reply, - link_tracking=link_tracking, - click_tag=click_tag, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get automation details", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def comment_automations_get_comment_automation(automation_id: str) -> str: - """Get automation details - - Args: - automation_id: (required)""" - client = _get_client() - try: - response = client.comment_automations.get_comment_automation( - automation_id=automation_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update automation settings", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comment_automations_update_comment_automation( - automation_id: str, - name: str | None = None, - keywords: list[str] | None = None, - match_mode: str | None = None, - dm_message: str | None = None, - buttons: list[dict[str, Any]] | None = None, - comment_reply: str | None = None, - link_tracking: bool | None = None, - click_tag: str | None = None, - is_active: bool | None = None, - ) -> str: - """Update automation settings - - Args: - automation_id: (required) - name - keywords - match_mode - dm_message - buttons: Inline DM buttons (1-3). Pass [] to clear all buttons. - comment_reply - link_tracking: Wrap link buttons in a tracked redirect to count clicks. Pass false to send links untouched. - click_tag: Tag applied to a contact when they click a tracked link (requires linkTracking). Empty string clears it. - is_active""" - client = _get_client() - try: - response = client.comment_automations.update_comment_automation( - automation_id=automation_id, - name=name, - keywords=keywords, - match_mode=match_mode, - dm_message=dm_message, - buttons=buttons, - comment_reply=comment_reply, - link_tracking=link_tracking, - click_tag=click_tag, - is_active=is_active, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete automation", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comment_automations_delete_comment_automation(automation_id: str) -> str: - """Delete automation - - Args: - automation_id: (required)""" - client = _get_client() - try: - response = client.comment_automations.delete_comment_automation( - automation_id=automation_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List automation logs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def comment_automations_list_comment_automation_logs( - automation_id: str, status: str | None = None, limit: int = 50, skip: int = 0 - ) -> str: - """List automation logs - - Args: - automation_id: (required) - status: Filter by result status - limit - skip""" - client = _get_client() - try: - response = client.comment_automations.list_comment_automation_logs( - automation_id=automation_id, status=status, limit=limit, skip=skip - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # COMMENTS - - @mcp.tool( - annotations=ToolAnnotations( - title="List commented posts", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def comments_list_inbox_comments( - profile_id: str | None = None, - platform: str | None = None, - min_comments: int | None = None, - since: str | None = None, - sort_by: str = "date", - sort_order: str = "desc", - limit: int = 50, - cursor: str | None = None, - account_id: str | None = None, - ) -> str: - """List commented posts - - Args: - profile_id: Filter by profile ID - platform: Filter by platform. `metaads` is a synthetic value meaning the user's ads (boosted/dark posts) only; `facebook`/`instagram` return organic posts only. - min_comments: Minimum comment count - since: Posts created after this date - sort_by: Sort field - sort_order: Sort order - limit - cursor - account_id: Filter by specific social account ID""" - client = _get_client() - try: - response = client.comments.list_inbox_comments( - profile_id=profile_id, - platform=platform, - min_comments=min_comments, - since=since, - sort_by=sort_by, - sort_order=sort_order, - limit=limit, - cursor=cursor, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get post comments", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def comments_get_inbox_post_comments( - post_id: str, - account_id: str, - subreddit: str | None = None, - limit: int = 25, - cursor: str | None = None, - comment_id: str | None = None, - ) -> str: - """Get post comments - - Args: - post_id: Zernio post ID or platform-specific post ID. Zernio IDs are auto-resolved. LinkedIn third-party posts accept full activity URN or numeric ID. (required) - account_id: (required) - subreddit: (Reddit only) Subreddit name - limit: Maximum number of comments to return - cursor: Pagination cursor - comment_id: (Reddit only) Get replies to a specific comment""" - client = _get_client() - try: - response = client.comments.get_inbox_post_comments( - post_id=post_id, - account_id=account_id, - subreddit=subreddit, - limit=limit, - cursor=cursor, - comment_id=comment_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Reply to comment", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_reply_to_inbox_post( - post_id: str, - account_id: str, - message: str, - comment_id: str | None = None, - parent_cid: str | None = None, - root_uri: str | None = None, - root_cid: str | None = None, - ) -> str: - """Reply to comment - - Args: - post_id: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. (required) - account_id: (required) - message: (required) - comment_id: Reply to specific comment (optional) - parent_cid: (Bluesky only) Parent content identifier - root_uri: (Bluesky only) Root post URI - root_cid: (Bluesky only) Root post CID""" - client = _get_client() - try: - response = client.comments.reply_to_inbox_post( - post_id=post_id, - account_id=account_id, - message=message, - comment_id=comment_id, - parent_cid=parent_cid, - root_uri=root_uri, - root_cid=root_cid, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete comment", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_delete_inbox_comment( - post_id: str, account_id: str, comment_id: str - ) -> str: - """Delete comment - - Args: - post_id: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID. (required) - account_id: (required) - comment_id: (required)""" - client = _get_client() - try: - response = client.comments.delete_inbox_comment( - post_id=post_id, account_id=account_id, comment_id=comment_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Edit comment", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_edit_inbox_comment( - post_id: str, comment_id: str, account_id: str, platform: str, content: str - ) -> str: - """Edit comment - - Args: - post_id: (required) - comment_id: (required) - account_id: The social account ID (required) - platform: Only Reddit supports editing a comment (required) - content: The new comment body (required)""" - client = _get_client() - try: - response = client.comments.edit_inbox_comment( - post_id=post_id, - comment_id=comment_id, - account_id=account_id, - platform=platform, - content=content, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set comment moderation status", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_set_comment_moderation( - post_id: str, - comment_id: str, - account_id: str, - platform: str, - moderation_status: str, - ban_author: bool | None = None, - ) -> str: - """Set comment moderation status - - Args: - post_id: (required) - comment_id: (required) - account_id: The social account ID (required) - platform: Only YouTube supports comment moderation (required) - moderation_status: published approves the comment, rejected removes it, heldForReview returns it to the queue. (required) - ban_author: Also ban the comment's author, auto-rejecting their future comments. Only valid when moderationStatus is "rejected"; any other pairing is a 400.""" - client = _get_client() - try: - response = client.comments.set_comment_moderation( - post_id=post_id, - comment_id=comment_id, - account_id=account_id, - platform=platform, - moderation_status=moderation_status, - ban_author=ban_author, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Hide comment", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_hide_inbox_comment( - post_id: str, comment_id: str, account_id: str - ) -> str: - """Hide comment - - Args: - post_id: (required) - comment_id: (required) - account_id: The social account ID (required)""" - client = _get_client() - try: - response = client.comments.hide_inbox_comment( - post_id=post_id, comment_id=comment_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Unhide comment", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_unhide_inbox_comment( - post_id: str, comment_id: str, account_id: str - ) -> str: - """Unhide comment - - Args: - post_id: (required) - comment_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.comments.unhide_inbox_comment( - post_id=post_id, comment_id=comment_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Like comment", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_like_inbox_comment( - post_id: str, comment_id: str, account_id: str, cid: str | None = None - ) -> str: - """Like comment - - Args: - post_id: (required) - comment_id: (required) - account_id: The social account ID (required) - cid: (Bluesky only) Content identifier for the comment""" - client = _get_client() - try: - response = client.comments.like_inbox_comment( - post_id=post_id, comment_id=comment_id, account_id=account_id, cid=cid - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Unlike comment", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_unlike_inbox_comment( - post_id: str, comment_id: str, account_id: str, like_uri: str | None = None - ) -> str: - """Unlike comment - - Args: - post_id: (required) - comment_id: (required) - account_id: (required) - like_uri: (Bluesky only) The like URI returned when liking""" - client = _get_client() - try: - response = client.comments.unlike_inbox_comment( - post_id=post_id, - comment_id=comment_id, - account_id=account_id, - like_uri=like_uri, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send private reply", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def comments_send_private_reply_to_comment( - post_id: str, - comment_id: str, - account_id: str, - message: str, - quick_replies: list[dict[str, Any]] | None = None, - buttons: list[dict[str, Any]] | None = None, - ) -> str: - """Send private reply - - Args: - post_id: The media/post ID (Instagram media ID or Facebook post ID) (required) - comment_id: The comment ID to send a private reply to (required) - account_id: The social account ID (Instagram or Facebook) (required) - message: The message text to send as a private DM (required) - quick_replies: Optional quick-reply chips appended to the message. Visible only in the - Instagram and Messenger apps (not on web). Maximum 13 entries. Mutually - exclusive with `buttons`. Note: chips do NOT render in the Instagram - Message Requests folder where DMs from non-followers land — use `buttons` - instead for cold reach. - buttons: Optional 1-3 inline buttons rendered as part of the same message bubble - via Meta's button_template. Visible in the Instagram Message Requests - folder (unlike quick replies). Mutually exclusive with `quickReplies`.""" - client = _get_client() - try: - response = client.comments.send_private_reply_to_comment( - post_id=post_id, - comment_id=comment_id, - account_id=account_id, - message=message, - quick_replies=quick_replies, - buttons=buttons, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # CONNECT - - @mcp.tool( - annotations=ToolAnnotations( - title="Get OAuth connect URL", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_connect_url( - platform: str, - profile_id: str, - redirect_url: str | None = None, - headless: bool = False, - ) -> str: - """Get OAuth connect URL - - Args: - platform: Social media platform to connect (required) - profile_id: Your Zernio profile ID (get from /v1/profiles) (required) - redirect_url: Your custom redirect URL after connection completes. Accepts an http(s) URL, a custom app scheme for mobile deeplinks (e.g. myapp://callback), or a relative path. Result params are appended with the URL API, so an existing query string is preserved. Standard mode appends connected={platform}&profileId=X&accountId=Y&username=Z. Headless mode appends OAuth data params for platforms requiring selection (e.g. LinkedIn orgs, Facebook pages). If no selection is needed, the account is created directly and the redirect includes accountId. - headless: When true, the user is redirected to your redirect_url with raw OAuth data (code, state) instead of Zernio's default account selection UI. Use this to build a custom connect experience.""" - client = _get_client() - try: - response = client.connect.get_connect_url( - platform=platform, - profile_id=profile_id, - redirect_url=redirect_url, - headless=headless, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Complete OAuth callback", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_handle_o_auth_callback( - platform: str, code: str, state: str, profile_id: str - ) -> str: - """Complete OAuth callback - - Args: - platform: (required) - code: (required) - state: (required) - profile_id: (required)""" - client = _get_client() - try: - response = client.connect.handle_o_auth_callback( - platform=platform, code=code, state=state, profile_id=profile_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Connect ads for a platform", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_ads( - platform: str, - profile_id: str, - account_id: str | None = None, - redirect_url: str | None = None, - headless: bool = False, - ad_account_id: str | None = None, - ad_account_ids: list[str] | None = None, - ) -> str: - """Connect ads for a platform - - Args: - platform: Platform to connect ads for. Only platforms with ads support are accepted. (required) - profile_id: Your Zernio profile ID (required) - account_id: Existing SocialAccount ID. Required for `twitter` (X Ads). Optional for `tiktok` — - omit to enter ads-only mode (no TikTok posting account linked; ad creation uses - a Brand Identity instead of a TT_USER). Ignored for same-token (`facebook`, - `instagram`, `linkedin`, `pinterest`) and standalone (`googleads`) platforms. - redirect_url: Custom redirect URL after OAuth completes (same-token platforms only). Accepts an http(s) URL, a custom app scheme for mobile deeplinks (e.g. myapp://callback), or a relative path. - headless: Enable headless mode (same-token platforms only) - ad_account_id: Scope ad sync to a single platform ad account. Without this param, - sync covers every ad account the connected token can see. Supported - on `facebook`/`instagram` (Meta, `act_`), `linkedin` (bare - numeric sponsored-account id), `googleads` (bare customer id digits) - and `twitter` (X Ads, base36 account id). `tiktok` scopes advertisers - at OAuth and `pinterest` has no ads discovery, so both ignore it. - Meta ids are additionally validated against the connected token; - unreachable IDs return 400. Setting a scope also removes already - synced ads from de-scoped ad accounts. For multiple accounts use - `adAccountIds` instead. - ad_account_ids: Scope ad sync to multiple platform ad accounts (same platform - support and id shapes as `adAccountId`). Repeat the param - (`?adAccountIds=act_1&adAccountIds=act_2`) or comma-separate - (`?adAccountIds=act_1,act_2`). Persisted server-side; latest call - wins, and de-scoped ad accounts have their synced ads removed. - Omitting both `adAccountId` and `adAccountIds` keeps any previously - persisted scope unchanged.""" - client = _get_client() - try: - response = client.connect.connect_ads( - platform=platform, - profile_id=profile_id, - account_id=account_id, - redirect_url=redirect_url, - headless=headless, - ad_account_id=ad_account_id, - ad_account_ids=ad_account_ids, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set TikTok brand identity", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_configure_tik_tok_ads_brand_identity( - account_id: str, display_name: str, image_url: str - ) -> str: - """Set TikTok brand identity - - Args: - account_id: SocialAccount ID of the `tiktokads` account. (required) - display_name: Brand name shown above the ad on TikTok. (required) - image_url: Public URL of a square brand image (≥98×98 px, JPG/PNG, max 5 MB). Used as the brand avatar on the ad. (required)""" - client = _get_client() - try: - response = client.connect.configure_tik_tok_ads_brand_identity( - account_id=account_id, display_name=display_name, image_url=image_url - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Facebook pages", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_list_facebook_pages(profile_id: str, temp_token: str) -> str: - """List Facebook pages - - Args: - profile_id: Profile ID from your connection flow (required) - temp_token: Temporary Facebook access token from the OAuth callback redirect (required)""" - client = _get_client() - try: - response = client.connect.list_facebook_pages( - profile_id=profile_id, temp_token=temp_token - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Select Facebook page", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_select_facebook_page( - profile_id: str, - page_id: str, - temp_token: str, - user_profile: dict[str, Any] | None, - redirect_url: str | None = None, - ) -> str: - """Select Facebook page - - Args: - profile_id: Profile ID from your connection flow (required) - page_id: The Facebook Page ID selected by the user (required) - temp_token: Temporary Facebook access token from OAuth (required) - user_profile: Decoded user profile object from the OAuth callback (required) - redirect_url: Optional custom redirect URL to return to after selection""" - client = _get_client() - try: - response = client.connect.select_facebook_page( - profile_id=profile_id, - page_id=page_id, - temp_token=temp_token, - user_profile=user_profile, - redirect_url=redirect_url, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List GBP locations", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_list_google_business_locations( - profile_id: str | None = None, - pending_data_token: str | None = None, - temp_token: str | None = None, - search: str | None = None, - filter: str | None = None, - ) -> str: - """List GBP locations - - Args: - profile_id: Profile ID from your connection flow. Required for auth validation when provided. - pending_data_token: Token from the OAuth callback redirect. Preferred over tempToken because it preserves server-side token storage. One of pendingDataToken or tempToken is required. - temp_token: Legacy. Direct Google access token. Use pendingDataToken instead when available. - search: Free-text search on the business name, applied server-side by Google. Use this for accounts that own many locations (the response is bounded, see hasMore) so the user can find a specific location without loading the full list. - filter: Raw Google Business Information API filter expression (advanced; takes precedence over search). Supports fields such as title, storeCode, storefront_address.postal_code, labels and categories, e.g. storeCode="LH279411". See Google's "Work with location data" guide.""" - client = _get_client() - try: - response = client.connect.list_google_business_locations( - profile_id=profile_id, - pending_data_token=pending_data_token, - temp_token=temp_token, - search=search, - filter=filter, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Select GBP location", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_select_google_business_location( - profile_id: str, - location_id: str, - pending_data_token: str, - account_id: str | None = None, - redirect_url: str | None = None, - ) -> str: - """Select GBP location - - Args: - profile_id: Profile ID from your connection flow (required) - location_id: The Google Business location ID selected by the user (required) - account_id: Optional but recommended. The Google Business Account resource name ("accounts/123") that owns the selected location (returned per-location by GET /v1/connect/googlebusiness/locations). When provided, the location is resolved directly instead of by enumerating the account, which is required for accounts that own many locations. Omit only for small accounts. - pending_data_token: Token from the OAuth callback redirect (pendingDataToken query param). Tokens and profile data are retrieved server-side from this token. (required) - redirect_url: Optional custom redirect URL to return to after selection""" - client = _get_client() - try: - response = client.connect.select_google_business_location( - profile_id=profile_id, - location_id=location_id, - account_id=account_id, - pending_data_token=pending_data_token, - redirect_url=redirect_url, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get pending OAuth data", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_pending_o_auth_data(token: str) -> str: - """Get pending OAuth data - - Args: - token: The pending data token from the OAuth redirect URL (pendingDataToken parameter) (required)""" - client = _get_client() - try: - response = client.connect.get_pending_o_auth_data(token=token) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List LinkedIn orgs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_list_linked_in_organizations(temp_token: str, org_ids: str) -> str: - """List LinkedIn orgs - - Args: - temp_token: The temporary LinkedIn access token from the OAuth redirect (required) - org_ids: Comma-separated list of organization IDs to fetch details for (max 100) (required)""" - client = _get_client() - try: - response = client.connect.list_linked_in_organizations( - temp_token=temp_token, org_ids=org_ids - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Select LinkedIn org", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_select_linked_in_organization( - profile_id: str, - temp_token: str, - user_profile: dict[str, Any] | None, - account_type: str, - selected_organization: dict[str, Any] | None = None, - redirect_url: str | None = None, - ) -> str: - """Select LinkedIn org - - Args: - profile_id: (required) - temp_token: (required) - user_profile: (required) - account_type: (required) - selected_organization - redirect_url""" - client = _get_client() - try: - response = client.connect.select_linked_in_organization( - profile_id=profile_id, - temp_token=temp_token, - user_profile=user_profile, - account_type=account_type, - selected_organization=selected_organization, - redirect_url=redirect_url, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Pinterest boards", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_list_pinterest_boards_for_selection( - profile_id: str, temp_token: str - ) -> str: - """List Pinterest boards - - Args: - profile_id: Your Zernio profile ID (required) - temp_token: Temporary Pinterest access token from the OAuth callback redirect (required)""" - client = _get_client() - try: - response = client.connect.list_pinterest_boards_for_selection( - profile_id=profile_id, temp_token=temp_token - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Select Pinterest board", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_select_pinterest_board( - profile_id: str, - board_id: str, - temp_token: str, - board_name: str | None = None, - user_profile: dict[str, Any] | None = None, - refresh_token: str | None = None, - expires_in: int | None = None, - redirect_url: str | None = None, - ) -> str: - """Select Pinterest board - - Args: - profile_id: Your Zernio profile ID (required) - board_id: The Pinterest Board ID selected by the user (required) - board_name: The board name (for display purposes) - temp_token: Temporary Pinterest access token from OAuth (required) - user_profile: User profile data from OAuth redirect - refresh_token: Pinterest refresh token (if available) - expires_in: Token expiration time in seconds - redirect_url: Custom redirect URL after connection completes""" - client = _get_client() - try: - response = client.connect.select_pinterest_board( - profile_id=profile_id, - board_id=board_id, - board_name=board_name, - temp_token=temp_token, - user_profile=user_profile, - refresh_token=refresh_token, - expires_in=expires_in, - redirect_url=redirect_url, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Snapchat profiles", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_list_snapchat_profiles(profile_id: str, temp_token: str) -> str: - """List Snapchat profiles - - Args: - profile_id: Your Zernio profile ID (required) - temp_token: Temporary Snapchat access token from the OAuth callback redirect (required)""" - client = _get_client() - try: - response = client.connect.list_snapchat_profiles( - profile_id=profile_id, temp_token=temp_token - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Select Snapchat profile", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_select_snapchat_profile( - profile_id: str, - selected_public_profile: dict[str, Any] | None, - temp_token: str, - user_profile: dict[str, Any] | None, - refresh_token: str | None = None, - expires_in: int | None = None, - redirect_url: str | None = None, - ) -> str: - """Select Snapchat profile - - Args: - profile_id: Your Zernio profile ID (required) - selected_public_profile: The selected Snapchat Public Profile (required) - temp_token: Temporary Snapchat access token from OAuth (required) - user_profile: User profile data from OAuth redirect (required) - refresh_token: Snapchat refresh token (if available) - expires_in: Token expiration time in seconds - redirect_url: Custom redirect URL after connection completes""" - client = _get_client() - try: - response = client.connect.select_snapchat_profile( - profile_id=profile_id, - selected_public_profile=selected_public_profile, - temp_token=temp_token, - user_profile=user_profile, - refresh_token=refresh_token, - expires_in=expires_in, - redirect_url=redirect_url, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Connect Bluesky account", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_bluesky_credentials( - identifier: str, app_password: str, state: str, redirect_uri: str | None = None - ) -> str: - """Connect Bluesky account - - Args: - identifier: Your Bluesky handle (e.g. user.bsky.social) or email address (required) - app_password: App password generated from Bluesky Settings > App Passwords (required) - state: Required state formatted as {userId}-{profileId}. Get userId from GET /v1/users and profileId from GET /v1/profiles. (required) - redirect_uri: Optional URL to redirect to after successful connection""" - client = _get_client() - try: - response = client.connect.connect_bluesky_credentials( - identifier=identifier, - app_password=app_password, - state=state, - redirect_uri=redirect_uri, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Connect WhatsApp via credentials", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_whats_app_credentials( - profile_id: str, access_token: str, waba_id: str, phone_number_id: str - ) -> str: - """Connect WhatsApp via credentials - - Args: - profile_id: Your Zernio profile ID (required) - access_token: Permanent System User access token from Meta Business Suite (required) - waba_id: WhatsApp Business Account ID from Meta (required) - phone_number_id: Phone Number ID from Meta WhatsApp Manager (required)""" - client = _get_client() - try: - response = client.connect.connect_whats_app_credentials( - profile_id=profile_id, - access_token=access_token, - waba_id=waba_id, - phone_number_id=phone_number_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List numbers for selection", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_list_whats_app_phone_numbers(profile_id: str, temp_token: str) -> str: - """List numbers for selection - - Args: - profile_id: The Zernio profile ID from the headless redirect (required) - temp_token: The temporary access token from the headless redirect (required)""" - client = _get_client() - try: - response = client.connect.list_whats_app_phone_numbers( - profile_id=profile_id, temp_token=temp_token - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Complete number selection", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_complete_whats_app_phone_selection( - profile_id: str, - phone_number_id: str, - waba_id: str, - temp_token: str, - user_profile: dict[str, Any] | None = None, - redirect_url: str | None = None, - ) -> str: - """Complete number selection - - Args: - profile_id: The Zernio profile ID (required) - phone_number_id: The selected phone number ID (from listWhatsAppPhoneNumbers) (required) - waba_id: The WABA ID containing the selected phone (required) - temp_token: The temporary access token from the headless redirect (required) - user_profile: Optional user profile data (passthrough) - redirect_url: Optional URL to receive the post-connection redirect target""" - client = _get_client() - try: - response = client.connect.complete_whats_app_phone_selection( - profile_id=profile_id, - phone_number_id=phone_number_id, - waba_id=waba_id, - temp_token=temp_token, - user_profile=user_profile, - redirect_url=redirect_url, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Generate Telegram code", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_telegram_connect_status(profile_id: str) -> str: - """Generate Telegram code - - Args: - profile_id: The profile ID to connect the Telegram account to (required)""" - client = _get_client() - try: - response = client.connect.get_telegram_connect_status(profile_id=profile_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Connect Telegram directly", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_initiate_telegram_connect(chat_id: str, profile_id: str) -> str: - """Connect Telegram directly - - Args: - chat_id: The Telegram chat ID. Numeric ID (e.g. "-1001234567890") or username with @ prefix (e.g. "@mychannel"). (required) - profile_id: The profile ID to connect the account to (required)""" - client = _get_client() - try: - response = client.connect.initiate_telegram_connect( - chat_id=chat_id, profile_id=profile_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check Telegram status", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_complete_telegram_connect(code: str) -> str: - """Check Telegram status - - Args: - code: The access code to check status for (required)""" - client = _get_client() - try: - response = client.connect.complete_telegram_connect(code=code) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Facebook pages", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_facebook_pages(account_id: str, refresh: bool | None = None) -> str: - """List Facebook pages - - Args: - account_id: (required) - refresh: When true, bypasses the page cache and fetches fresh pages from Meta. Rate-limited server-side to 1 refresh per 60s. Pages no longer accessible to the connected account will be removed from the list on refresh.""" - client = _get_client() - try: - response = client.connect.get_facebook_pages( - account_id=account_id, refresh=refresh - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update Facebook page", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_update_facebook_page(account_id: str, selected_page_id: str) -> str: - """Update Facebook page - - Args: - account_id: (required) - selected_page_id: (required)""" - client = _get_client() - try: - response = client.connect.update_facebook_page( - account_id=account_id, selected_page_id=selected_page_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List LinkedIn orgs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_linked_in_organizations(account_id: str) -> str: - """List LinkedIn orgs - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.connect.get_linked_in_organizations(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Switch LinkedIn account type", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_update_linked_in_organization( - account_id: str, - account_type: str, - selected_organization: dict[str, Any] | None = None, - ) -> str: - """Switch LinkedIn account type - - Args: - account_id: (required) - account_type: (required) - selected_organization""" - client = _get_client() - try: - response = client.connect.update_linked_in_organization( - account_id=account_id, - account_type=account_type, - selected_organization=selected_organization, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Pinterest boards", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_pinterest_boards(account_id: str) -> str: - """List Pinterest boards - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.connect.get_pinterest_boards(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set default Pinterest board", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_update_pinterest_boards( - account_id: str, default_board_id: str, default_board_name: str | None = None - ) -> str: - """Set default Pinterest board - - Args: - account_id: (required) - default_board_id: (required) - default_board_name""" - client = _get_client() - try: - response = client.connect.update_pinterest_boards( - account_id=account_id, - default_board_id=default_board_id, - default_board_name=default_board_name, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List YouTube playlists", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_youtube_playlists(account_id: str) -> str: - """List YouTube playlists - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.connect.get_youtube_playlists(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set default YouTube playlist", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_update_youtube_default_playlist( - account_id: str, - default_playlist_id: str, - default_playlist_name: str | None = None, - ) -> str: - """Set default YouTube playlist - - Args: - account_id: (required) - default_playlist_id: (required) - default_playlist_name""" - client = _get_client() - try: - response = client.connect.update_youtube_default_playlist( - account_id=account_id, - default_playlist_id=default_playlist_id, - default_playlist_name=default_playlist_name, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List GBP locations", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_gmb_locations( - account_id: str, search: str | None = None, filter: str | None = None - ) -> str: - """List GBP locations - - Args: - account_id: (required) - search: Free-text search on the business name, applied server-side by Google. Use for accounts with many locations. - filter: Raw Google Business Information API filter expression (advanced; takes precedence over search), e.g. storeCode="LH279411".""" - client = _get_client() - try: - response = client.connect.get_gmb_locations( - account_id=account_id, search=search, filter=filter - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update GBP location", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_update_gmb_location( - account_id: str, selected_location_id: str, google_account_id: str | None = None - ) -> str: - """Update GBP location - - Args: - account_id: (required) - selected_location_id: (required) - google_account_id: Optional but recommended. The Google Business Account resource name ("accounts/123") that owns the new location (from GET gmb-locations). When provided, the location is resolved directly instead of by enumerating the account, which is required for accounts with many locations. Named `googleAccountId` to disambiguate from the path `accountId` (the Zernio account). The legacy field name `accountId` is still accepted for backwards compatibility.""" - client = _get_client() - try: - response = client.connect.update_gmb_location( - account_id=account_id, - selected_location_id=selected_location_id, - google_account_id=google_account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Reddit subreddits", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_reddit_subreddits(account_id: str) -> str: - """List Reddit subreddits - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.connect.get_reddit_subreddits(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set default subreddit", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_update_reddit_subreddits( - account_id: str, default_subreddit: str - ) -> str: - """Set default subreddit - - Args: - account_id: (required) - default_subreddit: (required)""" - client = _get_client() - try: - response = client.connect.update_reddit_subreddits( - account_id=account_id, default_subreddit=default_subreddit - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get subreddit rules", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_subreddit_rules(account_id: str, subreddit: str) -> str: - """Get subreddit rules - - Args: - account_id: The ID of the Reddit account (required) - subreddit: Subreddit name (without the "r/" prefix) (required)""" - client = _get_client() - try: - response = client.connect.get_subreddit_rules( - account_id=account_id, subreddit=subreddit - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Vote on a Reddit post or comment", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_vote_reddit_thing( - account_id: str, thing_id: str, direction: int - ) -> str: - """Vote on a Reddit post or comment - - Args: - account_id: The ID of the Reddit account casting the vote (required) - thing_id: Reddit fullname of the target. Prefix "t3_" for a post and "t1_" for a comment. A bare id with no prefix is treated as a post ("t3_"). - (required) - direction: 1 to upvote, -1 to downvote, 0 to clear an existing vote (required)""" - client = _get_client() - try: - response = client.connect.vote_reddit_thing( - account_id=account_id, thing_id=thing_id, direction=direction - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List subreddit flairs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def connect_get_reddit_flairs(account_id: str, subreddit: str) -> str: - """List subreddit flairs - - Args: - account_id: (required) - subreddit: Subreddit name (without "r/" prefix) to fetch flairs for (required)""" - client = _get_client() - try: - response = client.connect.get_reddit_flairs( - account_id=account_id, subreddit=subreddit - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set Reddit post flair", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def connect_set_reddit_post_flair( - account_id: str, - subreddit: str, - post_id: str, - flair_template_id: str, - text: str | None = None, - ) -> str: - """Set Reddit post flair - - Args: - account_id: The ID of the Reddit account that owns the post (required) - subreddit: Subreddit name (without the "r/" prefix) (required) - post_id: Reddit post id, with or without the t3_ prefix (required) - flair_template_id: Flair template id from the GET on this path (required) - text: Optional override text, only for editable flair templates""" - client = _get_client() - try: - response = client.connect.set_reddit_post_flair( - account_id=account_id, - subreddit=subreddit, - post_id=post_id, - flair_template_id=flair_template_id, - text=text, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # CONTACTS - - @mcp.tool( - annotations=ToolAnnotations( - title="List contacts", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def contacts_list_contacts( - profile_id: str | None = None, - search: str | None = None, - tag: str | None = None, - platform: str | None = None, - is_subscribed: str | None = None, - limit: int = 50, - skip: int = 0, - ) -> str: - """List contacts - - Args: - profile_id: Filter by profile. Omit to list across all profiles - search - tag - platform - is_subscribed - limit - skip""" - client = _get_client() - try: - response = client.contacts.list_contacts( - profile_id=profile_id, - search=search, - tag=tag, - platform=platform, - is_subscribed=is_subscribed, - limit=limit, - skip=skip, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create contact", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def contacts_create_contact( - profile_id: str, - name: str, - email: str | None = None, - company: str | None = None, - tags: list[str] | None = None, - is_subscribed: bool = True, - notes: str | None = None, - account_id: str | None = None, - platform: str | None = None, - platform_identifier: str | None = None, - display_identifier: str | None = None, - ) -> str: - """Create contact - - Args: - profile_id: (required) - name: (required) - email - company - tags - is_subscribed - notes - account_id: Optional. Creates a channel if provided with platform + platformIdentifier - platform - platform_identifier - display_identifier""" - client = _get_client() - try: - response = client.contacts.create_contact( - profile_id=profile_id, - name=name, - email=email, - company=company, - tags=tags, - is_subscribed=is_subscribed, - notes=notes, - account_id=account_id, - platform=platform, - platform_identifier=platform_identifier, - display_identifier=display_identifier, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get contact", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def contacts_get_contact(contact_id: str) -> str: - """Get contact - - Args: - contact_id: (required)""" - client = _get_client() - try: - response = client.contacts.get_contact(contact_id=contact_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update contact", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def contacts_update_contact( - contact_id: str, - name: str | None = None, - email: str | None = None, - company: str | None = None, - avatar_url: str | None = None, - tags: list[str] | None = None, - is_subscribed: bool | None = None, - is_blocked: bool | None = None, - notes: str | None = None, - ) -> str: - """Update contact - - Args: - contact_id: (required) - name - email - company - avatar_url - tags - is_subscribed - is_blocked - notes""" - client = _get_client() - try: - response = client.contacts.update_contact( - contact_id=contact_id, - name=name, - email=email, - company=company, - avatar_url=avatar_url, - tags=tags, - is_subscribed=is_subscribed, - is_blocked=is_blocked, - notes=notes, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete contact", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def contacts_delete_contact(contact_id: str) -> str: - """Delete contact - - Args: - contact_id: (required)""" - client = _get_client() - try: - response = client.contacts.delete_contact(contact_id=contact_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List channels for a contact", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def contacts_get_contact_channels(contact_id: str) -> str: - """List channels for a contact - - Args: - contact_id: (required)""" - client = _get_client() - try: - response = client.contacts.get_contact_channels(contact_id=contact_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Bulk create contacts", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def contacts_bulk_create_contacts( - profile_id: str, - account_id: str, - platform: str, - contacts: list[dict[str, Any]] | None, - ) -> str: - """Bulk create contacts - - Args: - profile_id: (required) - account_id: (required) - platform: (required) - contacts: (required)""" - client = _get_client() - try: - response = client.contacts.bulk_create_contacts( - profile_id=profile_id, - account_id=account_id, - platform=platform, - contacts=contacts, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # CUSTOM_FIELDS - - @mcp.tool( - annotations=ToolAnnotations( - title="Set custom field value", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def custom_fields_set_contact_field_value( - contact_id: str, slug: str, value: str - ) -> str: - """Set custom field value - - Args: - contact_id: (required) - slug: (required) - value: Field value (type depends on field definition) (required)""" - client = _get_client() - try: - response = client.custom_fields.set_contact_field_value( - contact_id=contact_id, slug=slug, value=value - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Clear custom field value", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def custom_fields_clear_contact_field_value(contact_id: str, slug: str) -> str: - """Clear custom field value - - Args: - contact_id: (required) - slug: (required)""" - client = _get_client() - try: - response = client.custom_fields.clear_contact_field_value( - contact_id=contact_id, slug=slug - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List custom field definitions", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def custom_fields_list_custom_fields(profile_id: str | None = None) -> str: - """List custom field definitions - - Args: - profile_id: Filter by profile. Omit to list across all profiles""" - client = _get_client() - try: - response = client.custom_fields.list_custom_fields(profile_id=profile_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create custom field", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def custom_fields_create_custom_field( - profile_id: str, - name: str, - type: str, - slug: str | None = None, - options: list[str] | None = None, - ) -> str: - """Create custom field - - Args: - profile_id: (required) - name: (required) - slug: Auto-generated from name if not provided - type: (required) - options: Required for select type""" - client = _get_client() - try: - response = client.custom_fields.create_custom_field( - profile_id=profile_id, name=name, slug=slug, type=type, options=options - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update custom field", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def custom_fields_update_custom_field( - field_id: str, name: str | None = None, options: list[str] | None = None - ) -> str: - """Update custom field - - Args: - field_id: (required) - name - options""" - client = _get_client() - try: - response = client.custom_fields.update_custom_field( - field_id=field_id, name=name, options=options - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete custom field", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def custom_fields_delete_custom_field(field_id: str) -> str: - """Delete custom field - - Args: - field_id: (required)""" - client = _get_client() - try: - response = client.custom_fields.delete_custom_field(field_id=field_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # DISCORD - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Discord account settings", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def discord_get_discord_settings(account_id: str) -> str: - """Get Discord account settings - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.discord.get_discord_settings(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update Discord settings", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_update_discord_settings( - account_id: str, - webhook_username: str | None = None, - webhook_avatar_url: str | None = None, - channel_id: str | None = None, - ) -> str: - """Update Discord settings - - Args: - account_id: (required) - webhook_username: Custom display name for the webhook (1-80 chars). Empty string resets to default ("Zernio"). Cannot contain "clyde" or "discord". - webhook_avatar_url: Custom avatar URL. Empty string resets to default bot avatar. - channel_id: Switch to a different channel in the same guild. Must be a text (0), announcement (5), or forum (15) channel.""" - client = _get_client() - try: - response = client.discord.update_discord_settings( - account_id=account_id, - webhook_username=webhook_username, - webhook_avatar_url=webhook_avatar_url, - channel_id=channel_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Discord guild channels", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def discord_get_discord_channels(account_id: str) -> str: - """List Discord guild channels - - Args: - account_id: (required)""" - client = _get_client() - try: - response = client.discord.get_discord_channels(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send a Discord Direct Message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_send_discord_direct_message( - account_id: str, - user_id: str, - content: str | None = None, - embeds: list[dict[str, Any]] | None = None, - attachments: list[dict[str, Any]] | None = None, - tts: bool | None = None, - ) -> str: - """Send a Discord Direct Message - - Args: - account_id: SocialAccount _id of the connected Discord account the bot speaks as. Caller must own the account (directly or via team membership). (required) - user_id: Discord snowflake ID of the recipient (15-21 digits). (required) - content: Message text, up to 2,000 characters. - embeds: Up to 10 Discord embeds. Same shape as channel-post embeds (title, description, color, fields, etc.). See DiscordPlatformData.embeds for the embed object schema. - attachments: Up to 10 media attachments. Each is `{ type: image|video|gif|document, url, filename?, mimeType?, size? }`. - tts: Send as text-to-speech message.""" - client = _get_client() - try: - response = client.discord.send_discord_direct_message( - account_id=account_id, - user_id=user_id, - content=content, - embeds=embeds, - attachments=attachments, - tts=tts, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Discord guild roles", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def discord_list_discord_guild_roles(guild_id: str, account_id: str) -> str: - """List Discord guild roles - - Args: - guild_id: Discord guild snowflake ID (required) - account_id: SocialAccount _id of the Discord account bound to this guild (required)""" - client = _get_client() - try: - response = client.discord.list_discord_guild_roles( - guild_id=guild_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a Discord guild role", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_create_discord_guild_role( - guild_id: str, - account_id: str, - name: str, - color: int | None = None, - hoist: bool | None = None, - mentionable: bool | None = None, - permissions: str | None = None, - ) -> str: - """Create a Discord guild role - - Args: - guild_id: Discord guild snowflake ID (required) - account_id: SocialAccount _id of the Discord account bound to this guild (required) - name: (required) - color: Decimal color (0 = no color). 0xFF0000 red is 16711680. - hoist: Display members with this role separately in the member list - mentionable: Allow anyone to @mention this role - permissions: Permissions bitfield as a stringified integer""" - client = _get_client() - try: - response = client.discord.create_discord_guild_role( - guild_id=guild_id, - account_id=account_id, - name=name, - color=color, - hoist=hoist, - mentionable=mentionable, - permissions=permissions, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Edit a Discord guild role", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_edit_discord_guild_role( - guild_id: str, - role_id: str, - account_id: str, - name: str | None = None, - color: int | None = None, - hoist: bool | None = None, - mentionable: bool | None = None, - permissions: str | None = None, - ) -> str: - """Edit a Discord guild role - - Args: - guild_id: Discord guild snowflake ID (required) - role_id: Discord role snowflake ID (required) - account_id: SocialAccount _id of the Discord account bound to this guild (required) - name - color - hoist - mentionable - permissions: Permissions bitfield as a stringified integer""" - client = _get_client() - try: - response = client.discord.edit_discord_guild_role( - guild_id=guild_id, - role_id=role_id, - account_id=account_id, - name=name, - color=color, - hoist=hoist, - mentionable=mentionable, - permissions=permissions, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete a Discord guild role", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_delete_discord_guild_role( - guild_id: str, role_id: str, account_id: str - ) -> str: - """Delete a Discord guild role - - Args: - guild_id: Discord guild snowflake ID (required) - role_id: Discord role snowflake ID (required) - account_id: SocialAccount _id of the Discord account bound to this guild (required)""" - client = _get_client() - try: - response = client.discord.delete_discord_guild_role( - guild_id=guild_id, role_id=role_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Discord guild members", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def discord_list_discord_guild_members( - guild_id: str, account_id: str, limit: int = 100, after: str | None = None - ) -> str: - """List Discord guild members - - Args: - guild_id: (required) - account_id: (required) - limit: Page size (1-1000). - after: Snowflake of the last member from the previous page.""" - client = _get_client() - try: - response = client.discord.list_discord_guild_members( - guild_id=guild_id, account_id=account_id, limit=limit, after=after - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Assign a role to a guild member", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_add_discord_member_role( - guild_id: str, user_id: str, role_id: str, account_id: str - ) -> str: - """Assign a role to a guild member - - Args: - guild_id: (required) - user_id: Discord user snowflake to assign the role to. (required) - role_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.discord.add_discord_member_role( - guild_id=guild_id, - user_id=user_id, - role_id=role_id, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Remove a role from a guild member", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_remove_discord_member_role( - guild_id: str, user_id: str, role_id: str, account_id: str - ) -> str: - """Remove a role from a guild member - - Args: - guild_id: (required) - user_id: (required) - role_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.discord.remove_discord_member_role( - guild_id=guild_id, - user_id=user_id, - role_id=role_id, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete a Discord channel message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_delete_discord_message( - channel_id: str, message_id: str, account_id: str - ) -> str: - """Delete a Discord channel message - - Args: - channel_id: Discord channel snowflake ID (required) - message_id: Discord message snowflake ID (required) - account_id: SocialAccount _id of the Discord account bound to this channel's guild (required)""" - client = _get_client() - try: - response = client.discord.delete_discord_message( - channel_id=channel_id, message_id=message_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Crosspost Discord message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_crosspost_discord_message( - channel_id: str, message_id: str, account_id: str - ) -> str: - """Crosspost Discord message - - Args: - channel_id: Discord announcement channel snowflake ID (required) - message_id: Discord message snowflake ID (required) - account_id: SocialAccount _id of the Discord account bound to this channel's guild (required)""" - client = _get_client() - try: - response = client.discord.crosspost_discord_message( - channel_id=channel_id, message_id=message_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a Discord public thread", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_create_discord_thread( - channel_id: str, - account_id: str, - name: str, - message_id: str | None = None, - auto_archive_duration: int | None = None, - ) -> str: - """Create a Discord public thread - - Args: - channel_id: Discord channel snowflake ID (required) - account_id: SocialAccount _id of the Discord account bound to this channel's guild (required) - name: Thread name (required) - message_id: Optional message snowflake to start the thread from. Omit for a standalone thread. - auto_archive_duration: Minutes of inactivity before the thread auto-archives. Discord accepts only these four values.""" - client = _get_client() - try: - response = client.discord.create_discord_thread( - channel_id=channel_id, - account_id=account_id, - name=name, - message_id=message_id, - auto_archive_duration=auto_archive_duration, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List pinned messages", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def discord_list_discord_pinned_messages(channel_id: str, account_id: str) -> str: - """List pinned messages - - Args: - channel_id: Discord channel snowflake. (required) - account_id: SocialAccount _id of any Discord account in the same guild. (required)""" - client = _get_client() - try: - response = client.discord.list_discord_pinned_messages( - channel_id=channel_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pin a Discord message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_pin_discord_message( - channel_id: str, message_id: str, account_id: str - ) -> str: - """Pin a Discord message - - Args: - channel_id: (required) - message_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.discord.pin_discord_message( - channel_id=channel_id, message_id=message_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Unpin a Discord message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_unpin_discord_message( - channel_id: str, message_id: str, account_id: str - ) -> str: - """Unpin a Discord message - - Args: - channel_id: (required) - message_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.discord.unpin_discord_message( - channel_id=channel_id, message_id=message_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List Discord scheduled events", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def discord_list_discord_scheduled_events( - guild_id: str, account_id: str, with_user_count: bool | None = None - ) -> str: - """List Discord scheduled events - - Args: - guild_id: (required) - account_id: (required) - with_user_count: Include user_count on each event.""" - client = _get_client() - try: - response = client.discord.list_discord_scheduled_events( - guild_id=guild_id, - account_id=account_id, - with_user_count=with_user_count, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a Discord scheduled event", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_create_discord_scheduled_event( - guild_id: str, - account_id: str, - name: str, - starts_at: str, - entity: dict[str, Any] | None, - description: str | None = None, - image_data_uri: str | None = None, - ) -> str: - """Create a Discord scheduled event - - Args: - guild_id: (required) - account_id: (required) - name: (required) - description - starts_at: ISO 8601 start time. Must be in the future. (required) - entity: (required) - image_data_uri: Optional cover image as a base64 data URI.""" - client = _get_client() - try: - response = client.discord.create_discord_scheduled_event( - guild_id=guild_id, - account_id=account_id, - name=name, - description=description, - starts_at=starts_at, - entity=entity, - image_data_uri=image_data_uri, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a Discord scheduled event", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def discord_get_discord_scheduled_event( - guild_id: str, event_id: str, account_id: str - ) -> str: - """Get a Discord scheduled event - - Args: - guild_id: (required) - event_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.discord.get_discord_scheduled_event( - guild_id=guild_id, event_id=event_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update a Discord scheduled event", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_update_discord_scheduled_event( - guild_id: str, - event_id: str, - account_id: str, - name: str | None = None, - description: str | None = None, - starts_at: str | None = None, - ends_at: str | None = None, - location: str | None = None, - status: str | None = None, - image_data_uri: str | None = None, - ) -> str: - """Update a Discord scheduled event - - Args: - guild_id: (required) - event_id: (required) - account_id: (required) - name - description - starts_at - ends_at - location: For external events. - status: Status transition. Most common: 'cancelled' to cancel an event. - image_data_uri""" - client = _get_client() - try: - response = client.discord.update_discord_scheduled_event( - guild_id=guild_id, - event_id=event_id, - account_id=account_id, - name=name, - description=description, - starts_at=starts_at, - ends_at=ends_at, - location=location, - status=status, - image_data_uri=image_data_uri, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete a Discord scheduled event", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def discord_delete_discord_scheduled_event( - guild_id: str, event_id: str, account_id: str - ) -> str: - """Delete a Discord scheduled event - - Args: - guild_id: (required) - event_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.discord.delete_discord_scheduled_event( - guild_id=guild_id, event_id=event_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # GMB_SERVICES - - @mcp.tool( - annotations=ToolAnnotations( - title="Get services", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def gmb_services_get_google_business_services( - account_id: str, location_id: str | None = None - ) -> str: - """Get services - - Args: - account_id: (required) - location_id: Override which location to query. If omitted, uses the account's selected location.""" - client = _get_client() - try: - response = client.gmb_services.get_google_business_services( - account_id=account_id, location_id=location_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Replace services", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def gmb_services_update_google_business_services( - account_id: str, - service_items: list[dict[str, Any]] | None, - location_id: str | None = None, - ) -> str: - """Replace services - - Args: - account_id: (required) - location_id: Override which location to target. If omitted, uses the account's selected location. - service_items: (required)""" - client = _get_client() - try: - response = client.gmb_services.update_google_business_services( - account_id=account_id, - location_id=location_id, - service_items=service_items, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # GMB_VERIFICATIONS - - @mcp.tool( - annotations=ToolAnnotations( - title="Get verification state", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def gmb_verifications_get_google_business_verifications( - account_id: str, location_id: str | None = None - ) -> str: - """Get verification state - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - location_id: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.""" - client = _get_client() - try: - response = client.gmb_verifications.get_google_business_verifications( - account_id=account_id, location_id=location_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Start a verification", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def gmb_verifications_start_google_business_verification( - account_id: str, - method: str, - location_id: str | None = None, - language_code: str | None = None, - phone_number: str | None = None, - email_address: str | None = None, - mailer_contact: dict[str, Any] | None = None, - context: dict[str, Any] | None = None, - ) -> str: - """Start a verification - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - location_id: Override which location to target. If omitted, uses the account's selected location. - method: The verification method. Selects which method-specific field below is required. (required) - language_code - phone_number: For PHONE_CALL / SMS. - email_address: For EMAIL. - mailer_contact: For ADDRESS (postcard) verification. - context: ServiceBusinessContext (e.g. service address). Required for service-area businesses.""" - client = _get_client() - try: - response = client.gmb_verifications.start_google_business_verification( - account_id=account_id, - location_id=location_id, - method=method, - language_code=language_code, - phone_number=phone_number, - email_address=email_address, - mailer_contact=mailer_contact, - context=context, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Fetch verification options", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def gmb_verifications_fetch_google_business_verification_options( - account_id: str, - language_code: str, - location_id: str | None = None, - context: dict[str, Any] | None = None, - ) -> str: - """Fetch verification options - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - location_id: Override which location to query. If omitted, uses the account's selected location. - language_code: (required) - context: ServiceBusinessContext. Required for service-area businesses (must include the service address).""" - client = _get_client() - try: - response = ( - client.gmb_verifications.fetch_google_business_verification_options( - account_id=account_id, - location_id=location_id, - language_code=language_code, - context=context, - ) - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Complete a verification", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def gmb_verifications_complete_google_business_verification( - account_id: str, verification_id: str, pin: str, location_id: str | None = None - ) -> str: - """Complete a verification - - Args: - account_id: The Zernio account ID (from /v1/accounts) (required) - verification_id: The last segment of a verification `name` from GET /gmb-verifications. (required) - location_id: Override which location to target. If omitted, uses the account's selected location. - pin: The code Google sent to the business. (required)""" - client = _get_client() - try: - response = client.gmb_verifications.complete_google_business_verification( - account_id=account_id, - verification_id=verification_id, - location_id=location_id, - pin=pin, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # INBOX_ANALYTICS - - @mcp.tool( - annotations=ToolAnnotations( - title="Get inbox messaging volume", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def inbox_analytics_get_inbox_volume( - from_date: str, - to_date: str | None = None, - profile_id: str | None = None, - platform: str | None = None, - account_id: str | None = None, - source: str | None = None, - ) -> str: - """Get inbox messaging volume - - Args: - from_date: Inclusive lower bound (YYYY-MM-DD). Required. (required) - to_date: Inclusive upper bound (YYYY-MM-DD). Defaults to today. - profile_id - platform: Filter by single platform (facebook, instagram, twitter, etc.). - account_id - source: Filter by metadata.source lineage (human, workflow, sequence, broadcast, comment_automation, api, contact, platform).""" - client = _get_client() - try: - response = client.inbox_analytics.get_inbox_volume( - from_date=from_date, - to_date=to_date, - profile_id=profile_id, - platform=platform, - account_id=account_id, - source=source, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get day × hour heatmap", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def inbox_analytics_get_inbox_heatmap( - from_date: str, - to_date: str | None = None, - profile_id: str | None = None, - platform: str | None = None, - account_id: str | None = None, - source: str | None = None, - action: str | None = None, - ) -> str: - """Get day × hour heatmap - - Args: - from_date: (required) - to_date - profile_id - platform - account_id - source - action: Narrow to a single event type. "all" or omitted means no filter.""" - client = _get_client() - try: - response = client.inbox_analytics.get_inbox_heatmap( - from_date=from_date, - to_date=to_date, - profile_id=profile_id, - platform=platform, - account_id=account_id, - source=source, - action=action, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get inbox source breakdown", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def inbox_analytics_get_inbox_source_breakdown( - from_date: str, - to_date: str | None = None, - profile_id: str | None = None, - platform: str | None = None, - account_id: str | None = None, - ) -> str: - """Get inbox source breakdown - - Args: - from_date: (required) - to_date - profile_id - platform - account_id""" - client = _get_client() - try: - response = client.inbox_analytics.get_inbox_source_breakdown( - from_date=from_date, - to_date=to_date, - profile_id=profile_id, - platform=platform, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get inbox response-time stats", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def inbox_analytics_get_inbox_response_time( - from_date: str, - to_date: str | None = None, - profile_id: str | None = None, - platform: str | None = None, - account_id: str | None = None, - ) -> str: - """Get inbox response-time stats - - Args: - from_date: (required) - to_date - profile_id - platform - account_id""" - client = _get_client() - try: - response = client.inbox_analytics.get_inbox_response_time( - from_date=from_date, - to_date=to_date, - profile_id=profile_id, - platform=platform, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get top accounts by inbox volume", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def inbox_analytics_get_inbox_top_accounts( - from_date: str, - to_date: str | None = None, - profile_id: str | None = None, - platform: str | None = None, - source: str | None = None, - limit: int = 10, - ) -> str: - """Get top accounts by inbox volume - - Args: - from_date: (required) - to_date - profile_id - platform - source - limit: Cap on returned rows. Lower than the posting listing's 100 because each row triggers a SocialAccount Mongo lookup.""" - client = _get_client() - try: - response = client.inbox_analytics.get_inbox_top_accounts( - from_date=from_date, - to_date=to_date, - profile_id=profile_id, - platform=platform, - source=source, - limit=limit, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List conversation analytics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def inbox_analytics_list_inbox_conversation_analytics( - from_date: str, - to_date: str | None = None, - profile_id: str | None = None, - platform: str | None = None, - account_id: str | None = None, - source: str | None = None, - limit: int = 50, - page: int = 1, - sort_by: str = "lastMessageAt", - order: str = "desc", - ) -> str: - """List conversation analytics - - Args: - from_date: (required) - to_date - profile_id - platform - account_id - source - limit - page - sort_by - order""" - client = _get_client() - try: - response = client.inbox_analytics.list_inbox_conversation_analytics( - from_date=from_date, - to_date=to_date, - profile_id=profile_id, - platform=platform, - account_id=account_id, - source=source, - limit=limit, - page=page, - sort_by=sort_by, - order=order, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get conversation analytics", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def inbox_analytics_get_inbox_conversation_analytics( - conversation_id: str, from_date: str, to_date: str | None = None - ) -> str: - """Get conversation analytics - - Args: - conversation_id: Mongo _id or platformConversationId. (required) - from_date: (required) - to_date""" - client = _get_client() - try: - response = client.inbox_analytics.get_inbox_conversation_analytics( - conversation_id=conversation_id, from_date=from_date, to_date=to_date - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # INSTAGRAM - - @mcp.tool( - annotations=ToolAnnotations( - title="List active Instagram stories", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def instagram_list_instagram_stories(account_id: str) -> str: - """List active Instagram stories - - Args: - account_id: The Instagram account ID (required)""" - client = _get_client() - try: - response = client.instagram.list_instagram_stories(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Instagram publishing limit", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def instagram_get_instagram_publishing_limit(account_id: str) -> str: - """Get Instagram publishing limit - - Args: - account_id: The ID of the Instagram account (required)""" - client = _get_client() - try: - response = client.instagram.get_instagram_publishing_limit( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get Instagram story insights", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def instagram_get_instagram_story_insights(account_id: str, story_id: str) -> str: - """Get Instagram story insights - - Args: - account_id: The Instagram account ID (required) - story_id: The Instagram media ID of the story. (required)""" - client = _get_client() - try: - response = client.instagram.get_instagram_story_insights( - account_id=account_id, story_id=story_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # INVITES - - @mcp.tool( - annotations=ToolAnnotations( - title="Create invite token", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def invites_create_invite_token( - scope: str, - profile_ids: list[str] | None = None, - role: str = "member", - read_only: bool | None = None, - ) -> str: - """Create invite token - - Args: - scope: 'all' grants access to all profiles, 'profiles' restricts to specific profiles (required) - profile_ids: Required if scope is 'profiles'. Array of profile IDs to grant access to. - role: Org role granted to the invitee. Defaults to 'member'. 'admin' can manage the team (invite/remove members, change roles and access) and billing, but not ownership transfer or account deletion. 'billing_admin' (displayed as Billing Manager) manages billing only. 'viewer' creates a read-only member who can view everything in their profile scope but cannot perform any content mutation (publish, edit, delete, connect accounts). - read_only: Deprecated. Use role 'viewer' instead. When true, the invite is created with role 'viewer'. Cannot be combined with role 'billing_admin' or 'admin'.""" - client = _get_client() - try: - response = client.invites.create_invite_token( - scope=scope, profile_ids=profile_ids, role=role, read_only=read_only - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # LOGS - - @mcp.tool( - annotations=ToolAnnotations( - title="List activity logs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def logs_list_logs( - type: str = "publishing", - status: str | None = None, - platform: str | None = None, - action: str | None = None, - search: str | None = None, - days: int = 90, - limit: int = 50, - skip: int = 0, - account_id: str | None = None, - event: str | None = None, - request_id: str | None = None, - from_: str | None = None, - to: str | None = None, - status_code: int | None = None, - api_key_id: str | None = None, - include_read_receipts: bool = False, - ) -> str: - """List activity logs - - Args: - type: Log category to query. Use `all` for the unified view across every category, - or `api_request` for your API request logs (method, path, status, latency). - status: Filter by status - platform: Filter by platform - action: Filter by action (e.g., post.published, message.sent, account.connected, webhook.delivered) - search: Free-text search across log fields - days: Number of days to look back (max 90) - limit: Maximum number of logs to return (max 100) - skip: Number of logs to skip (for pagination) - account_id: Filter by connected account ID - event: Filter webhook logs by event (e.g. post.published, message.received) - request_id: Correlation ID — returns every log spawned by a single API request - from_: Precise start instant (ISO 8601); narrows within the day range - to: Precise end instant (ISO 8601) - status_code: Filter by exact HTTP status code (api_request logs) - api_key_id: Filter by the API key that made the request (api_request logs) - include_read_receipts: Include message.read / message.delivered events (hidden by default for messaging logs)""" - client = _get_client() - try: - response = client.logs.list_logs( - type=type, - status=status, - platform=platform, - action=action, - search=search, - days=days, - limit=limit, - skip=skip, - account_id=account_id, - event=event, - request_id=request_id, - from_=from_, - to=to, - status_code=status_code, - api_key_id=api_key_id, - include_read_receipts=include_read_receipts, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # MEDIA - - @mcp.tool( - annotations=ToolAnnotations( - title="Get upload URL", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def media_get_media_presigned_url( - filename: str, content_type: str, size: int | None = None - ) -> str: - """Get upload URL - - Args: - filename: Name of the file to upload (required) - content_type: MIME type of the file (required) - size: Optional file size in bytes for pre-validation (max 5GB)""" - client = _get_client() - try: - response = client.media.get_media_presigned_url( - filename=filename, content_type=content_type, size=size - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # MENTIONS - - @mcp.tool( - annotations=ToolAnnotations( - title="List mentions", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def mentions_list_inbox_mentions( - account_id: str | None = None, - profile_id: str | None = None, - sort_order: str = "desc", - limit: int = 25, - cursor: str | None = None, - ) -> str: - """List mentions - - Args: - account_id: Filter by social account ID - profile_id: Filter by profile ID - sort_order: Sort order by publishedAt - limit - cursor: Cursor for pagination (ID of the last item from the previous page)""" - client = _get_client() - try: - response = client.mentions.list_inbox_mentions( - account_id=account_id, - profile_id=profile_id, - sort_order=sort_order, - limit=limit, - cursor=cursor, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Reply to a mention", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def mentions_reply_to_mention( - account_id: str, media_id: str, message: str, comment_id: str | None = None - ) -> str: - """Reply to a mention - - Args: - account_id: The Instagram social account ID (required) - media_id: The ID of the media the account was mentioned in (required) - comment_id: The mentioning comment's ID. Omit for a caption mention. - message: The reply text (required)""" - client = _get_client() - try: - response = client.mentions.reply_to_mention( - account_id=account_id, - media_id=media_id, - comment_id=comment_id, - message=message, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # MESSAGES - - @mcp.tool( - annotations=ToolAnnotations( - title="List conversations", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def messages_list_inbox_conversations( - profile_id: str | None = None, - platform: str | None = None, - status: str | None = None, - sort_order: str = "desc", - limit: int = 50, - cursor: str | None = None, - account_id: str | None = None, - ) -> str: - """List conversations - - Args: - profile_id: Filter by profile ID - platform: Filter by platform - status: Filter by conversation status - sort_order: Sort order by updated time - limit: Maximum number of conversations to return - cursor: Pagination cursor for next page - account_id: Filter by specific social account ID""" - client = _get_client() - try: - response = client.messages.list_inbox_conversations( - profile_id=profile_id, - platform=platform, - status=status, - sort_order=sort_order, - limit=limit, - cursor=cursor, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create conversation", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_create_inbox_conversation( - account_id: str, - participant_id: str | None = None, - participant_username: str | None = None, - message: str | None = None, - skip_dm_check: bool = False, - template_name: str | None = None, - template_language: str | None = None, - template_params: list[str] | None = None, - header_media: dict[str, Any] | None = None, - ) -> str: - """Create conversation - - Args: - account_id: The social account ID to send from (required) - participant_id: Recipient identifier. For X this is the numeric user ID; for WhatsApp, the recipient phone number in international format (digits, country code included). Provide either this or participantUsername. - participant_username: Recipient handle/username — an X or Bluesky handle (with or without @) or a Reddit username (with or without u/). Resolved via lookup. Provide either this or participantId. - message: Text content of the message. At least one of message, attachment, or (for WhatsApp) templateName is required. - skip_dm_check: X/Twitter only. Skip the receives_your_dm eligibility check before sending. Use if you have already verified the recipient accepts DMs. - template_name: WhatsApp only. Name of the approved template to start the conversation with (required for WhatsApp). - template_language: WhatsApp only. Template language code (e.g. en_US). - template_params: WhatsApp only. Template variable values as one flat array, in the order the variables appear across the whole template: text-header variables first, then body variables, then one value per dynamic URL button (in button order). Works with positional placeholders ({{1}}, {{2}}, ...) and with named placeholders ({{name}}, {{company}} - how Meta Business Manager creates templates), where values fill the named slots in order of appearance. Example - a body with {{1}}, {{2}} plus a URL button https://example.com/{{1}} takes three values: [body1, body2, buttonSuffix]. Media headers (image, video, document) are filled automatically from the approved template and take no value here (use headerMedia to override the header asset per send). - header_media: WhatsApp only. Overrides a media-header template's header asset for THIS send, so a template with an image/video/document header can carry a different asset per message (e.g. each recipient their own invoice PDF). Without it, the template's approved sample asset is sent. Provide exactly one of link or id.""" - client = _get_client() - try: - response = client.messages.create_inbox_conversation( - account_id=account_id, - participant_id=participant_id, - participant_username=participant_username, - message=message, - skip_dm_check=skip_dm_check, - template_name=template_name, - template_language=template_language, - template_params=template_params, - header_media=header_media, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Search conversations", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def messages_search_inbox_conversations( - query: str, - direction: str | None = None, - profile_id: str | None = None, - platform: str | None = None, - account_id: str | None = None, - limit: int = 20, - cursor: str | None = None, - ) -> str: - """Search conversations - - Args: - query: Text to search for, in message content and in the contact's name, username, or phone number (required) - direction: Only match messages sent to you (incoming) or by you (outgoing). Contact-identity matching is not applied when this is set. - profile_id: Filter by profile ID - platform: Filter by platform (searchable platforms only) - account_id: Filter by specific social account ID - limit: Maximum number of conversations to return - cursor: Pagination cursor for next page""" - client = _get_client() - try: - response = client.messages.search_inbox_conversations( - query=query, - direction=direction, - profile_id=profile_id, - platform=platform, - account_id=account_id, - limit=limit, - cursor=cursor, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get conversation", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def messages_get_inbox_conversation(conversation_id: str, account_id: str) -> str: - """Get conversation - - Args: - conversation_id: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. (required) - account_id: The social account ID (required)""" - client = _get_client() - try: - response = client.messages.get_inbox_conversation( - conversation_id=conversation_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update conversation status", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_update_inbox_conversation( - conversation_id: str, account_id: str, status: str - ) -> str: - """Update conversation status - - Args: - conversation_id: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. (required) - account_id: Social account ID (required) - status: (required)""" - client = _get_client() - try: - response = client.messages.update_inbox_conversation( - conversation_id=conversation_id, account_id=account_id, status=status - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List messages", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def messages_get_inbox_conversation_messages( - conversation_id: str, - account_id: str, - limit: int = 100, - cursor: str | None = None, - sort_order: str = "asc", - ) -> str: - """List messages - - Args: - conversation_id: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. (required) - account_id: Social account ID (required) - limit: Number of messages to return per page. Default 100, max 100. - cursor: Opaque pagination cursor. Pass `pagination.nextCursor` from a prior response. - sort_order: Order of returned messages. Default `asc` (oldest first, chat style). - Twitter, Instagram, Telegram, WhatsApp and Reddit honor this order - across cursor pages. For Facebook and Bluesky, only intra-page - ordering is affected — pages always walk newest→oldest. See - `sortOrderApplied` in the response.""" - client = _get_client() - try: - response = client.messages.get_inbox_conversation_messages( - conversation_id=conversation_id, - account_id=account_id, - limit=limit, - cursor=cursor, - sort_order=sort_order, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_send_inbox_message( - conversation_id: str, - account_id: str, - message: str | None = None, - attachment_url: str | None = None, - attachment_type: str | None = None, - attachment_name: str | None = None, - voice_note: bool | None = None, - quick_replies: list[dict[str, Any]] | None = None, - buttons: list[dict[str, Any]] | None = None, - template: dict[str, Any] | None = None, - interactive: dict[str, Any] | None = None, - reply_markup: dict[str, Any] | None = None, - messaging_type: str | None = None, - message_tag: str | None = None, - reply_to: str | None = None, - location: dict[str, Any] | None = None, - contacts: list[dict[str, Any]] | None = None, - ) -> str: - """Send message - - Args: - conversation_id: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID. (required) - account_id: Social account ID (required) - message: Message text - attachment_url: URL of the attachment to send (image, video, audio, or file). The URL must be publicly accessible. For binary file uploads, use multipart/form-data instead. - attachment_type: Type of attachment. Defaults to file if not specified. - attachment_name: WhatsApp only. Display name for a document sent via attachmentUrl with attachmentType: file (e.g. "Report.pdf"). Maps to the recipient's file name; without it WhatsApp derives the name from the URL and shows "Untitled". Ignored for image/video/audio and for binary uploads (which use the uploaded file's name). - voice_note: WhatsApp only. When `true` on an audio attachment, the message is sent - as a voice message (PTT) — the recipient sees the waveform + voice-note - UI instead of a basic audio attachment. The audio file MUST be `.ogg` - encoded with the OPUS codec (mono) per Meta's voice-message contract; - other formats are rejected by WhatsApp. Ignored for non-audio attachments. - quick_replies: Quick reply buttons. Mutually exclusive with buttons. Max 13 items. - buttons: Action buttons. Mutually exclusive with quickReplies. Max 3 items. - template: Platform-dependent template payload. Ignored on Telegram. - - Instagram / Facebook: a generic template (carousel). Set `type: generic` - and provide up to 10 `elements`, each with a `title` (required) and - optional `subtitle`, `imageUrl`, and `buttons`. - - WhatsApp: sends an approved WhatsApp template message, the only message - type WhatsApp accepts when the 24-hour customer-service window is closed. - Provide exactly one element carrying the template reference: - `{ "elements": [{ "name": "order_update", "language": "en_US", "components": [...] }] }` - (`type` is ignored on WhatsApp). `components` is optional and is forwarded - unchanged as the `template.components` array of Meta's Cloud API send - payload; use it to fill body/header variables and button parameters, e.g. - `[{ "type": "body", "parameters": [{ "type": "text", "text": "John" }] }]`. - Templates with media headers (image, video, document) must include the - header component with its media link here at send time. To send a template - to a phone number with no existing conversation, or to have media headers - filled in automatically from the template definition, use the - create-conversation endpoint (POST /v1/inbox/conversations) instead. - interactive: WhatsApp-only. Rich interactive payload for list messages, CTA URL - buttons, Flow prompts, location requests, voice-call buttons, and - commerce messages (single product, product list, catalog, and - carousel). When set, takes priority over `buttons` and - `quickReplies`. The shape mirrors Meta's Cloud API `interactive` - object verbatim, so any payload that works against Meta directly - will also work here. - - Use `buttons` / `quickReplies` for simple button replies - (WhatsApp's `interactive.type: "button"`): the abstraction caps at - 3 buttons and handles the auto-conversion for you. Use this field - only for the types listed in the enum below. - - All interactive messages are session messages: they can only be - sent inside the 24-hour customer service window opened by the - user's last inbound message. - - Commerce types (`product`, `product_list`, `catalog_message`, and - product carousels) require a Meta catalog connected to the - WhatsApp Business Account in Commerce Manager. Media carousels - (image/video cards) do not need a catalog. - - For `product`, `body` is optional (WhatsApp renders the product - card itself) and `header` is not allowed (the product image is - the header). For `product_list`, a `header` with `type: "text"` - is required. For `carousel`, top-level `header`/`footer` are not - supported; media goes on each card instead. - - For `voice_call`, the message renders WhatsApp's native call - button; tapping it starts a voice call to your business number. - Requires WhatsApp Business Calling to be enabled on the sending - number. The optional `parameters.payload` string is echoed back on - the `calls` webhook (as `cta_payload`) for attribution. - - For `location_request_message`, `action` may be omitted (we default - it to `{ "name": "send_location" }`). WhatsApp renders a localized - "Send location" button; the user's reply arrives as a regular - location message in the conversation. - - For `catalog_message`, `action` may also be omitted (we default it - to `{ "name": "catalog_message" }`). - - Tap events come back via the `message.received` webhook with - `metadata.interactiveType` set to `list_reply` or `nfm_reply`. - Carts submitted from commerce messages arrive as `metadata.order`; - product inquiries arrive as `metadata.referredProduct`. - reply_markup: Telegram-native keyboard markup. Ignored on other platforms. - messaging_type: Facebook messaging type. Required when using messageTag. - message_tag: Facebook message tag for messaging outside 24h window. Requires messagingType MESSAGE_TAG. Instagram only supports HUMAN_AGENT. - reply_to: Platform message ID to quote-reply to. For WhatsApp, pass the wamid (available in message.platformMessageId from webhooks). For Telegram, pass the Telegram message ID. - location: WhatsApp-only. Send a location pin. - contacts: WhatsApp-only. Send one or more contact cards.""" - client = _get_client() - try: - response = client.messages.send_inbox_message( - conversation_id=conversation_id, - account_id=account_id, - message=message, - attachment_url=attachment_url, - attachment_type=attachment_type, - attachment_name=attachment_name, - voice_note=voice_note, - quick_replies=quick_replies, - buttons=buttons, - template=template, - interactive=interactive, - reply_markup=reply_markup, - messaging_type=messaging_type, - message_tag=message_tag, - reply_to=reply_to, - location=location, - contacts=contacts, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Edit message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_edit_inbox_message( - conversation_id: str, - message_id: str, - account_id: str, - text: str | None = None, - reply_markup: dict[str, Any] | None = None, - ) -> str: - """Edit message - - Args: - conversation_id: The conversation ID (required) - message_id: The Telegram message ID to edit (required) - account_id: Social account ID (required) - text: New message text - reply_markup: New inline keyboard markup""" - client = _get_client() - try: - response = client.messages.edit_inbox_message( - conversation_id=conversation_id, - message_id=message_id, - account_id=account_id, - text=text, - reply_markup=reply_markup, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_delete_inbox_message( - conversation_id: str, message_id: str, account_id: str - ) -> str: - """Delete message - - Args: - conversation_id: The conversation ID (required) - message_id: The platform message ID to delete (required) - account_id: Social account ID (required)""" - client = _get_client() - try: - response = client.messages.delete_inbox_message( - conversation_id=conversation_id, - message_id=message_id, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send typing indicator", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_send_typing_indicator(conversation_id: str, account_id: str) -> str: - """Send typing indicator - - Args: - conversation_id: The conversation ID (required) - account_id: Social account ID (required)""" - client = _get_client() - try: - response = client.messages.send_typing_indicator( - conversation_id=conversation_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Mark a conversation as read", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_mark_conversation_read(conversation_id: str, account_id: str) -> str: - """Mark a conversation as read - - Args: - conversation_id: The conversation ID (required) - account_id: Social account ID (required)""" - client = _get_client() - try: - response = client.messages.mark_conversation_read( - conversation_id=conversation_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Add reaction", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_add_message_reaction( - conversation_id: str, message_id: str, account_id: str, emoji: str - ) -> str: - """Add reaction - - Args: - conversation_id: The conversation ID (required) - message_id: The platform message ID to react to (required) - account_id: Social account ID (required) - emoji: Emoji character (e.g. "👍", "❤️") (required)""" - client = _get_client() - try: - response = client.messages.add_message_reaction( - conversation_id=conversation_id, - message_id=message_id, - account_id=account_id, - emoji=emoji, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Remove reaction", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_remove_message_reaction( - conversation_id: str, message_id: str, account_id: str - ) -> str: - """Remove reaction - - Args: - conversation_id: The conversation ID (required) - message_id: The platform message ID (required) - account_id: Social account ID (required)""" - client = _get_client() - try: - response = client.messages.remove_message_reaction( - conversation_id=conversation_id, - message_id=message_id, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Upload media file", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def messages_upload_media_direct() -> str: - """Upload media file""" - client = _get_client() - try: - response = client.messages.upload_media_direct() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # PHONE_NUMBERS - - @mcp.tool( - annotations=ToolAnnotations( - title="List phone numbers", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def phone_numbers_list_phone_numbers( - status: str | None = None, profile_id: str | None = None - ) -> str: - """List phone numbers - - Args: - status: Filter by status (by default excludes released numbers). NOTE: - `status=pending_regulatory` returns the "provisioning" view — numbers - still in review PLUS recently-declined (last 30 days) ones, so a - failed registration surfaces (with `regulatoryDeclineReason`) instead - of silently disappearing. Declined numbers can be re-submitted via - POST /v1/phone-numbers/{id}/remediate. `verifying` is the - short-lived state after the number is provisioned on our side while - WhatsApp confirms the activation code; the number is not billed until - it reaches `active`. - profile_id: Filter by profile""" - client = _get_client() - try: - response = client.phone_numbers.list_phone_numbers( - status=status, profile_id=profile_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get phone number", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def phone_numbers_get_phone_number(id: str) -> str: - """Get phone number - - Args: - id: Phone number record ID (required)""" - client = _get_client() - try: - response = client.phone_numbers.get_phone_number(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Release phone number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_release_phone_number(id: str) -> str: - """Release phone number - - Args: - id: Phone number record ID (required)""" - client = _get_client() - try: - response = client.phone_numbers.release_phone_number(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Purchase phone number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_purchase_phone_number( - profile_id: str, - country: str = "US", - connect_whatsapp: bool = True, - wants_sms: bool = False, - purchase_intent_id: str | None = None, - allow_multiple: bool = False, - ) -> str: - """Purchase phone number - - Args: - profile_id: Profile to associate the number with (required) - country: ISO 3166-1 alpha-2 country for the number (default US). International numbers require usage-based billing. Tier 3/4 countries return 202 { status: "kyc_required", kycUrl } — the customer must complete KYC at that URL before the number is ordered. See GET /v1/phone-numbers/countries. - connect_whatsapp: A phone number is the unit; WhatsApp is one optional feature. Pass false to buy a STANDALONE number (Calls/SMS only): provisioning skips the Meta pre-verify/OTP steps and the number activates immediately. Omitted defaults to the WhatsApp provisioning path. WhatsApp can be connected to a standalone number later from the connect flow. - wants_sms: SMS capability is per-number, not per-country. Pass true to provision from the SMS-capable inventory pool so the number can actually text (see also GET /v1/phone-numbers/available with sms=true, and smsAvailable on GET /v1/phone-numbers/countries). - purchase_intent_id: Optional idempotency key. Send the same value when retrying a purchase: if a number was already bought under this key, the API returns { status: "already_purchased", numberId, phoneNumber } instead of provisioning a second number. Generate a fresh key for each genuinely new purchase. - allow_multiple: Any second purchase within 10 minutes of a previous one is rejected with 409 code PURCHASE_VELOCITY as duplicate protection. Pass true to confirm the additional purchase is intentional (e.g. bulk provisioning).""" - client = _get_client() - try: - response = client.phone_numbers.purchase_phone_number( - profile_id=profile_id, - country=country, - connect_whatsapp=connect_whatsapp, - wants_sms=wants_sms, - purchase_intent_id=purchase_intent_id, - allow_multiple=allow_multiple, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List offerable number countries", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def phone_numbers_list_phone_number_countries() -> str: - """List offerable number countries""" - client = _get_client() - try: - response = client.phone_numbers.list_phone_number_countries() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Search available numbers", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def phone_numbers_search_available_phone_numbers( - country: str = "US", - type: str | None = None, - prefix: str | None = None, - locality: str | None = None, - contains: str | None = None, - sms: bool | None = None, - limit: int = 20, - ) -> str: - """Search available numbers - - Args: - country - type: Number type; defaults to the country's WhatsApp-safe type - prefix: Area code - locality: City - contains: Pattern to match within the number - sms: true narrows the pool to SMS-capable numbers. Each result still carries its full `features` list for per-number capability badging. - limit""" - client = _get_client() - try: - response = client.phone_numbers.search_available_phone_numbers( - country=country, - type=type, - prefix=prefix, - locality=locality, - contains=contains, - sms=sms, - limit=limit, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check country availability", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def phone_numbers_check_phone_number_availability(country: str) -> str: - """Check country availability - - Args: - country: ISO-2 country code. (required)""" - client = _get_client() - try: - response = client.phone_numbers.check_phone_number_availability( - country=country - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get KYC form spec", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def phone_numbers_get_phone_number_kyc_form(country: str) -> str: - """Get KYC form spec - - Args: - country: (required)""" - client = _get_client() - try: - response = client.phone_numbers.get_phone_number_kyc_form(country=country) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Submit KYC", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_submit_phone_number_kyc( - profile_id: str, - country: str, - submission_id: str | None = None, - quantity: int = 1, - reuse: bool | None = None, - reuse_from: str | None = None, - end_user_first_name: str | None = None, - end_user_last_name: str | None = None, - values: dict[str, Any] | None = None, - documents: list[dict[str, Any]] | None = None, - address: dict[str, Any] | None = None, - ) -> str: - """Submit KYC - - Args: - profile_id: (required) - country: (required) - submission_id: Idempotency token for this submission attempt. A retry/double-submit with the same token returns the same number; omit and each call creates a new number. - quantity: Provision several same-country numbers from one submission (1-5). The single verification covers all of them; each number is billed only when it activates. Numbers that fail to order are skipped (best-effort). - reuse: Reuse a prior approved verification for this country (skips document/field collection; places the order immediately). - reuse_from: Which approved verification to reuse when several exist: the phone number it was originally approved for (GET reusable.options[].fromPhoneNumber). Omitted = newest. No match = 409. - end_user_first_name: End user's legal first name. Required when the country has an action/ID-verification (Onfido) requirement. - end_user_last_name: End user's legal last name. Same condition as endUserFirstName. - values: requirementId → textual value - documents: One per document requirement. Each is EITHER inline base64 OR a `documentId` returned by POST /v1/phone-numbers/kyc/upload-document (use the upload endpoint for large files to stay under the request-size limit). - address""" - client = _get_client() - try: - response = client.phone_numbers.submit_phone_number_kyc( - profile_id=profile_id, - country=country, - submission_id=submission_id, - quantity=quantity, - reuse=reuse, - reuse_from=reuse_from, - end_user_first_name=end_user_first_name, - end_user_last_name=end_user_last_name, - values=values, - documents=documents, - address=address, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Upload a KYC document", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_upload_phone_number_kyc_document() -> str: - """Upload a KYC document""" - client = _get_client() - try: - response = client.phone_numbers.upload_phone_number_kyc_document() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pre-validate KYC address", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_validate_phone_number_kyc_address( - country: str, - street_address: str, - locality: str, - postal_code: str, - administrative_area: str | None = None, - ) -> str: - """Pre-validate KYC address - - Args: - country: ISO 3166-1 alpha-2 country code. (required) - street_address: (required) - locality: City / town. (required) - administrative_area: State / province / region. When omitted, the pre-check is skipped (the final submit still validates). - postal_code: (required)""" - client = _get_client() - try: - response = client.phone_numbers.validate_phone_number_kyc_address( - country=country, - street_address=street_address, - locality=locality, - administrative_area=administrative_area, - postal_code=postal_code, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a hosted KYC link", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_create_phone_number_kyc_link( - profile_id: str, - country: str, - branding: dict[str, Any] | None = None, - redirect_url: str | None = None, - ) -> str: - """Create a hosted KYC link - - Args: - profile_id: (required) - country: ISO 3166-1 alpha-2 country code (must be a regulated/KYC country). (required) - branding: Optional white-label of the hosted page the end customer sees. - redirect_url: Where to send the end customer's browser after a successful - submit. On completion Zernio appends `kyc=submitted` and - `country=` as query params. When omitted, the hosted - page shows a built-in confirmation screen instead.""" - client = _get_client() - try: - response = client.phone_numbers.create_phone_number_kyc_link( - profile_id=profile_id, - country=country, - branding=branding, - redirect_url=redirect_url, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Port numbers in", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_create_phone_number_port_in( - phone_numbers: list[str] | None, - end_user: dict[str, Any] | None, - loa_document_id: str, - invoice_document_id: str, - foc_datetime_requested: str | None = None, - customer_reference: str | None = None, - ) -> str: - """Port numbers in - - Args: - phone_numbers: E.164 numbers to port in. (required) - end_user: End-user / current-carrier account info that authorizes the port. (required) - loa_document_id: Document id from POST /v1/phone-numbers/port-in/documents (kind=loa). (required) - invoice_document_id: Document id from POST /v1/phone-numbers/port-in/documents (kind=invoice). (required) - foc_datetime_requested: Requested port date; the carrier confirms the actual FOC later. - customer_reference""" - client = _get_client() - try: - response = client.phone_numbers.create_phone_number_port_in( - phone_numbers=phone_numbers, - end_user=end_user, - loa_document_id=loa_document_id, - invoice_document_id=invoice_document_id, - foc_datetime_requested=foc_datetime_requested, - customer_reference=customer_reference, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List port-in orders", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def phone_numbers_list_phone_number_port_ins() -> str: - """List port-in orders""" - client = _get_client() - try: - response = client.phone_numbers.list_phone_number_port_ins() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check portability", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_check_phone_number_portability( - phone_numbers: list[str] | None, - ) -> str: - """Check portability - - Args: - phone_numbers: E.164 numbers to check, e.g. +13035550000. (required)""" - client = _get_client() - try: - response = client.phone_numbers.check_phone_number_portability( - phone_numbers=phone_numbers - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Upload a porting document", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_upload_phone_number_port_in_document() -> str: - """Upload a porting document""" - client = _get_client() - try: - response = client.phone_numbers.upload_phone_number_port_in_document() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Cancel a port-in", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_cancel_phone_number_port_in(id: str) -> str: - """Cancel a port-in - - Args: - id: Porting order ID (from the port-in list). (required)""" - client = _get_client() - try: - response = client.phone_numbers.cancel_phone_number_port_in(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pre-review a KYC packet", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_review_phone_number_kyc_packet( - country: str, - number_type: str, - docs: list[dict[str, Any]] | None, - values: dict[str, Any] | None = None, - address: dict[str, Any] | None = None, - ) -> str: - """Pre-review a KYC packet - - Args: - country: (required) - number_type: (required) - values: requirementId to declared textual value. - address: Declared address (street_address, locality, ...), so a mismatched proof-of-address can be flagged. - docs: (required)""" - client = _get_client() - try: - response = client.phone_numbers.review_phone_number_kyc_packet( - country=country, - number_type=number_type, - values=values, - address=address, - docs=docs, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get declined requirements", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def phone_numbers_get_phone_number_remediation(id: str) -> str: - """Get declined requirements - - Args: - id: Phone number record ID. (required)""" - client = _get_client() - try: - response = client.phone_numbers.get_phone_number_remediation(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Resubmit a declined number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def phone_numbers_remediate_phone_number( - id: str, - values: dict[str, Any] | None = None, - documents: list[dict[str, Any]] | None = None, - address: dict[str, Any] | None = None, - ) -> str: - """Resubmit a declined number - - Args: - id: (required) - values - documents - address: Same shape as the KYC submit address.""" - client = _get_client() - try: - response = client.phone_numbers.remediate_phone_number( - id=id, values=values, documents=documents, address=address - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # POSTS - - @mcp.tool( - annotations=ToolAnnotations( - title="List posts", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def posts_list_posts( - page: int = 1, - limit: int = 10, - source: str = "zernio", - status: str | None = None, - platform: str | None = None, - profile_id: str | None = None, - created_by: str | None = None, - date_from: str | None = None, - date_to: str | None = None, - include_hidden: bool = False, - search: str | None = None, - sort_by: str = "scheduled-desc", - account_id: str | None = None, - ) -> str: - """List posts - - Args: - page: Page number - limit: Results per page - source: Which collection to read. `zernio` (default) returns posts authored through Zernio. `external` returns posts synced from the platform (existing/historical posts that were published outside Zernio). Combine with `accountId` and paginate via `page`/`limit` to walk the full synced history (we keep up to the last ~12 months per account). - status - platform - profile_id - created_by - date_from - date_to - include_hidden - search: Search posts by text content. - sort_by: Sort order for results. - account_id: Filter posts to those published via a specific social account (24-char hex ObjectId).""" - client = _get_client() - try: - response = client.posts.list_posts( - page=page, - limit=limit, - source=source, - status=status, - platform=platform, - profile_id=profile_id, - created_by=created_by, - date_from=date_from, - date_to=date_to, - include_hidden=include_hidden, - search=search, - sort_by=sort_by, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create post", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def posts_create_post( - title: str | None = None, - content: str | None = None, - media_items: list[dict[str, Any]] | None = None, - platforms: list[dict[str, Any]] | None = None, - scheduled_for: str | None = None, - publish_now: bool = False, - is_draft: bool = False, - timezone: str = "UTC", - tags: list[str] | None = None, - hashtags: list[str] | None = None, - mentions: list[str] | None = None, - crossposting_enabled: bool = True, - metadata: dict[str, Any] | None = None, - tiktok_settings: dict[str, Any] | None = None, - facebook_settings: dict[str, Any] | None = None, - recycling: dict[str, Any] | None = None, - queued_from_profile: str | None = None, - queue_id: str | None = None, - ) -> str: - """Create post - - Args: - title - content: Post caption/text. Optional when media is attached or all platforms have customContent. Required for text-only posts. - media_items - platforms: Target platforms and accounts for this post. Required for non-draft posts (returns 400 if empty). Drafts can omit platforms. - scheduled_for - publish_now - is_draft: When true, saves the post as a draft. When none of scheduledFor, publishNow, or queuedFromProfile are provided, the post defaults to draft automatically. - timezone - tags: Tags/keywords. YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates auto-removed. - hashtags - mentions: Stored for reference only. This field does NOT automatically create @mentions when publishing. For LinkedIn @mentions, use the /v1/accounts/{accountId}/linkedin-mentions endpoint to resolve profile URLs to URNs, then embed the returned mentionFormat directly in the post content field. - crossposting_enabled - metadata - tiktok_settings: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - facebook_settings: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - recycling - queued_from_profile: Profile ID to schedule via queue. When provided without scheduledFor, the post is auto-assigned to the next available slot. Do not call /v1/queue/next-slot and use that time in scheduledFor, as that bypasses queue locking. - queue_id: Specific queue ID to use when scheduling via queue. - Only used when queuedFromProfile is also provided. - If omitted, uses the profile's default queue.""" - client = _get_client() - try: - response = client.posts.create_post( - title=title, - content=content, - media_items=media_items, - platforms=platforms, - scheduled_for=scheduled_for, - publish_now=publish_now, - is_draft=is_draft, - timezone=timezone, - tags=tags, - hashtags=hashtags, - mentions=mentions, - crossposting_enabled=crossposting_enabled, - metadata=metadata, - tiktok_settings=tiktok_settings, - facebook_settings=facebook_settings, - recycling=recycling, - queued_from_profile=queued_from_profile, - queue_id=queue_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get post", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def posts_get_post(post_id: str) -> str: - """Get post - - Args: - post_id: (required)""" - client = _get_client() - try: - response = client.posts.get_post(post_id=post_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update post", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def posts_update_post( - post_id: str, - title: str | None = None, - content: str | None = None, - media_items: list[dict[str, Any]] | None = None, - platforms: list[dict[str, Any]] | None = None, - scheduled_for: str | None = None, - publish_now: bool = False, - is_draft: bool | None = None, - timezone: str | None = None, - visibility: str | None = None, - tags: list[str] | None = None, - hashtags: list[str] | None = None, - mentions: list[str] | None = None, - crossposting_enabled: bool | None = None, - metadata: dict[str, Any] | None = None, - queued_from_profile: str | None = None, - queue_id: str | None = None, - tiktok_settings: dict[str, Any] | None = None, - facebook_settings: dict[str, Any] | None = None, - recycling: dict[str, Any] | None = None, - ) -> str: - """Update post - - Args: - post_id: (required) - title - content - media_items - platforms: Target platforms and accounts for this post. Each item must include platform and accountId. - scheduled_for - publish_now - is_draft: When omitted, the post keeps its current draft status. Send `false` to promote a draft to scheduled (combined with `scheduledFor`, `publishNow`, or a queue). - timezone - visibility - tags - hashtags - mentions - crossposting_enabled - metadata - queued_from_profile: Profile ID to schedule via queue. - queue_id: Specific queue ID to use when scheduling via queue. - tiktok_settings: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - facebook_settings: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence. - recycling""" - client = _get_client() - try: - response = client.posts.update_post( - post_id=post_id, - title=title, - content=content, - media_items=media_items, - platforms=platforms, - scheduled_for=scheduled_for, - publish_now=publish_now, - is_draft=is_draft, - timezone=timezone, - visibility=visibility, - tags=tags, - hashtags=hashtags, - mentions=mentions, - crossposting_enabled=crossposting_enabled, - metadata=metadata, - queued_from_profile=queued_from_profile, - queue_id=queue_id, - tiktok_settings=tiktok_settings, - facebook_settings=facebook_settings, - recycling=recycling, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete post", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def posts_delete_post(post_id: str) -> str: - """Delete post - - Args: - post_id: (required)""" - client = _get_client() - try: - response = client.posts.delete_post(post_id=post_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Bulk upload from CSV", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def posts_bulk_upload_posts(dry_run: bool = False) -> str: - """Bulk upload from CSV - - Args: - dry_run""" - client = _get_client() - try: - response = client.posts.bulk_upload_posts(dry_run=dry_run) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Unpublish post", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def posts_unpublish_post(post_id: str, platform: str) -> str: - """Unpublish post - - Args: - post_id: (required) - platform: The platform to delete the post from (required)""" - client = _get_client() - try: - response = client.posts.unpublish_post(post_id=post_id, platform=platform) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Edit published post", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def posts_edit_post(post_id: str, platform: str, content: str) -> str: - """Edit published post - - Args: - post_id: (required) - platform: The platform to edit the post on. (required) - content: The new post text content (required)""" - client = _get_client() - try: - response = client.posts.edit_post( - post_id=post_id, platform=platform, content=content - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update post metadata", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def posts_update_post_metadata( - post_id: str, - platform: str, - video_id: str | None = None, - account_id: str | None = None, - title: str | None = None, - description: str | None = None, - tags: list[str] | None = None, - category_id: str | None = None, - privacy_status: str | None = None, - thumbnail_url: str | None = None, - made_for_kids: bool | None = None, - contains_synthetic_media: bool | None = None, - playlist_id: str | None = None, - ) -> str: - """Update post metadata - - Args: - post_id: Zernio post ID, or "_" when using direct video ID mode (required) - platform: The platform to update metadata on (required) - video_id: YouTube video ID (required for direct mode, ignored for post-based mode) - account_id: Zernio social account ID (required for direct mode, ignored for post-based mode) - title: New video title (max 100 characters for YouTube) - description: New video description - tags: Array of keyword tags (max 500 characters combined for YouTube) - category_id: YouTube video category ID - privacy_status: Video privacy setting - thumbnail_url: Public URL of a custom thumbnail image (JPEG, PNG, or GIF, max 2 MB, recommended 1280x720). Works on any video you own, including existing videos not published through Zernio. The channel must be verified (phone verification) to set custom thumbnails. - made_for_kids: COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting). - contains_synthetic_media: AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label. - playlist_id: YouTube playlist ID to add the video to (e.g. 'PLxxxxxxxxxxxxx'). Use GET /v1/accounts/{id}/youtube-playlists to list available playlists. Only playlists owned by the channel are supported.""" - client = _get_client() - try: - response = client.posts.update_post_metadata( - post_id=post_id, - platform=platform, - video_id=video_id, - account_id=account_id, - title=title, - description=description, - tags=tags, - category_id=category_id, - privacy_status=privacy_status, - thumbnail_url=thumbnail_url, - made_for_kids=made_for_kids, - contains_synthetic_media=contains_synthetic_media, - playlist_id=playlist_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # PROFILES - - @mcp.tool( - annotations=ToolAnnotations( - title="List profiles", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def profiles_list_profiles(include_over_limit: bool = False) -> str: - """List profiles - - Args: - include_over_limit: When true, includes over-limit profiles (marked with isOverLimit: true).""" - client = _get_client() - try: - response = client.profiles.list_profiles( - include_over_limit=include_over_limit - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create profile", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def profiles_create_profile( - name: str, description: str | None = None, color: str | None = None - ) -> str: - """Create profile - - Args: - name: (required) - description - color""" - client = _get_client() - try: - response = client.profiles.create_profile( - name=name, description=description, color=color - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get profile", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def profiles_get_profile(profile_id: str) -> str: - """Get profile - - Args: - profile_id: (required)""" - client = _get_client() - try: - response = client.profiles.get_profile(profile_id=profile_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update profile", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def profiles_update_profile( - profile_id: str, - name: str | None = None, - description: str | None = None, - color: str | None = None, - is_default: bool | None = None, - ) -> str: - """Update profile - - Args: - profile_id: (required) - name - description - color - is_default""" - client = _get_client() - try: - response = client.profiles.update_profile( - profile_id=profile_id, - name=name, - description=description, - color=color, - is_default=is_default, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete profile", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def profiles_delete_profile(profile_id: str) -> str: - """Delete profile - - Args: - profile_id: (required)""" - client = _get_client() - try: - response = client.profiles.delete_profile(profile_id=profile_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # QUEUE - - @mcp.tool( - annotations=ToolAnnotations( - title="List schedules", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def queue_list_queue_slots( - profile_id: str, queue_id: str | None = None, all: str | None = None - ) -> str: - """List schedules - - Args: - profile_id: Profile ID to get queues for (required) - queue_id: Specific queue ID to retrieve (optional) - all: Set to 'true' to list all queues for the profile""" - client = _get_client() - try: - response = client.queue.list_queue_slots( - profile_id=profile_id, queue_id=queue_id, all=all - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create schedule", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def queue_create_queue_slot( - profile_id: str, - name: str, - timezone: str, - slots: list[dict[str, Any]] | None, - active: bool = True, - ) -> str: - """Create schedule - - Args: - profile_id: Profile ID (required) - name: Queue name (e.g., Evening Posts) (required) - timezone: IANA timezone (required) - slots: (required) - active""" - client = _get_client() - try: - response = client.queue.create_queue_slot( - profile_id=profile_id, - name=name, - timezone=timezone, - slots=slots, - active=active, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update schedule", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def queue_update_queue_slot( - profile_id: str, - timezone: str, - slots: list[dict[str, Any]] | None, - queue_id: str | None = None, - name: str | None = None, - active: bool = True, - set_as_default: bool | None = None, - reshuffle_existing: bool = False, - ) -> str: - """Update schedule - - Args: - profile_id: (required) - queue_id: Queue ID to update (optional) - name: Queue name - timezone: (required) - slots: (required) - active - set_as_default: Make this queue the default - reshuffle_existing: Whether to reschedule existing queued posts to match new slots""" - client = _get_client() - try: - response = client.queue.update_queue_slot( - profile_id=profile_id, - queue_id=queue_id, - name=name, - timezone=timezone, - slots=slots, - active=active, - set_as_default=set_as_default, - reshuffle_existing=reshuffle_existing, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete schedule", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def queue_delete_queue_slot(profile_id: str, queue_id: str) -> str: - """Delete schedule - - Args: - profile_id: (required) - queue_id: Queue ID to delete (required)""" - client = _get_client() - try: - response = client.queue.delete_queue_slot( - profile_id=profile_id, queue_id=queue_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Preview upcoming slots", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def queue_preview_queue( - profile_id: str, queue_id: str | None = None, count: int = 20 - ) -> str: - """Preview upcoming slots - - Args: - profile_id: (required) - queue_id: Filter by specific queue ID. Omit to use the default queue. - count""" - client = _get_client() - try: - response = client.queue.preview_queue( - profile_id=profile_id, queue_id=queue_id, count=count - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get next available slot", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def queue_get_next_queue_slot(profile_id: str, queue_id: str | None = None) -> str: - """Get next available slot - - Args: - profile_id: (required) - queue_id: Specific queue ID (optional, defaults to profile's default queue)""" - client = _get_client() - try: - response = client.queue.get_next_queue_slot( - profile_id=profile_id, queue_id=queue_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # REDDIT - - @mcp.tool( - annotations=ToolAnnotations( - title="Search posts", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def reddit_search_reddit( - account_id: str, - q: str, - subreddit: str | None = None, - restrict_sr: str | None = None, - sort: str = "new", - limit: int = 25, - after: str | None = None, - ) -> str: - """Search posts - - Args: - account_id: (required) - subreddit - q: (required) - restrict_sr - sort - limit - after""" - client = _get_client() - try: - response = client.reddit.search_reddit( - account_id=account_id, - subreddit=subreddit, - q=q, - restrict_sr=restrict_sr, - sort=sort, - limit=limit, - after=after, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get subreddit feed", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def reddit_get_reddit_feed( - account_id: str, - subreddit: str | None = None, - sort: str = "hot", - limit: int = 25, - after: str | None = None, - t: str | None = None, - ) -> str: - """Get subreddit feed - - Args: - account_id: (required) - subreddit - sort - limit - after - t""" - client = _get_client() - try: - response = client.reddit.get_reddit_feed( - account_id=account_id, - subreddit=subreddit, - sort=sort, - limit=limit, - after=after, - t=t, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # REVIEWS - - @mcp.tool( - annotations=ToolAnnotations( - title="List reviews", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def reviews_list_inbox_reviews( - profile_id: str | None = None, - platform: str | None = None, - min_rating: int | None = None, - max_rating: int | None = None, - has_reply: bool | None = None, - sort_by: str = "date", - sort_order: str = "desc", - limit: int = 25, - cursor: str | None = None, - account_id: str | None = None, - ) -> str: - """List reviews - - Args: - profile_id - platform - min_rating - max_rating - has_reply: Filter by reply status - sort_by - sort_order - limit - cursor - account_id: Filter by specific social account ID""" - client = _get_client() - try: - response = client.reviews.list_inbox_reviews( - profile_id=profile_id, - platform=platform, - min_rating=min_rating, - max_rating=max_rating, - has_reply=has_reply, - sort_by=sort_by, - sort_order=sort_order, - limit=limit, - cursor=cursor, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Reply to review", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def reviews_reply_to_inbox_review( - review_id: str, account_id: str, message: str - ) -> str: - """Reply to review - - Args: - review_id: Review ID (URL-encoded for Google Business) (required) - account_id: (required) - message: (required)""" - client = _get_client() - try: - response = client.reviews.reply_to_inbox_review( - review_id=review_id, account_id=account_id, message=message - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete review reply", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def reviews_delete_inbox_review_reply(review_id: str, account_id: str) -> str: - """Delete review reply - - Args: - review_id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.reviews.delete_inbox_review_reply( - review_id=review_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # SEQUENCES - - @mcp.tool( - annotations=ToolAnnotations( - title="List sequences", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def sequences_list_sequences( - profile_id: str | None = None, - status: str | None = None, - limit: int = 50, - skip: int = 0, - ) -> str: - """List sequences - - Args: - profile_id: Filter by profile. Omit to list across all profiles - status - limit - skip""" - client = _get_client() - try: - response = client.sequences.list_sequences( - profile_id=profile_id, status=status, limit=limit, skip=skip - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create sequence", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sequences_create_sequence( - profile_id: str, - account_id: str, - platform: str, - name: str, - description: str | None = None, - steps: list[dict[str, Any]] | None = None, - exit_on_reply: bool = True, - exit_on_unsubscribe: bool = True, - ) -> str: - """Create sequence - - Args: - profile_id: (required) - account_id: (required) - platform: (required) - name: (required) - description - steps - exit_on_reply - exit_on_unsubscribe""" - client = _get_client() - try: - response = client.sequences.create_sequence( - profile_id=profile_id, - account_id=account_id, - platform=platform, - name=name, - description=description, - steps=steps, - exit_on_reply=exit_on_reply, - exit_on_unsubscribe=exit_on_unsubscribe, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get sequence with steps", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def sequences_get_sequence(sequence_id: str) -> str: - """Get sequence with steps - - Args: - sequence_id: (required)""" - client = _get_client() - try: - response = client.sequences.get_sequence(sequence_id=sequence_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update sequence", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sequences_update_sequence( - sequence_id: str, - name: str | None = None, - description: str | None = None, - steps: list[dict[str, Any]] | None = None, - exit_on_reply: bool | None = None, - exit_on_unsubscribe: bool | None = None, - ) -> str: - """Update sequence - - Args: - sequence_id: (required) - name - description - steps: Replace the full step list. Only allowed while the sequence is draft or paused. - exit_on_reply - exit_on_unsubscribe""" - client = _get_client() - try: - response = client.sequences.update_sequence( - sequence_id=sequence_id, - name=name, - description=description, - steps=steps, - exit_on_reply=exit_on_reply, - exit_on_unsubscribe=exit_on_unsubscribe, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete sequence", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sequences_delete_sequence(sequence_id: str) -> str: - """Delete sequence - - Args: - sequence_id: (required)""" - client = _get_client() - try: - response = client.sequences.delete_sequence(sequence_id=sequence_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Activate sequence", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sequences_activate_sequence(sequence_id: str) -> str: - """Activate sequence - - Args: - sequence_id: (required)""" - client = _get_client() - try: - response = client.sequences.activate_sequence(sequence_id=sequence_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pause sequence", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sequences_pause_sequence(sequence_id: str) -> str: - """Pause sequence - - Args: - sequence_id: (required)""" - client = _get_client() - try: - response = client.sequences.pause_sequence(sequence_id=sequence_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Enroll contacts in a sequence", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sequences_enroll_contacts( - sequence_id: str, - contact_ids: list[str] | None, - channel_ids: list[str] | None = None, - ) -> str: - """Enroll contacts in a sequence - - Args: - sequence_id: (required) - contact_ids: (required) - channel_ids: Optional. Auto-detected if not provided.""" - client = _get_client() - try: - response = client.sequences.enroll_contacts( - sequence_id=sequence_id, - contact_ids=contact_ids, - channel_ids=channel_ids, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Unenroll contact", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sequences_unenroll_contact(sequence_id: str, contact_id: str) -> str: - """Unenroll contact - - Args: - sequence_id: (required) - contact_id: (required)""" - client = _get_client() - try: - response = client.sequences.unenroll_contact( - sequence_id=sequence_id, contact_id=contact_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List enrollments for a sequence", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def sequences_list_sequence_enrollments( - sequence_id: str, status: str | None = None, limit: int = 50, skip: int = 0 - ) -> str: - """List enrollments for a sequence - - Args: - sequence_id: (required) - status - limit - skip""" - client = _get_client() - try: - response = client.sequences.list_sequence_enrollments( - sequence_id=sequence_id, status=status, limit=limit, skip=skip - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # SMS - - @mcp.tool( - annotations=ToolAnnotations( - title="Send an SMS/MMS", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sms_send_sms( - from_: str, - to: str, - text: str | None = None, - media_urls: list[str] | None = None, - ) -> str: - """Send an SMS/MMS - - Args: - from_: One of your SMS-enabled numbers (E.164; formatting is normalized). (required) - to: Recipient number (E.164). (required) - text: Message body. Required unless `mediaUrls` is set. Max 10 SMS segments (1530 GSM-7 or 670 unicode characters). - media_urls: Public media URLs to attach (sends as MMS). Max 10.""" - client = _get_client() - try: - response = client.sms.send_sms( - from_=from_, to=to, text=text, media_urls=media_urls - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Look up carrier + line type", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def sms_lookup_sms_number(number: str) -> str: - """Look up carrier + line type - - Args: - number: Number to look up (E.164; formatting is normalized). (required)""" - client = _get_client() - try: - response = client.sms.lookup_sms_number(number=number) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List SMS opt-outs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def sms_list_sms_opt_outs(format: str = "json", limit: int = 500) -> str: - """List SMS opt-outs - - Args: - format - limit""" - client = _get_client() - try: - response = client.sms.list_sms_opt_outs(format=format, limit=limit) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Start a carrier registration", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sms_start_sms_registration( - registration_type: str, - phone_numbers: list[str] | None, - brand: dict[str, Any] | None = None, - campaign: dict[str, Any] | None = None, - toll_free: dict[str, Any] | None = None, - ) -> str: - """Start a carrier registration - - Args: - registration_type: (required) - phone_numbers: Your numbers this registration covers. (required) - brand: Required for 10DLC. The legal entity behind the traffic (TCR brand). - campaign: Required for 10DLC. What you'll send and how recipients opt in/out. - Opt-in/opt-out/help auto-responses must name the registered brand and - carry the carrier-required disclosures; submissions that don't (or that - are blank) are automatically rewritten to a compliant, brand-named - template before the campaign is filed. - toll_free: Required for toll_free.""" - client = _get_client() - try: - response = client.sms.start_sms_registration( - registration_type=registration_type, - phone_numbers=phone_numbers, - brand=brand, - campaign=campaign, - toll_free=toll_free, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List carrier registrations", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def sms_list_sms_registrations() -> str: - """List carrier registrations""" - client = _get_client() - try: - response = client.sms.list_sms_registrations() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a carrier registration", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def sms_get_sms_registration(id: str) -> str: - """Get a carrier registration - - Args: - id: (required)""" - client = _get_client() - try: - response = client.sms.get_sms_registration(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Submit the sole-prop OTP", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sms_verify_sms_registration_otp(id: str, otp_pin: str) -> str: - """Submit the sole-prop OTP - - Args: - id: (required) - otp_pin: (required)""" - client = _get_client() - try: - response = client.sms.verify_sms_registration_otp(id=id, otp_pin=otp_pin) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Appeal a rejected campaign", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sms_appeal_sms_registration( - id: str, - appeal_reason: str, - message_flow: str | None = None, - sample1: str | None = None, - sample2: str | None = None, - ) -> str: - """Appeal a rejected campaign - - Args: - id: (required) - appeal_reason: Goes verbatim to the carrier reviewer — address the decline reason directly. (required) - message_flow: Corrected opt-in flow; include a link to the opt-in page/form. - sample1 - sample2""" - client = _get_client() - try: - response = client.sms.appeal_sms_registration( - id=id, - appeal_reason=appeal_reason, - message_flow=message_flow, - sample1=sample1, - sample2=sample2, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a registration share link", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sms_share_sms_registration(number_id: str) -> str: - """Create a registration share link - - Args: - number_id: Your phone number's ID (from GET /v1/phone-numbers). (required)""" - client = _get_client() - try: - response = client.sms.share_sms_registration(number_id=number_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Enable SMS on a number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sms_enable_sms_on_number(id: str) -> str: - """Enable SMS on a number - - Args: - id: Phone number record ID (from GET /v1/phone-numbers). (required)""" - client = _get_client() - try: - response = client.sms.enable_sms_on_number(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Disable SMS on a number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sms_disable_sms_on_number(id: str) -> str: - """Disable SMS on a number - - Args: - id: (required)""" - client = _get_client() - try: - response = client.sms.disable_sms_on_number(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Add number to SMS registration", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def sms_reuse_sms_registration_for_number(id: str) -> str: - """Add number to SMS registration - - Args: - id: (required)""" - client = _get_client() - try: - response = client.sms.reuse_sms_registration_for_number(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # TRACKING_TAGS - - @mcp.tool( - annotations=ToolAnnotations( - title="List tracking tags", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def tracking_tags_list_tracking_tags( - account_id: str, ad_account_id: str | None = None - ) -> str: - """List tracking tags - - Args: - account_id: Meta ads SocialAccount id (platform `metaads`). (required) - ad_account_id: Optional. Scope to one ad account, e.g. `act_123456789`.""" - client = _get_client() - try: - response = client.tracking_tags.list_tracking_tags( - account_id=account_id, ad_account_id=ad_account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a tracking tag", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def tracking_tags_create_tracking_tag( - account_id: str, ad_account_id: str, name: str - ) -> str: - """Create a tracking tag - - Args: - account_id: Meta ads SocialAccount id (platform `metaads`). (required) - ad_account_id: Meta ad account id, e.g. `act_123456789`. (required) - name: (required)""" - client = _get_client() - try: - response = client.tracking_tags.create_tracking_tag( - account_id=account_id, ad_account_id=ad_account_id, name=name - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a tracking tag", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def tracking_tags_get_tracking_tag(account_id: str, tag_id: str) -> str: - """Get a tracking tag - - Args: - account_id: (required) - tag_id: Pixel id. (required)""" - client = _get_client() - try: - response = client.tracking_tags.get_tracking_tag( - account_id=account_id, tag_id=tag_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update a tracking tag", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def tracking_tags_update_tracking_tag( - account_id: str, - tag_id: str, - name: str | None = None, - enable_automatic_matching: bool | None = None, - automatic_matching_fields: list[str] | None = None, - first_party_cookie_status: str | None = None, - data_use_setting: str | None = None, - ) -> str: - """Update a tracking tag - - Args: - account_id: (required) - tag_id: Pixel id. (required) - name - enable_automatic_matching: Meta Advanced Matching toggle (`enable_automatic_matching`). - automatic_matching_fields: Which user fields Advanced Matching may collect. Meta's - terse codes: em=email, ph=phone, fn=first name, ln=last - name, ge=gender, db=date of birth, ct=city, st=state, - zp=zip. - first_party_cookie_status - data_use_setting""" - client = _get_client() - try: - response = client.tracking_tags.update_tracking_tag( - account_id=account_id, - tag_id=tag_id, - name=name, - enable_automatic_matching=enable_automatic_matching, - automatic_matching_fields=automatic_matching_fields, - first_party_cookie_status=first_party_cookie_status, - data_use_setting=data_use_setting, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List accounts it is shared with", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def tracking_tags_list_tracking_tag_shared_accounts( - account_id: str, tag_id: str - ) -> str: - """List accounts it is shared with - - Args: - account_id: (required) - tag_id: Pixel id. (required)""" - client = _get_client() - try: - response = client.tracking_tags.list_tracking_tag_shared_accounts( - account_id=account_id, tag_id=tag_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Share with an ad account", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def tracking_tags_add_tracking_tag_shared_account( - account_id: str, tag_id: str, ad_account_id: str - ) -> str: - """Share with an ad account - - Args: - account_id: (required) - tag_id: Pixel id. (required) - ad_account_id: Ad account to share with, e.g. `act_123456789`. (required)""" - client = _get_client() - try: - response = client.tracking_tags.add_tracking_tag_shared_account( - account_id=account_id, tag_id=tag_id, ad_account_id=ad_account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Stop sharing with an account", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def tracking_tags_remove_tracking_tag_shared_account( - account_id: str, tag_id: str, ad_account_id: str | None = None - ) -> str: - """Stop sharing with an account - - Args: - account_id: (required) - tag_id: Pixel id. (required) - ad_account_id: Ad account to unshare, e.g. `act_123456789`. May also be sent in the JSON body.""" - client = _get_client() - try: - response = client.tracking_tags.remove_tracking_tag_shared_account( - account_id=account_id, tag_id=tag_id, ad_account_id=ad_account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get aggregated event stats", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def tracking_tags_get_tracking_tag_stats( - account_id: str, - tag_id: str, - aggregation: str = "event", - start_time: int | None = None, - end_time: int | None = None, - ) -> str: - """Get aggregated event stats - - Args: - account_id: (required) - tag_id: Pixel id. (required) - aggregation: Aggregation dimension. Defaults to `event`. - start_time: Unix seconds lower bound. - end_time: Unix seconds upper bound.""" - client = _get_client() - try: - response = client.tracking_tags.get_tracking_tag_stats( - account_id=account_id, - tag_id=tag_id, - aggregation=aggregation, - start_time=start_time, - end_time=end_time, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # TWITTER_ENGAGEMENT - - @mcp.tool( - annotations=ToolAnnotations( - title="Retweet a post", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def twitter_engagement_retweet_post(account_id: str, tweet_id: str) -> str: - """Retweet a post - - Args: - account_id: The social account ID (required) - tweet_id: The ID of the tweet to retweet (required)""" - client = _get_client() - try: - response = client.twitter_engagement.retweet_post( - account_id=account_id, tweet_id=tweet_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Undo retweet", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def twitter_engagement_undo_retweet(account_id: str, tweet_id: str) -> str: - """Undo retweet - - Args: - account_id: (required) - tweet_id: The ID of the original tweet to un-retweet (required)""" - client = _get_client() - try: - response = client.twitter_engagement.undo_retweet( - account_id=account_id, tweet_id=tweet_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Bookmark a tweet", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def twitter_engagement_bookmark_post(account_id: str, tweet_id: str) -> str: - """Bookmark a tweet - - Args: - account_id: The social account ID (required) - tweet_id: The ID of the tweet to bookmark (required)""" - client = _get_client() - try: - response = client.twitter_engagement.bookmark_post( - account_id=account_id, tweet_id=tweet_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Remove bookmark", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def twitter_engagement_remove_bookmark(account_id: str, tweet_id: str) -> str: - """Remove bookmark - - Args: - account_id: (required) - tweet_id: The ID of the tweet to unbookmark (required)""" - client = _get_client() - try: - response = client.twitter_engagement.remove_bookmark( - account_id=account_id, tweet_id=tweet_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Follow a user", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def twitter_engagement_follow_user(account_id: str, target_user_id: str) -> str: - """Follow a user - - Args: - account_id: The social account ID (required) - target_user_id: The Twitter ID of the user to follow (required)""" - client = _get_client() - try: - response = client.twitter_engagement.follow_user( - account_id=account_id, target_user_id=target_user_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Unfollow a user", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def twitter_engagement_unfollow_user(account_id: str, target_user_id: str) -> str: - """Unfollow a user - - Args: - account_id: (required) - target_user_id: The Twitter ID of the user to unfollow (required)""" - client = _get_client() - try: - response = client.twitter_engagement.unfollow_user( - account_id=account_id, target_user_id=target_user_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # USAGE - - @mcp.tool( - annotations=ToolAnnotations( - title="Account billing snapshot (plan, cycle, balance, caps, status)", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def usage_get_billing() -> str: - """Account billing snapshot (plan, cycle, balance, caps, status)""" - client = _get_client() - try: - response = client.usage.get_billing() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get X/Twitter API pricing table", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def usage_get_x_api_pricing() -> str: - """Get X/Twitter API pricing table""" - client = _get_client() - try: - response = client.usage.get_x_api_pricing() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Usage snapshot (default) or billed-spend metering (with params)", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def usage_get_usage( - reconcile: bool | None = None, - range: str = "cycle", - from_: str | None = None, - to: str | None = None, - granularity: str = "day", - ) -> str: - """Usage snapshot (default) or billed-spend metering (with params) - - Args: - reconcile: Snapshot mode only. For Stripe subscription users, `true` forces a - subscription reconciliation pass even when cached plan data looks - complete. - range: Window to report. `cycle` / `prev-cycle` resolve to the customer's - real billing-period bounds (falling back to a trailing 30 days when - no invoice exists yet); `7d`…`12mo` are trailing windows; `custom` - uses `from` / `to`. - from_: Inclusive start (UTC date). Required when `range=custom`. - to: Inclusive end (UTC date). Required when `range=custom`. Max span 366 days. - granularity: Bucketing of the `days` series: `day` (one row per UTC day), - `month` (one row per calendar month, dated to the 1st), or `total` - (no series — read `totals`). Does not affect `totals`.""" - client = _get_client() - try: - response = client.usage.get_usage( - reconcile=reconcile, - range=range, - from_=from_, - to=to, - granularity=granularity, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get plan and usage snapshot (plan, limits, payment status)", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def usage_get_usage_stats(reconcile: bool | None = None) -> str: - """Get plan and usage snapshot (plan, limits, payment status) - - Args: - reconcile: For Stripe subscription users, `true` forces a subscription - reconciliation pass even when cached plan data looks complete. - Omit the parameter, or pass `false`, to use the default - first-time-only reconciliation behavior. Invalid boolean values are - rejected.""" - client = _get_client() - try: - response = client.usage.get_usage_stats(reconcile=reconcile) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Calling usage and cost", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def usage_get_calls_usage( - since: str | None = None, - until: str | None = None, - channel: str | None = None, - number: str | None = None, - group_by: str | None = None, - ) -> str: - """Calling usage and cost - - Args: - since: Start of the window (inclusive). Default 30 days before `until`. - until: End of the window (exclusive). Default now. - channel - number: Scope to calls involving this number (typically one of YOUR numbers). E.164, leading + optional. - group_by""" - client = _get_client() - try: - response = client.usage.get_calls_usage( - since=since, - until=until, - channel=channel, - number=number, - group_by=group_by, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="SMS usage (volumes)", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def usage_get_sms_usage( - since: str | None = None, - until: str | None = None, - number: str | None = None, - group_by: str | None = None, - ) -> str: - """SMS usage (volumes) - - Args: - since: Start of the window (inclusive). Default 30 days before `until`. - until: End of the window (exclusive). Default now. - number: Scope to one of YOUR SMS-enabled numbers (E.164, leading + optional). - group_by""" - client = _get_client() - try: - response = client.usage.get_sms_usage( - since=since, until=until, number=number, group_by=group_by - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # USERS - - @mcp.tool( - annotations=ToolAnnotations( - title="List users", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def users_list_users() -> str: - """List users""" - client = _get_client() - try: - response = client.users.list_users() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get user", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def users_get_user(user_id: str) -> str: - """Get user - - Args: - user_id: (required)""" - client = _get_client() - try: - response = client.users.get_user(user_id=user_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # VALIDATE - - @mcp.tool( - annotations=ToolAnnotations( - title="Validate character count", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def validate_post_length(text: str) -> str: - """Validate character count - - Args: - text: The post text to check (required)""" - client = _get_client() - try: - response = client.validate.validate_post_length(text=text) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Validate post content", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def validate_post( - platforms: list[dict[str, Any]] | None, - content: str | None = None, - media_items: list[dict[str, Any]] | None = None, - ) -> str: - """Validate post content - - Args: - content: Post text content - platforms: Target platforms (same format as POST /v1/posts) (required) - media_items: Root media items shared across platforms""" - client = _get_client() - try: - response = client.validate.validate_post( - content=content, platforms=platforms, media_items=media_items - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Validate media URL", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def validate_media(url: str) -> str: - """Validate media URL - - Args: - url: Public media URL to validate (required)""" - client = _get_client() - try: - response = client.validate.validate_media(url=url) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check subreddit existence", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def validate_subreddit(name: str, account_id: str | None = None) -> str: - """Check subreddit existence - - Args: - name: Subreddit name (with or without "r/" prefix) (required) - account_id: Reddit social account ID for authenticated lookup (recommended for reliable results)""" - client = _get_client() - try: - response = client.validate.validate_subreddit( - name=name, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # VOICE - - @mcp.tool( - annotations=ToolAnnotations( - title="Place an outbound phone call", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def voice_create_voice_call( - to: str, - from_number: str | None = None, - forward_to: str | None = None, - greeting: str | None = None, - record_override: bool | None = None, - transcribe_override: bool | None = None, - transcription_language: str | None = None, - amd: bool | None = None, - voicemail_drop_message: str | None = None, - ) -> str: - """Place an outbound phone call - - Args: - to: Destination to dial, E.164 with leading +. (required) - from_number: Which of your voice-enabled numbers to dial from. Optional when you have exactly one. - forward_to: Per-call agent override (tel:+E164, sip:..., or wss://...); defaults to the number's stored forward destination. - greeting: Spoken to the callee when they answer, before the bridge. - record_override: Per-call recording toggle; defaults to the number's setting. - transcribe_override: Per-call transcription toggle; defaults to the number's setting. - transcription_language: 'auto' derives from the callee's country; 'en'/'es' force it. - amd: Answering-machine detection; defers the bridge until human vs machine is known. - voicemail_drop_message: Spoken to a detected machine, then hang up (implies `amd`). For outbound voicemail drops.""" - client = _get_client() - try: - response = client.voice.create_voice_call( - to=to, - from_number=from_number, - forward_to=forward_to, - greeting=greeting, - record_override=record_override, - transcribe_override=transcribe_override, - transcription_language=transcription_language, - amd=amd, - voicemail_drop_message=voicemail_drop_message, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List phone calls", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def voice_list_voice_calls( - status: str | None = None, - direction: str | None = None, - number: str | None = None, - before: str | None = None, - limit: int = 50, - ) -> str: - """List phone calls - - Args: - status - direction - number: Exact filter: calls involving this number (typically one of your DIDs). E.164, leading + optional. - before - limit""" - client = _get_client() - try: - response = client.voice.list_voice_calls( - status=status, - direction=direction, - number=number, - before=before, - limit=limit, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a phone call", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def voice_get_voice_call(id: str) -> str: - """Get a phone call - - Args: - id: (required)""" - client = _get_client() - try: - response = client.voice.get_voice_call(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Hang up a live call", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def voice_end_voice_call(id: str) -> str: - """Hang up a live call - - Args: - id: (required)""" - client = _get_client() - try: - response = client.voice.end_voice_call(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a call recording", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def voice_get_voice_call_recording(id: str, as_: str | None = None) -> str: - """Get a call recording - - Args: - id: (required) - as_: `json` returns `{ url }` instead of a 302 redirect.""" - client = _get_client() - try: - response = client.voice.get_voice_call_recording(id=id, as_=as_) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Blind-transfer a live call", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def voice_transfer_voice_call(id: str, to: str) -> str: - """Blind-transfer a live call - - Args: - id: (required) - to: +E164 phone number (tel: prefix optional) or a sip: URI. wss:// is not a valid transfer target. (required)""" - client = _get_client() - try: - response = client.voice.transfer_voice_call(id=id, to=to) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Estimate call cost", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def voice_get_voice_call_estimate( - to: str, - minutes: int = 1, - recording: bool | None = None, - transcription: bool | None = None, - ) -> str: - """Estimate call cost - - Args: - to: Destination number, E.164 (leading + optional). (required) - minutes - recording - transcription""" - client = _get_client() - try: - response = client.voice.get_voice_call_estimate( - to=to, minutes=minutes, recording=recording, transcription=transcription - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Mint a browser softphone session", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def voice_create_voice_web_session() -> str: - """Mint a browser softphone session""" - client = _get_client() - try: - response = client.voice.create_voice_web_session() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Dial from the browser softphone", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def voice_dial_voice_web_call( - to: str, - credential_id: str, - from_number: str | None = None, - record_override: bool | None = None, - ) -> str: - """Dial from the browser softphone - - Args: - to: The number to call, E.164 with leading +. (required) - credential_id: The WebRTC credential id returned by POST /v1/voice/calls/web (the registered browser). (required) - from_number: Which of your voice-enabled numbers to call from (optional when you have one). - record_override""" - client = _get_client() - try: - response = client.voice.dial_voice_web_call( - to=to, - credential_id=credential_id, - from_number=from_number, - record_override=record_override, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Enable phone calling on a number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def voice_enable_voice_on_number( - id: str, - forward_to: str | None = None, - recording_enabled: bool | None = None, - transcription_enabled: bool | None = None, - transcription_language: str | None = None, - voicemail_enabled: bool | None = None, - voicemail_greeting: str | None = None, - business_hours_enabled: bool | None = None, - business_hours_timezone: str | None = None, - business_hours: list[dict[str, Any]] | None = None, - blocked_callers: list[str] | None = None, - forward_caller_id: str | None = None, - ivr_enabled: bool | None = None, - ivr_prompt: str | None = None, - ivr_options: list[dict[str, Any]] | None = None, - ) -> str: - """Enable phone calling on a number - - Args: - id: Phone number record ID (from GET /v1/phone-numbers). (required) - forward_to: tel:+E164, sip:..., or wss://... destination for inbound calls. Empty string clears the forward (outbound-only); omitted preserves the current one. - recording_enabled - transcription_enabled - transcription_language - voicemail_enabled: Voicemail is taken when there's no live destination. Default on. - voicemail_greeting: Custom spoken greeting; empty string restores the default. - business_hours_enabled: Outside the windows, inbound skips the forward and goes to voicemail. Off = 24/7. - business_hours_timezone: IANA timezone the windows are evaluated in. - business_hours - blocked_callers: E.164 numbers rejected before answer. Replaces the whole list; bare 10-digit values are normalized as US numbers. - forward_caller_id: Caller ID on the forwarded leg: your number (`business`) or the original caller's (`caller`). - ivr_enabled: IVR menu (supersedes the plain forward within business hours). - ivr_prompt - ivr_options""" - client = _get_client() - try: - response = client.voice.enable_voice_on_number( - id=id, - forward_to=forward_to, - recording_enabled=recording_enabled, - transcription_enabled=transcription_enabled, - transcription_language=transcription_language, - voicemail_enabled=voicemail_enabled, - voicemail_greeting=voicemail_greeting, - business_hours_enabled=business_hours_enabled, - business_hours_timezone=business_hours_timezone, - business_hours=business_hours, - blocked_callers=blocked_callers, - forward_caller_id=forward_caller_id, - ivr_enabled=ivr_enabled, - ivr_prompt=ivr_prompt, - ivr_options=ivr_options, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Disable phone calling on a number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def voice_disable_voice_on_number(id: str) -> str: - """Disable phone calling on a number - - Args: - id: (required)""" - client = _get_client() - try: - response = client.voice.disable_voice_on_number(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # WEBHOOKS - - @mcp.tool( - annotations=ToolAnnotations( - title="List webhooks", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def webhooks_get_webhook_settings() -> str: - """List webhooks""" - client = _get_client() - try: - response = client.webhooks.get_webhook_settings() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create webhook", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def webhooks_create_webhook_settings( - name: str, - url: str, - events: list[str] | None, - secret: str | None = None, - is_active: bool = True, - custom_headers: dict[str, Any] | None = None, - ) -> str: - """Create webhook - - Args: - name: Webhook name (1-50 characters) (required) - url: Webhook endpoint URL (must be a valid URL, whitespace trimmed) (required) - secret: Secret key for HMAC-SHA256 signature verification - events: Events to subscribe to (at least one required) (required) - is_active: Enable or disable webhook delivery. Defaults to `true` when omitted. - custom_headers: Custom headers to include in webhook requests""" - client = _get_client() - try: - response = client.webhooks.create_webhook_settings( - name=name, - url=url, - secret=secret, - events=events, - is_active=is_active, - custom_headers=custom_headers, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update webhook", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def webhooks_update_webhook_settings( - id: str, - name: str | None = None, - url: str | None = None, - secret: str | None = None, - events: list[str] | None = None, - is_active: bool | None = None, - custom_headers: dict[str, Any] | None = None, - ) -> str: - """Update webhook - - Args: - id: Webhook ID to update (required) (required) - name: Webhook name (1-50 characters). Must be non-empty if provided. - url: Webhook endpoint URL (must be a valid URL, whitespace trimmed). Must be a valid URL if provided. - secret: Secret key for HMAC-SHA256 signature verification - events: Events to subscribe to. Must contain at least one event if provided. - is_active: Enable or disable webhook delivery - custom_headers: Custom headers to include in webhook requests""" - client = _get_client() - try: - response = client.webhooks.update_webhook_settings( - id=id, - name=name, - url=url, - secret=secret, - events=events, - is_active=is_active, - custom_headers=custom_headers, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete webhook", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def webhooks_delete_webhook_settings(id: str) -> str: - """Delete webhook - - Args: - id: Webhook ID to delete (required)""" - client = _get_client() - try: - response = client.webhooks.delete_webhook_settings(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List webhook delivery logs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def webhooks_get_webhook_logs( - limit: int = 50, - skip: int = 0, - status: str | None = None, - event: str | None = None, - webhook_id: str | None = None, - event_id: str | None = None, - ) -> str: - """List webhook delivery logs - - Args: - limit: Maximum number of logs to return - skip: Number of logs to skip (offset-based pagination) - status: Filter by delivery outcome - event: Filter by event type (e.g. post.published) - webhook_id: Filter by webhook configuration ID - event_id: Filter by stable webhook event ID""" - client = _get_client() - try: - response = client.webhooks.get_webhook_logs( - limit=limit, - skip=skip, - status=status, - event=event, - webhook_id=webhook_id, - event_id=event_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send test webhook", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def webhooks_test_webhook(webhook_id: str) -> str: - """Send test webhook - - Args: - webhook_id: ID of the webhook to test (required)""" - client = _get_client() - try: - response = client.webhooks.test_webhook(webhook_id=webhook_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # WHATSAPP - - @mcp.tool( - annotations=ToolAnnotations( - title="List templates", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whats_app_templates(account_id: str) -> str: - """List templates - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whats_app_templates(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create template", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_create_whats_app_template( - account_id: str, - name: str, - category: str, - language: str, - components: list[dict[str, Any]] | None = None, - library_template_name: str | None = None, - library_template_body_inputs: dict[str, Any] | None = None, - library_template_button_inputs: list[dict[str, Any]] | None = None, - ) -> str: - """Create template - - Args: - account_id: WhatsApp social account ID (required) - name: Template name (lowercase, letters/numbers/underscores, must start with a letter) (required) - category: Template category (required) - language: Template language code (e.g., en_US) (required) - components: Template components (header, body, footer, buttons). Required for custom templates, omit when using library_template_name. - library_template_name: Name of a pre-built template from Meta's template library (e.g., "appointment_reminder", - "auto_pay_reminder_1", "address_update"). When provided, the template is pre-approved - by Meta with no review wait. Omit components when using this field. - library_template_body_inputs: Optional body customizations for library templates. Available options depend on the - template (e.g., add_contact_number, add_learn_more_link, add_security_recommendation, - add_track_package_link, code_expiration_minutes). - library_template_button_inputs: Optional button customizations for library templates. Each item specifies button type - and configuration (e.g., URL, phone number, quick reply).""" - client = _get_client() - try: - response = client.whatsapp.create_whats_app_template( - account_id=account_id, - name=name, - category=category, - language=language, - components=components, - library_template_name=library_template_name, - library_template_body_inputs=library_template_body_inputs, - library_template_button_inputs=library_template_button_inputs, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get template", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whats_app_template(template_name: str, account_id: str) -> str: - """Get template - - Args: - template_name: Template name (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whats_app_template( - template_name=template_name, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update template", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_update_whats_app_template( - template_name: str, account_id: str, components: list[dict[str, Any]] | None - ) -> str: - """Update template - - Args: - template_name: Template name (required) - account_id: WhatsApp social account ID (required) - components: Updated template components (required)""" - client = _get_client() - try: - response = client.whatsapp.update_whats_app_template( - template_name=template_name, - account_id=account_id, - components=components, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete template", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_delete_whats_app_template(template_name: str, account_id: str) -> str: - """Delete template - - Args: - template_name: Template name (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.delete_whats_app_template( - template_name=template_name, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get business profile", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whats_app_business_profile(account_id: str) -> str: - """Get business profile - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whats_app_business_profile( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update business profile", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_update_whats_app_business_profile( - account_id: str, - about: str | None = None, - address: str | None = None, - description: str | None = None, - email: str | None = None, - websites: list[str] | None = None, - vertical: str | None = None, - profile_picture_handle: str | None = None, - ) -> str: - """Update business profile - - Args: - account_id: WhatsApp social account ID (required) - about: Short business description (max 139 characters) - address: Business address - description: Full business description (max 512 characters) - email: Business email - websites: Business websites (max 2) - vertical: Business category (e.g., RETAIL, ENTERTAINMENT, etc.) - profile_picture_handle: Handle from resumable upload for profile picture""" - client = _get_client() - try: - response = client.whatsapp.update_whats_app_business_profile( - account_id=account_id, - about=about, - address=address, - description=description, - email=email, - websites=websites, - vertical=vertical, - profile_picture_handle=profile_picture_handle, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Upload profile picture", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_upload_whats_app_profile_photo(account_id: str, url: str) -> str: - """Upload profile picture - - Args: - account_id: WhatsApp social account ID (required) - url: Publicly reachable https URL of the image (JPEG or PNG, max 5MB, recommended 640x640). Fetched server-side; must resolve directly without redirects. (required)""" - client = _get_client() - try: - response = client.whatsapp.upload_whats_app_profile_photo( - account_id=account_id, url=url - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get display name status", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whats_app_display_name(account_id: str) -> str: - """Get display name status - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whats_app_display_name(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Request display name change", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_update_whats_app_display_name( - account_id: str, display_name: str - ) -> str: - """Request display name change - - Args: - account_id: WhatsApp social account ID (required) - display_name: New display name (must follow WhatsApp naming guidelines) (required)""" - client = _get_client() - try: - response = client.whatsapp.update_whats_app_display_name( - account_id=account_id, display_name=display_name - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get business username", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whatsapp_business_username(account_id: str) -> str: - """Get business username - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whatsapp_business_username( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Set business username", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_set_whatsapp_business_username( - account_id: str, username: str, transfer_action: str = "none" - ) -> str: - """Set business username - - Args: - account_id: WhatsApp social account ID (required) - username: Desired username. Letters, digits, period, and underscore only. Must contain at least one letter. No leading, trailing, or consecutive periods. No www prefix. No domain TLD suffix. - (required) - transfer_action: Pass `force_transfer` to request a transfer if the username is held by another account""" - client = _get_client() - try: - response = client.whatsapp.set_whatsapp_business_username( - account_id=account_id, - username=username, - transfer_action=transfer_action, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete business username", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_delete_whatsapp_business_username(account_id: str) -> str: - """Delete business username - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.delete_whatsapp_business_username( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get username suggestions", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whatsapp_business_username_suggestions(account_id: str) -> str: - """Get username suggestions - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whatsapp_business_username_suggestions( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check if a user is blocked", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whats_app_block_status(account_id: str, user: str) -> str: - """Check if a user is blocked - - Args: - account_id: (required) - user: Consumer wa_id or E.164 phone (leading + optional) (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whats_app_block_status( - account_id=account_id, user=user - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List blocked users", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whats_app_blocked_users( - account_id: str, limit: int | None = None, after: str | None = None - ) -> str: - """List blocked users - - Args: - account_id: WhatsApp social account ID (required) - limit: Page size. - after: Cursor from a previous response's `nextCursor`.""" - client = _get_client() - try: - response = client.whatsapp.get_whats_app_blocked_users( - account_id=account_id, limit=limit, after=after - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Block users", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_block_whats_app_users(account_id: str, users: list[str] | None) -> str: - """Block users - - Args: - account_id: WhatsApp social account ID (required) - users: Phone numbers (E.164, e.g. "+16505551234") or WhatsApp user IDs to block. (required)""" - client = _get_client() - try: - response = client.whatsapp.block_whats_app_users( - account_id=account_id, users=users - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Unblock users", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_unblock_whats_app_users( - account_id: str, users: list[str] | None - ) -> str: - """Unblock users - - Args: - account_id: WhatsApp social account ID (required) - users: Phone numbers (E.164) or WhatsApp user IDs to unblock. (required)""" - client = _get_client() - try: - response = client.whatsapp.unblock_whats_app_users( - account_id=account_id, users=users - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get CTWA conversions dataset", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whats_app_dataset(account_id: str) -> str: - """Get CTWA conversions dataset - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whats_app_dataset(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Provision CTWA dataset", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_create_whats_app_dataset(account_id: str) -> str: - """Provision CTWA dataset - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.create_whats_app_dataset(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List active groups", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_list_whats_app_group_chats( - account_id: str, limit: int = 25, after: str | None = None - ) -> str: - """List active groups - - Args: - account_id: WhatsApp social account ID (required) - limit: Max groups to return - after: Pagination cursor""" - client = _get_client() - try: - response = client.whatsapp.list_whats_app_group_chats( - account_id=account_id, limit=limit, after=after - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create group", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_create_whats_app_group_chat( - account_id: str, - subject: str, - description: str | None = None, - join_approval_mode: str | None = None, - ) -> str: - """Create group - - Args: - account_id: WhatsApp social account ID (required) - subject: Group name (max 128 characters) (required) - description: Group description (max 2048 characters) - join_approval_mode: Whether users need approval to join via invite link""" - client = _get_client() - try: - response = client.whatsapp.create_whats_app_group_chat( - account_id=account_id, - subject=subject, - description=description, - join_approval_mode=join_approval_mode, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get group info", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_get_whats_app_group_chat(group_id: str, account_id: str) -> str: - """Get group info - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.get_whats_app_group_chat( - group_id=group_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update group settings", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_update_whats_app_group_chat( - group_id: str, - account_id: str, - subject: str | None = None, - description: str | None = None, - join_approval_mode: str | None = None, - ) -> str: - """Update group settings - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required) - subject - description - join_approval_mode""" - client = _get_client() - try: - response = client.whatsapp.update_whats_app_group_chat( - group_id=group_id, - account_id=account_id, - subject=subject, - description=description, - join_approval_mode=join_approval_mode, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete group", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_delete_whats_app_group_chat(group_id: str, account_id: str) -> str: - """Delete group - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.delete_whats_app_group_chat( - group_id=group_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Add participants", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_add_whats_app_group_participants( - group_id: str, account_id: str, phone_numbers: list[str] | None - ) -> str: - """Add participants - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required) - phone_numbers: Phone numbers in E.164 format (max 8) (required)""" - client = _get_client() - try: - response = client.whatsapp.add_whats_app_group_participants( - group_id=group_id, account_id=account_id, phone_numbers=phone_numbers - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Remove participants", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_remove_whats_app_group_participants( - group_id: str, account_id: str, phone_numbers: list[str] | None - ) -> str: - """Remove participants - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required) - phone_numbers: Phone numbers to remove (required)""" - client = _get_client() - try: - response = client.whatsapp.remove_whats_app_group_participants( - group_id=group_id, account_id=account_id, phone_numbers=phone_numbers - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create invite link", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_create_whats_app_group_invite_link( - group_id: str, account_id: str - ) -> str: - """Create invite link - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.create_whats_app_group_invite_link( - group_id=group_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List join requests", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_list_whats_app_group_join_requests( - group_id: str, account_id: str - ) -> str: - """List join requests - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp.list_whats_app_group_join_requests( - group_id=group_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Approve join requests", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_approve_whats_app_group_join_requests( - group_id: str, account_id: str, phone_numbers: list[str] | None - ) -> str: - """Approve join requests - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required) - phone_numbers: Phone numbers to approve (required)""" - client = _get_client() - try: - response = client.whatsapp.approve_whats_app_group_join_requests( - group_id=group_id, account_id=account_id, phone_numbers=phone_numbers - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Reject join requests", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_reject_whats_app_group_join_requests( - group_id: str, account_id: str, phone_numbers: list[str] | None - ) -> str: - """Reject join requests - - Args: - group_id: Group ID (required) - account_id: WhatsApp social account ID (required) - phone_numbers: Phone numbers to reject (required)""" - client = _get_client() - try: - response = client.whatsapp.reject_whats_app_group_join_requests( - group_id=group_id, account_id=account_id, phone_numbers=phone_numbers - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List conversion events", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_list_whats_app_conversions(account_id: str, limit: int = 50) -> str: - """List conversion events - - Args: - account_id: WhatsApp social account ID (required) - limit: Max events to return (1-200, default 50).""" - client = _get_client() - try: - response = client.whatsapp.list_whats_app_conversions( - account_id=account_id, limit=limit - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send WhatsApp conversion event", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_send_whats_app_conversion( - account_id: str, - event_name: str, - event_id: str, - event_time: float | None = None, - conversation_id: str | None = None, - phone_e164: str | None = None, - value: float | None = None, - currency: str | None = None, - content_ids: list[str] | None = None, - email: str | None = None, - external_id: str | None = None, - test_code: str | None = None, - ) -> str: - """Send WhatsApp conversion event - - Args: - account_id: WhatsApp SocialAccount ID. (required) - event_name: Live-verified allowlist of event names accepted by Meta's - CAPI for Business Messaging (Graph API v25.0). Other - standard pixel events including `Lead`, - `CompleteRegistration`, `Subscribe`, `Schedule`, `Contact`, - `StartTrial`, `AddPaymentInfo`, `Search`, and - `SubmitApplication` are rejected with subcode 2804066 - ("Messaging Event Invalid Event Type") on - `action_source = business_messaging` events. Custom event - names are also rejected. - - Use `LeadSubmitted` (NOT `Lead`) for lead-style conversions. - (required) - event_time: Unix seconds. Defaults to the time of the request when - omitted. Meta's attribution window is 7 days from click; - events older than that lose attribution. - event_id: Stable dedup key. Reuse to suppress duplicate events - (Meta dedupes against pixel events with the same id). - (required) - conversation_id: Zernio Conversation `_id` (preferred lookup). The - conversation must have a captured `ctwa_clid` in metadata - (set automatically by the WhatsApp webhook on the first - inbound message after a CTWA ad click). - phone_e164: Contact phone number, digits only with no '+'. When used - in lieu of `conversationId`, the handler resolves to the - most recent CTWA-attributed conversation for this phone - on the supplied account. - value: Conversion value (e.g. order total). - currency: ISO 4217 currency code (e.g. `USD`). - content_ids: Optional product / content identifiers. - email: User email. Normalized + SHA-256 hashed before sending to Meta. - external_id: Stable customer identifier. Lowercased + SHA-256 hashed - before sending to Meta. - test_code: Meta `test_event_code` passthrough. Routes the event to - the Test Events tab in Events Manager instead of the - production dataset, useful for development.""" - client = _get_client() - try: - response = client.whatsapp.send_whats_app_conversion( - account_id=account_id, - event_name=event_name, - event_time=event_time, - event_id=event_id, - conversation_id=conversation_id, - phone_e164=phone_e164, - value=value, - currency=currency, - content_ids=content_ids, - email=email, - external_id=external_id, - test_code=test_code, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # WHATSAPP_CALLING - - @mcp.tool( - annotations=ToolAnnotations( - title="Get calling config for an account", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_calling_get_whats_app_calling_config(account_id: str) -> str: - """Get calling config for an account - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp_calling.get_whats_app_calling_config( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Enable calling on a number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_calling_enable_whats_app_calling_legacy( - id: str, - account_id: str, - forward_to: str, - sip_auth_username: str | None = None, - sip_auth_password: str | None = None, - recording_enabled: bool = False, - call_icon_countries: list[str] | None = None, - ) -> str: - """Enable calling on a number - - Args: - id: WhatsAppPhoneNumber Mongo ID (required) - account_id: (required) - forward_to: tel:+E164 / sip:... / wss://... destination (required) - sip_auth_username - sip_auth_password: Stored encrypted, never returned by any endpoint. - recording_enabled - call_icon_countries""" - client = _get_client() - try: - response = client.whatsapp_calling.enable_whats_app_calling_legacy( - id=id, - account_id=account_id, - forward_to=forward_to, - sip_auth_username=sip_auth_username, - sip_auth_password=sip_auth_password, - recording_enabled=recording_enabled, - call_icon_countries=call_icon_countries, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update calling config", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_calling_update_whats_app_calling_legacy( - id: str, - account_id: str, - forward_to: str | None = None, - sip_auth_username: str | None = None, - sip_auth_password: str | None = None, - recording_enabled: bool | None = None, - call_icon_countries: str | None = None, - ) -> str: - """Update calling config - - Args: - id: (required) - account_id: (required) - forward_to - sip_auth_username - sip_auth_password - recording_enabled - call_icon_countries""" - client = _get_client() - try: - response = client.whatsapp_calling.update_whats_app_calling_legacy( - id=id, - account_id=account_id, - forward_to=forward_to, - sip_auth_username=sip_auth_username, - sip_auth_password=sip_auth_password, - recording_enabled=recording_enabled, - call_icon_countries=call_icon_countries, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Disable calling on a number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_calling_disable_whats_app_calling_legacy( - id: str, account_id: str - ) -> str: - """Disable calling on a number - - Args: - id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.whatsapp_calling.disable_whats_app_calling_legacy( - id=id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check call permission", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_calling_get_whats_app_call_permissions( - account_id: str, to: str - ) -> str: - """Check call permission - - Args: - account_id: (required) - to: Consumer wa_id (E.164, leading + optional) (required)""" - client = _get_client() - try: - response = client.whatsapp_calling.get_whats_app_call_permissions( - account_id=account_id, to=to - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Initiate outbound call", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_calling_initiate_whats_app_call( - account_id: str, - to: str, - action: str | None = None, - body_text: str | None = None, - forward_to: str | None = None, - record_override: bool | None = None, - biz_opaque_callback_data: str | None = None, - ) -> str: - """Initiate outbound call - - Args: - account_id: (required) - to: Consumer wa_id (E.164, leading + optional) (required) - action: Omit to place a call. Set to send the consent prompt instead. - body_text: Body text shown with the consent prompt (send_call_permission_request only). - forward_to: Per-call destination override. Same accepted shape as the - number's stored forwardTo (tel:+E164, sip:..., wss://...). - record_override - biz_opaque_callback_data: Accepted for forward compatibility. Not currently echoed - back in webhook payloads (SIP-first flow does not pass - through Meta's Graph API where Meta would echo this).""" - client = _get_client() - try: - response = client.whatsapp_calling.initiate_whats_app_call( - account_id=account_id, - to=to, - action=action, - body_text=body_text, - forward_to=forward_to, - record_override=record_override, - biz_opaque_callback_data=biz_opaque_callback_data, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List call history for an account", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_calling_list_whats_app_calls( - account_id: str, - status: str | None = None, - direction: str | None = None, - since: str | None = None, - until: str | None = None, - before: str | None = None, - limit: int | None = None, - ) -> str: - """List call history for an account - - Args: - account_id: (required) - status - direction - since - until - before: Return calls with startedAt strictly before this instant (use the previous page's nextCursor). - limit""" - client = _get_client() - try: - response = client.whatsapp_calling.list_whats_app_calls( - account_id=account_id, - status=status, - direction=direction, - since=since, - until=until, - before=before, - limit=limit, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a single call", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_calling_get_whats_app_call(id: str, account_id: str) -> str: - """Get a single call - - Args: - id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.whatsapp_calling.get_whats_app_call( - id=id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a call recording", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_calling_get_whats_app_call_recording( - id: str, account_id: str, as_: str | None = None - ) -> str: - """Get a call recording - - Args: - id: (required) - account_id: (required) - as_: `json` returns `{ url }` instead of a 302 redirect.""" - client = _get_client() - try: - response = client.whatsapp_calling.get_whats_app_call_recording( - id=id, account_id=account_id, as_=as_ - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Estimate per-minute cost", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_calling_get_whats_app_call_estimate( - account_id: str, - to: str, - minutes: int | None = None, - recording: bool | None = None, - ) -> str: - """Estimate per-minute cost - - Args: - account_id: (required) - to: (required) - minutes - recording""" - client = _get_client() - try: - response = client.whatsapp_calling.get_whats_app_call_estimate( - account_id=account_id, to=to, minutes=minutes, recording=recording - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get calling config for a number", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_calling_get_whats_app_calling(id: str) -> str: - """Get calling config for a number - - Args: - id: Phone number record ID (from GET /v1/phone-numbers). (required)""" - client = _get_client() - try: - response = client.whatsapp_calling.get_whats_app_calling(id=id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Enable calling on a number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_calling_enable_whats_app_calling( - id: str, - account_id: str, - forward_to: str, - sip_auth_username: str | None = None, - sip_auth_password: str | None = None, - recording_enabled: bool = False, - call_icon_countries: list[str] | None = None, - ) -> str: - """Enable calling on a number - - Args: - id: Phone number record ID (from GET /v1/phone-numbers). (required) - account_id: (required) - forward_to: tel:+E164 / sip:... / wss://... destination (required) - sip_auth_username - sip_auth_password: Stored encrypted, never returned by any endpoint. - recording_enabled - call_icon_countries""" - client = _get_client() - try: - response = client.whatsapp_calling.enable_whats_app_calling( - id=id, - account_id=account_id, - forward_to=forward_to, - sip_auth_username=sip_auth_username, - sip_auth_password=sip_auth_password, - recording_enabled=recording_enabled, - call_icon_countries=call_icon_countries, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update calling config", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_calling_update_whats_app_calling( - id: str, - account_id: str, - forward_to: str | None = None, - sip_auth_username: str | None = None, - sip_auth_password: str | None = None, - recording_enabled: bool | None = None, - call_icon_countries: str | None = None, - ) -> str: - """Update calling config - - Args: - id: (required) - account_id: (required) - forward_to - sip_auth_username - sip_auth_password - recording_enabled - call_icon_countries""" - client = _get_client() - try: - response = client.whatsapp_calling.update_whats_app_calling( - id=id, - account_id=account_id, - forward_to=forward_to, - sip_auth_username=sip_auth_username, - sip_auth_password=sip_auth_password, - recording_enabled=recording_enabled, - call_icon_countries=call_icon_countries, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Disable calling on a number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_calling_disable_whats_app_calling(id: str, account_id: str) -> str: - """Disable calling on a number - - Args: - id: (required) - account_id: (required)""" - client = _get_client() - try: - response = client.whatsapp_calling.disable_whats_app_calling( - id=id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # WHATSAPP_FLOWS - - @mcp.tool( - annotations=ToolAnnotations( - title="List flows", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_flows_list_whats_app_flows(account_id: str) -> str: - """List flows - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp_flows.list_whats_app_flows(account_id=account_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create flow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_flows_create_whats_app_flow( - account_id: str, - name: str, - categories: list[str] | None, - clone_flow_id: str | None = None, - as_version: bool | None = None, - ) -> str: - """Create flow - - Args: - account_id: WhatsApp social account ID (required) - name: Flow display name (required) - categories: Flow categories (required) - clone_flow_id: Optional: ID of an existing flow to clone the Flow JSON from - as_version: When cloning, true keeps the clone in cloneFlowId's version lineage (auto-numbered next version); false/absent creates an independent flow. Ignored without cloneFlowId.""" - client = _get_client() - try: - response = client.whatsapp_flows.create_whats_app_flow( - account_id=account_id, - name=name, - categories=categories, - clone_flow_id=clone_flow_id, - as_version=as_version, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get flow", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_flows_get_whats_app_flow( - flow_id: str, account_id: str, fields: str | None = None - ) -> str: - """Get flow - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required) - fields: Comma-separated fields to return (default: id,name,status,categories,validation_errors,json_version,preview,data_api_version,endpoint_uri)""" - client = _get_client() - try: - response = client.whatsapp_flows.get_whats_app_flow( - flow_id=flow_id, account_id=account_id, fields=fields - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update flow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_flows_update_whats_app_flow( - flow_id: str, - account_id: str, - name: str | None = None, - categories: list[str] | None = None, - ) -> str: - """Update flow - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required) - name: New flow name - categories""" - client = _get_client() - try: - response = client.whatsapp_flows.update_whats_app_flow( - flow_id=flow_id, account_id=account_id, name=name, categories=categories - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete flow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_flows_delete_whats_app_flow(flow_id: str, account_id: str) -> str: - """Delete flow - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp_flows.delete_whats_app_flow( - flow_id=flow_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get flow JSON asset", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_flows_get_whats_app_flow_json(flow_id: str, account_id: str) -> str: - """Get flow JSON asset - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp_flows.get_whats_app_flow_json( - flow_id=flow_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Upload flow JSON", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_flows_upload_whats_app_flow_json( - flow_id: str, account_id: str, flow_json: str - ) -> str: - """Upload flow JSON - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required) - flow_json: The Flow JSON content. Pass as a JSON object or a JSON string. (required)""" - client = _get_client() - try: - response = client.whatsapp_flows.upload_whats_app_flow_json( - flow_id=flow_id, account_id=account_id, flow_json=flow_json - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get flow preview URL", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_flows_get_whats_app_flow_preview( - flow_id: str, account_id: str, invalidate: bool | None = None - ) -> str: - """Get flow preview URL - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required) - invalidate: Mint a fresh preview link (default false)""" - client = _get_client() - try: - response = client.whatsapp_flows.get_whats_app_flow_preview( - flow_id=flow_id, account_id=account_id, invalidate=invalidate - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List flow versions", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_flows_list_whats_app_flow_versions( - flow_id: str, account_id: str - ) -> str: - """List flow versions - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp_flows.list_whats_app_flow_versions( - flow_id=flow_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Publish flow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_flows_publish_whats_app_flow(flow_id: str, account_id: str) -> str: - """Publish flow - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp_flows.publish_whats_app_flow( - flow_id=flow_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Deprecate flow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_flows_deprecate_whats_app_flow(flow_id: str, account_id: str) -> str: - """Deprecate flow - - Args: - flow_id: Flow ID (required) - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp_flows.deprecate_whats_app_flow( - flow_id=flow_id, account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Send flow message", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_flows_send_whats_app_flow_message( - account_id: str, - to: str, - flow_id: str, - flow_cta: str, - body: str, - flow_action: str = "navigate", - flow_token: str | None = None, - flow_action_payload: dict[str, Any] | None = None, - header: dict[str, Any] | None = None, - footer: str | None = None, - draft: bool | None = None, - ) -> str: - """Send flow message - - Args: - account_id: WhatsApp social account ID (required) - to: Recipient phone number (E.164 format, e.g. +1234567890) (required) - flow_id: Published flow ID (required) - flow_cta: CTA button text (e.g. 'Book Now', 'Sign Up') (required) - flow_action: Action type: navigate opens a screen directly, data_exchange hits your endpoint first - flow_token: Unique token to correlate responses. If omitted, auto-generated as ':' so the response can be attributed to this flow in the Flow Responses view. - flow_action_payload - body: Message body text (required) - header - footer: Optional footer text - draft: Set true to test an unpublished (DRAFT) flow""" - client = _get_client() - try: - response = client.whatsapp_flows.send_whats_app_flow_message( - account_id=account_id, - to=to, - flow_id=flow_id, - flow_cta=flow_cta, - flow_action=flow_action, - flow_token=flow_token, - flow_action_payload=flow_action_payload, - body=body, - header=header, - footer=footer, - draft=draft, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List flow responses", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_flows_list_whats_app_flow_responses( - account_id: str, flow_id: str | None = None, limit: int = 50 - ) -> str: - """List flow responses - - Args: - account_id: WhatsApp social account ID (required) - flow_id: Scope to responses for this flow - limit: Max responses to return""" - client = _get_client() - try: - response = client.whatsapp_flows.list_whats_app_flow_responses( - account_id=account_id, flow_id=flow_id, limit=limit - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # WHATSAPP_PHONE_NUMBERS - - @mcp.tool( - annotations=ToolAnnotations( - title="Get number status", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_phone_numbers_get_whats_app_number_info(account_id: str) -> str: - """Get number status - - Args: - account_id: WhatsApp social account ID (required)""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.get_whats_app_number_info( - account_id=account_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List phone numbers", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_phone_numbers_get_whats_app_phone_numbers( - status: str | None = None, profile_id: str | None = None - ) -> str: - """List phone numbers - - Args: - status: Filter by status (by default excludes released numbers). NOTE: - `status=pending_regulatory` returns the "provisioning" view — numbers - still in review PLUS recently-declined (last 30 days) ones, so a - failed registration surfaces (with `regulatoryDeclineReason`) instead - of silently disappearing. Declined numbers can be re-submitted via - POST /v1/whatsapp/phone-numbers/{id}/remediate. `verifying` is the - short-lived state after the number is provisioned on our side while - WhatsApp confirms the activation code; the number is not billed until - it reaches `active`. - profile_id: Filter by profile""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.get_whats_app_phone_numbers( - status=status, profile_id=profile_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Purchase phone number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_phone_numbers_purchase_whats_app_phone_number( - profile_id: str, - country: str = "US", - purchase_intent_id: str | None = None, - allow_multiple: bool = False, - ) -> str: - """Purchase phone number - - Args: - profile_id: Profile to associate the number with (required) - country: ISO 3166-1 alpha-2 country for the number (default US). International numbers require usage-based billing. Tier 3/4 countries return 202 { status: "kyc_required", kycUrl } — the customer must complete KYC at that URL before the number is ordered. See GET /v1/whatsapp/phone-numbers/countries. - purchase_intent_id: Optional idempotency key. Send the same value when retrying a purchase: if a number was already bought under this key, the API returns { status: "already_purchased", numberId, phoneNumber } instead of provisioning a second number. Generate a fresh key for each genuinely new purchase. - allow_multiple: Any second purchase within 10 minutes of a previous one is rejected with 409 code PURCHASE_VELOCITY as duplicate protection. Pass true to confirm the additional purchase is intentional (e.g. bulk provisioning).""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.purchase_whats_app_phone_number( - profile_id=profile_id, - country=country, - purchase_intent_id=purchase_intent_id, - allow_multiple=allow_multiple, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List offerable number countries", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_phone_numbers_list_whats_app_number_countries() -> str: - """List offerable number countries""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.list_whats_app_number_countries() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Search available numbers", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_phone_numbers_search_available_whats_app_numbers( - country: str = "US", - type: str | None = None, - prefix: str | None = None, - locality: str | None = None, - contains: str | None = None, - limit: int = 20, - ) -> str: - """Search available numbers - - Args: - country - type: Number type; defaults to the country's WhatsApp-safe type - prefix: Area code - locality: City - contains: Pattern to match within the number - limit""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.search_available_whats_app_numbers( - country=country, - type=type, - prefix=prefix, - locality=locality, - contains=contains, - limit=limit, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Check country availability", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_phone_numbers_check_whats_app_number_availability(country: str) -> str: - """Check country availability - - Args: - country: ISO-2 country code. (required)""" - client = _get_client() - try: - response = ( - client.whatsapp_phone_numbers.check_whats_app_number_availability( - country=country - ) - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get KYC form spec", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_phone_numbers_get_whats_app_number_kyc_form( - country: str, profile_id: str - ) -> str: - """Get KYC form spec - - Args: - country: (required) - profile_id: (required)""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.get_whats_app_number_kyc_form( - country=country, profile_id=profile_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Submit KYC", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_phone_numbers_submit_whats_app_number_kyc( - profile_id: str, - country: str, - submission_id: str | None = None, - quantity: int = 1, - reuse: bool | None = None, - reuse_from: str | None = None, - end_user_first_name: str | None = None, - end_user_last_name: str | None = None, - values: dict[str, Any] | None = None, - documents: list[dict[str, Any]] | None = None, - address: dict[str, Any] | None = None, - ) -> str: - """Submit KYC - - Args: - profile_id: (required) - country: (required) - submission_id: Idempotency token for this submission attempt. A retry/double-submit with the same token returns the same number; omit and each call creates a new number. - quantity: Provision several same-country numbers from one submission (1-5). The single verification covers all of them; each number is billed only when it activates. Numbers that fail to order are skipped (best-effort). - reuse: Reuse a prior approved verification for this country (skips document/field collection; places the order immediately). - reuse_from: Which approved verification to reuse when several exist: the phone number it was originally approved for (GET reusable.options[].fromPhoneNumber). Omitted = newest. No match = 409. - end_user_first_name: End user's legal first name. Required when the country has an action/ID-verification (Onfido) requirement. - end_user_last_name: End user's legal last name. Same condition as endUserFirstName. - values: requirementId → textual value - documents: One per document requirement. Each is EITHER inline base64 OR a `documentId` returned by POST /v1/whatsapp/phone-numbers/kyc/upload-document (use the upload endpoint for large files to stay under the request-size limit). - address""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.submit_whats_app_number_kyc( - profile_id=profile_id, - country=country, - submission_id=submission_id, - quantity=quantity, - reuse=reuse, - reuse_from=reuse_from, - end_user_first_name=end_user_first_name, - end_user_last_name=end_user_last_name, - values=values, - documents=documents, - address=address, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Upload a KYC document", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_phone_numbers_upload_whats_app_number_kyc_document() -> str: - """Upload a KYC document""" - client = _get_client() - try: - response = ( - client.whatsapp_phone_numbers.upload_whats_app_number_kyc_document() - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pre-validate KYC address", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_phone_numbers_validate_whats_app_number_kyc_address( - country: str, - street_address: str, - locality: str, - postal_code: str, - administrative_area: str | None = None, - ) -> str: - """Pre-validate KYC address - - Args: - country: ISO 3166-1 alpha-2 country code. (required) - street_address: (required) - locality: City / town. (required) - administrative_area: State / province / region. When omitted, the pre-check is skipped (the final submit still validates). - postal_code: (required)""" - client = _get_client() - try: - response = ( - client.whatsapp_phone_numbers.validate_whats_app_number_kyc_address( - country=country, - street_address=street_address, - locality=locality, - administrative_area=administrative_area, - postal_code=postal_code, - ) - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create a hosted KYC link", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_phone_numbers_create_whats_app_number_kyc_link( - profile_id: str, - country: str, - branding: dict[str, Any] | None = None, - redirect_url: str | None = None, - ) -> str: - """Create a hosted KYC link - - Args: - profile_id: (required) - country: ISO 3166-1 alpha-2 country code (must be a regulated/KYC country). (required) - branding: Optional white-label of the hosted page the end customer sees. - redirect_url: Where to send the end customer's browser after a successful - submit. On completion Zernio appends `kyc=submitted` and - `country=` as query params. When omitted, the hosted - page shows a built-in confirmation screen instead.""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.create_whats_app_number_kyc_link( - profile_id=profile_id, - country=country, - branding=branding, - redirect_url=redirect_url, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get declined requirements", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_phone_numbers_get_whats_app_number_remediation(id: str) -> str: - """Get declined requirements - - Args: - id: WhatsAppPhoneNumber id. (required)""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.get_whats_app_number_remediation( - id=id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Resubmit a declined number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_phone_numbers_remediate_whats_app_number( - id: str, - values: dict[str, Any] | None = None, - documents: list[dict[str, Any]] | None = None, - address: dict[str, Any] | None = None, - ) -> str: - """Resubmit a declined number - - Args: - id: (required) - values - documents - address: Same shape as the KYC submit address.""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.remediate_whats_app_number( - id=id, values=values, documents=documents, address=address - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get phone number", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_phone_numbers_get_whats_app_phone_number(phone_number_id: str) -> str: - """Get phone number - - Args: - phone_number_id: Phone number record ID (required)""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.get_whats_app_phone_number( - phone_number_id=phone_number_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Release phone number", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_phone_numbers_release_whats_app_phone_number( - phone_number_id: str, - ) -> str: - """Release phone number - - Args: - phone_number_id: Phone number record ID (required)""" - client = _get_client() - try: - response = client.whatsapp_phone_numbers.release_whats_app_phone_number( - phone_number_id=phone_number_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # WHATSAPP_SANDBOX - - @mcp.tool( - annotations=ToolAnnotations( - title="List your sandbox sessions", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_sandbox_list_whats_app_sandbox_sessions() -> str: - """List your sandbox sessions""" - client = _get_client() - try: - response = client.whatsapp_sandbox.list_whats_app_sandbox_sessions() - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Start a sandbox activation", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_sandbox_create_whats_app_sandbox_session(phone: str) -> str: - """Start a sandbox activation - - Args: - phone: Recipient phone in international format. Digits, spaces, dashes and a leading `+` are all accepted; the server normalizes to E.164 digits-only. (required)""" - client = _get_client() - try: - response = client.whatsapp_sandbox.create_whats_app_sandbox_session( - phone=phone - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Revoke a sandbox session", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def whatsapp_sandbox_delete_whats_app_sandbox_session(session_id: str) -> str: - """Revoke a sandbox session - - Args: - session_id: The session id returned by POST /v1/whatsapp/sandbox/sessions. (required)""" - client = _get_client() - try: - response = client.whatsapp_sandbox.delete_whats_app_sandbox_session( - session_id=session_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # WHATSAPP_TEMPLATES - - @mcp.tool( - annotations=ToolAnnotations( - title="Look up a library template", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def whatsapp_templates_get_whats_app_library_template( - account_id: str, name: str - ) -> str: - """Look up a library template - - Args: - account_id: WhatsApp social account ID (required) - name: Exact library template name (required)""" - client = _get_client() - try: - response = client.whatsapp_templates.get_whats_app_library_template( - account_id=account_id, name=name - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - # WORKFLOWS - - @mcp.tool( - annotations=ToolAnnotations( - title="List workflows", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def workflows_list_workflows( - profile_id: str | None = None, - status: str | None = None, - limit: int = 50, - skip: int = 0, - ) -> str: - """List workflows - - Args: - profile_id: Filter by profile. Omit to list across all profiles - status - limit - skip""" - client = _get_client() - try: - response = client.workflows.list_workflows( - profile_id=profile_id, status=status, limit=limit, skip=skip - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Create workflow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def workflows_create_workflow( - profile_id: str, - account_id: str, - name: str, - platform: str = "whatsapp", - description: str | None = None, - nodes: list[dict[str, Any]] | None = None, - edges: list[dict[str, Any]] | None = None, - entry_node_id: str | None = None, - ) -> str: - """Create workflow - - Args: - profile_id: (required) - account_id: (required) - platform - name: (required) - description - nodes - edges - entry_node_id: The trigger node id; derived from the single trigger node if omitted""" - client = _get_client() - try: - response = client.workflows.create_workflow( - profile_id=profile_id, - account_id=account_id, - platform=platform, - name=name, - description=description, - nodes=nodes, - edges=edges, - entry_node_id=entry_node_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get workflow with graph", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def workflows_get_workflow(workflow_id: str) -> str: - """Get workflow with graph - - Args: - workflow_id: (required)""" - client = _get_client() - try: - response = client.workflows.get_workflow(workflow_id=workflow_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Update workflow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def workflows_update_workflow( - workflow_id: str, - name: str | None = None, - description: str | None = None, - nodes: list[dict[str, Any]] | None = None, - edges: list[dict[str, Any]] | None = None, - entry_node_id: str | None = None, - account_id: str | None = None, - ) -> str: - """Update workflow - - Args: - workflow_id: (required) - name - description - nodes - edges - entry_node_id - account_id: Reassign the workflow to a different `SocialAccount`. `platform` and `profileId` are derived server-side from the new account (the client never sends them directly). The account must belong to the caller's workspace and be on a workflow-supported platform (whatsapp, instagram, facebook, telegram, twitter, bluesky, reddit). Changing this triggers a graph revalidation against the new platform.""" - client = _get_client() - try: - response = client.workflows.update_workflow( - workflow_id=workflow_id, - name=name, - description=description, - nodes=nodes, - edges=edges, - entry_node_id=entry_node_id, - account_id=account_id, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Delete workflow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def workflows_delete_workflow(workflow_id: str) -> str: - """Delete workflow - - Args: - workflow_id: (required)""" - client = _get_client() - try: - response = client.workflows.delete_workflow(workflow_id=workflow_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Activate workflow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def workflows_activate_workflow(workflow_id: str) -> str: - """Activate workflow - - Args: - workflow_id: (required)""" - client = _get_client() - try: - response = client.workflows.activate_workflow(workflow_id=workflow_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Pause workflow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def workflows_pause_workflow(workflow_id: str) -> str: - """Pause workflow - - Args: - workflow_id: (required)""" - client = _get_client() - try: - response = client.workflows.pause_workflow(workflow_id=workflow_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List workflow runs", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def workflows_list_workflow_executions( - workflow_id: str, status: str | None = None, limit: int = 25, skip: int = 0 - ) -> str: - """List workflow runs - - Args: - workflow_id: (required) - status - limit - skip""" - client = _get_client() - try: - response = client.workflows.list_workflow_executions( - workflow_id=workflow_id, status=status, limit=limit, skip=skip - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Manually start a workflow run", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def workflows_trigger_workflow( - workflow_id: str, - to: str | None = None, - conversation_id: str | None = None, - text: str | None = None, - ) -> str: - """Manually start a workflow run - - Args: - workflow_id: (required) - to: Recipient phone (WhatsApp only) - conversation_id: An existing conversation to run in (required for non-WhatsApp workflows) - text: Simulated inbound text, seeded as the run's lastMessage variable""" - client = _get_client() - try: - response = client.workflows.trigger_workflow( - workflow_id=workflow_id, - to=to, - conversation_id=conversation_id, - text=text, - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get an execution's timeline", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def workflows_list_workflow_execution_events( - workflow_id: str, execution_id: str - ) -> str: - """Get an execution's timeline - - Args: - workflow_id: (required) - execution_id: (required)""" - client = _get_client() - try: - response = client.workflows.list_workflow_execution_events( - workflow_id=workflow_id, execution_id=execution_id - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Duplicate a workflow", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def workflows_duplicate_workflow(workflow_id: str) -> str: - """Duplicate a workflow - - Args: - workflow_id: (required)""" - client = _get_client() - try: - response = client.workflows.duplicate_workflow(workflow_id=workflow_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="List a workflow's version history", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def workflows_list_workflow_versions(workflow_id: str) -> str: - """List a workflow's version history - - Args: - workflow_id: (required)""" - client = _get_client() - try: - response = client.workflows.list_workflow_versions(workflow_id=workflow_id) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Get a specific workflow version", - readOnlyHint=True, - destructiveHint=False, - openWorldHint=False, - ) - ) - def workflows_get_workflow_version(workflow_id: str, version: int) -> str: - """Get a specific workflow version - - Args: - workflow_id: (required) - version: (required)""" - client = _get_client() - try: - response = client.workflows.get_workflow_version( - workflow_id=workflow_id, version=version - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" - - @mcp.tool( - annotations=ToolAnnotations( - title="Restore a workflow version", - readOnlyHint=False, - destructiveHint=True, - openWorldHint=True, - ) - ) - def workflows_restore_workflow_version(workflow_id: str, version: int) -> str: - """Restore a workflow version - - Args: - workflow_id: (required) - version: (required)""" - client = _get_client() - try: - response = client.workflows.restore_workflow_version( - workflow_id=workflow_id, version=version - ) - return _format_response(response) - except Exception as e: - return f"Error: {e}" diff --git a/src/late/mcp/http_server.py b/src/late/mcp/http_server.py deleted file mode 100644 index 6e38c1d..0000000 --- a/src/late/mcp/http_server.py +++ /dev/null @@ -1,199 +0,0 @@ -"""HTTP server for Zernio MCP — Streamable HTTP (+ legacy SSE) on FastMCP. - -Serves the FastMCP application (tool-search transform + OAuth resource-server -auth) over Streamable HTTP at /mcp. Authentication, the RFC 9728 -protected-resource metadata, and the 401 WWW-Authenticate challenge are all -provided by FastMCP's RemoteAuthProvider (see late.mcp.auth). DNS-rebinding -Origin protection is layered on as a lightweight ASGI middleware, scoped to the -transport endpoints so the public /health, / and discovery routes stay open. - -The legacy SSE transport (GET /sse + POST /messages/) is deprecated but still -served for backwards compatibility — production continues to receive SSE -connections from older client configs. Its routes come from the same FastMCP -instance (mcp.http_app(transport="sse")), so tools and auth are identical to -the Streamable HTTP endpoint. -""" - -import argparse -import sys - -import uvicorn -from starlette.applications import Starlette -from starlette.requests import Request -from starlette.responses import JSONResponse, RedirectResponse -from starlette.types import ASGIApp, Receive, Scope, Send - -from late.mcp.auth import is_allowed_origin -from late.mcp.config import ServerConfig, validate_environment -from late.mcp.constants import ( - DOCS_URL, - ENDPOINT_HEALTH, - ENDPOINT_MCP, - ENDPOINT_MESSAGES, - ENDPOINT_OAUTH_PROTECTED_RESOURCE, - ENDPOINT_ROOT, - ENDPOINT_SSE, - SERVICE_NAME, - SERVICE_VERSION, - TRANSPORT_TYPE, -) -from late.mcp.server import mcp - - -@mcp.custom_route(ENDPOINT_ROOT, methods=["GET"]) -async def handle_root(_request: Request) -> JSONResponse: - """Root endpoint with server information (public, no auth).""" - return JSONResponse( - { - "service": SERVICE_NAME, - "version": SERVICE_VERSION, - "transport": TRANSPORT_TYPE, - "endpoints": { - "mcp": f"{ENDPOINT_MCP} (POST) - Streamable HTTP transport (recommended)", - "sse": f"{ENDPOINT_SSE} (GET) - SSE connection endpoint (legacy, deprecated)", - "messages": f"{ENDPOINT_MESSAGES} (POST) - SSE message handler (legacy, deprecated)", - "health": f"{ENDPOINT_HEALTH} (GET) - Health check", - }, - "documentation": DOCS_URL, - "authentication": "Required: 'Authorization: Bearer YOUR_API_KEY'", - } - ) - - -@mcp.custom_route(ENDPOINT_HEALTH, methods=["GET"]) -async def handle_health(_request: Request) -> JSONResponse: - """Health check endpoint (public, no auth).""" - return JSONResponse( - { - "status": "healthy", - "service": "zernio-mcp-http", - "version": SERVICE_VERSION, - "transport": TRANSPORT_TYPE, - } - ) - - -@mcp.custom_route(ENDPOINT_OAUTH_PROTECTED_RESOURCE, methods=["GET"]) -async def handle_oauth_protected_resource_legacy(_request: Request) -> RedirectResponse: - """Legacy RFC 9728 discovery path (public, no auth). - - The pre-FastMCP server served the protected-resource metadata here; - FastMCP's RemoteAuthProvider serves the canonical document at the - path-inserted URL (/.well-known/oauth-protected-resource/mcp), which is - also what the 401 WWW-Authenticate challenge advertises. Permanently - redirect so clients still holding the old URL keep working. - """ - return RedirectResponse( - f"{ENDPOINT_OAUTH_PROTECTED_RESOURCE}{ENDPOINT_MCP}", status_code=308 - ) - - -_ORIGIN_GUARDED_PATHS = ( - ENDPOINT_MCP.rstrip("/"), - ENDPOINT_SSE.rstrip("/"), - ENDPOINT_MESSAGES.rstrip("/"), -) - - -class OriginGuardMiddleware: - """DNS-rebinding protection, scoped to the transport endpoints. - - Browsers attach an Origin header to cross-site requests; native MCP clients - and server-to-server callers send none (and are allowed). Only the MCP, - SSE, and SSE-message endpoints are gated — /health, / and the OAuth - discovery docs stay public. - - Implemented as a pure ASGI middleware (not BaseHTTPMiddleware) so it never - buffers the Streamable HTTP response body. - """ - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] == "http": - request = Request(scope) - path = request.url.path.rstrip("/") - guarded = any( - path == g or path.startswith(g + "/") for g in _ORIGIN_GUARDED_PATHS - ) - if guarded and not is_allowed_origin(request): - response = JSONResponse({"error": "Origin not allowed"}, status_code=403) - await response(scope, receive, send) - return - await self.app(scope, receive, send) - - -def build_app() -> Starlette: - """Build the FastMCP Streamable HTTP ASGI app with Origin protection. - - FastMCP's own host/origin checks are opened up ('*') so they never reject - production traffic (the app sits behind mcp.zernio.com / Railway); the - OriginGuardMiddleware is the sole DNS-rebinding gate, using our allowlist - (auth.is_allowed_origin, honouring MCP_ALLOWED_ORIGINS). - """ - app = mcp.http_app( - path=ENDPOINT_MCP, - transport="http", - stateless_http=True, - allowed_hosts=["*"], - allowed_origins=["*"], - ) - - # Legacy SSE transport, deprecated but kept for backwards compatibility - # (production still receives GET /sse from older client configs). Built - # from the same FastMCP instance so tools and auth are identical; only the - # /sse and /messages routes are grafted onto the main app, where they run - # under its middleware stack and shared server lifespan. - sse_app = mcp.http_app( - path=ENDPOINT_SSE, - transport="sse", - allowed_hosts=["*"], - allowed_origins=["*"], - ) - # Graft /sse, /messages, and the SSE discovery doc (the /sse 401 challenge - # advertises the path-inserted metadata for its own endpoint). - sse_paths = ( - ENDPOINT_SSE.rstrip("/"), - ENDPOINT_MESSAGES.rstrip("/"), - f"{ENDPOINT_OAUTH_PROTECTED_RESOURCE}{ENDPOINT_SSE}", - ) - app.router.routes.extend( - r for r in sse_app.routes if getattr(r, "path", "") in sse_paths - ) - - app.add_middleware(OriginGuardMiddleware) - return app - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser(description="Zernio MCP HTTP Server (Streamable HTTP)") - parser.add_argument("--host", help="Host to bind to (default: 0.0.0.0)") - parser.add_argument("--port", type=int, help="Port to listen on (default: 8080)") - parser.add_argument("--debug", action="store_true", help="Enable debug mode") - return parser.parse_args() - - -def main() -> None: - """Entry point for the HTTP server (Streamable HTTP).""" - validate_environment() - args = parse_args() - config = ServerConfig.from_env(host=args.host, port=args.port, debug=args.debug) - - app = build_app() - - print("Zernio MCP HTTP Server starting...", file=sys.stderr) - print(f" Streamable HTTP: http://{config.host}:{config.port}{ENDPOINT_MCP}", file=sys.stderr) - print(f" Health check: http://{config.host}:{config.port}{ENDPOINT_HEALTH}", file=sys.stderr) - - uvicorn.run( - app, - host=config.host, - port=config.port, - log_level="debug" if config.debug else "info", - ) - - -if __name__ == "__main__": - main() diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py deleted file mode 100644 index f619b3a..0000000 --- a/src/late/mcp/server.py +++ /dev/null @@ -1,1037 +0,0 @@ -""" -Zernio MCP Server. - -Exposes Zernio API functionality through Model Context Protocol. - -Usage: - # Run directly - uv run python -m late.mcp.server - - # Or in Claude Desktop config: - { - "mcpServers": { - "zernio": { - "command": "uvx", - "args": ["--from", "zernio-sdk[mcp]", "zernio-mcp"], - "env": { - "ZERNIO_API_KEY": "your_api_key" - } - } - } - } -""" - -from __future__ import annotations - -import os -import re -from contextvars import ContextVar -from datetime import datetime, timedelta -from typing import Any - -import httpx -from fastmcp import FastMCP -from fastmcp.server.dependencies import get_access_token -from fastmcp.server.transforms.search import BM25SearchTransform -from mcp.types import ToolAnnotations - -from late import Late, MediaType, PostStatus - -from .auth import build_auth_provider -from .tool_definitions import TOOL_DEFINITIONS - -# Context variable to store the Zernio API key for the current connection -_zernio_api_key: ContextVar[str | None] = ContextVar("zernio_api_key", default=None) - -# Cache for documentation content -_docs_cache: dict[str, tuple[str, datetime]] = {} -_DOCS_URL = "https://docs.zernio.com/llms-full.txt" -_CACHE_TTL_HOURS = 24 - -# Always-visible core: the 20 hand-written ergonomic tools (guardrails + -# LLM-shaped args, preferred over their raw generated twins) plus the generated -# tools central to working on a profile — posting, the scheduling queue, -# pre-publish validation, account health, cross-platform analytics basics, and -# engagement on published posts. The remaining ~430 generated tools (messaging -# suite, ads, connect, platform-specific analytics, ...) sit behind the BM25 -# tool-search transform (search_tools / call_tool), so clients see ~51 tools up -# front instead of 481 — small enough that clients which cap or truncate large -# tool lists show them all. -_PINNED_TOOLS = [ - # Hand-written ergonomic tools - "accounts_list", "accounts_get", - "profiles_list", "profiles_get", "profiles_create", "profiles_update", "profiles_delete", - "posts_list", "posts_get", "posts_create", "posts_publish_now", "posts_cross_post", - "posts_update", "posts_delete", "posts_retry", "posts_list_failed", "posts_retry_all_failed", - "media_generate_upload_link", "media_check_upload_status", - "docs_search", - # Posting operations not covered by the ergonomic set - "posts_bulk_upload_posts", "posts_unpublish_post", "posts_edit_post", - # Scheduling queue - "queue_list_queue_slots", "queue_create_queue_slot", "queue_update_queue_slot", - "queue_delete_queue_slot", "queue_preview_queue", "queue_get_next_queue_slot", - # Pre-publish validation - "validate_post", "validate_post_length", "validate_media", - # Account health & organisation - "accounts_get_all_accounts_health", "accounts_get_account_health", - "accounts_get_follower_stats", "accounts_move_account_to_profile", - "account_groups_list_account_groups", - # Cross-platform analytics basics - "analytics_get_analytics", "analytics_get_best_time_to_post", - "analytics_get_daily_metrics", "analytics_get_post_timeline", - # Engagement on published posts - "comments_list_inbox_comments", "comments_get_inbox_post_comments", - "comments_reply_to_inbox_post", - "mentions_list_inbox_mentions", "mentions_reply_to_mention", - # Campaign tags & usage - "tracking_tags_list_tracking_tags", "tracking_tags_get_tracking_tag_stats", - "usage_get_usage", -] - -# Initialize MCP server -mcp = FastMCP( - "Zernio", - instructions=""" -Zernio API server for scheduling social media posts. - -Available tools are prefixed by resource: -- accounts_* : Manage connected social media accounts -- profiles_* : Manage profiles (groups of accounts) -- posts_* : Create, list, update, delete posts -- media_* : Upload images and videos -- docs_* : Search Zernio API documentation - -MULTI-ACCOUNT WORKFLOW (agencies, multi-client setups): -When a user has more than one account on the same platform, you MUST -disambiguate before posting: - - 1. Call accounts_list (or profiles_list then accounts_list with a - profile_id filter) to discover account IDs. - 2. Pass account_id to posts_create / posts_publish_now, or account_ids - (parallel to platforms) to posts_cross_post. - -If account_id is omitted and the selection is ambiguous, the write tools -return an error containing the candidate list - read it, pick the right -account, and retry with account_id set. Never guess. The legacy behaviour -of silently picking the first matching account has been removed. -""", - # Resource-server auth: validates the bearer against the Zernio API and - # auto-serves RFC 9728 protected-resource discovery (see late.mcp.auth). - auth=build_auth_provider(), - # Collapse the ~383 generated tools behind search_tools/call_tool; the - # pinned ergonomic tools remain always-visible. - transforms=[BM25SearchTransform(always_visible=_PINNED_TOOLS, max_results=8)], -) - - -# Hand-written tools that only read data. Everything else is treated as a -# destructive write for annotation purposes. Drives readOnlyHint/destructiveHint -# below; the Anthropic Connectors Directory requires every tool to carry one of -# those hints. (Generated tools get the same treatment from their HTTP method -# in scripts/generate_mcp_tools.py.) -_READ_ONLY_TOOLS = { - "accounts_list", - "accounts_get", - "profiles_list", - "profiles_get", - "posts_list", - "posts_get", - "posts_list_failed", - "media_check_upload_status", - "docs_search", -} - - -def tool_def(name: str): - """Register a hand-written MCP tool from its TOOL_DEFINITIONS entry. - - Pulls the docstring + parameter docs from TOOL_DEFINITIONS (the single - source of truth) and attaches a tool annotation (human-readable title plus - readOnlyHint/destructiveHint). Replaces the old `@mcp.tool()` + - `@use_tool_def(...)` decorator pair so the docstring and the annotation - stay defined in one place. - """ - - def decorator(func): - td = TOOL_DEFINITIONS.get(name) - if td: - func.__doc__ = td.get_docstring() - read_only = name in _READ_ONLY_TOOLS - title = td.summary.rstrip(".") if td else name - return mcp.tool( - annotations=ToolAnnotations( - title=title, - readOnlyHint=read_only, - destructiveHint=not read_only, - # Write tools act on external social platforms; reads do not - # change external state. Required explicitly for ChatGPT Apps. - openWorldHint=not read_only, - ) - )(func) - - return decorator - - -def set_late_api_key(api_key: str) -> None: - """ - Set the Zernio API key for the current async context. - - In HTTP mode the key now flows through FastMCP's AccessToken (see - _get_client); this setter remains for tests and embedding use. - - Args: - api_key: The Zernio API key to use for this connection. - """ - _zernio_api_key.set(api_key) - - -def _get_client() -> Late: - """ - Get Zernio client with API key from context or environment. - - For HTTP connections, uses the bearer validated by the FastMCP auth layer. - For STDIO connections (Claude Desktop), falls back to env vars. - Priority: ZERNIO_API_KEY > LATE_API_KEY (legacy). - - Returns: - Late client instance. - - Raises: - ValueError: If no API key is available. - """ - # HTTP mode: the FastMCP auth layer validated the bearer and exposes it as - # the current request's AccessToken. get_access_token() returns None off - # HTTP (STDIO) or when unauthenticated. - api_key = "" - try: - token = get_access_token() - except Exception: - token = None - if token is not None: - api_key = token.token or "" - - # Fall back to the per-request ContextVar (kept for tests) then env vars - # (STDIO mode for Claude Desktop). - if not api_key: - api_key = _zernio_api_key.get() or "" - if not api_key: - api_key = os.getenv("ZERNIO_API_KEY") or os.getenv("LATE_API_KEY", "") - - if not api_key: - raise ValueError( - "Zernio API key is required. " - "For HTTP: provide via Authorization: Bearer header. " - "For STDIO: set ZERNIO_API_KEY environment variable." - ) - - base_url = os.getenv("ZERNIO_BASE_URL") or os.getenv("LATE_BASE_URL", None) - return Late(api_key=api_key, base_url=base_url) - - -# ============================================================================ -# HELPERS -# ============================================================================ - - -def _platform_str(value: Any) -> str: - """Extract string value from a platform enum or return as-is if already a string. - - The auto-generated models use plain Enum classes (Platform5, Platform6, etc.) - whose instances don't have .lower() and whose str() returns 'Platform5.TWITTER' - instead of 'twitter'. This helper normalises any platform value to a clean - lowercase string regardless of the underlying type. - """ - if value is None: - return "" - # Platform(str, Enum) from enums.py is already a str, so this branch - # handles both plain strings and StrEnum instances. - if isinstance(value, str): - return value.lower() - # Plain Enum from generated models: extract .value - if hasattr(value, "value"): - return str(value.value).lower() - return str(value).lower() - - -def _resolve_account( - client: Late, - platform: str, - account_id: str = "", - profile_id: str = "", -) -> Any: - """Resolve a single account for a given platform without silent guessing. - - Resolution order: - 1. account_id -> verify it exists and matches the requested platform. - 2. profile_id -> list accounts in that profile, filter by platform. - 3. neither -> list all accounts, filter by platform. - - Raises ValueError with an LLM-readable message when the selection is - ambiguous (>= 2 candidates) or empty. This is the load-bearing piece of - the multi-account fix: previously the MCP silently picked matching[0], - which routed agency posts to the wrong client account. - """ - platform_l = platform.lower() - - # Path 1: explicit account_id. We still cross-check the platform so the - # LLM can't accidentally publish a tweet to a LinkedIn account. - if account_id: - accounts = (client.accounts.list().accounts or []) - acc = next((a for a in accounts if a.field_id == account_id), None) - if not acc: - raise ValueError( - f"Account {account_id} not found or not accessible with this API key." - ) - if _platform_str(acc.platform) != platform_l: - raise ValueError( - f"Account {account_id} is a {_platform_str(acc.platform)} " - f"account, not {platform_l}." - ) - return acc - - # Path 2 & 3: list (optionally scoped to a profile) then filter by platform. - pool = ( - client.accounts.list(profile_id=profile_id).accounts - if profile_id else client.accounts.list().accounts - ) or [] - - matching = [ - a for a in pool - if a.platform and _platform_str(a.platform) == platform_l - ] - - if not matching: - scope = f" in profile {profile_id}" if profile_id else "" - available = sorted({_platform_str(a.platform) for a in pool if a.platform}) - raise ValueError( - f"No {platform_l} account found{scope}. " - f"Available platforms: {', '.join(available) or '(none)'}." - ) - - if len(matching) > 1: - listing = "\n".join( - f" - {a.username or a.displayName or a.field_id} (account_id: {a.field_id})" - for a in matching - ) - raise ValueError( - f"Multiple {platform_l} accounts available. Pass account_id " - f"(or profile_id to scope) and retry:\n{listing}" - ) - - return matching[0] - - -# ============================================================================ -# ACCOUNTS -# ============================================================================ - - -@tool_def("accounts_list") -def accounts_list() -> str: - client = _get_client() - response = client.accounts.list() - accounts = response.accounts or [] - - if not accounts: - return "No accounts connected. Connect accounts at https://zernio.com" - - lines = [f"Found {len(accounts)} connected account(s):\n"] - for acc in accounts: - username = acc.username or acc.displayName or acc.field_id - lines.append(f"- {_platform_str(acc.platform)}: {username} (ID: {acc.field_id})") - - return "\n".join(lines) - - -@tool_def("accounts_get") -def accounts_get(platform: str) -> str: - client = _get_client() - try: - acc = _resolve_account(client, platform) - except ValueError as e: - # Surface ambiguity / missing-account errors to the LLM so it can - # call accounts_list and retry with a specific ID. Previously this - # path silently returned matching[0]. - return str(e) - return ( - f"Platform: {_platform_str(acc.platform)}\n" - f"Username: {acc.username or 'N/A'}\n" - f"ID: {acc.field_id}" - ) - - -# ============================================================================ -# PROFILES -# ============================================================================ - - -@tool_def("profiles_list") -def profiles_list() -> str: - client = _get_client() - response = client.profiles.list() - profiles = response.profiles or [] - - if not profiles: - return "No profiles found." - - lines = [f"Found {len(profiles)} profile(s):\n"] - for profile in profiles: - default = " (default)" if profile.isDefault else "" - color = f" [{profile.color}]" if profile.color else "" - lines.append(f"- {profile.name}{default}{color} (ID: {profile.field_id})") - if profile.description: - lines.append(f" Description: {profile.description}") - - return "\n".join(lines) - - -@tool_def("profiles_get") -def profiles_get(profile_id: str) -> str: - client = _get_client() - response = client.profiles.get(profile_id) - profile = response.profile - if not profile: - return f"Profile {profile_id} not found." - - lines = [ - f"Name: {profile.name}", - f"ID: {profile.field_id}", - f"Default: {'Yes' if profile.isDefault else 'No'}", - ] - if profile.description: - lines.append(f"Description: {profile.description}") - if profile.color: - lines.append(f"Color: {profile.color}") - - return "\n".join(lines) - - -@tool_def("profiles_create") -def profiles_create(name: str, description: str = "", color: str = "") -> str: - client = _get_client() - - params: dict[str, str] = {"name": name} - if description: - params["description"] = description - if color: - params["color"] = color - - response = client.profiles.create(**params) - profile = response.profile - - return f"\u2705 Profile created!\nName: {profile.name if profile else 'N/A'}\nID: {profile.field_id if profile else 'N/A'}" - - -@tool_def("profiles_update") -def profiles_update( - profile_id: str, - name: str = "", - description: str = "", - color: str = "", - is_default: bool = False, -) -> str: - client = _get_client() - - params: dict[str, str | bool] = {} - if name: - params["name"] = name - if description: - params["description"] = description - if color: - params["color"] = color - if is_default: - params["is_default"] = True - - if not params: - return "\u26a0\ufe0f No changes specified. Provide at least one field to update." - - response = client.profiles.update(profile_id, **params) - profile = response.profile - - return f"\u2705 Profile updated!\nName: {profile.name if profile else 'N/A'}\nID: {profile.field_id if profile else 'N/A'}" - - -@tool_def("profiles_delete") -def profiles_delete(profile_id: str) -> str: - client = _get_client() - client.profiles.delete(profile_id) - return f"\u2705 Profile {profile_id} deleted" - - -# ============================================================================ -# POSTS -# ============================================================================ - - -@tool_def("posts_list") -def posts_list(status: str = "", limit: int = 10) -> str: - client = _get_client() - params: dict[str, str | int] = {"limit": limit} - if status: - params["status"] = status - - response = client.posts.list(**params) - posts = response.posts or [] - - if not posts: - return f"No posts found{f' with status {status}' if status else ''}." - - lines = [f"Found {len(posts)} post(s):\n"] - for post in posts: - content = post.content or "" - content_preview = content[:60] + "..." if len(content) > 60 else content - platforms = ", ".join( - t.platform or "?" for t in (post.platforms or []) - ) - status = post.status.value if post.status else "unknown" - lines.append(f"- [{status}] {content_preview}") - lines.append(f" Platforms: {platforms} | ID: {post.field_id}") - - return "\n".join(lines) - - -@tool_def("posts_get") -def posts_get(post_id: str) -> str: - client = _get_client() - response = client.posts.get(post_id) - post = response.post - if not post: - return f"Post {post_id} not found." - - content = post.content or "" - content_preview = content[:100] + "..." if len(content) > 100 else content - platforms = ", ".join(t.platform or "?" for t in (post.platforms or [])) - - status = post.status.value if post.status else "unknown" - lines = [ - f"Post ID: {post.field_id}", - f"Status: {status}", - f"Platforms: {platforms}", - f"Content: {content_preview}", - ] - - if post.scheduledFor: - lines.append(f"Scheduled for: {post.scheduledFor}") - - if hasattr(post, "publishedAt") and post.publishedAt: - lines.append(f"Published at: {post.publishedAt}") - - if post.metadata and post.metadata.get("error"): - lines.append(f"Error: {post.metadata['error']}") - - return "\n".join(lines) - - -@tool_def("posts_create") -def posts_create( - content: str, - platform: str, - account_id: str = "", - profile_id: str = "", - is_draft: bool = False, - publish_now: bool = False, - schedule_minutes: int = 0, - media_urls: str = "", - title: str = "", -) -> str: - client = _get_client() - - # Resolve the target account. _resolve_account raises ValueError with a - # helpful candidate list when the user has multiple accounts and no - # account_id was passed - we surface that string back to the LLM so it - # can retry. Previously this silently picked accounts[0]. - try: - account = _resolve_account(client, platform, account_id, profile_id) - except ValueError as e: - return f"❌ {e}" - - # Build request - params: dict[str, Any] = { - "content": content, - "platforms": [ - { - "platform": _platform_str(account.platform), - "accountId": account.field_id, - } - ], - } - - if title: - params["title"] = title - - # Add media items if provided - if media_urls: - urls = [u.strip() for u in media_urls.split(",") if u.strip()] - media_items = [] - for url in urls: - media_type: MediaType | str = MediaType.IMAGE - if any( - ext in url.lower() for ext in [".mp4", ".mov", ".avi", ".webm", ".m4v"] - ): - media_type = MediaType.VIDEO - elif any(ext in url.lower() for ext in [".gif"]): - media_type = MediaType.GIF - media_items.append({"type": media_type, "url": url}) - params["media_items"] = media_items - - if is_draft: - params["is_draft"] = True - elif publish_now: - params["publish_now"] = True - else: - minutes = schedule_minutes if schedule_minutes > 0 else 60 - params["scheduled_for"] = datetime.now() + timedelta(minutes=minutes) - - response = client.posts.create(**params) - post = response.post - - username = account.username or account.displayName or account.field_id - media_info = ( - f" with {len(params.get('media_items', []))} media file(s)" - if params.get("media_items") - else "" - ) - - post_id = post.field_id if post else "N/A" - if is_draft: - return f"📝 Draft saved for {platform} (@{username}){media_info}\nPost ID: {post_id}\nStatus: draft" - elif publish_now: - return f"\u2705 Published to {platform} (@{username}){media_info}\nPost ID: {post_id}" - else: - scheduled = params["scheduled_for"].strftime("%Y-%m-%d %H:%M") - return f"\u2705 Scheduled for {platform} (@{username}){media_info}\nPost ID: {post_id}\nScheduled: {scheduled}" - - -@tool_def("posts_publish_now") -def posts_publish_now( - content: str, - platform: str, - account_id: str = "", - profile_id: str = "", - media_urls: str = "", -) -> str: - return posts_create( - content=content, - platform=platform, - account_id=account_id, - profile_id=profile_id, - publish_now=True, - media_urls=media_urls, - ) - - -@tool_def("posts_cross_post") -def posts_cross_post( - content: str, - platforms: str, - account_ids: str = "", - profile_id: str = "", - is_draft: bool = False, - publish_now: bool = False, - media_urls: str = "", -) -> str: - client = _get_client() - - target_platforms = [p.strip().lower() for p in platforms.split(",") if p.strip()] - # account_ids is parallel to platforms (same order). Pad with empty strings - # so positions without a specific ID fall back to profile/auto-resolution. - ids = [i.strip() for i in account_ids.split(",")] if account_ids else [] - ids += [""] * max(0, len(target_platforms) - len(ids)) - - platform_targets = [] - errors = [] - - # Resolve each (platform, account_id) pair via the shared resolver so the - # ambiguity / not-found behaviour matches posts_create exactly. Repeating - # a platform with different account_ids is supported (e.g. agency posting - # to two Twitter accounts in one cross_post call). - # ids is padded to len(target_platforms) above, so strict=True is safe - # and catches any future logic error that would silently truncate. - for plat, acc_id in zip(target_platforms, ids, strict=True): - try: - acc = _resolve_account(client, plat, acc_id, profile_id) - platform_targets.append( - { - "platform": _platform_str(acc.platform), - "accountId": acc.field_id, - } - ) - except ValueError as e: - errors.append(f"{plat}: {e}") - - if errors: - return "❌ Could not resolve account(s):\n" + "\n".join(errors) - - if not platform_targets: - return "❌ No platforms specified." - - params: dict[str, Any] = { - "content": content, - "platforms": platform_targets, - } - - if media_urls: - urls = [u.strip() for u in media_urls.split(",") if u.strip()] - media_items = [] - for url in urls: - media_type: MediaType | str = MediaType.IMAGE - if any( - ext in url.lower() for ext in [".mp4", ".mov", ".avi", ".webm", ".m4v"] - ): - media_type = MediaType.VIDEO - elif any(ext in url.lower() for ext in [".gif"]): - media_type = MediaType.GIF - media_items.append({"type": media_type, "url": url}) - params["media_items"] = media_items - - if is_draft: - params["is_draft"] = True - elif publish_now: - params["publish_now"] = True - else: - params["scheduled_for"] = datetime.now() + timedelta(hours=1) - - response = client.posts.create(**params) - post = response.post - - posted_to = [t["platform"] for t in platform_targets] - media_info = ( - f" with {len(params.get('media_items', []))} media file(s)" - if params.get("media_items") - else "" - ) - - post_id = post.field_id if post else "N/A" - if is_draft: - result = f"📝 Draft saved for: {', '.join(posted_to)}{media_info}\nPost ID: {post_id}\nStatus: draft" - else: - result = f"\u2705 {'Published' if publish_now else 'Scheduled'} to: {', '.join(posted_to)}{media_info}\nPost ID: {post_id}" - - # not_found is no longer accumulated: resolution errors short-circuit - # above. If we got here, every requested target was resolved. - return result - - -@tool_def("posts_update") -def posts_update( - post_id: str, - content: str = "", - scheduled_for: str = "", - title: str = "", -) -> str: - client = _get_client() - - params = {} - if content: - params["content"] = content - if scheduled_for: - params["scheduled_for"] = scheduled_for - if title: - params["title"] = title - - if not params: - return "\u26a0\ufe0f No changes specified. Provide at least one field to update." - - response = client.posts.update(post_id, **params) - post = response.post - - post_id_str = post.field_id if post else "N/A" - status = post.status if post else "N/A" - return f"\u2705 Post updated!\nID: {post_id_str}\nStatus: {status}" - - -@tool_def("posts_delete") -def posts_delete(post_id: str) -> str: - client = _get_client() - client.posts.delete(post_id) - return f"\u2705 Post {post_id} deleted" - - -@tool_def("posts_retry") -def posts_retry(post_id: str) -> str: - client = _get_client() - - try: - post_response = client.posts.get(post_id) - post = post_response.post - if not post: - return f"\u274c Post {post_id} not found" - if post.status != PostStatus.FAILED: - return f"\u26a0\ufe0f Post {post_id} is not in failed status (current: {post.status})" - except Exception as e: - return f"\u274c Could not find post {post_id}: {e}" - - try: - client.posts.retry(post_id) - return f"\u2705 Post {post_id} has been queued for retry" - except Exception as e: - return f"\u274c Failed to retry post: {e}" - - -@tool_def("posts_list_failed") -def posts_list_failed(limit: int = 10) -> str: - client = _get_client() - response = client.posts.list(status=PostStatus.FAILED, limit=limit) - posts = response.posts or [] - - if not posts: - return "No failed posts found." - - lines = [f"Found {len(posts)} failed post(s):\n"] - for post in posts: - content = post.content or "" - content_preview = content[:50] + "..." if len(content) > 50 else content - platforms = ", ".join(t.platform or "?" for t in (post.platforms or [])) - error = post.metadata.get("error", "Unknown error") if post.metadata else "Unknown error" - lines.append(f"- {content_preview}") - lines.append(f" Platforms: {platforms} | ID: {post.field_id}") - lines.append(f" Error: {error}") - lines.append("") - - return "\n".join(lines) - - -@tool_def("posts_retry_all_failed") -def posts_retry_all_failed() -> str: - client = _get_client() - response = client.posts.list(status=PostStatus.FAILED, limit=50) - posts = response.posts or [] - - if not posts: - return "No failed posts to retry." - - results = [] - success_count = 0 - fail_count = 0 - - for post in posts: - try: - client.posts.retry(post.field_id) - success_count += 1 - except Exception as e: - fail_count += 1 - results.append(f"\u274c {post.field_id}: {e}") - - summary = f"\u2705 Retried {success_count} post(s)" - if fail_count > 0: - summary += f"\n\u274c Failed to retry {fail_count} post(s)" - summary += "\n" + "\n".join(results) - - return summary - - -# ============================================================================ -# MEDIA UPLOAD -# ============================================================================ - - -@tool_def("media_generate_upload_link") -def media_generate_upload_link() -> str: - client = _get_client() - - try: - response = client.media.generate_upload_token() - - upload_url = str(response.uploadUrl) if response.uploadUrl else "" - token = response.token or "" - expires_at = str(response.expiresAt) if response.expiresAt else "" - - return f"""📤 Upload link generated! - -**Open this link in your browser to upload files:** -{upload_url} - -Token: {token} -Expires: {expires_at} - -Once you've uploaded your files, let me know and I'll check the status to get the URLs.""" - - except Exception as e: - return f"\u274c Failed to generate upload link: {e}" - - -@tool_def("media_check_upload_status") -def media_check_upload_status(token: str) -> str: - client = _get_client() - - try: - response = client.media.check_upload_token(token) - - status = response.status.value if response.status else "unknown" - files = response.files or [] - - if status == "pending": - return f"""\u23f3 Upload pending - -The user hasn't uploaded files yet. Please wait for them to complete the upload in their browser. - -Token: {token}""" - - elif status == "expired": - return """\u23f0 Upload link expired - -The upload link has expired. Use media_generate_upload_link to create a new one.""" - - elif status == "completed": - if not files: - return "\u2705 Upload completed but no files were found." - - lines = [f"\u2705 Upload completed! {len(files)} file(s) uploaded:\n"] - media_urls = [] - - for f in files: - url = str(f.url) if f.url else "" - media_urls.append(url) - lines.append(f"- {f.filename or 'unknown'}") - lines.append(f" Type: {f.type.value if f.type else 'N/A'}") - lines.append(f" URL: {url}") - lines.append(f" Size: {(f.size or 0) / 1024:.1f} KB") - lines.append("") - - lines.append( - "\n📝 You can now create a post with these media URLs using posts_create with the media_urls parameter." - ) - lines.append(f"\nMedia URLs: {','.join(media_urls)}") - - return "\n".join(lines) - - else: - return f"Unknown status: {status}" - - except Exception as e: - return f"\u274c Failed to check upload status: {e}" - - -# ============================================================================ -# DOCS -# ============================================================================ - - -def _get_docs_content() -> str: - """Fetch and cache documentation content.""" - cache_key = "docs" - - # Check cache - if cache_key in _docs_cache: - content, cached_at = _docs_cache[cache_key] - if datetime.now() - cached_at < timedelta(hours=_CACHE_TTL_HOURS): - return content - - # Fetch fresh content - try: - response = httpx.get(_DOCS_URL, timeout=30.0) - response.raise_for_status() - content = response.text - _docs_cache[cache_key] = (content, datetime.now()) - return content - except Exception as e: - # Return cached content if available, even if expired - if cache_key in _docs_cache: - return _docs_cache[cache_key][0] - raise RuntimeError(f"Failed to fetch documentation: {e}") from e - - -def _search_docs(content: str, query: str, max_results: int = 5) -> list[dict[str, str]]: - """Search documentation content for relevant sections.""" - results: list[dict[str, str]] = [] - query_lower = query.lower() - query_terms = query_lower.split() - - # Split content into sections (by markdown headers) - sections = re.split(r'\n(?=#{1,3} )', content) - - scored_sections: list[tuple[int, str, str]] = [] - - for section in sections: - if not section.strip(): - continue - - section_lower = section.lower() - - # Calculate relevance score - score = 0 - - # Exact phrase match (highest priority) - if query_lower in section_lower: - score += 100 - - # Individual term matches - for term in query_terms: - if term in section_lower: - score += 10 - # Bonus for term in header - first_line = section.split('\n')[0].lower() - if term in first_line: - score += 20 - - if score > 0: - # Extract title from first line - lines = section.strip().split('\n') - title = lines[0].lstrip('#').strip() if lines else "Untitled" - scored_sections.append((score, title, section.strip())) - - # Sort by score and take top results - scored_sections.sort(key=lambda x: x[0], reverse=True) - - for score, title, section_text in scored_sections[:max_results]: - # Truncate long sections - if len(section_text) > 1500: - section_text = section_text[:1500] + "\n...(truncated)" - - results.append({ - "title": title, - "content": section_text, - "relevance": str(score), - }) - - return results - - -@tool_def("docs_search") -def docs_search(query: str) -> str: - try: - content = _get_docs_content() - results = _search_docs(content, query) - - if not results: - return f"No documentation found for '{query}'. Try different search terms." - - lines = [f"Found {len(results)} relevant section(s) for '{query}':\n"] - - for i, result in enumerate(results, 1): - lines.append(f"--- Result {i}: {result['title']} ---") - lines.append(result["content"]) - lines.append("") - - return "\n".join(lines) - - except Exception as e: - return f"\u274c Failed to search documentation: {e}" - - -# ============================================================================ -# AUTO-GENERATED TOOLS -# ============================================================================ - -# Import and register auto-generated tools from OpenAPI spec -# These complement the custom tools above with full API coverage -try: - from .generated_tools import register_generated_tools - register_generated_tools(mcp, _get_client) -except ImportError: - # generated_tools.py not yet created - run scripts/generate_mcp_tools.py - pass - - -# ============================================================================ -# MAIN -# ============================================================================ - - -def main() -> None: - """Entry point for STDIO transport (Claude Desktop).""" - mcp.run(transport="stdio") - - -if __name__ == "__main__": - main() diff --git a/src/late/mcp/tool_definitions.py b/src/late/mcp/tool_definitions.py deleted file mode 100644 index fcac441..0000000 --- a/src/late/mcp/tool_definitions.py +++ /dev/null @@ -1,756 +0,0 @@ -""" -Centralized tool definitions for MCP and documentation. - -This file is the SINGLE SOURCE OF TRUTH for tool parameters and descriptions. -Used by: -- MCP server (server.py) for tool definitions via @use_tool_def decorator -- Documentation generation (generate_docs.py for MDX output) - -To update tool documentation, edit ONLY this file. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Callable - - -@dataclass -class ParamDef: - """Definition of a tool parameter.""" - - name: str - type: str - description: str - required: bool = False - default: Any = None - - def to_mdx_row(self) -> str: - """Generate MDX table row.""" - req = "Yes" if self.required else "No" - default_str = f"`{self.default}`" if self.default is not None else "-" - return f"| `{self.name}` | `{self.type}` | {self.description} | {req} | {default_str} |" - - -@dataclass -class ToolDef: - """Definition of a tool.""" - - name: str - summary: str # Short one-line description - description: str # Detailed description with usage guidelines - params: list[ParamDef] = field(default_factory=list) - - def get_docstring(self) -> str: - """Generate docstring for MCP function.""" - lines = [self.description, ""] - - if self.params: - lines.append("Args:") - for param in self.params: - req = " (required)" if param.required else "" - default = ( - f" Default: {param.default}." - if param.default is not None and not param.required - else "" - ) - lines.append(f" {param.name}: {param.description}{req}{default}") - - return "\n".join(lines) - - def to_mdx_section(self) -> str: - """Generate MDX documentation section.""" - lines = [ - f"### `{self.name}`", - "", - self.summary, - "", - self.description, - "", - ] - - if self.params: - lines.extend([ - "| Parameter | Type | Description | Required | Default |", - "|-----------|------|-------------|----------|---------|", - ]) - lines.extend(p.to_mdx_row() for p in self.params) - lines.append("") - - return "\n".join(lines) - - -# ============================================================================= -# DECORATOR FOR APPLYING TOOL DEFINITIONS -# ============================================================================= - - -def use_tool_def(tool_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """ - Decorator to apply tool definition docstring to a function. - - Usage: - @mcp.tool() - @use_tool_def("posts_create") - def posts_create(...): - ... # Implementation only, no docstring needed - """ - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - tool = TOOL_DEFINITIONS.get(tool_name) - if tool: - # Set docstring on the original function - func.__doc__ = tool.get_docstring() - # Return the function directly - no wrapper needed - # The @mcp.tool() decorator will read __doc__ from this function - return func - - return decorator - - -# ============================================================================= -# ACCOUNTS TOOLS -# ============================================================================= - -ACCOUNTS_LIST = ToolDef( - name="accounts_list", - summary="List all connected social media accounts.", - description="""List all connected social media accounts. - -Returns the platform, username, and account ID for each connected account. -Use this to find account IDs needed for creating posts.""", - params=[], -) - -ACCOUNTS_GET = ToolDef( - name="accounts_get", - summary="Get account details for a specific platform.", - description="""Get account details for a specific platform. - -Returns username and ID for the first account matching the platform.""", - params=[ - ParamDef( - name="platform", - type="str", - description="Platform name: twitter, instagram, linkedin, tiktok, bluesky, facebook, youtube, pinterest, threads", - required=True, - ), - ], -) - -# ============================================================================= -# PROFILES TOOLS -# ============================================================================= - -PROFILES_LIST = ToolDef( - name="profiles_list", - summary="List all profiles.", - description="""List all profiles. - -Profiles group multiple social accounts together for easier management.""", - params=[], -) - -PROFILES_GET = ToolDef( - name="profiles_get", - summary="Get details of a specific profile.", - description="Get details of a specific profile including name, description, and color.", - params=[ - ParamDef( - name="profile_id", - type="str", - description="The profile ID", - required=True, - ), - ], -) - -PROFILES_CREATE = ToolDef( - name="profiles_create", - summary="Create a new profile.", - description="Create a new profile for grouping social accounts.", - params=[ - ParamDef( - name="name", - type="str", - description="Profile name", - required=True, - ), - ParamDef( - name="description", - type="str", - description="Optional description", - required=False, - default="", - ), - ParamDef( - name="color", - type="str", - description="Optional hex color (e.g., '#4CAF50')", - required=False, - default="", - ), - ], -) - -PROFILES_UPDATE = ToolDef( - name="profiles_update", - summary="Update an existing profile.", - description="Update an existing profile. Only provided fields will be changed.", - params=[ - ParamDef( - name="profile_id", - type="str", - description="The profile ID to update", - required=True, - ), - ParamDef( - name="name", - type="str", - description="New name (leave empty to keep current)", - required=False, - default="", - ), - ParamDef( - name="description", - type="str", - description="New description (leave empty to keep current)", - required=False, - default="", - ), - ParamDef( - name="color", - type="str", - description="New hex color (leave empty to keep current)", - required=False, - default="", - ), - ParamDef( - name="is_default", - type="bool", - description="Set as default profile", - required=False, - default=False, - ), - ], -) - -PROFILES_DELETE = ToolDef( - name="profiles_delete", - summary="Delete a profile.", - description="Delete a profile. The profile must have no connected accounts.", - params=[ - ParamDef( - name="profile_id", - type="str", - description="The profile ID to delete", - required=True, - ), - ], -) - -# ============================================================================= -# POSTS TOOLS -# ============================================================================= - -POSTS_LIST = ToolDef( - name="posts_list", - summary="List posts with optional filtering.", - description="""List posts with optional filtering by status. - -Status options: draft, scheduled, published, failed""", - params=[ - ParamDef( - name="status", - type="str", - description="Filter by status: draft, scheduled, published, failed. Leave empty for all posts", - required=False, - default="", - ), - ParamDef( - name="limit", - type="int", - description="Maximum number of posts to return", - required=False, - default=10, - ), - ], -) - -POSTS_GET = ToolDef( - name="posts_get", - summary="Get details of a specific post.", - description="Get full details of a specific post including content, status, and scheduling info.", - params=[ - ParamDef( - name="post_id", - type="str", - description="The post ID to retrieve", - required=True, - ), - ], -) - -POSTS_CREATE = ToolDef( - name="posts_create", - summary="Create a social media post (draft, scheduled, or immediate).", - description="""Create a social media post. Can be saved as DRAFT, SCHEDULED, or PUBLISHED immediately. - -⚠️ IMPORTANT - Choose the correct mode based on user intent: - -**DRAFT MODE (is_draft=True)** -Use when user says: "draft", "borrador", "save for later", "don't publish", "save it", "guardar" -→ Post is saved but NOT published and NOT scheduled. User can edit it later. - -**IMMEDIATE MODE (publish_now=True)** -Use when user says: "publish now", "post now", "publica ya", "immediately", "right now", "ahora" -→ Post goes live IMMEDIATELY. - -**SCHEDULED MODE (default)** -Use when user says: "schedule", "programar", "in X minutes/hours", "at 3pm", "tomorrow" -→ Post is scheduled for future publication. Use schedule_minutes to set the delay. - -⚠️ MULTI-ACCOUNT USERS (agencies, multi-client setups): -If the user has more than one account on the target platform, you MUST pass -`account_id`. Call `accounts_list` (or `profiles_list` then `accounts_list`) -first to discover the right ID. If you omit account_id when multiple accounts -exist, the tool returns an error listing the candidates - use it to retry. - -Examples: -- "Create a draft tweet" → is_draft=True -- "Post this to Twitter now" → publish_now=True -- "Schedule a LinkedIn post for 2 hours from now" → schedule_minutes=120 -- "Post this to Acme's Twitter" → call accounts_list, find Acme's twitter ID, pass account_id""", - params=[ - ParamDef( - name="content", - type="str", - description="The post text/content", - required=True, - ), - ParamDef( - name="platform", - type="str", - description="Target platform: twitter, instagram, linkedin, tiktok, bluesky, facebook, youtube, pinterest, threads", - required=True, - ), - ParamDef( - name="account_id", - type="str", - description="Specific account ID to post from. REQUIRED when the user has multiple accounts on this platform. Call accounts_list first to find IDs. Leave empty only if there is exactly one account for this platform.", - required=False, - default="", - ), - ParamDef( - name="profile_id", - type="str", - description="Scope account resolution to one profile (e.g. one client in an agency setup). Use when account_id is unknown but the target profile is. Call profiles_list to find IDs.", - required=False, - default="", - ), - ParamDef( - name="is_draft", - type="bool", - description="Set to True to save as DRAFT (not published, not scheduled). Use when user wants to save without publishing", - required=False, - default=False, - ), - ParamDef( - name="publish_now", - type="bool", - description="Set to True to publish IMMEDIATELY. Post goes live right now", - required=False, - default=False, - ), - ParamDef( - name="schedule_minutes", - type="int", - description="Minutes from now to schedule. Only used when is_draft=False AND publish_now=False", - required=False, - default=60, - ), - ParamDef( - name="media_urls", - type="str", - description="Comma-separated URLs of media files to attach (images, videos)", - required=False, - default="", - ), - ParamDef( - name="title", - type="str", - description="Post title (required for YouTube, recommended for Pinterest)", - required=False, - default="", - ), - ], -) - -POSTS_PUBLISH_NOW = ToolDef( - name="posts_publish_now", - summary="Publish a post immediately.", - description="""Publish a post immediately to a platform. The post goes live right away. - -Use this when user explicitly wants to publish NOW, not schedule for later. -This is a convenience wrapper around posts_create with publish_now=True. - -⚠️ MULTI-ACCOUNT USERS: pass `account_id` when the user has more than one -account on this platform. Call `accounts_list` first to find the right ID.""", - params=[ - ParamDef( - name="content", - type="str", - description="The post text/content", - required=True, - ), - ParamDef( - name="platform", - type="str", - description="Target platform: twitter, instagram, linkedin, tiktok, bluesky, etc.", - required=True, - ), - ParamDef( - name="account_id", - type="str", - description="Specific account ID. REQUIRED when the user has multiple accounts on this platform. Leave empty only if there is exactly one.", - required=False, - default="", - ), - ParamDef( - name="profile_id", - type="str", - description="Scope auto-resolution to a single profile when account_id is unknown.", - required=False, - default="", - ), - ParamDef( - name="media_urls", - type="str", - description="Comma-separated URLs of media files to attach", - required=False, - default="", - ), - ], -) - -POSTS_CROSS_POST = ToolDef( - name="posts_cross_post", - summary="Post the same content to multiple platforms.", - description="""Post the same content to multiple platforms at once. - -⚠️ IMPORTANT - Choose the correct mode based on user intent: - -**DRAFT MODE (is_draft=True)** -Use when user says: "draft", "borrador", "save for later", "don't publish" -→ Posts are saved but NOT published. User can edit them later. - -**IMMEDIATE MODE (publish_now=True)** -Use when user says: "publish now", "post now", "immediately" -→ Posts go live IMMEDIATELY on all platforms. - -**SCHEDULED MODE (default)** -Use when user says: "schedule", "programar", "in X hours" -→ Posts are scheduled for 1 hour from now. - -⚠️ MULTI-ACCOUNT USERS: -- To pick a specific account per platform, pass `account_ids` parallel to - `platforms` (same order, comma-separated). Use empty string for a position - to fall back to profile/auto-resolution. -- To target multiple accounts of the SAME platform in one call, repeat the - platform: platforms='twitter,twitter', account_ids='acc_a,acc_b'. -- If you omit account_ids and the user has multiple accounts for any of the - requested platforms, the tool errors with the candidate list - use it to retry.""", - params=[ - ParamDef( - name="content", - type="str", - description="The post text/content", - required=True, - ), - ParamDef( - name="platforms", - type="str", - description="Comma-separated list of platforms (e.g., 'twitter,linkedin,bluesky'). Repeat a platform to target multiple accounts of it: 'twitter,twitter'.", - required=True, - ), - ParamDef( - name="account_ids", - type="str", - description="Comma-separated account IDs, parallel to `platforms`. Empty positions fall back to profile/auto-resolution. Required for multi-account users to disambiguate.", - required=False, - default="", - ), - ParamDef( - name="profile_id", - type="str", - description="Scope auto-resolution to one profile when account_ids is empty.", - required=False, - default="", - ), - ParamDef( - name="is_draft", - type="bool", - description="Set to True to save as DRAFT (not published). Use when user wants to save without publishing", - required=False, - default=False, - ), - ParamDef( - name="publish_now", - type="bool", - description="Set to True to publish IMMEDIATELY to all platforms", - required=False, - default=False, - ), - ParamDef( - name="media_urls", - type="str", - description="Comma-separated URLs of media files to attach", - required=False, - default="", - ), - ], -) - -POSTS_UPDATE = ToolDef( - name="posts_update", - summary="Update an existing post.", - description="""Update an existing post. - -Only draft, scheduled, and failed posts can be updated. -Published posts cannot be modified.""", - params=[ - ParamDef( - name="post_id", - type="str", - description="The post ID to update", - required=True, - ), - ParamDef( - name="content", - type="str", - description="New content (leave empty to keep current)", - required=False, - default="", - ), - ParamDef( - name="scheduled_for", - type="str", - description="New schedule time as ISO string (leave empty to keep current)", - required=False, - default="", - ), - ParamDef( - name="title", - type="str", - description="New title (leave empty to keep current)", - required=False, - default="", - ), - ], -) - -POSTS_DELETE = ToolDef( - name="posts_delete", - summary="Delete a post.", - description="""Delete a post by ID. - -Published posts cannot be deleted.""", - params=[ - ParamDef( - name="post_id", - type="str", - description="The post ID to delete", - required=True, - ), - ], -) - -POSTS_RETRY = ToolDef( - name="posts_retry", - summary="Retry a failed post.", - description="Retry publishing a failed post. Only works on posts with 'failed' status.", - params=[ - ParamDef( - name="post_id", - type="str", - description="The ID of the failed post to retry", - required=True, - ), - ], -) - -POSTS_LIST_FAILED = ToolDef( - name="posts_list_failed", - summary="List all failed posts.", - description="List all failed posts that can be retried.", - params=[ - ParamDef( - name="limit", - type="int", - description="Maximum number of posts to return", - required=False, - default=10, - ), - ], -) - -POSTS_RETRY_ALL_FAILED = ToolDef( - name="posts_retry_all_failed", - summary="Retry all failed posts.", - description="Retry all failed posts at once.", - params=[], -) - -# ============================================================================= -# DOCS TOOLS -# ============================================================================= - -DOCS_SEARCH = ToolDef( - name="docs_search", - summary="Search the Late API documentation.", - description="""Search across the Late API documentation to find relevant information, code examples, API references, and guides. - -Use this tool when you need to answer questions about Late, find specific documentation, understand how features work, or locate implementation details. - -The search returns contextual content with section titles and relevant snippets.""", - params=[ - ParamDef( - name="query", - type="str", - description="Search query (e.g., 'webhooks', 'create post', 'authentication')", - required=True, - ), - ], -) - -# ============================================================================= -# MEDIA TOOLS -# ============================================================================= - -MEDIA_GENERATE_UPLOAD_LINK = ToolDef( - name="media_generate_upload_link", - summary="Generate an upload URL for media files.", - description="""Generate a unique upload URL for the user to upload files via browser. - -Use this when the user wants to include images or videos in their post. -The flow is: -1. Call this tool to get an upload URL -2. Ask the user to open the URL in their browser -3. User uploads files through the web interface -4. Call media_check_upload_status to get the uploaded file URLs -5. Use those URLs when creating the post with posts_create""", - params=[], -) - -MEDIA_CHECK_UPLOAD_STATUS = ToolDef( - name="media_check_upload_status", - summary="Check upload status and get file URLs.", - description="""Check the status of an upload token and get uploaded file URLs. - -Use this after the user has uploaded files through the browser upload page. -Returns: pending (waiting for upload), completed (files ready), or expired (token expired).""", - params=[ - ParamDef( - name="token", - type="str", - description="The upload token from media_generate_upload_link", - required=True, - ), - ], -) - - -# ============================================================================= -# ALL TOOL DEFINITIONS REGISTRY -# ============================================================================= - -TOOL_DEFINITIONS: dict[str, ToolDef] = { - # Accounts - "accounts_list": ACCOUNTS_LIST, - "accounts_get": ACCOUNTS_GET, - # Profiles - "profiles_list": PROFILES_LIST, - "profiles_get": PROFILES_GET, - "profiles_create": PROFILES_CREATE, - "profiles_update": PROFILES_UPDATE, - "profiles_delete": PROFILES_DELETE, - # Posts - "posts_list": POSTS_LIST, - "posts_get": POSTS_GET, - "posts_create": POSTS_CREATE, - "posts_publish_now": POSTS_PUBLISH_NOW, - "posts_cross_post": POSTS_CROSS_POST, - "posts_update": POSTS_UPDATE, - "posts_delete": POSTS_DELETE, - "posts_retry": POSTS_RETRY, - "posts_list_failed": POSTS_LIST_FAILED, - "posts_retry_all_failed": POSTS_RETRY_ALL_FAILED, - # Media - "media_generate_upload_link": MEDIA_GENERATE_UPLOAD_LINK, - "media_check_upload_status": MEDIA_CHECK_UPLOAD_STATUS, - # Docs - "docs_search": DOCS_SEARCH, -} - - -# ============================================================================= -# DOCUMENTATION GENERATION -# ============================================================================= - - -def generate_mdx_tools_reference() -> str: - """Generate MDX documentation for all tools.""" - sections = [ - "## Tool Reference", - "", - "Detailed parameters for each MCP tool.", - "", - ] - - # Group by category - categories = { - "Accounts": ["accounts_list", "accounts_get"], - "Profiles": [ - "profiles_list", - "profiles_get", - "profiles_create", - "profiles_update", - "profiles_delete", - ], - "Posts": [ - "posts_create", - "posts_publish_now", - "posts_cross_post", - "posts_list", - "posts_get", - "posts_update", - "posts_delete", - "posts_retry", - "posts_list_failed", - "posts_retry_all_failed", - ], - "Media": ["media_generate_upload_link", "media_check_upload_status"], - "Docs": ["docs_search"], - } - - for category, tool_names in categories.items(): - sections.append(f"### {category}") - sections.append("") - for name in tool_names: - tool = TOOL_DEFINITIONS.get(name) - if tool: - sections.append(tool.to_mdx_section()) - sections.append("") - - return "\n".join(sections) - - -def get_tool_docstring(tool_name: str) -> str: - """Get the docstring for a tool, formatted for MCP.""" - tool = TOOL_DEFINITIONS.get(tool_name) - if not tool: - return "" - return tool.get_docstring() diff --git a/tests/test_exhaustive.py b/tests/test_exhaustive.py index ebefe5d..c2a2ce0 100644 --- a/tests/test_exhaustive.py +++ b/tests/test_exhaustive.py @@ -537,69 +537,6 @@ def test_create_cross_post_error_result(self): assert result.error == "Rate limit exceeded" -# ============================================================================ -# MCP TESTS -# ============================================================================ - - -class TestMCPImports: - """Test MCP module imports.""" - - def test_import_mcp_server(self): - """Test MCP server import.""" - from late.mcp import mcp - - assert mcp is not None - - def test_import_mcp_tools(self): - """Test MCP tools can be imported.""" - from late.mcp.server import ( - accounts_get, - accounts_list, - media_check_upload_status, - media_generate_upload_link, - posts_create, - posts_cross_post, - posts_delete, - posts_get, - posts_list, - posts_list_failed, - posts_publish_now, - posts_retry, - posts_retry_all_failed, - posts_update, - profiles_create, - profiles_delete, - profiles_get, - profiles_list, - profiles_update, - ) - - # Accounts - assert accounts_list is not None - assert accounts_get is not None - # Profiles - assert profiles_list is not None - assert profiles_get is not None - assert profiles_create is not None - assert profiles_update is not None - assert profiles_delete is not None - # Posts - assert posts_list is not None - assert posts_get is not None - assert posts_create is not None - assert posts_publish_now is not None - assert posts_cross_post is not None - assert posts_update is not None - assert posts_delete is not None - assert posts_retry is not None - assert posts_list_failed is not None - assert posts_retry_all_failed is not None - # Media - assert media_generate_upload_link is not None - assert media_check_upload_status is not None - - # ============================================================================ # RATE LIMITER TESTS # ============================================================================ @@ -756,58 +693,6 @@ def test_generate_content(self): assert response.provider == "openai" -@pytest.mark.skipif( - not os.getenv("LATE_API_KEY"), - reason="LATE_API_KEY not set", -) -class TestIntegrationMCP: - """Integration tests for MCP tools.""" - - def test_mcp_list_accounts(self): - """Test MCP list_accounts tool.""" - from late.mcp.server import list_accounts - - result = list_accounts() - assert "account" in result.lower() or "connected" in result.lower() - - def test_mcp_list_posts(self): - """Test MCP list_posts tool.""" - from late.mcp.server import list_posts - - result = list_posts(limit=3) - assert isinstance(result, str) - - def test_mcp_get_account(self): - """Test MCP get_account tool.""" - from late.mcp.server import get_account - - result = get_account("twitter") - assert isinstance(result, str) - - def test_mcp_list_failed_posts(self): - """Test MCP list_failed_posts tool.""" - from late.mcp.server import list_failed_posts - - result = list_failed_posts(limit=5) - assert isinstance(result, str) - - def test_mcp_get_post(self): - """Test MCP get_post tool.""" - from late.mcp.server import get_post, list_posts - - # Get a post ID from list - posts_result = list_posts(limit=1) - # Extract post ID if available - if "ID:" in posts_result: - import re - match = re.search(r"ID: ([a-f0-9]+)", posts_result) - if match: - post_id = match.group(1) - result = get_post(post_id) - assert "Post ID:" in result - assert "Status:" in result - - # ============================================================================ # ASYNC TESTS # ============================================================================ diff --git a/tests/test_integration.py b/tests/test_integration.py index f785d07..8d90e00 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -536,49 +536,6 @@ def test_get_follower_stats_parses_stats_and_granularity( assert len(result.accounts) == 1 assert result.accounts[0].currentFollowers == 5000 - def test_format_follower_stats_response(self) -> None: - """_format_response must return lossless JSON for FollowerStatsResponse, - not the lossy 'Found N account(s): - platform: username' one-liner. - Pre-fix this returned the generic accounts branch output.""" - import json - - from late.mcp.generated_tools import _format_response - from late.models._generated.models import FollowerStatsResponse - - wire = { - "accounts": [ - { - "_id": "acc_111", - "platform": "linkedin", - "username": "acme", - "profileId": "prof_1", - "isActive": True, - "currentFollowers": 5000, - "growth": 100, - } - ], - "stats": { - "acc_111": [ - {"date": "2026-01-01", "followers": 4900}, - {"date": "2026-01-02", "followers": 5000}, - ] - }, - "granularity": "daily", - } - response = FollowerStatsResponse.model_validate(wire) - output = _format_response(response) - - # Must be valid JSON (model_dump_json path) - parsed = json.loads(output) - assert parsed.get("granularity") == "daily" - assert "stats" in parsed - # Must include the follower count from the daily series - first_series = list(parsed["stats"].values())[0] - assert any(point["followers"] == 5000 for point in first_series) - # Must NOT be the lossy one-liner - assert not output.startswith("Found ") - - # ============================================================================= # Media Resource Tests # ============================================================================= diff --git a/tests/test_resolve_account.py b/tests/test_resolve_account.py deleted file mode 100644 index d3cac7d..0000000 --- a/tests/test_resolve_account.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Unit tests for `_resolve_account` - the multi-account disambiguation -helper that replaced silent matching[0] selection in the MCP write tools. - -Coverage: - 1. account_id happy path - 2. account_id present but the account belongs to a different platform - 3. account_id not found / not accessible - 4. profile_id with exactly one matching account on the platform - 5. profile_id with no matching accounts (scoped error) - 6. ambiguous selection (>=2 accounts) without account_id raises with the - candidate list in the error message - 7. no accounts at all raises with available platforms (empty) - -These tests use a stubbed Zernio client so they run offline and don't depend -on any HTTP fixtures. The contract being tested is purely: - inputs -> account, or inputs -> ValueError() -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -import pytest - -from late.mcp.server import _resolve_account - -# --------------------------------------------------------------------------- -# Stubs -# --------------------------------------------------------------------------- - - -@dataclass -class _Account: - """Stand-in for the SDK Account model. Only exposes the fields the - resolver actually reads (field_id, platform, username, displayName).""" - - field_id: str - platform: str - username: str = "" - displayName: str = "" - - -@dataclass -class _ListResponse: - accounts: list[_Account] - - -class _AccountsResource: - """Stub of `client.accounts` that returns a fixed list, optionally - scoped by profile_id (matching the real SDK signature).""" - - def __init__(self, all_accounts: list[_Account], by_profile: dict[str, list[_Account]] | None = None): - self._all = all_accounts - self._by_profile = by_profile or {} - - def list(self, *, profile_id: str | None = None) -> _ListResponse: - if profile_id: - return _ListResponse(accounts=self._by_profile.get(profile_id, [])) - return _ListResponse(accounts=self._all) - - -class _Client: - def __init__(self, accounts: _AccountsResource): - self.accounts = accounts - - -def _make_client( - all_accounts: list[_Account], - by_profile: dict[str, list[_Account]] | None = None, -) -> Any: - return _Client(_AccountsResource(all_accounts, by_profile)) - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -def test_account_id_happy_path() -> None: - """When account_id is given and matches a real account on the requested - platform, return that account directly.""" - accs = [ - _Account(field_id="acc_1", platform="twitter", username="acme"), - _Account(field_id="acc_2", platform="twitter", username="beta"), - ] - client = _make_client(accs) - - result = _resolve_account(client, "twitter", account_id="acc_2") - - assert result.field_id == "acc_2" - assert result.username == "beta" - - -def test_account_id_wrong_platform_errors() -> None: - """If account_id resolves to a real account but for the wrong platform, - raise ValueError so the LLM can't post to the wrong network.""" - accs = [_Account(field_id="acc_1", platform="linkedin", username="bob")] - client = _make_client(accs) - - with pytest.raises(ValueError, match="is a linkedin account, not twitter"): - _resolve_account(client, "twitter", account_id="acc_1") - - -def test_account_id_not_found_errors() -> None: - """Unknown / inaccessible account_id raises explicitly rather than - falling back to auto-resolution.""" - client = _make_client([_Account(field_id="acc_1", platform="twitter")]) - - with pytest.raises(ValueError, match="not found or not accessible"): - _resolve_account(client, "twitter", account_id="acc_does_not_exist") - - -def test_profile_id_single_match_returns_account() -> None: - """profile_id scopes the lookup; when exactly one account in that - profile matches the platform, return it.""" - in_profile = [_Account(field_id="acc_p1", platform="twitter", username="acme")] - out_of_profile = [_Account(field_id="acc_o1", platform="twitter", username="other")] - client = _make_client( - all_accounts=in_profile + out_of_profile, - by_profile={"prof_acme": in_profile}, - ) - - result = _resolve_account(client, "twitter", profile_id="prof_acme") - - assert result.field_id == "acc_p1" - - -def test_profile_id_no_match_errors_with_scope() -> None: - """When the profile exists but has no account on the requested platform, - the error should mention the profile so the LLM knows where to look.""" - client = _make_client( - all_accounts=[], - by_profile={"prof_x": [_Account(field_id="acc_p", platform="linkedin")]}, - ) - - with pytest.raises(ValueError, match="in profile prof_x"): - _resolve_account(client, "twitter", profile_id="prof_x") - - -def test_ambiguous_selection_lists_candidates() -> None: - """The core bug fix: when 2+ accounts match without an explicit - account_id, raise ValueError with all candidate IDs in the message so - the LLM can pick one and retry. Previously this silently returned - matching[0], which routed agency posts to the wrong client.""" - accs = [ - _Account(field_id="acc_1", platform="twitter", username="acme"), - _Account(field_id="acc_2", platform="twitter", username="beta"), - _Account(field_id="acc_3", platform="twitter", username="gamma"), - ] - client = _make_client(accs) - - with pytest.raises(ValueError) as exc_info: - _resolve_account(client, "twitter") - - msg = str(exc_info.value) - assert "Multiple twitter accounts" in msg - # Every candidate ID must appear so the LLM has the full menu. - assert "acc_1" in msg - assert "acc_2" in msg - assert "acc_3" in msg - # Usernames help the LLM pick by semantic name. - assert "acme" in msg - assert "beta" in msg - - -def test_no_accounts_at_all_errors() -> None: - """When the user has no accounts, the error should still be readable - and mention an empty available-platforms list.""" - client = _make_client([]) - - with pytest.raises(ValueError, match="No twitter account found"): - _resolve_account(client, "twitter") diff --git a/uv.lock b/uv.lock index 8a6e97e..3abb01d 100644 --- a/uv.lock +++ b/uv.lock @@ -10,40 +10,6 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform != 'win32'", ] -[[package]] -name = "aiofile" -version = "3.9.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'win32'", - "python_full_version < '3.11' and sys_platform != 'win32'", -] -dependencies = [ - { name = "caio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, -] - -[[package]] -name = "aiofile" -version = "3.11.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", -] -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 = "annotated-types" version = "0.7.0" @@ -95,28 +61,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - -[[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 = "backports-asyncio-runner" version = "1.2.0" @@ -126,24 +70,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] -[[package]] -name = "backports-tarfile" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, -] - -[[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 = "black" version = "25.12.0" @@ -188,44 +114,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, ] -[[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/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, - { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, - { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, - { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, - { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, - { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, - { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, - { 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/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, - { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, - { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, - { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, - { 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 = "2025.11.12" @@ -235,88 +123,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - [[package]] name = "click" version = "8.3.1" @@ -442,88 +248,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, -] - -[[package]] -name = "cyclopts" -version = "4.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/53/977540be379b0c82550df872912446def343ab0dfa138b8726120427f83d/cyclopts-4.21.0.tar.gz", hash = "sha256:477c18c791c924cca4836f79fce000a7bae45f551e340d9e1654e102c6d9ab9d", size = 190914, upload-time = "2026-07-09T19:07:07.154Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/f6/1c671874560a70d902dd57028a75411c176910de2df3d6a6a533de424131/cyclopts-4.21.0-py3-none-any.whl", hash = "sha256:ded3ddb15b0c815f44d245011fc4cdd5a4809a3bb8202869e9e02195a87c0e18", size = 230066, upload-time = "2026-07-09T19:07:05.326Z" }, -] - [[package]] name = "datamodel-code-generator" version = "0.43.0" @@ -554,15 +278,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[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.17.0" @@ -572,94 +287,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] -[[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", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] 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.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastmcp-slim", extra = ["client", "server"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458, upload-time = "2026-07-09T00:32:41.352Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019, upload-time = "2026-07-09T00:32:39.411Z" }, -] - -[[package]] -name = "fastmcp-slim" -version = "3.4.4" -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/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" }, -] - -[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 = "genson" version = "1.3.0" @@ -669,15 +308,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, ] -[[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" @@ -700,49 +330,6 @@ 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 = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, - { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, - { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, -] - [[package]] name = "httpx" version = "0.28.1" @@ -758,15 +345,6 @@ 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.11" @@ -776,18 +354,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "importlib-metadata" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, -] - [[package]] name = "inflect" version = "7.5.0" @@ -819,51 +385,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, ] -[[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" } -dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, -] -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 = "jinja2" version = "3.1.6" @@ -973,87 +494,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, ] -[[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.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, -] - -[[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 = "importlib-metadata", marker = "python_full_version < '3.12'" }, - { 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.7.3" @@ -1127,18 +567,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/0c/6605b6199de8178afe7efc77ca1d8e6db00453bc1d3349d27605c0f42104/librt-0.7.3-cp314-cp314t-win_arm64.whl", hash = "sha256:a9f9b661f82693eb56beb0605156c7fca57f535704ab91837405913417d6990b", size = 45647, upload-time = "2025-12-06T19:04:31.302Z" }, ] -[[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 = "markupsafe" version = "3.0.3" @@ -1224,40 +652,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[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 = "10.8.0" @@ -1341,30 +735,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/fd/ae2da789cd923dd033c99b8d544071a827c92046b150db01cfa5cea5b3fd/openai-2.9.0-py3-none-any.whl", hash = "sha256:0d168a490fbb45630ad508a6f3022013c155a68fd708069b6a1a01a5e8f0ffad", size = 1030836, upload-time = "2025-12-04T18:15:07.063Z" }, ] -[[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 = "25.0" @@ -1374,15 +744,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[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 = "0.12.1" @@ -1410,41 +771,6 @@ 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", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "anyio" }, -] -keyring = [ - { name = "keyring" }, -] -memory = [ - { name = "cachetools" }, -] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - [[package]] name = "pydantic" version = "2.12.5" @@ -1460,11 +786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - [[package]] name = "pydantic-core" version = "2.41.5" @@ -1583,20 +904,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -1606,29 +913,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pyjwt" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, -] - -[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.0.2" @@ -1675,24 +959,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - -[[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 = "pytokens" version = "0.3.0" @@ -1702,37 +968,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, -] - -[[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" @@ -1797,20 +1032,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -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.22.0" @@ -1823,154 +1044,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, ] -[[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 = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, -] - [[package]] name = "ruff" version = "0.14.8" @@ -1997,19 +1070,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, ] -[[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 = "sniffio" version = "1.3.1" @@ -2019,31 +1079,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sse-starlette" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, -] - -[[package]] -name = "starlette" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -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 = "tomli" version = "2.3.0" @@ -2138,258 +1173,9 @@ 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 = "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.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, - { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, - { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, - { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, - { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, - { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, -] - -[[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/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, - { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, - { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, - { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { 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/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { 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" }, -] - [[package]] name = "zernio-sdk" -version = "1.4.234" +version = "1.4.235" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -2402,7 +1188,6 @@ ai = [ ] all = [ { name = "anthropic" }, - { name = "mcp" }, { name = "openai" }, ] anthropic = [ @@ -2410,7 +1195,6 @@ anthropic = [ ] dev = [ { name = "datamodel-code-generator" }, - { name = "mcp" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2419,23 +1203,13 @@ dev = [ { name = "respx" }, { name = "ruff" }, ] -mcp = [ - { name = "fastmcp" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn", extra = ["standard"] }, -] [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'all'", specifier = ">=0.40.0" }, { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" }, { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = ">=0.26.0" }, - { name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=3.1.0" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "mcp", marker = "extra == 'all'", specifier = ">=1.8.0" }, - { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.8.0" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.8.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "openai", marker = "extra == 'all'", specifier = ">=1.0.0" }, @@ -2446,16 +1220,5 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, - { name = "starlette", marker = "extra == 'mcp'", specifier = ">=0.42.0" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'mcp'", specifier = ">=0.32.0" }, -] -provides-extras = ["ai", "anthropic", "all", "mcp", "dev"] - -[[package]] -name = "zipp" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] +provides-extras = ["ai", "anthropic", "all", "dev"]