From 38e6287b27c5f27aa90a40a4b1ffb0fe0aa50dc0 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Wed, 29 Apr 2026 21:42:24 -0500 Subject: [PATCH 1/4] feat(cli): JSON output for company list + endpoint list/info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --format / -f to: - bcli company list → [{id, name, alias, is_default}] - bcli endpoint list → [{name, category, custom, supported_ops, key_field, publisher, group, version, description}] - bcli endpoint info → object with the list shape plus entity_name, fields, source_table, page_number Output contracts are fixed and documented in each command's help text so external consumers can rely on them. The bcli-mcp server (next commit) shells out to these commands and depends on the JSON shapes. The default Rich-table output is unchanged. --- src/bcli_cli/commands/company_cmd.py | 59 +++++++++++++++++------- src/bcli_cli/commands/endpoint_cmd.py | 65 +++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 20 deletions(-) diff --git a/src/bcli_cli/commands/company_cmd.py b/src/bcli_cli/commands/company_cmd.py index 1a3464e..709554c 100644 --- a/src/bcli_cli/commands/company_cmd.py +++ b/src/bcli_cli/commands/company_cmd.py @@ -12,45 +12,72 @@ from bcli.config import save_config from bcli.config._model import CompanyAlias from bcli_cli._state import state -from bcli_cli.output import print_context_banner +from bcli_cli.output import format_output, print_context_banner app = typer.Typer(no_args_is_help=True) console = Console() @app.command("list") -def list_companies() -> None: - """List all companies in the current environment.""" +def list_companies( + format: Optional[str] = typer.Option( + None, "--format", "-f", + help="Output format: table (default), json, markdown, csv, ndjson", + ), +) -> None: + """List all companies in the current environment. + + JSON shape: ``[{"id": str, "name": str, "alias": str | null, "is_default": bool}]``. + Stable contract — consumed by the bcli-mcp server's ``list_companies`` tool. + """ + output_format = format or state.format + if output_format in ("json", "csv", "ndjson", "markdown", "md"): + state.quiet = True + print_context_banner() try: companies = asyncio.run(_fetch_companies()) profile = state.profile + alias_map = {c.id: alias for alias, c in profile.companies.items()} + # Structured rows used by every format. Keep field names stable — + # bcli-mcp's list_companies tool consumes this contract. + rows = [ + { + "id": company.get("id", ""), + "name": company.get("name", ""), + "alias": alias_map.get(company.get("id", ""), "") or None, + "is_default": ( + bool(profile.company_id) + and company.get("id", "") == profile.company_id + ), + } + for company in companies + ] + + if output_format and output_format != "table": + format_output(rows, output_format) + return + + # Default Rich-table presentation table = Table(show_header=True, header_style="bold") table.add_column("#", style="dim") table.add_column("Alias", style="cyan") table.add_column("Company Name") table.add_column("Company ID") - # Build reverse lookup: company_id → alias - alias_map = {c.id: alias for alias, c in profile.companies.items()} - - for i, company in enumerate(companies, 1): - name = company.get("name", "") - cid = company.get("id", "") - alias = alias_map.get(cid, "") - - # Highlight the current default company - if profile.company_id and cid == profile.company_id: + for i, row in enumerate(rows, 1): + name = row["name"] + alias = row["alias"] or "" + if row["is_default"]: name = f"[bold green]{name} ◄[/bold green]" if not alias: alias = "[dim]default[/dim]" - - table.add_row(str(i), alias, name, cid) + table.add_row(str(i), alias, name, row["id"]) console.print(table) - console.print(f"[dim]{len(companies)} company(ies)[/dim]") + console.print(f"[dim]{len(rows)} company(ies)[/dim]") if not profile.companies: console.print( diff --git a/src/bcli_cli/commands/endpoint_cmd.py b/src/bcli_cli/commands/endpoint_cmd.py index c514616..9eca791 100644 --- a/src/bcli_cli/commands/endpoint_cmd.py +++ b/src/bcli_cli/commands/endpoint_cmd.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json as _json from typing import Optional import typer @@ -10,25 +11,57 @@ from rich.table import Table from bcli_cli._state import state +from bcli_cli.output import format_output app = typer.Typer(no_args_is_help=True) console = Console() stderr_console = Console(stderr=True) +def _endpoint_to_dict(ep) -> dict: + """Stable JSON shape for an endpoint — consumed by bcli-mcp.""" + return { + "name": ep.entity_set_name, + "category": ep.category, + "custom": ep.is_custom, + "supported_ops": list(ep.supports), + "key_field": ep.key_field, + "publisher": ep.api_publisher, + "group": ep.api_group, + "version": ep.api_version, + "description": ep.description or "", + } + + @app.command("list") def list_endpoints( custom: bool = typer.Option(False, "--custom", help="Show only custom (imported) endpoints"), standard: bool = typer.Option(False, "--standard", help="Show only standard v2.0 endpoints"), category: Optional[str] = typer.Option(None, "--category", help="Filter by category"), + format: Optional[str] = typer.Option( + None, "--format", "-f", + help="Output format: table (default), json, markdown, csv, ndjson", + ), ) -> None: - """List all known endpoints (standard + custom).""" + """List all known endpoints (standard + custom). + + JSON shape: ``[{"name": str, "category": str, "custom": bool, + "supported_ops": [str], "key_field": str, "publisher": str|null, + "group": str|null, "version": str|null, "description": str}]``. + """ registry = state.registry endpoints = registry.list_all(custom_only=custom, standard_only=standard) if category: endpoints = [e for e in endpoints if e.category.lower() == category.lower()] + output_format = format or state.format + + if output_format and output_format != "table": + rows = [_endpoint_to_dict(ep) for ep in endpoints] + format_output(rows, output_format) + return + table = Table(show_header=True, header_style="bold") table.add_column("Entity", style="cyan") table.add_column("Route") @@ -78,16 +111,38 @@ def search_endpoints( @app.command("info") def endpoint_info( name: str = typer.Argument(help="Entity set name"), + format: Optional[str] = typer.Option( + None, "--format", "-f", + help="Output format: text (default) or json", + ), ) -> None: - """Show detailed metadata for an endpoint.""" + """Show detailed metadata for an endpoint. + + JSON shape extends ``endpoint list`` with ``entity_name``, ``fields`` + (from prior ``bcli endpoint fields`` runs), ``source_table``, and + ``page_number``. + """ ep = state.registry.get(name) if not ep: - console.print(f"[red]Endpoint '{name}' not found.[/red]") + stderr_console.print(f"[red]Endpoint '{name}' not found.[/red]") suggestions = state.registry.search(name)[:3] if suggestions: - console.print(f"[dim]Did you mean: {', '.join(s.entity_set_name for s in suggestions)}?[/dim]") + stderr_console.print( + f"[dim]Did you mean: {', '.join(s.entity_set_name for s in suggestions)}?[/dim]" + ) raise typer.Exit(1) + output_format = format or state.format + + if output_format == "json": + payload = _endpoint_to_dict(ep) + payload["entity_name"] = ep.entity_name + payload["fields"] = [{"name": f, "type": ""} for f in ep.field_names] + payload["source_table"] = ep.source_table + payload["page_number"] = ep.page_number + print(_json.dumps(payload, indent=2, default=str)) + return + console.print(f"[bold]{ep.entity_set_name}[/bold]") console.print(f" Entity name: {ep.entity_name}") console.print(f" Route: {ep.route_display}") @@ -101,6 +156,8 @@ def endpoint_info( console.print(f" Source table: {ep.source_table}") if ep.page_number: console.print(f" Page number: {ep.page_number}") + if ep.field_names: + console.print(f" Fields: {', '.join(ep.field_names)}") @app.command("fields") From d95da1ef1e876a023df9e61af7303bc242f83960 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Wed, 29 Apr 2026 21:42:46 -0500 Subject: [PATCH 2/4] feat(mcp): bcli-mcp preview server for Claude Desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/bcli_mcp/ — a preview MCP (Model Context Protocol) server that lets Claude Desktop and other MCP-aware clients drive bcli. Subprocess- only architecture: every tool delegates to 'bcli ... --format json' so profile resolution, auth, retry, telemetry, and the disable_writes gate are inherited from the CLI for free. Day-1 tool surface (4 read-only tools, deliberately small): - query - OData query against an entity, top defaults to 50 with a 1000 cap so an agent can't pull a whole table into context. - list_endpoints - Honours profile-level disable_standard_api, allowed_categories, allowed_endpoints filters. - describe_endpoint - Fields, key, supported ops, route. - list_companies - [{id, name, alias, is_default}] on the active environment. Mutating commands (post/patch/delete), file uploads, batch runs, and admin/setup flows are deliberately not exposed. The CLI fall-back path trips the existing disable_writes prompt for those. Trust model: __main__ chdirs to $HOME before instantiating FastMCP so .bcli.toml auto-discovery from cwd can't pick up a hostile project config inherited from Claude Desktop's launch directory. Documented in docs/mcp-server.md. Packaging: new [mcp] extra (depends on [cli] for the typer/rich deps the subprocesses need + mcp >= 1.0). New 'bcli-mcp' console script. src/bcli_mcp added to Hatch wheel packages. Tests: 32 unit tests covering the runner (subprocess wrapper edge cases, Rich-markup stripping, profile passthrough, malformed JSON, timeout) and the four tools (argv shape, top-cap enforcement, single- record wrap, profile passthrough). All 400 tests + 5 skipped pass. Out of scope (future): saved-query MCP tools, mutating MCP tools, plugin entry-point system, HTTP/SSE transport, version bump (lands as [Unreleased]). --- CHANGELOG.md | 17 ++ CLAUDE.md | 2 +- README.md | 1 + docs/mcp-server.md | 139 +++++++++++++++ pyproject.toml | 10 +- src/bcli_mcp/__init__.py | 21 +++ src/bcli_mcp/__main__.py | 37 ++++ src/bcli_mcp/_runner.py | 99 ++++++++++ src/bcli_mcp/_server.py | 138 ++++++++++++++ tests/test_mcp/__init__.py | 0 tests/test_mcp/test_runner.py | 140 +++++++++++++++ tests/test_mcp/test_server_tools.py | 166 +++++++++++++++++ uv.lock | 268 +++++++++++++++++++++++++++- 13 files changed, 1035 insertions(+), 3 deletions(-) create mode 100644 docs/mcp-server.md create mode 100644 src/bcli_mcp/__init__.py create mode 100644 src/bcli_mcp/__main__.py create mode 100644 src/bcli_mcp/_runner.py create mode 100644 src/bcli_mcp/_server.py create mode 100644 tests/test_mcp/__init__.py create mode 100644 tests/test_mcp/test_runner.py create mode 100644 tests/test_mcp/test_server_tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1e6a2..1346904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`bcli-mcp` preview server** — an MCP (Model Context Protocol) server + that lets Claude Desktop and other MCP clients drive bcli. Four + read-only tools: `query`, `list_endpoints`, `describe_endpoint`, + `list_companies`. Subprocess-only architecture inherits profile, auth, + retry, telemetry, and `disable_writes` from the CLI. Install with + `pip install "bc-cli[mcp]"`. See `docs/mcp-server.md`. + +### Changed + +- `bcli company list` accepts `--format` (`json`, `markdown`, `csv`, + `ndjson`, `table`). Stable JSON shape: + `[{"id", "name", "alias", "is_default"}]`. +- `bcli endpoint list` and `bcli endpoint info` accept `--format json`. + Stable JSON shapes documented inline in each command's help text. + ## [0.1.2] — 2026-04-29 Security release. Closes four findings from a strix.ai run against the diff --git a/CLAUDE.md b/CLAUDE.md index 720e608..76f8a4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ uv run bcli --help ## Architecture -Two packages in `src/`: **bcli** (SDK library) and **bcli_cli** (Typer CLI). The CLI imports the SDK; the SDK has no dependency on the CLI. +Three packages in `src/`: **bcli** (SDK library), **bcli_cli** (Typer CLI), and **bcli_mcp** (MCP server, optional `[mcp]` extra). The CLI imports the SDK. `bcli_mcp` subprocesses `bcli` (the CLI) — no Python imports from `bcli_mcp` into `bcli` or `bcli_cli`. See `docs/mcp-server.md`. ``` bcli_cli (Typer CLI) → bcli (Python SDK) → Business Central APIs diff --git a/README.md b/README.md index b07531e..2ac6ca4 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ pip install -e ".[dev,etl]" | [Multi-Company](docs/multi-company.md) | Company aliases, cross-entity queries | | [Batch Operations](docs/batch-operations.md) | YAML batch files | | [SDK Usage](docs/sdk-usage.md) | Python SDK for developers and MCP servers | +| [MCP Server](docs/mcp-server.md) | Drive bcli from Claude Desktop via the `bcli-mcp` server (preview) | | [Command Reference](docs/command-reference.md) | Complete CLI command reference | | [Contributing](docs/contributing.md) | Development setup, architecture, testing | diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 0000000..b6e9f64 --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,139 @@ +# bcli-mcp — MCP server for Claude Desktop and other MCP clients + +> **Preview / experimental** in 0.2.x. Tool surface and JSON shapes may shift before we cut 1.0. + +`bcli-mcp` is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) +server that lets MCP-aware clients drive bcli. The intended caller is Claude +Desktop, but it also works with the official [MCP Inspector](https://github.com/modelcontextprotocol/inspector) +and any other client that speaks the spec. + +The server is deliberately small (4 read-only tools) and delegates every call +to the bcli CLI as a subprocess. Profile resolution, auth, retry, telemetry, +and the read-only `disable_writes` gate are inherited from the CLI for free. + +## Install + +```bash +pip install "bc-cli[mcp]" +# or, with uv (recommended) +uv tool install "bc-cli[mcp]" +``` + +The `mcp` extra brings in the `cli` extras (typer/rich/pyyaml/keyring/workos) +plus the `mcp` package itself, since the server subprocesses bcli. + +After install, the `bcli-mcp` console script is on PATH: + +```bash +bcli-mcp --help # FastMCP doesn't currently print help — the script + # waits on stdio for an MCP client to connect. +``` + +## Configure Claude Desktop + +Add a `mcpServers` entry to `~/Library/Application Support/Claude/claude_desktop_config.json` +(macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): + +```json +{ + "mcpServers": { + "bcli": { + "command": "bcli-mcp", + "env": { + "BCLI_PROFILE": "production" + } + } + } +} +``` + +Restart Claude Desktop. You should see four tools register: `query`, +`list_endpoints`, `describe_endpoint`, `list_companies`. + +If `bcli-mcp` isn't on Claude Desktop's PATH (uv tool install paths can be +tricky), use the full path: + +```json +{ + "mcpServers": { + "bcli": { + "command": "/Users/you/.local/bin/bcli-mcp", + "env": { "BCLI_PROFILE": "production" } + } + } +} +``` + +## Tool surface + +| Tool | What it does | Notes | +|------|--------------|-------| +| `query` | Run an OData query against an entity. | `top` defaults to 50, capped at 1000. Use `select` to keep payloads small. | +| `list_endpoints` | List entities the active profile can reach. | Honours `disable_standard_api`, `allowed_categories`, `allowed_endpoints`. | +| `describe_endpoint` | Show fields, key, supported ops, and route for one entity. | `fields` is populated only after `bcli endpoint fields ` has been run. | +| `list_companies` | Companies on the active environment. | Returns `[{id, name, alias, is_default}]`. | + +Mutating commands (`post` / `patch` / `delete`), file uploads, batch runs, and +admin/setup flows are deliberately not exposed. Claude can always fall back to +`Bash` + `bcli` directly when those are needed — and that path trips the +existing `disable_writes` confirmation prompt. + +## Trust model — why the server resets cwd + +`bcli` auto-discovers `.bcli.toml` from the current working directory upward +(see `src/bcli/config/_loader.py`). Claude Desktop launches MCP servers +inheriting whatever cwd it was started from, which could be a directory +containing a stale or hostile project-level config. + +`bcli-mcp` mitigates this by `chdir`-ing to `$HOME` at startup, before +constructing the FastMCP server or running any tool. The trusted config +sources are then exactly: + +* `~/.config/bcli/config.toml` (global config) +* `BCLI_PROFILE` (env var, set in `claude_desktop_config.json`) + +Per-tool calls do not honour a per-request `cwd` argument. The server runs +with a single fixed working directory for its lifetime. + +## Token economy — when the MCP wins, when it loses + +Pairing an MCP server with a CLI tool is empirically token-favorable for +**bounded, schema-stable** responses. The OSS server ships with two +guard-rails baked in: + +* `query.top` defaults to 50 (max 1000) — an unbounded request can't pull a + whole table into context. +* Tool docstrings are short. The schema-payload Claude sees is small. + +It is **not** universally a token win. For browse-style "show me everything" +workflows, falling back to `bcli get --format markdown` via Bash is +often cheaper because Claude renders compact markdown tables directly without +the JSON serialization overhead. Use the right tool for the shape of the +question. + +## Future: a Beautech-specific MCP (separate package) + +The OSS `bcli-mcp` is intentionally generic. Domain-specific MCPs that combine +BC OData with cross-system data (legal docs, market intel, fleet analytics) +should live in their own packages, installed alongside this one. + +The boundary rule: + +* **OSS owns** generic BC transport, query construction, registry, OData + escaping, auth, retry, telemetry plumbing. +* **Private package owns** domain tool composition only — tools like + `engine_lookup`, `lease_amendments_for`, `vendor_analytics` that combine + multiple BC queries with cross-system data into one named operation. +* The private MCP is a *consumer* of `bcli` (subprocess or + `from bcli import AsyncBCClient`), never a layer that re-implements + transport. + +Two separate MCP processes, two `mcpServers` blocks in +`claude_desktop_config.json`. Failure isolation: a private-package bug can't +crash the public MCP, and an OSS install never needs to skip optional +private-package imports. + +If exposing mutating tools from a private MCP, the package must either +subprocess `bcli post / patch / delete` (so the CLI's `disable_writes` gate +applies) or reimplement that gate locally — the SDK alone does not enforce +it. See `src/bcli_cli/_safety.py` for the canonical helper. diff --git a/pyproject.toml b/pyproject.toml index aaff941..02d6053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ Documentation = "https://github.com/igor-ctrl/bcli/tree/main/docs" [project.scripts] bcli = "bcli_cli.app:app" +bcli-mcp = "bcli_mcp:main" [project.optional-dependencies] cli = [ @@ -59,6 +60,12 @@ etl = [ telemetry = [ "azure-monitor-opentelemetry>=1.6", ] +mcp = [ + # The MCP server subprocesses bcli, which means it needs the CLI + # extras (typer, rich, pyyaml, keyring, workos) on PATH at runtime. + "bc-cli[cli]", + "mcp>=1.0", +] polaris = [ "bc-cli[etl]", "pyarrow>=16.0", @@ -66,6 +73,7 @@ polaris = [ ] dev = [ "bc-cli[cli]", + "bc-cli[mcp]", "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-httpx>=0.30", @@ -73,7 +81,7 @@ dev = [ ] [tool.hatch.build.targets.wheel] -packages = ["src/bcli", "src/bcli_cli"] +packages = ["src/bcli", "src/bcli_cli", "src/bcli_mcp"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/bcli_mcp/__init__.py b/src/bcli_mcp/__init__.py new file mode 100644 index 0000000..d4944ab --- /dev/null +++ b/src/bcli_mcp/__init__.py @@ -0,0 +1,21 @@ +"""bcli-mcp — MCP (Model Context Protocol) server that wraps the bcli CLI. + +The server exposes a small, deliberately tight surface (4 read-only tools) that +let an MCP-aware client (Claude Desktop, MCP Inspector, etc.) drive bcli. +Every tool subprocesses ``bcli ... --format json`` so profile resolution, +auth, retry, telemetry, and the read-only ``disable_writes`` gate are +inherited from the CLI for free. + +This package is preview/experimental in 0.2.0. See ``docs/mcp-server.md``. +""" + +from __future__ import annotations + +__all__ = ["main"] + + +def main() -> None: + """Entry point used by the ``bcli-mcp`` console script.""" + from bcli_mcp.__main__ import main as _main + + _main() diff --git a/src/bcli_mcp/__main__.py b/src/bcli_mcp/__main__.py new file mode 100644 index 0000000..8f50786 --- /dev/null +++ b/src/bcli_mcp/__main__.py @@ -0,0 +1,37 @@ +"""``python -m bcli_mcp`` / ``bcli-mcp`` entry point. + +Resets cwd to the user's home directory before constructing the FastMCP +server. Why: ``bcli`` config loading auto-discovers ``.bcli.toml`` from cwd +upward (see ``src/bcli/config/_loader.py``). Claude Desktop launches MCP +servers with whatever cwd it was started from, which could be a directory +containing a hostile or stale project-level ``.bcli.toml``. Pinning cwd to +``$HOME`` makes ``~/.config/bcli/config.toml`` the only trusted config +source for every subprocess this MCP launches. +""" + +from __future__ import annotations + +import os +import sys + + +def main() -> None: + # Trust-model: chdir BEFORE any bcli import or FastMCP instantiation + # so any side effects of import time (config probes, registry loads) + # also see the safe cwd. + os.chdir(os.path.expanduser("~")) + + try: + from bcli_mcp._server import mcp + except ImportError as exc: + sys.stderr.write( + f"bcli-mcp: failed to import server: {exc}\n" + "Hint: install the MCP extra: pip install 'bc-cli[mcp]'\n" + ) + raise SystemExit(1) from exc + + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/bcli_mcp/_runner.py b/src/bcli_mcp/_runner.py new file mode 100644 index 0000000..d5c09a5 --- /dev/null +++ b/src/bcli_mcp/_runner.py @@ -0,0 +1,99 @@ +"""Subprocess wrapper around ``bcli ... --format json``. + +The MCP server's tools all delegate to the bcli CLI. This module owns the +process boundary: build the argv, set the env (so ``BCLI_PROFILE`` / a +per-call profile override are honoured), capture stdout + stderr, parse the +JSON response, and surface a clean ``ToolError`` on non-zero exits with +Rich markup stripped from the error message. + +We deliberately do NOT import ``bcli`` Python modules here. Subprocess +delegation is the design — see ``docs/mcp-server.md``. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from typing import Any + +# Imported lazily so the runner module is importable in environments +# that don't have the optional ``mcp`` package installed (tests). +try: + from mcp.server.fastmcp.exceptions import ToolError as _ToolError +except ImportError: # pragma: no cover — optional dep missing in test envs + _ToolError = RuntimeError # type: ignore[assignment,misc] + + +# Strip Rich markup like [red]…[/red], [bold]…[/bold], [dim]…[/dim]. +# Rich tags are non-nested in bcli's CLI output — a single regex sweep is +# enough. We don't try to render them, just remove them so MCP error +# messages read cleanly to the model. +_RICH_MARKUP = re.compile(r"\[/?[a-zA-Z][a-zA-Z0-9 _#=,\-]*\]") + + +def _strip_rich(text: str) -> str: + return _RICH_MARKUP.sub("", text).strip() + + +def run_bcli_json( + *args: str, + profile: str | None = None, + timeout: float = 120.0, +) -> Any: + """Run ``bcli --format json`` and return parsed JSON. + + ``profile`` overrides ``BCLI_PROFILE`` for this call only (the env-var + inherited by the MCP server still acts as the default). Other env vars + pass through unchanged. + + Raises ``ToolError`` on non-zero exit, malformed JSON, or timeout. The + error message has Rich markup stripped so the agent sees plain text. + """ + argv = ["bcli"] + if profile: + argv.extend(["--profile", profile]) + argv.extend(args) + argv.extend(["--format", "json"]) + + env = os.environ.copy() + if profile: + env["BCLI_PROFILE"] = profile + + try: + proc = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=timeout, + env=env, + check=False, + ) + except FileNotFoundError as exc: + raise _ToolError( + "bcli executable not found on PATH. Install with " + "'pip install bc-cli[cli]' or 'uv tool install bc-cli'." + ) from exc + except subprocess.TimeoutExpired as exc: + raise _ToolError( + f"bcli {' '.join(args)} timed out after {timeout}s" + ) from exc + + if proc.returncode != 0: + message = _strip_rich(proc.stderr or proc.stdout or "(no output)") + raise _ToolError( + f"bcli {' '.join(args)} exited {proc.returncode}: {message}" + ) + + if not proc.stdout.strip(): + # Some CLI commands print to stderr only when there's no data; + # surface that as an empty list rather than an error. + return [] + + try: + return json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise _ToolError( + f"bcli {' '.join(args)} produced non-JSON output: {exc}" + ) from exc diff --git a/src/bcli_mcp/_server.py b/src/bcli_mcp/_server.py new file mode 100644 index 0000000..7fa0c31 --- /dev/null +++ b/src/bcli_mcp/_server.py @@ -0,0 +1,138 @@ +"""FastMCP server with the day-1 bcli tool surface (4 tools). + +Design constraints: + +* Token economy. Tool docstrings are short and concrete. ``query`` caps + ``top`` at 1000 with a sane default of 50 so an unbounded request can't + pull a whole table into context. +* Subprocess only. Every tool delegates to ``run_bcli_json`` so profile + resolution, auth, retry, telemetry, and the registry filters that + ``bcli_cli._state`` applies (``disable_standard_api``, + ``allowed_categories``) are inherited for free. +* Read-only. Mutating commands (post/patch/delete, batch, attach upload) + are deliberately NOT exposed — they go through the CLI directly where + the existing ``disable_writes`` prompt protects. +""" + +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from bcli_mcp._runner import run_bcli_json + +mcp = FastMCP("bcli") + +# Hard caps so an agent can't accidentally pull a whole table. +_QUERY_DEFAULT_TOP = 50 +_QUERY_MAX_TOP = 1000 + + +@mcp.tool() +async def query( + entity: str, + filter: str | None = None, + select: str | None = None, + top: int | None = None, + skip: int | None = None, + orderby: str | None = None, + expand: str | None = None, + record_id: str | None = None, + publisher: str | None = None, + group: str | None = None, + version: str | None = None, + profile: str | None = None, +) -> list[dict[str, Any]]: + """Run an OData query against a Business Central entity. + + Returns a list of records. ``top`` defaults to 50 and is capped at + 1000 — for browse-style "show me everything" use the bcli CLI directly. + Use ``select`` to limit fields and keep responses small. + + ``entity`` is the OData entity-set name (e.g. ``customers``, + ``salesInvoices``). Use ``list_endpoints`` to discover what's + available; ``describe_endpoint`` to see the field shape. + """ + effective_top = top if top is not None else _QUERY_DEFAULT_TOP + if effective_top < 1: + effective_top = 1 + if effective_top > _QUERY_MAX_TOP: + effective_top = _QUERY_MAX_TOP + + args: list[str] = ["get", entity] + if record_id: + args.append(record_id) + if filter: + args.extend(["--filter", filter]) + if select: + args.extend(["--select", select]) + args.extend(["--top", str(effective_top)]) + if skip is not None: + args.extend(["--skip", str(skip)]) + if orderby: + args.extend(["--orderby", orderby]) + if expand: + args.extend(["--expand", expand]) + if publisher: + args.extend(["--publisher", publisher]) + if group: + args.extend(["--group", group]) + if version: + args.extend(["--version", version]) + + result = run_bcli_json(*args, profile=profile) + # `bcli get ` returns a list. `bcli get ` + # returns a single object — wrap so the tool's return type is stable. + if isinstance(result, dict): + return [result] + return result + + +@mcp.tool() +async def list_endpoints( + category: str | None = None, + custom_only: bool = False, + standard_only: bool = False, + profile: str | None = None, +) -> list[dict[str, Any]]: + """List Business Central entities the active profile can reach. + + Returns ``[{name, category, custom, supported_ops, key_field, + publisher, group, version, description}]``. Honours profile-level + filters (``disable_standard_api``, ``allowed_categories``). + """ + args = ["endpoint", "list"] + if custom_only: + args.append("--custom") + if standard_only: + args.append("--standard") + if category: + args.extend(["--category", category]) + return run_bcli_json(*args, profile=profile) + + +@mcp.tool() +async def describe_endpoint( + name: str, + profile: str | None = None, +) -> dict[str, Any]: + """Show fields, key, supported operations, and route for one entity. + + ``fields`` is populated only after ``bcli endpoint fields `` has + been run for the entity (it costs one BC API call). Empty list = not + yet discovered. + """ + return run_bcli_json("endpoint", "info", name, profile=profile) + + +@mcp.tool() +async def list_companies( + profile: str | None = None, +) -> list[dict[str, Any]]: + """List companies on the active environment. + + Returns ``[{id, name, alias, is_default}]``. ``alias`` is the local + nickname configured via ``bcli company alias …`` (null if unset). + """ + return run_bcli_json("company", "list", profile=profile) diff --git a/tests/test_mcp/__init__.py b/tests/test_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mcp/test_runner.py b/tests/test_mcp/test_runner.py new file mode 100644 index 0000000..b493129 --- /dev/null +++ b/tests/test_mcp/test_runner.py @@ -0,0 +1,140 @@ +"""Tests for the bcli-mcp subprocess wrapper. + +The wrapper translates a Python tool call into ``bcli ... --format json``, +parses stdout, and surfaces non-zero exits as ``ToolError`` with Rich +markup stripped. These tests mock ``subprocess.run`` so no real bcli +process is spawned. +""" + +from __future__ import annotations + +import json +import subprocess +from unittest.mock import patch + +import pytest + +from bcli_mcp._runner import _strip_rich, run_bcli_json +from mcp.server.fastmcp.exceptions import ToolError + + +def _mock_completed(returncode: int, stdout: str = "", stderr: str = ""): + return subprocess.CompletedProcess( + args=["bcli"], returncode=returncode, stdout=stdout, stderr=stderr, + ) + + +# ── _strip_rich ────────────────────────────────────────────────────────── + + +class TestStripRich: + def test_removes_simple_color_tags(self): + assert _strip_rich("[red]Error[/red]") == "Error" + + def test_removes_nested_styles(self): + assert _strip_rich("[bold][cyan]name[/cyan][/bold]") == "name" + + def test_removes_dim_with_attribute(self): + assert _strip_rich("[dim]42 records[/dim]") == "42 records" + + def test_passes_through_plain_text(self): + assert _strip_rich("plain text") == "plain text" + + def test_strips_leading_trailing_whitespace(self): + assert _strip_rich(" [red]oops[/red]\n") == "oops" + + def test_handles_unmatched_tags(self): + # Unbalanced tags still get scrubbed (best-effort) + assert _strip_rich("[red]oops") == "oops" + + +# ── run_bcli_json — happy path ─────────────────────────────────────────── + + +class TestRunnerHappyPath: + def test_parses_json_array(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout='[{"id": "c-1"}]') + result = run_bcli_json("company", "list") + assert result == [{"id": "c-1"}] + + def test_parses_json_object(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout='{"name": "customers"}') + result = run_bcli_json("endpoint", "info", "customers") + assert result == {"name": "customers"} + + def test_appends_format_json_flag(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="[]") + run_bcli_json("get", "customers") + argv = run.call_args.args[0] + assert argv[-2:] == ["--format", "json"] + assert argv[0] == "bcli" + + def test_empty_stdout_returns_empty_list(self): + """Some CLI commands print nothing to stdout when there's no data — + treat that as an empty result, not a parse failure.""" + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="", stderr="") + assert run_bcli_json("get", "customers") == [] + + +# ── Profile passthrough ────────────────────────────────────────────────── + + +class TestRunnerProfile: + def test_profile_overrides_argv_and_env(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="[]") + run_bcli_json("get", "customers", profile="prod") + argv = run.call_args.args[0] + env = run.call_args.kwargs["env"] + # --profile lands before the subcommand + assert "--profile" in argv and argv[argv.index("--profile") + 1] == "prod" + # And BCLI_PROFILE env var is set so any nested process honours it too + assert env["BCLI_PROFILE"] == "prod" + + def test_no_profile_omits_flag(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="[]") + run_bcli_json("get", "customers") + argv = run.call_args.args[0] + assert "--profile" not in argv + + +# ── Error paths ────────────────────────────────────────────────────────── + + +class TestRunnerErrors: + def test_non_zero_exit_raises_toolerror_with_stripped_markup(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed( + 1, stdout="", stderr="[red]Auth failed:[/red] [bold]401[/bold]", + ) + with pytest.raises(ToolError, match=r"exited 1.*Auth failed.*401"): + run_bcli_json("get", "customers") + + def test_falls_back_to_stdout_when_stderr_empty(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed( + 2, stdout="usage: bcli get …", stderr="", + ) + with pytest.raises(ToolError, match=r"usage: bcli get"): + run_bcli_json("get") + + def test_malformed_json_raises_toolerror(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="500") + with pytest.raises(ToolError, match=r"non-JSON output"): + run_bcli_json("get", "customers") + + def test_missing_bcli_binary_raises_toolerror(self): + with patch("subprocess.run", side_effect=FileNotFoundError("bcli")): + with pytest.raises(ToolError, match=r"bcli executable not found"): + run_bcli_json("get", "customers") + + def test_timeout_raises_toolerror(self): + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(["bcli"], 1)): + with pytest.raises(ToolError, match=r"timed out"): + run_bcli_json("get", "customers", timeout=1) diff --git a/tests/test_mcp/test_server_tools.py b/tests/test_mcp/test_server_tools.py new file mode 100644 index 0000000..d74186e --- /dev/null +++ b/tests/test_mcp/test_server_tools.py @@ -0,0 +1,166 @@ +"""Tests for the bcli-mcp tool callables. + +We invoke each ``@mcp.tool()`` function directly with the runner mocked. +Tests assert: argv shape passed to bcli, top-cap enforcement on ``query``, +single-record wrap behaviour, and profile passthrough. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from bcli_mcp import _server + + +# ── query ───────────────────────────────────────────────────────────────── + + +class TestQueryTool: + @pytest.mark.asyncio + async def test_default_top_is_50(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.query(entity="customers") + args = run.call_args.args + assert "--top" in args + assert args[args.index("--top") + 1] == "50" + + @pytest.mark.asyncio + async def test_explicit_top_passes_through(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.query(entity="customers", top=200) + args = run.call_args.args + assert args[args.index("--top") + 1] == "200" + + @pytest.mark.asyncio + async def test_top_clamps_at_max(self): + """An agent asking for 50000 records gets clamped — keeps responses + bounded so the model can't accidentally swamp its own context.""" + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.query(entity="customers", top=50_000) + args = run.call_args.args + assert args[args.index("--top") + 1] == "1000" + + @pytest.mark.asyncio + async def test_top_floor_is_1(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.query(entity="customers", top=0) + args = run.call_args.args + assert args[args.index("--top") + 1] == "1" + + @pytest.mark.asyncio + async def test_filter_select_orderby_passed_through(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.query( + entity="customers", + filter="displayName eq 'X'", + select="number,displayName", + orderby="number desc", + ) + args = run.call_args.args + assert "--filter" in args + assert args[args.index("--filter") + 1] == "displayName eq 'X'" + assert args[args.index("--select") + 1] == "number,displayName" + assert args[args.index("--orderby") + 1] == "number desc" + + @pytest.mark.asyncio + async def test_record_id_appended_after_entity(self): + with patch("bcli_mcp._server.run_bcli_json", return_value={"id": "c-1"}) as run: + result = await _server.query(entity="customers", record_id="c-1") + args = run.call_args.args + assert args[:3] == ("get", "customers", "c-1") + # Single-record dict gets wrapped to keep return type stable + assert result == [{"id": "c-1"}] + + @pytest.mark.asyncio + async def test_publisher_group_version_passed_through(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.query( + entity="customEntity", + publisher="acme", group="finance", version="v1.5", + ) + args = run.call_args.args + assert args[args.index("--publisher") + 1] == "acme" + assert args[args.index("--group") + 1] == "finance" + assert args[args.index("--version") + 1] == "v1.5" + + @pytest.mark.asyncio + async def test_profile_passes_to_runner(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.query(entity="customers", profile="sandbox") + assert run.call_args.kwargs["profile"] == "sandbox" + + +# ── list_endpoints ──────────────────────────────────────────────────────── + + +class TestListEndpointsTool: + @pytest.mark.asyncio + async def test_basic_call(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.list_endpoints() + assert run.call_args.args == ("endpoint", "list") + + @pytest.mark.asyncio + async def test_filters_passed_through(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.list_endpoints( + category="finance", custom_only=True, + ) + args = run.call_args.args + assert "--custom" in args + assert args[args.index("--category") + 1] == "finance" + + @pytest.mark.asyncio + async def test_standard_only_flag(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.list_endpoints(standard_only=True) + assert "--standard" in run.call_args.args + + +# ── describe_endpoint ───────────────────────────────────────────────────── + + +class TestDescribeEndpointTool: + @pytest.mark.asyncio + async def test_calls_endpoint_info(self): + with patch( + "bcli_mcp._server.run_bcli_json", + return_value={"name": "customers"}, + ) as run: + result = await _server.describe_endpoint(name="customers") + assert run.call_args.args == ("endpoint", "info", "customers") + assert result == {"name": "customers"} + + +# ── list_companies ──────────────────────────────────────────────────────── + + +class TestListCompaniesTool: + @pytest.mark.asyncio + async def test_calls_company_list(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.list_companies() + assert run.call_args.args == ("company", "list") + + @pytest.mark.asyncio + async def test_profile_propagates(self): + with patch("bcli_mcp._server.run_bcli_json", return_value=[]) as run: + await _server.list_companies(profile="sandbox") + assert run.call_args.kwargs["profile"] == "sandbox" + + +# ── Tool registration sanity ────────────────────────────────────────────── + + +class TestServerRegistration: + def test_four_tools_exposed(self): + """The tool surface is deliberately small — the design contract.""" + registered = set(_server.mcp._tool_manager._tools.keys()) + assert registered == { + "query", + "list_endpoints", + "describe_endpoint", + "list_companies", + } diff --git a/uv.lock b/uv.lock index ff12ca2..37620f9 100644 --- a/uv.lock +++ b/uv.lock @@ -321,6 +321,7 @@ cli = [ ] dev = [ { name = "keyring" }, + { name = "mcp" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-httpx" }, @@ -333,6 +334,14 @@ dev = [ etl = [ { name = "dlt", extra = ["filesystem", "parquet", "s3"] }, ] +mcp = [ + { name = "keyring" }, + { name = "mcp" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "typer" }, + { name = "workos" }, +] polaris = [ { name = "dlt", extra = ["filesystem", "parquet", "s3"] }, { name = "pyarrow" }, @@ -346,10 +355,13 @@ telemetry = [ requires-dist = [ { name = "azure-monitor-opentelemetry", marker = "extra == 'telemetry'", specifier = ">=1.6" }, { name = "bc-cli", extras = ["cli"], marker = "extra == 'dev'" }, + { name = "bc-cli", extras = ["cli"], marker = "extra == 'mcp'" }, { name = "bc-cli", extras = ["etl"], marker = "extra == 'polaris'" }, + { name = "bc-cli", extras = ["mcp"], marker = "extra == 'dev'" }, { name = "dlt", extras = ["parquet", "filesystem", "s3"], marker = "extra == 'etl'", specifier = ">=1.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "keyring", marker = "extra == 'cli'", specifier = ">=25.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "msal", specifier = ">=1.28" }, { name = "pyarrow", marker = "extra == 'polaris'", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.0" }, @@ -364,7 +376,7 @@ requires-dist = [ { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12" }, { name = "workos", marker = "extra == 'cli'", specifier = ">=5.0" }, ] -provides-extras = ["cli", "etl", "telemetry", "polaris", "dev"] +provides-extras = ["cli", "etl", "telemetry", "mcp", "polaris", "dev"] [[package]] name = "botocore" @@ -871,6 +883,15 @@ 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 = "humanize" version = "4.15.0" @@ -985,6 +1006,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/5a/73ecb3d82f8615f32ccdadeb9356726d6cae3a4bbc840b437ceb95708063/jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6", size = 30105, upload-time = "2024-11-20T17:58:30.418Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-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" @@ -1015,6 +1063,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "mcp" +version = "1.27.0" +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/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2005,6 +2078,20 @@ 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.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2207,6 +2294,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, +] + [[package]] name = "pytz" version = "2026.1.post1" @@ -2299,6 +2404,20 @@ 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 = "requests" version = "2.33.1" @@ -2364,6 +2483,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/80/97b6f357ac458d9ad9872cc3183ca09ef7439ac89e030ea43053ba1294b6/rich_argparse-1.7.2-py3-none-any.whl", hash = "sha256:0559b1f47a19bbeb82bf15f95a057f99bcbbc98385532f57937f9fc57acc501a", size = 25476, upload-time = "2025-11-01T10:35:42.681Z" }, ] +[[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/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.15.10" @@ -2518,6 +2745,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/19/7df8b292accba3bc0de92c611c1e89423b25c08c82c18b14ca1fdbcf6e44/sqlglot-30.4.3-py3-none-any.whl", hash = "sha256:58ea8e723444569da5cec91e4c8f16e385bce3f0ce0374b8c722c3088e1c1c7a", size = 670965, upload-time = "2026-04-13T17:05:13.128Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/9a/f35932a8c0eb6b2287b66fa65a0321df8c84e4e355a659c1841a37c39fdb/sse_starlette-3.4.1.tar.gz", hash = "sha256:f780bebcf6c8997fe514e3bd8e8c648d8284976b391c8bed0bcb1f611632b555", size = 35127, upload-time = "2026-04-26T13:32:32.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/45c21ed03d708c477367305726b89919b020a3a2a01f72aaf5ad941caf35/sse_starlette-3.4.1-py3-none-any.whl", hash = "sha256:6b43cf21f1d574d582a6e1b0cfbde1c94dc86a32a701a7168c99c4475c6bd1d0", size = 16487, upload-time = "2026-04-26T13:32:30.819Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +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/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "strictyaml" version = "1.7.3" @@ -2602,6 +2855,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + [[package]] name = "win-precise-time" version = "1.4.2" From b02997e94e2f5880eacd8667d2023fb3b918f377 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Thu, 30 Apr 2026 13:09:38 -0500 Subject: [PATCH 3/4] feat(mcp): fields_discovered hint + opt-in field discovery Real-world feedback from a Claude Desktop session: when describe_endpoint returns 'fields': [], the agent can't tell whether the entity has no fields or whether the local registry just hasn't probed BC yet. Two fixes land here: 1. bcli endpoint info --format json now includes fields_discovered: bool. False means the cache is cold; the agent should either probe via query(top=1) or call describe_endpoint with discover_fields=True. 2. bcli-mcp describe_endpoint accepts discover_fields: bool = False. When true, the tool runs 'bcli endpoint fields ' first as a side-effect call (new run_bcli_side_effect helper that doesn't parse stdout), populates the registry, then returns the now- populated metadata. Discovery failures are swallowed so the agent still gets a usable response with fields_discovered=false. Also documents the BC query-object caveat in docs/mcp-server.md: some endpoints (e.g. customerSales, vendorPurchases) don't support server- side $orderby or $filter. The recovery pattern is bounded top + client- side sort, which is what an agent figures out empirically when an orderby attempt 400s. Tests: 9 new (4 for run_bcli_side_effect, 4 for the discover_fields parameter, 1 already covered the default). All 41 MCP tests + 409 project-wide tests pass; lint clean. --- docs/mcp-server.md | 47 +++++++++++++++++++++++++ src/bcli_cli/commands/endpoint_cmd.py | 5 +++ src/bcli_mcp/_runner.py | 49 +++++++++++++++++++++++++++ src/bcli_mcp/_server.py | 32 ++++++++++++++--- tests/test_mcp/test_runner.py | 45 +++++++++++++++++++++++- tests/test_mcp/test_server_tools.py | 49 +++++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 5 deletions(-) diff --git a/docs/mcp-server.md b/docs/mcp-server.md index b6e9f64..9f69510 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -95,6 +95,53 @@ sources are then exactly: Per-tool calls do not honour a per-request `cwd` argument. The server runs with a single fixed working directory for its lifetime. +## BC query objects vs entity pages — what to expect from `query()` + +Not every endpoint in BC's OData surface is a fully-featured entity. Some +are "query objects" — read-only summary pages exposed via OData (e.g. +`customerSales`, `vendorPurchases`). They behave like entities for `GET` +but Microsoft's runtime drops `$orderby` and `$filter` support on most of +them. A `query()` call against one of these with `orderby=` or `filter=` +will 400 from BC. + +How to recognise one: there's no flag in the registry today (it'd require +a hint per endpoint), so the practical signal is the 400 itself. The +recovery pattern that works: + +1. `query(entity="customerSales", top=1000)` — pull a bounded page. +2. Sort the result client-side in your reasoning step. +3. Take the top N. + +This is what an agent will figure out on its own when the orderby +attempt 400s. It also means **`top` must be large enough to contain the +true top-N** — if there are 5000 customers and you sort the first 1000, +you may miss the actual top customer. For BC tenants with very large +summary pages, fall back to `bcli get …` via Bash with `--all` (which +follows pagination). + +Entity pages (most of `bcli endpoint list`) support full OData. Use +`describe_endpoint(name)` to see whether `fields_discovered` is `true` +and what `fields` look like; the registry doesn't currently track which +endpoints are query objects vs entities, so you'll learn this empirically. + +## Discovering field names — `discover_fields` + +`describe_endpoint(name)` returns `fields: []` and `fields_discovered: +false` if the local registry hasn't probed BC for that entity yet. Two +ways to recover, in order of cost: + +1. Cheapest: call `query(entity=name, top=1)` once and read the keys off + the returned record. No registry mutation, zero cache pollution. +2. One-time: pass `discover_fields=true` to `describe_endpoint`. The + tool runs `bcli endpoint fields ` first, which fetches one + record, persists the field names to the local registry, and then + returns the populated metadata. Every subsequent call (any user, any + session) gets `fields_discovered: true` for free. + +Pick (1) for one-shot analysis. Pick (2) when the entity is one you'll +revisit a lot — the registry-cached field list also feeds bcli's +filter-validation suggestions in the CLI. + ## Token economy — when the MCP wins, when it loses Pairing an MCP server with a CLI tool is empirically token-favorable for diff --git a/src/bcli_cli/commands/endpoint_cmd.py b/src/bcli_cli/commands/endpoint_cmd.py index 9eca791..1708948 100644 --- a/src/bcli_cli/commands/endpoint_cmd.py +++ b/src/bcli_cli/commands/endpoint_cmd.py @@ -138,6 +138,11 @@ def endpoint_info( payload = _endpoint_to_dict(ep) payload["entity_name"] = ep.entity_name payload["fields"] = [{"name": f, "type": ""} for f in ep.field_names] + # Hint for downstream consumers (esp. bcli-mcp) so they can tell the + # difference between "no fields exist" and "fields not yet cached". + # Empty fields with fields_discovered=False means: run + # `bcli endpoint fields ` to populate, or probe via a query. + payload["fields_discovered"] = bool(ep.field_names) payload["source_table"] = ep.source_table payload["page_number"] = ep.page_number print(_json.dumps(payload, indent=2, default=str)) diff --git a/src/bcli_mcp/_runner.py b/src/bcli_mcp/_runner.py index d5c09a5..906db9e 100644 --- a/src/bcli_mcp/_runner.py +++ b/src/bcli_mcp/_runner.py @@ -97,3 +97,52 @@ def run_bcli_json( raise _ToolError( f"bcli {' '.join(args)} produced non-JSON output: {exc}" ) from exc + + +def run_bcli_side_effect( + *args: str, + profile: str | None = None, + timeout: float = 120.0, +) -> None: + """Run ``bcli `` for its side effect; ignore stdout content. + + Some bcli subcommands (e.g. ``endpoint fields``) emit human-readable + text on stdout but persist their useful work to the local registry as + a side effect. We don't want that text — we want the cache write. + + Raises ``ToolError`` on non-zero exit (with Rich markup stripped) or + timeout. Otherwise silent on success. + """ + argv = ["bcli"] + if profile: + argv.extend(["--profile", profile]) + argv.extend(args) + + env = os.environ.copy() + if profile: + env["BCLI_PROFILE"] = profile + + try: + proc = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=timeout, + env=env, + check=False, + ) + except FileNotFoundError as exc: + raise _ToolError( + "bcli executable not found on PATH. Install with " + "'pip install bc-cli[cli]' or 'uv tool install bc-cli'." + ) from exc + except subprocess.TimeoutExpired as exc: + raise _ToolError( + f"bcli {' '.join(args)} timed out after {timeout}s" + ) from exc + + if proc.returncode != 0: + message = _strip_rich(proc.stderr or proc.stdout or "(no output)") + raise _ToolError( + f"bcli {' '.join(args)} exited {proc.returncode}: {message}" + ) diff --git a/src/bcli_mcp/_server.py b/src/bcli_mcp/_server.py index 7fa0c31..4daa927 100644 --- a/src/bcli_mcp/_server.py +++ b/src/bcli_mcp/_server.py @@ -20,7 +20,7 @@ from mcp.server.fastmcp import FastMCP -from bcli_mcp._runner import run_bcli_json +from bcli_mcp._runner import run_bcli_json, run_bcli_side_effect mcp = FastMCP("bcli") @@ -115,14 +115,38 @@ async def list_endpoints( @mcp.tool() async def describe_endpoint( name: str, + discover_fields: bool = False, profile: str | None = None, ) -> dict[str, Any]: """Show fields, key, supported operations, and route for one entity. - ``fields`` is populated only after ``bcli endpoint fields `` has - been run for the entity (it costs one BC API call). Empty list = not - yet discovered. + The response includes ``fields_discovered: bool``. When false, ``fields`` + is empty *because the local registry hasn't probed BC for this entity + yet*, not because the entity has no fields. Two ways to recover: + + * Pass ``discover_fields=True`` (this tool runs ``bcli endpoint fields + `` first — costs one BC API call, populates the cache for every + future call). + * Or call ``query(entity=name, top=1)`` and read the keys off the first + record. Cheaper if you only need the schema for one analysis. + + Note: some BC pages are "query objects" (read-only summary views) which + don't support server-side ``$orderby`` or ``$filter``. If a ``query()`` + call against this entity 400s on those, fall back to client-side sort + over a bounded ``top`` window — see docs/mcp-server.md. """ + if discover_fields: + # ``bcli endpoint fields`` fetches one record from BC and persists + # the field names into the local registry. The text output is + # discarded — we only care about the cache write. If discovery + # fails (e.g. entity needs a filter, no records returned), the + # subsequent ``endpoint info`` call returns ``fields_discovered: + # false`` and the agent can fall back to a probe query. + try: + run_bcli_side_effect("endpoint", "fields", name, profile=profile) + except Exception: + pass + return run_bcli_json("endpoint", "info", name, profile=profile) diff --git a/tests/test_mcp/test_runner.py b/tests/test_mcp/test_runner.py index b493129..58a7ac2 100644 --- a/tests/test_mcp/test_runner.py +++ b/tests/test_mcp/test_runner.py @@ -14,7 +14,7 @@ import pytest -from bcli_mcp._runner import _strip_rich, run_bcli_json +from bcli_mcp._runner import _strip_rich, run_bcli_json, run_bcli_side_effect from mcp.server.fastmcp.exceptions import ToolError @@ -138,3 +138,46 @@ def test_timeout_raises_toolerror(self): with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(["bcli"], 1)): with pytest.raises(ToolError, match=r"timed out"): run_bcli_json("get", "customers", timeout=1) + + +# ── run_bcli_side_effect ───────────────────────────────────────────────── + + +class TestSideEffectRunner: + def test_does_not_append_format_json(self): + """Side-effect commands have non-JSON human stdout — adding + --format json to commands like ``endpoint fields`` would either + be a no-op or change behaviour. Make sure we DON'T append it.""" + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="Fields for…") + run_bcli_side_effect("endpoint", "fields", "customers") + argv = run.call_args.args[0] + assert "--format" not in argv + + def test_returns_none_on_success(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="any text") + assert run_bcli_side_effect("endpoint", "fields", "customers") is None + + def test_ignores_stdout_content(self): + """Non-JSON, garbage, empty — all fine as long as exit is 0.""" + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="") + run_bcli_side_effect("endpoint", "fields", "customers") # no raise + + def test_non_zero_exit_raises_toolerror(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed( + 1, stdout="", stderr="[red]Endpoint not found[/red]", + ) + with pytest.raises(ToolError, match=r"Endpoint not found"): + run_bcli_side_effect("endpoint", "fields", "nope") + + def test_profile_passes_through(self): + with patch("subprocess.run") as run: + run.return_value = _mock_completed(0, stdout="") + run_bcli_side_effect("endpoint", "fields", "customers", profile="prod") + argv = run.call_args.args[0] + env = run.call_args.kwargs["env"] + assert "--profile" in argv and argv[argv.index("--profile") + 1] == "prod" + assert env["BCLI_PROFILE"] == "prod" diff --git a/tests/test_mcp/test_server_tools.py b/tests/test_mcp/test_server_tools.py index d74186e..6b4ee3a 100644 --- a/tests/test_mcp/test_server_tools.py +++ b/tests/test_mcp/test_server_tools.py @@ -133,6 +133,55 @@ async def test_calls_endpoint_info(self): assert run.call_args.args == ("endpoint", "info", "customers") assert result == {"name": "customers"} + @pytest.mark.asyncio + async def test_discover_fields_default_false_skips_side_effect(self): + with patch("bcli_mcp._server.run_bcli_json", return_value={}) as info, \ + patch("bcli_mcp._server.run_bcli_side_effect") as side: + await _server.describe_endpoint(name="customers") + side.assert_not_called() + info.assert_called_once() + + @pytest.mark.asyncio + async def test_discover_fields_true_runs_fields_then_info(self): + with patch("bcli_mcp._server.run_bcli_json", return_value={}) as info, \ + patch("bcli_mcp._server.run_bcli_side_effect") as side: + await _server.describe_endpoint(name="customers", discover_fields=True) + side.assert_called_once_with( + "endpoint", "fields", "customers", profile=None, + ) + info.assert_called_once_with( + "endpoint", "info", "customers", profile=None, + ) + + @pytest.mark.asyncio + async def test_discover_fields_swallows_side_effect_failure(self): + """If discovery fails (entity needs filter, no records, etc.) we + still return the info payload — fields_discovered will be False + and the agent can fall back to a probe query.""" + from mcp.server.fastmcp.exceptions import ToolError + + with patch( + "bcli_mcp._server.run_bcli_json", + return_value={"name": "customerSales", "fields_discovered": False}, + ) as info, patch( + "bcli_mcp._server.run_bcli_side_effect", + side_effect=ToolError("BC returned 400"), + ): + result = await _server.describe_endpoint( + name="customerSales", discover_fields=True, + ) + assert result == {"name": "customerSales", "fields_discovered": False} + info.assert_called_once() + + @pytest.mark.asyncio + async def test_discover_fields_passes_profile_through(self): + with patch("bcli_mcp._server.run_bcli_json", return_value={}), \ + patch("bcli_mcp._server.run_bcli_side_effect") as side: + await _server.describe_endpoint( + name="customers", discover_fields=True, profile="sandbox", + ) + assert side.call_args.kwargs["profile"] == "sandbox" + # ── list_companies ──────────────────────────────────────────────────────── From aa3773918251b2ded9d6f37edf3450cc3b58b50e Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Mon, 4 May 2026 08:08:56 -0500 Subject: [PATCH 4/4] fix(workflow): reject YAML 1.1 boolean-key trap at load time Bare `no:` / `yes:` / `on:` / `off:` as YAML keys parsed as booleans, silently producing broken payloads for BC fields named `no` (GL account numbers, line numbers). The shipped purchase-invoice example was emitting `{"false": "6260-..."}` instead of `{"no": "6260-..."}` on the GL-account field. New `bcli.workflow.load_workflow_yaml` fails fast at YAML load time, naming the path and suggesting the quote-it fix. Wired into `bcli batch run`. Example fixed. 9 tests cover the bool-key paths and round-trip the shipped example through the loader. --- examples/create-purchase-invoice.yaml | 4 +- src/bcli/workflow/__init__.py | 2 + src/bcli/workflow/_loader.py | 59 ++++++++++++++ src/bcli_cli/commands/batch_cmd.py | 11 ++- tests/test_workflow/test_loader.py | 110 ++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 src/bcli/workflow/_loader.py create mode 100644 tests/test_workflow/test_loader.py diff --git a/examples/create-purchase-invoice.yaml b/examples/create-purchase-invoice.yaml index 923b95b..1e23dcb 100644 --- a/examples/create-purchase-invoice.yaml +++ b/examples/create-purchase-invoice.yaml @@ -40,7 +40,7 @@ steps: documentType: "Invoice" documentNo: "${{ steps.create_header.no }}" type: "G_x002F_L_x0020_Account" - no: "6260-000000-000" + "no": "6260-000000-000" description: "${{ params.inspection_desc }}" quantity: 1 directUnitCost: ${{ params.inspection_cost }} @@ -53,7 +53,7 @@ steps: documentType: "Invoice" documentNo: "${{ steps.create_header.no }}" type: "G_x002F_L_x0020_Account" - no: "6260-000000-000" + "no": "6260-000000-000" description: "Travel and per diem" quantity: 1 directUnitCost: ${{ params.travel_cost }} diff --git a/src/bcli/workflow/__init__.py b/src/bcli/workflow/__init__.py index 7b9e7cd..5b96c55 100644 --- a/src/bcli/workflow/__init__.py +++ b/src/bcli/workflow/__init__.py @@ -1,5 +1,6 @@ """Workflow engine — step chaining and runtime parameters for batch files.""" +from bcli.workflow._loader import load_workflow_yaml from bcli.workflow._models import ( ParamDef, StepDef, @@ -15,5 +16,6 @@ "StepResult", "WorkflowContext", "WorkflowDef", + "load_workflow_yaml", "resolve_references", ] diff --git a/src/bcli/workflow/_loader.py b/src/bcli/workflow/_loader.py new file mode 100644 index 0000000..3acfbbc --- /dev/null +++ b/src/bcli/workflow/_loader.py @@ -0,0 +1,59 @@ +"""Safe YAML loading for workflow files. + +YAML 1.1 (PyYAML's default) parses bare ``no:``, ``yes:``, ``on:``, +``off:`` as booleans. BC fields use ``no`` as a primary-key field +(account number, line number) — silently coercing those to +``False`` produces broken workflows that hit BC with payloads like +``{"false": "6260-..."}`` instead of ``{"no": "6260-..."}``. + +Rather than coerce-and-guess (which spelling did the user intend — +``no``? ``No``? ``NO``?), this loader rejects boolean keys with a +clear, actionable error. Authors quote the key (``\"no\":``) and +re-run. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from bcli.errors import WorkflowError + + +def load_workflow_yaml(source: str | Path) -> Any: + """Load a workflow YAML and reject YAML-1.1 boolean-key traps. + + Accepts a Path or a raw YAML string. Returns the parsed object. + Raises ``WorkflowError`` if any dict key was parsed as a bool — + the error message names the offending path so the author can + quote the key in their YAML. + """ + text = ( + source.read_text(encoding="utf-8") + if isinstance(source, Path) + else source + ) + obj = yaml.safe_load(text) + _reject_bool_keys(obj, path="", source=source) + return obj + + +def _reject_bool_keys(obj: Any, *, path: str, source: str | Path) -> None: + if isinstance(obj, dict): + for key, value in obj.items(): + child_path = f"{path}.{key!r}" + if isinstance(key, bool): + literal = "yes" if key else "no" + where = f" in {source}" if isinstance(source, Path) else "" + raise WorkflowError( + f"YAML key parsed as boolean {key} at {path}" + f"{where}. The bare word {literal!r} is YAML 1.1 " + f"boolean syntax. Quote it as \"{literal}\": to use " + f"as a string field name." + ) + _reject_bool_keys(value, path=child_path, source=source) + elif isinstance(obj, list): + for i, item in enumerate(obj): + _reject_bool_keys(item, path=f"{path}[{i}]", source=source) diff --git a/src/bcli_cli/commands/batch_cmd.py b/src/bcli_cli/commands/batch_cmd.py index 7ceca0f..77ad3e9 100644 --- a/src/bcli_cli/commands/batch_cmd.py +++ b/src/bcli_cli/commands/batch_cmd.py @@ -156,13 +156,20 @@ def run_batch( raise typer.Exit(1) try: - import yaml + import yaml # noqa: F401 (still used by helpers; load_workflow_yaml wraps the workflow body) except ImportError: console.print("[red]PyYAML is required for batch mode.[/red]") console.print("[dim]Install it: pip install pyyaml[/dim]") raise typer.Exit(1) - raw = yaml.safe_load(file.read_text(encoding="utf-8")) + from bcli.errors import WorkflowError + from bcli.workflow import load_workflow_yaml + + try: + raw = load_workflow_yaml(file) + except WorkflowError as e: + console.print(f"[red]Invalid workflow YAML:[/red] {e}") + raise typer.Exit(1) batch_name = raw.get("name", file.stem) steps = raw.get("steps", []) diff --git a/tests/test_workflow/test_loader.py b/tests/test_workflow/test_loader.py new file mode 100644 index 0000000..b8c85d6 --- /dev/null +++ b/tests/test_workflow/test_loader.py @@ -0,0 +1,110 @@ +"""Tests for the workflow YAML loader's bool-key rejection.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from bcli.errors import WorkflowError +from bcli.workflow import load_workflow_yaml + + +class TestBoolKeyRejection: + """YAML 1.1 parses bare ``no:`` as ``False``. We reject that. + + Why: BC fields use ``no`` as a string field name (account + number, line number). Silent coercion produces payloads with + ``"false"`` keys that fail at BC with confusing errors. + """ + + def test_bare_no_key_rejected(self) -> None: + yaml_text = """ + steps: + - action: post + endpoint: purchaseLines + data: + no: "6260-000000-000" + """ + with pytest.raises(WorkflowError, match="boolean False"): + load_workflow_yaml(yaml_text) + + def test_bare_yes_key_rejected(self) -> None: + yaml_text = """ + config: + yes: 1 + """ + with pytest.raises(WorkflowError, match="boolean True"): + load_workflow_yaml(yaml_text) + + def test_quoted_no_key_accepted(self) -> None: + yaml_text = """ + steps: + - action: post + endpoint: purchaseLines + data: + "no": "6260-000000-000" + """ + result = load_workflow_yaml(yaml_text) + assert result["steps"][0]["data"]["no"] == "6260-000000-000" + + def test_no_inside_string_value_accepted(self) -> None: + """Templates referencing ``no`` field work — the bool trap + only bites when ``no`` is a *bare YAML key*.""" + yaml_text = """ + steps: + - action: post + endpoint: purchaseLines + data: + documentNo: "${{ steps.create_header.no }}" + """ + result = load_workflow_yaml(yaml_text) + assert ( + result["steps"][0]["data"]["documentNo"] + == "${{ steps.create_header.no }}" + ) + + def test_error_message_names_path(self) -> None: + yaml_text = """ + steps: + - data: + no: "X" + """ + with pytest.raises(WorkflowError) as exc_info: + load_workflow_yaml(yaml_text) + assert "data" in str(exc_info.value) + + def test_error_message_suggests_fix(self) -> None: + with pytest.raises(WorkflowError) as exc_info: + load_workflow_yaml('no: 1') + assert '"no"' in str(exc_info.value) + + +class TestLoaderAcceptsValidYaml: + """Sanity: the loader is permissive for everything that's not + a bool-key trap. It is a thin wrapper around ``yaml.safe_load``.""" + + def test_load_from_path(self, tmp_path: Path) -> None: + f = tmp_path / "wf.yaml" + f.write_text("steps:\n - action: get\n endpoint: customers\n") + result = load_workflow_yaml(f) + assert result["steps"][0]["endpoint"] == "customers" + + def test_load_empty_yaml_returns_none(self) -> None: + assert load_workflow_yaml("") is None + + def test_load_real_example(self) -> None: + """The shipped example must round-trip cleanly through the + loader. This catches future YAML bool-key regressions in + examples/.""" + repo_root = Path(__file__).resolve().parents[2] + example = repo_root / "examples" / "create-purchase-invoice.yaml" + result = load_workflow_yaml(example) + # The fix: "no" is a string key with the GL account + post_lines = [ + step + for step in result["steps"] + if step.get("endpoint") == "purchaseLines" + ] + assert post_lines, "example should have purchaseLines steps" + assert post_lines[0]["data"]["no"] == "6260-000000-000"