Skip to content

0.2.2

0.2.2 #54

Workflow file for this run

name: CI
# Everything here has to pass with no API key, no network to Lexware and no
# account. No key ships with this repository and none goes into CI, so a check
# that needs one cannot be a gate. The live check lives in tests/smoke.py and
# is run by hand, see SPECS.md section 14.1.
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# A full version, not a major. This action stopped publishing floating
# `vN` and `vN.M` tags with its v8: `@v10` resolves to nothing at all,
# which fails the job before it starts. Dependabot moves this line.
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
- name: Install dependencies (locked)
run: uv sync --extra dev --frozen
- name: Lockfile is in step with pyproject.toml
run: uv lock --check
- name: Ruff (lint)
run: uv run ruff check .
- name: Ruff (format check)
run: uv run ruff format --check .
- name: Mypy (type check)
run: uv run mypy
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- name: Install dependencies (locked)
run: uv sync --extra dev --frozen
# httpx is mocked at the transport, so the suite never opens a socket.
# tests/smoke.py is not collected: it holds no test_* functions and is
# the read-only live check.
- name: Run tests with coverage
run: uv run pytest -q --cov=benethos_lexware_office_mcp --cov-fail-under=80
# Every other job installs from uv.lock, which pins dependencies to a set
# known to work. Reproducibility needs that, but it hides a broken *declared*
# range: someone installing from PyPI resolves the ranges in pyproject.toml
# instead, so `mcp>=2.0.0,<3` is a promise nothing else here tests. This job
# resolves the way that person does, with no lockfile involved.
#
# It also guards the rule the server is built around, which no offline test
# can reach because every one of them builds a policy file first: an
# installation that has none offers no tools at all.
fresh-install:
runs-on: ubuntu-latest
env:
# The per-user configuration directory, pointed at an empty one rather
# than trusting the runner to have none. What is being asserted is what
# a fresh machine does, so leave nothing about that to chance.
XDG_CONFIG_HOME: /tmp/xdg
steps:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v10.0.1
- name: Build the wheel
run: uv build --wheel
- name: Install it into a clean environment (no lockfile)
run: |
uv venv /tmp/fresh
uv pip install --python /tmp/fresh/bin/python dist/*.whl
echo "--- resolved dependency versions ---"
uv pip list --python /tmp/fresh/bin/python
# From an empty directory, because the file search reads the working
# directory too and a checkout is not what a user installs.
- name: A fresh installation starts and offers nothing
run: |
mkdir -p /tmp/empty
cd /tmp/empty
/tmp/fresh/bin/python -c "import benethos_lexware_office_mcp as p; print('version', p.__version__)"
/tmp/fresh/bin/benethos-lexware-office-mcp --help > /dev/null
/tmp/fresh/bin/python -c "
import asyncio
from benethos_lexware_office_mcp import config, server
settings = config.load_settings()
policy = settings.policy_file()
assert not policy.is_file(), 'a runner should not have a policy file'
tools = asyncio.run(server.build_server(settings).list_tools())
assert not tools, 'a fresh installation offered ' + str(len(tools)) + ' tools'
print('OK: no policy file, no tools, no key needed to find that out')
"
- name: The policy file is the only thing that turns tools on
run: |
cd /tmp/empty
/tmp/fresh/bin/benethos-lexware-office-mcp --tools read-only --tools-file ./tools.json
/tmp/fresh/bin/python -c "
import asyncio
from benethos_lexware_office_mcp import config, server
settings = config.load_settings()
names = {t.name for t in asyncio.run(server.build_server(settings).list_tools())}
assert names, 'the read-only preset enabled nothing'
assert 'get_profile' in names, 'the read-only preset is missing a reading tool'
assert 'create_contact' not in names, 'the read-only preset offered a write tool'
print('OK: the preset enabled', len(names), 'tools, none of them writing')
"
# The image and the Compose file are the only part of this repository that no
# test touches: the suite is offline Python, and a Dockerfile is neither.
# Everything asserted here was verified by hand once, which is exactly the
# kind of proof that stops being true without anyone noticing.
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build the image
run: docker build -t benethos-lexware-office-mcp:ci .
# A release publishes linux/amd64 and linux/arm64, and only amd64 is
# built above, so an arm64-only break would first show itself during a
# release. Nothing is loaded or pushed here: that the build completes is
# the whole assertion, and the container below is run on the runner's own
# architecture.
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v4
- name: Build for arm64 as well
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/arm64
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
# The configuration interface has no login and takes an API key. It is
# behind a profile so that a plain `up` leaves it out, and that is a
# property worth failing a build over.
- name: Compose is valid, and the interface stays behind its profile
run: |
docker compose config -q
plain=$(docker compose config --services)
with=$(docker compose --profile setup config --services)
# `a && b` as a whole statement is not exempt from errexit, so a
# grep that finds nothing would end the step. Ask with `if`.
if echo "$plain" | grep -qx setup; then
echo "::error::a plain up would start the setup service"
exit 1
fi
echo "$with" | grep -qx setup || { echo "::error::the setup profile starts nothing"; exit 1; }
echo "OK: plain up starts $(echo "$plain" | wc -l) service, the setup profile adds one"
- name: Start it
run: |
docker run -d --name lxo-ci -p 8770:8770 benethos-lexware-office-mcp:ci
# curl writes 000 for %{http_code} when it cannot connect *and* exits
# non-zero, so the fallback has to be an assignment rather than an
# `|| echo` inside the substitution - that would concatenate the two
# and leave the loop on the first attempt.
code=000
for _ in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8770/mcp -d '{}') || code=000
[ "$code" = "000" ] || break
sleep 1
done
if [ "$code" != "401" ]; then
echo "::error::an unauthenticated request answered $code, expected 401"
exit 1
fi
echo "OK: the port serves, and it refuses a request without a token"
# What the image promises on a machine that was handed nothing: it makes
# its own token, only that token gets in, and an installation with no
# policy file offers no tools - the rule the whole server is built around,
# asserted here against the artefact people actually run.
- name: It generates a token, guards with it, and offers no tools
run: |
token=$(docker exec lxo-ci sh -c "grep '^LXO_MCP_BEARER_TOKEN=' /config/.env | cut -d= -f2-" | tr -d '\r\n')
[ ${#token} -ge 32 ] || { echo "::error::no generated token in /config/.env"; exit 1; }
wrong=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8770/mcp \
-H "authorization: Bearer not-the-token" -d '{}')
[ "$wrong" = "401" ] || { echo "::error::a wrong token answered $wrong"; exit 1; }
acc="accept: application/json, text/event-stream"
ct="content-type: application/json"
auth="authorization: Bearer $token"
sid=$(curl -s -D - -o /dev/null -X POST http://localhost:8770/mcp -H "$auth" -H "$ct" -H "$acc" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \
| tr -d '\r' | awk 'tolower($1)=="mcp-session-id:"{print $2}')
[ -n "$sid" ] || { echo "::error::initialize returned no session id"; exit 1; }
curl -s -o /dev/null -X POST http://localhost:8770/mcp -H "$auth" -H "$ct" -H "$acc" \
-H "mcp-session-id: $sid" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
body=$(curl -s -X POST http://localhost:8770/mcp -H "$auth" -H "$ct" -H "$acc" \
-H "mcp-session-id: $sid" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}')
echo "$body" | grep -q '"tools":\[\]' || {
echo "::error::the container offered tools without a policy file"
echo "$body" | head -5
exit 1
}
echo "OK: token generated, only it is accepted, and no policy file means no tools"
# compose depends on this one, and it is a shell command in a string that
# nothing else checks.
- name: The image reports itself healthy
run: |
for _ in $(seq 1 20); do
status=$(docker inspect -f '{{.State.Health.Status}}' lxo-ci)
[ "$status" = "starting" ] || break
sleep 3
done
[ "$status" = "healthy" ] || { echo "::error::health check reports $status"; exit 1; }
echo "OK: healthy"
- name: What it said
if: always()
run: docker logs lxo-ci || true