Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
186 changes: 186 additions & 0 deletions docs/mcp-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# 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 <name>` 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.

## 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 <name>` 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
**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 <entity> --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.
4 changes: 2 additions & 2 deletions examples/create-purchase-invoice.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -59,21 +60,28 @@ 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",
"pyiceberg[s3fs]>=0.7",
]
dev = [
"bc-cli[cli]",
"bc-cli[mcp]",
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pytest-httpx>=0.30",
"ruff>=0.5",
]

[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"]
Expand Down
2 changes: 2 additions & 0 deletions src/bcli/workflow/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,5 +16,6 @@
"StepResult",
"WorkflowContext",
"WorkflowDef",
"load_workflow_yaml",
"resolve_references",
]
59 changes: 59 additions & 0 deletions src/bcli/workflow/_loader.py
Original file line number Diff line number Diff line change
@@ -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="<root>", 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)
11 changes: 9 additions & 2 deletions src/bcli_cli/commands/batch_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [])

Expand Down
Loading
Loading