Skip to content

v0.1.3: MCP server + Python 3.10 floor + 3-path agent docs#1

Merged
hculap merged 6 commits into
mainfrom
feat/v0.1.3-mcp-py310
May 19, 2026
Merged

v0.1.3: MCP server + Python 3.10 floor + 3-path agent docs#1
hculap merged 6 commits into
mainfrom
feat/v0.1.3-mcp-py310

Conversation

@hculap
Copy link
Copy Markdown
Owner

@hculap hculap commented May 19, 2026

Summary

Adds an MCP (Model Context Protocol) server so chat-based AI agents (Claude Desktop, Cursor chat, Continue, Cline, Zed, JetBrains, OpenCode, Gemini CLI) can drive the emodul CLI through their native MCP client — no shell access required on the host. Also lowers Python floor to 3.10 (was 3.11) so sandbox-constrained agents (Cowork, Ubuntu 22.04 LTS containers) can install.

End state after this PR:

  • 3 install paths cover every reasonable AI runtime
    • Path A (MCP server) — chat / IDE agents
    • Path B (AGENT.md prompt) — CLI agents like Claude Code, Codex CLI, Cursor agent mode (existing flow, unchanged)
    • Path C (copy-paste fallback) — sandboxed agents
  • emodul mcp subcommand on the existing CLI + emodul-mcp console script alias
  • 16 MCP tools (~9 read, 5 write with destructiveHint=true, 2 auth)
  • Browser-login flow callable from MCP via login_browser tool

What's new

MCP server (emodul/mcp/)

  • mcp/server.py — FastMCP instance with 16 tools, all sync calls bridged into async via anyio.to_thread.run_sync
  • mcp/_helpers.py — `safely()` decorator (errors → `{ok:false, code, error}` envelopes), `open_api()` context manager, `AuthRequired` exception
  • commands/mcp.py — Click subcommand `emodul mcp` that calls `server.main()`
  • Hard deps added: `mcp[cli]>=1.20`, `anyio>=4`

Helper extractions (no behavior change, just deduplication)

  • `emodul/_resolver.py` — module name/udid resolution, was inline in `Ctx.resolve_module_udid`
  • `emodul/_schedule_format.py` — schedule decoder, was in `commands/schedules.py`
  • `emodul/_zone_resolver.py` — zone name/id resolver, killed 5x duplication in MCP tools + CLI

Documentation rewrite

  • `AGENT.md` — 4-section structure: "Pick your runtime" decision table + Path A (MCP, per-client JSON snippets for 8 runtimes) + Path B (existing CLI flow) + Path C (sandbox fallback)
  • `README.md` — "Three ways to use this" comparison table + 30-second Path A and Path B snippets
  • `SKILL.md` — mentions MCP option in overview
  • `web_auth.py` — added `on_url` callback param (CLI behavior unchanged; MCP uses it to surface URL via progress notification)

CI

  • Matrix now `["3.10", "3.11", "3.12", "3.13"]` × {ubuntu, macos}
  • New MCP smoke test: pipes `initialize` + `tools/list` over stdio, asserts ≥10 tools
  • Verifies `emodul-mcp` standalone entry point resolves
  • `mcp` added to subcommand --help loop

PR review findings addressed (commit `08e925d`)

After the first two commits, ran /pr-review-toolkit:review-pr — 4 specialized agents in parallel (code-reviewer, comment-analyzer, silent-failure-hunter, type-design-analyzer). All 7 Critical + 10 Important findings fixed in commit `08e925d`:

Critical

  • C1 `login_browser` progress notification was `anyio.from_thread.run_sync(lambda → coroutine)` — silently dropped because of `try/except: pass`. Now uses `from_thread.run` (awaits coroutines)
  • C2 README badge said `Python 3.11+` (contradicted the whole point of this PR)
  • C3 `safely` catch-all now `log.exception`s the full traceback; tool crashes are no longer opaque to operators
  • C4 `update_setting` now aggregates per-target success; `ok=true` no longer reported when every target failed
  • C5 `get_temperature_history` docstring shape was wrong + literal `|&^4fdkl|` placeholder removed
  • C6 Zed config snippet — `command` is a string, not an object
  • C7 Continue YAML now includes required `name/version/schema/mcpServers` wrapper

Important

  • I1 Extracted `_resolve_zone` helper (5x duplication in MCP tools + drift from CLI killed)
  • I2 `safely` no longer echoes `repr(exc)` (httpx/keyring repr may contain creds)
  • I3 `safely` raises TypeError at decoration time if applied to a sync def (verified)
  • I4 `whoami` flips `authenticated=false` on 401/403 so agents call `login_browser` instead of proceeding
  • I5 `set_default_module` validates udid actually exists in `list_modules` before persisting
  • I6 `audit_settings` reports `menu_errors` per module so users know the report is incomplete
  • I7 `login_browser` returns `keychain_ok` + actionable `warning` text
  • I8 SKILL.md broken link `AGENT.md` fixed
  • I9 README Path A 30-second example now includes `emodul skill install`
  • I10 AGENT.md failure-modes notes login_browser's 300s timeout vs Claude Desktop's 60s ceiling

Backwards compatibility

✅ No breaking changes. CLI surface unchanged. New `emodul mcp` subcommand is purely additive. Python 3.11 / 3.12 / 3.13 still supported.

Test plan

  • `ruff check` clean across the codebase
  • All 13 subcommands (`auth modules zones menu settings schedules stats alarms tiles watch skill mcp`) load `--help` without errors
  • MCP server smoke test: `initialize` + `tools/list` over stdio returns 16 tools
  • `@safely` sync-detection raises TypeError on plain functions (verified)
  • CLI `emodul status` still works (no regression in zone listing)
  • CI matrix passes on Python 3.10 / 3.11 / 3.12 / 3.13 (runs on this PR)
  • Local MCP Inspector test: `npx @modelcontextprotocol/inspector emodul mcp` → call each tool manually
  • Claude Desktop end-to-end: add to `claude_desktop_config.json`, restart, ask "list my emodul zones"

Out of scope (proposed v0.1.4 follow-up)

The type-design-analyzer suggested these quality/security improvements that are bigger than this PR can absorb:

  • `AuthRequired(RuntimeError)` → `EmodulError` hierarchy with `code` class attr and `to_envelope()` method
  • TypedDict / Pydantic models for MCP tool envelopes (so FastMCP publishes `outputSchema` for the LLM consumer)
  • `web_login_flow → dict[str, Any]` → `LoginCredentials` dataclass with redacting `repr` (security smell: password in loose dict)
  • `ParamSpec` on `safely` so the type checker preserves the wrapped tool's signature
  • Resolver exception hierarchy: `ModuleNotFound` vs `AmbiguousModule` (lossless for retry logic)

All in v0.1.4. Tracked as separate issues once this lands.

Files

17 files changed, +1517 / −202.

```
NEW:
emodul/_resolver.py
emodul/_schedule_format.py
emodul/_zone_resolver.py
emodul/mcp/init.py
emodul/mcp/_helpers.py
emodul/mcp/server.py
emodul/commands/mcp.py

MODIFIED:
pyproject.toml (version, deps, classifier, console script)
emodul/init.py (version 0.1.2 → 0.1.3)
emodul/cli.py (register mcp subcommand, use _resolver helper)
emodul/web_auth.py (added on_url callback)
emodul/commands/schedules.py (use _schedule_format helpers)
emodul/commands/zones.py (use _zone_resolver helper)
.github/workflows/ci.yml (matrix +3.10, MCP smoke test)
AGENT.md (4-section rewrite with per-runtime configs)
README.md ("Three ways to use this" + badge fix)
SKILL.md (mention MCP option)
```

Plan file (write-up of all architectural decisions with rationale): `~/.claude/plans/elegant-percolating-sky.md` (locally; happy to commit if useful).

hculap added 4 commits May 19, 2026 13:14
Phase 1: Python 3.10 downgrade
- pyproject.toml: requires-python >=3.11 → >=3.10
- Add classifier 3.10
- Bump version 0.1.2 → 0.1.3
- CI matrix: add "3.10"
- emodul/__init__.py: __version__ bumped

Phase 2: Extract reusable helpers (preparing for MCP)
- emodul/_resolver.py: resolve_module_udid (was inline in Ctx)
- emodul/_schedule_format.py: decode_schedule + zones_using_schedule
  (was in commands/schedules.py)
- emodul/cli.py: Ctx.resolve_module_udid now delegates to helper
- emodul/commands/schedules.py: imports from _schedule_format

Phase 3: MCP server core (skeleton + 13 tools)
- New deps: mcp[cli]>=1.20, anyio>=4
- emodul/mcp/__init__.py: module marker
- emodul/mcp/_helpers.py: open_api context manager, resolve_udid,
  safely() decorator (catches exceptions → error envelopes per MCP spec),
  AuthRequired exception
- emodul/mcp/server.py: FastMCP("emodul") with ~13 tools:
  • READ: whoami, get_status, list_zones, get_zone, list_modules,
    list_schedules, audit_settings, get_alarms, get_temperature_history
  • WRITE (destructiveHint=True): set_zone_temperature, boost_zone,
    toggle_zone, attach_schedule, update_setting
  • AUTH: login_browser, set_default_module
- All sync ApiClient calls wrapped in anyio.to_thread.run_sync
- Errors returned as {ok: false, error, code} envelopes (never raised
  to server top-level, which would crash stdio transport)
- Logging to stderr (stdio MCP reserves stdout for JSON-RPC)

WIP for next commits: web_auth.py on_url callback (Phase 4),
emodul/commands/mcp.py subcommand (registers `emodul mcp`),
pyproject.toml console scripts entry, AGENT.md rewrite, CI smoke test.

Per plan: /Users/szymonpaluch/.claude/plans/elegant-percolating-sky.md
Phase 3 finish:
- emodul/commands/mcp.py: `emodul mcp` Click subcommand
- emodul/cli.py: register mcp alongside other 12 groups
- pyproject.toml: add `emodul-mcp` console script alias

Phase 4: web_auth.py on_url callback
- web_login_flow(): new `on_url` kwarg invoked BEFORE blocking on done.wait()
- MCP login_browser tool uses callback to surface URL via progress
  notification (CLI behavior unchanged — stderr print stays)

Phase 5+6: documentation rewrite
- AGENT.md: 3-path structure (Pick runtime → Path A MCP / Path B CLI / Path C sandbox)
  + per-client JSON snippets (Claude Desktop, Cursor, Continue, Cline, Zed,
    JetBrains, OpenCode, Gemini CLI)
  + tools inventory table
  + safety constraints + failure modes
- README.md: "Three ways to use this" section at top with comparison table
  + 30-second Path A and Path B snippets
- SKILL.md: mention MCP option in overview; CLI flow unchanged

Phase 7: CI updates
- ci.yml: matrix now ["3.10", "3.11", "3.12", "3.13"]
- New step "MCP server smoke test" — initialize + tools/list via stdio,
  assert ≥10 tools registered
- New step verifying `emodul-mcp` standalone entry point resolves
- Subcommand --help loop includes `mcp`

Verified locally: all 12 subcommands + new `mcp` load cleanly; ruff passes;
MCP server returns 16 tools via JSON-RPC initialize + tools/list.

This branch ships v0.1.3 once merged + tagged.
## Critical (7)

- C1: login_browser progress notification — anyio.from_thread.run_sync(lambda) on async send_log_message produced an unawaited coroutine, silently dropped because the call was wrapped in try/except:pass. Switched to anyio.from_thread.run() which awaits coroutines, and demoted the swallow to log.debug.
- C2: README Python badge said 3.11+ even though pyproject requires-python is >=3.10 (the whole point of this PR). Now matches.
- C3: safely() catch-all now logs full traceback via log.exception. AttributeError/TypeError/KeyError in tools are no longer opaque "Internal error" strings without context.
- C4: update_setting now aggregates per-target success and returns code="partial_failure" with err_response when any target fails. Previously ok:true even when every target failed — agents would tell users "done" for non-events.
- C5: get_temperature_history docstring fixed — top-level shape is {ok, period, status, data: {history: {...}}} not {ok, period, history}. Removed misleading literal placeholder; documented the opaque-prefix-with-zone-name key convention.
- C6: Zed config snippet shape — command is a string, not an object. Matches current zed.dev/docs/ai/mcp.
- C7: Continue YAML now includes required name/version/schema fields and mcpServers wrapper.

## Important (10)

- I1: Extracted _resolve_zone helper to emodul/_zone_resolver.py — removes 5x duplication in MCP server (get_zone, set_zone_temperature, boost_zone, toggle_zone, attach_schedule). commands/zones._resolve_zone now delegates to the same helper, eliminating semantic drift (the old CLI version had a fall-through bug where "3" would substring-match "Pokój 3").
- I2: safely no longer leaks repr(exc) — uses type(exc).__name__ in user-facing message. httpx/keyring exceptions contain credentials in repr; never echo them.
- I3: safely now raises TypeError at decoration time if applied to a sync def — verified by unit test. Previously sync handlers silently became opaque envelopes.
- I4: whoami flips authenticated=false + sets refresh_required=true on 401/403 from /info. Previously a stale token reported authenticated=true and the agent would proceed to other tools that all 401'd.
- I5: set_default_module now validates that the resolved udid actually exists in list_modules before persisting. Previously the 32-hex fast-path could persist a bogus default with no verification.
- I6: audit_settings now records per-menu fetch errors (menu_errors dict per module) so users know the report is incomplete. Previously a 403 (PIN missing) silently turned into an empty menu treated as "all defaults".
- I7: login_browser now returns keychain_ok + warning text. Previously a keychain write failure was log.warning'd to stderr (which MCP clients don't surface) while the success envelope still said ok:true → auto-refresh broken next session with no clue why.
- I8: SKILL.md broken link [AGENT.md](README.md) — text said AGENT, link pointed at README.
- I9: README Path A 30-second example now includes emodul skill install — Claude Code users sharing the host wouldn't get skill auto-discovered.
- I10: AGENT.md failure-modes table now notes that login_browser's 300s default exceeds Claude Desktop's 60s tool ceiling. Recommends running `emodul auth login --browser` from a host terminal instead.

## Tests

- ruff: All checks passed!
- MCP smoke: initialize + tools/list returns 16 tools
- @safely sync-detection: TypeError raised at decoration time (verified)
The pipe-based test (printf | timeout emodul mcp) was racing the server's
stdin EOF handling and intermittently returned 0 tools in CI even though
the server worked. Replaced with a proper mcp.client.stdio + ClientSession
test that uses the SDK's own handshake — deterministic, identical
behaviour locally and in CI.

Also: the previous "emodul-mcp --help" check was always followed by
|| true because FastMCP doesn't accept --help. Replaced with an explicit
import check so the entry point is verified meaningfully.

Verified locally: 16 tools listed via real client handshake.
@hculap
Copy link
Copy Markdown
Owner Author

hculap commented May 19, 2026

Code review

Found 1 issue:

  1. SystemExit from web_login_flow escapes the @safely wrapper and will kill the MCP server. safely() in emodul/mcp/_helpers.py catches Exception (not BaseException), but web_login_flow raises SystemExit on the four expected operational paths (timeout, Ctrl-C, port-bind failure, missing-token). The login_browser MCP tool has no inner except SystemExit to bridge the gap. This directly violates the module's stated invariant: "errors come back as {ok: false, error, code} envelopes rather than raised exceptions (raising kills the server)". The 300s default timeout makes this very likely in Claude Desktop, whose hard tool ceiling is ~60s.

The four exit paths:
https://github.com/hculap/emodul/blob/ca540404875a748792923dc519156e3e8a2c8d27/emodul/web_auth.py#L415-L462

The catch-all that misses SystemExit:

async def wrapper(*args, **kwargs):
try:
return await fn(*args, **kwargs)
except AuthRequired as exc:
return err_response(str(exc), code="auth_required")
except EmodulApiError as exc:
# Log at warning — recoverable / expected operational failure.
log.warning(
"api_error status=%s path=%s body=%r",
exc.status, exc.path, exc.body,
)
return err_response(
f"eModul API {exc.status} on {exc.path}: {exc.body}",
code="api_error",
status=exc.status,
)
except LookupError as exc:
return err_response(str(exc), code="not_found")
except ValueError as exc:
return err_response(str(exc), code="bad_input")
except Exception as exc: # noqa: BLE001
# Full traceback to stderr (operator-visible via MCP server logs).
# User-facing message stays terse — never echo repr(exc) since
# httpx-like errors may include the request URL with user_id.

Suggested fix: in login_browser._impl, wrap the web_login_flow call with try / except (SystemExit, KeyboardInterrupt) and convert to err_response(str(exc), code="login_cancelled"). Or have web_login_flow raise a dedicated LoginFlowError (subclass of Exception) and have safely catch it.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

hculap added 2 commits May 19, 2026 14:40
web_login_flow raised SystemExit on timeout, Ctrl-C, bind failure, and
missing-token. The MCP `login_browser` tool's @safely decorator catches
Exception but not BaseException, so any of those paths killed the MCP
server process and Claude Desktop lost the connection.

Introduce LoginFlowError(Exception) so:
- @safely catches it cleanly and returns an envelope with code=login_failed
- CLI auth.py converts it back to SystemExit to preserve interactive UX

Addresses PR #1 review finding (score 95).
SKILL.md hardcoded "Parter" and "Piętro" as if they were universal
defaults — but those are one user's controller names. Other users will
have different ones. Replace with <module> placeholders and add a note
to always discover module names via `emodul --json modules list` first.

Also drop the leaky "Module names (typical for this user): Parter +
Piętro, both TECH L-4X WIFI v1.0.13" paragraph.
@hculap hculap merged commit a659b91 into main May 19, 2026
9 checks passed
@hculap hculap deleted the feat/v0.1.3-mcp-py310 branch May 19, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant