ermes-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ """FastMCP server for Ermes — exposes read-only API tools.
2
+
3
+ Tools are loaded from ``routes.yaml`` at import time, so adding a new
4
+ read-only MCP tool is a YAML edit, not a Python change. The CLI surface
5
+ (``ermes_cli/commands/*.py``) is hand-coded and richer (CRUD + prompts);
6
+ the manifest drives only the MCP tool list.
7
+
8
+ Profile-based auth via the existing ``ermes_cli`` config. Write tools
9
+ (create/update/delete) are deliberately omitted from the default surface;
10
+ enable them later when there's a clear use case and a per-tool review.
11
+
12
+ Set ``ERMES_MCP_DEBUG=1`` to print the loaded tool count to stderr at
13
+ startup.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from typing import Any, Callable
21
+
22
+ from fastmcp import FastMCP
23
+
24
+ from ermes_cli.client import ErmesClient
25
+ from ermes_cli.config import resolve_profile
26
+ from ermes_cli.manifest import (
27
+ QueryArg,
28
+ Route,
29
+ exposed_routes,
30
+ load_manifest,
31
+ )
32
+
33
+ mcp = FastMCP("ermes")
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Auth helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _client() -> ErmesClient:
42
+ profile = resolve_profile()
43
+ return ErmesClient(profile["base_url"], profile["api_key"])
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Route execution
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def _execute_route(route: Route, args: dict[str, Any]) -> Any:
52
+ """Run one manifest-defined route against the Ermes API."""
53
+ path_values: dict[str, str] = {}
54
+ for arg in route.path_args:
55
+ if arg.name not in args or args[arg.name] in (None, ""):
56
+ raise ValueError(f"missing required argument '{arg.name}'")
57
+ path_values[arg.name] = str(args[arg.name])
58
+
59
+ query_params: dict[str, Any] = {}
60
+ limit: int | None = None
61
+ for q in route.query_args:
62
+ value = args.get(q.name)
63
+ if value is None:
64
+ continue
65
+ if q.name == "limit" and route.paginated:
66
+ limit = int(value)
67
+ continue
68
+ query_params[q.name] = value
69
+
70
+ path = route.render_path(path_values)
71
+ client = _client()
72
+ try:
73
+ if route.paginated:
74
+ return client.get_paginated(
75
+ path,
76
+ params=query_params or None,
77
+ limit=limit if limit is not None else 100,
78
+ )
79
+ return client.get(path, params=query_params or None)
80
+ finally:
81
+ client.close()
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Dynamic tool handler factory
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ _QUERY_TYPE_MAP = {"str": "str", "int": "int", "bool": "bool"}
90
+
91
+
92
+ def _query_annotation(q: QueryArg) -> str:
93
+ base = _QUERY_TYPE_MAP[q.type]
94
+ return f"{base} | None" if q.optional else base
95
+
96
+
97
+ def _build_handler(route: Route) -> Callable[..., Any]:
98
+ """Generate a typed handler function for one route.
99
+
100
+ Builds the function via ``exec`` so the generated signature carries
101
+ real annotations and defaults — fastmcp introspects these to produce
102
+ the tool's JSON Schema.
103
+ """
104
+ sig_parts: list[str] = []
105
+ for arg in route.path_args:
106
+ sig_parts.append(f"{arg.name}: str")
107
+ for q in route.query_args:
108
+ ann = _query_annotation(q)
109
+ if q.default is not None:
110
+ sig_parts.append(f"{q.name}: {ann} = {q.default!r}")
111
+ elif q.optional:
112
+ sig_parts.append(f"{q.name}: {ann} = None")
113
+ else:
114
+ sig_parts.append(f"{q.name}: {ann}")
115
+
116
+ sig = ", ".join(sig_parts)
117
+ src = (
118
+ f"def handler({sig}) -> object:\n"
119
+ f" return _execute_route(_ROUTE, locals())\n"
120
+ )
121
+ ns: dict[str, Any] = {"_execute_route": _execute_route, "_ROUTE": route}
122
+ exec(src, ns)
123
+ fn = ns["handler"]
124
+ fn.__name__ = route.mcp.tool_name or route.id
125
+ fn.__doc__ = route.mcp.description
126
+ return fn
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Hand-coded tools (no HTTP call)
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ @mcp.tool
135
+ def whoami() -> dict[str, Any]:
136
+ """Return the current Ermes profile + base URL (no API call — local only)."""
137
+ profile = resolve_profile()
138
+ return {
139
+ "base_url": profile["base_url"],
140
+ "api_key_masked": profile["api_key"][:3] + "***" + profile["api_key"][-3:],
141
+ }
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Manifest-driven tool registration (at import time)
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ _ROUTES = load_manifest()
150
+ for _route in exposed_routes(_ROUTES):
151
+ mcp.tool(
152
+ _build_handler(_route),
153
+ name=_route.mcp.tool_name,
154
+ description=_route.mcp.description,
155
+ )
156
+
157
+
158
+ def main() -> None:
159
+ if os.environ.get("ERMES_MCP_DEBUG"):
160
+ # +1 for the hand-coded whoami tool.
161
+ count = len(exposed_routes(_ROUTES)) + 1
162
+ print(f"ermes-mcp starting ({count} tools)", file=sys.stderr)
163
+ mcp.run(transport="stdio", show_banner=False)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()
ermes_cli/output.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from typing import Any
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ console = Console()
8
+
9
+
10
+ def render(
11
+ data: list[dict],
12
+ columns: list[str],
13
+ use_json: bool,
14
+ title: str | None = None,
15
+ column_labels: dict[str, str] | None = None,
16
+ ) -> None:
17
+ if use_json:
18
+ print(json.dumps(data, indent=2, default=str))
19
+ return
20
+ if not data:
21
+ console.print("[dim]No results[/dim]")
22
+ return
23
+ labels = column_labels or {}
24
+ table = Table(title=title, show_lines=False)
25
+ for col in columns:
26
+ table.add_column(labels.get(col, col))
27
+ for row in data:
28
+ table.add_row(*[_format_value(row.get(col)) for col in columns])
29
+ console.print(table)
30
+
31
+
32
+ def render_detail(data: dict, use_json: bool) -> None:
33
+ if use_json:
34
+ print(json.dumps(data, indent=2, default=str))
35
+ return
36
+ table = Table(show_header=False, show_lines=False)
37
+ table.add_column("Key", style="bold")
38
+ table.add_column("Value")
39
+ for key, value in data.items():
40
+ table.add_row(key, _format_value(value))
41
+ console.print(table)
42
+
43
+
44
+ def render_message(message: str, use_json: bool) -> None:
45
+ if use_json:
46
+ print(json.dumps({"message": message}))
47
+ return
48
+ console.print(message)
49
+
50
+
51
+ def _format_value(value: Any) -> str:
52
+ if value is None:
53
+ return ""
54
+ if isinstance(value, bool):
55
+ return "yes" if value else "no"
56
+ if isinstance(value, (list, dict)):
57
+ return json.dumps(value, default=str)
58
+ return str(value)
ermes_cli/routes.yaml ADDED
@@ -0,0 +1,163 @@
1
+ # ermes-cli MCP route manifest (v0.3 — read-only surface).
2
+ #
3
+ # Each entry describes one Ermes API route exposed through the MCP server.
4
+ # The MCP dispatch builds its tool list from entries where `mcp.expose` is
5
+ # true, using `mcp.tool_name` as the tool id and `mcp.description` as the
6
+ # tool description shown to the model.
7
+ #
8
+ # Conventions:
9
+ # - `risk: read` — safe GETs only. Mutating tiers ("write", "delete") are
10
+ # reserved for v0.4+ when the confirmation UX lands.
11
+ # - `mcp.tool_name` — concept-style snake_case (e.g., `get_campaign`).
12
+ # - `paginated: true` — dispatch through `ErmesClient.get_paginated` so
13
+ # `limit` is honored across cursor pages.
14
+ #
15
+ # Adding a route:
16
+ # 1. Add an entry here.
17
+ # 2. Make sure `id` and `mcp.tool_name` are globally unique.
18
+ # 3. Run `pytest cli/tests/test_manifest.py` to validate.
19
+ #
20
+ # The hand-coded CLI (`ermes_cli/commands/*.py`) is NOT generated from this
21
+ # file. The CLI surface is richer (CRUD + prompts + validation) and lives
22
+ # separately. The manifest exists to drive the MCP tool set so adding a
23
+ # new MCP tool is a YAML edit, not a Python change.
24
+
25
+ - id: list_campaigns
26
+ path: /campaigns
27
+ method: GET
28
+ risk: read
29
+ paginated: true
30
+ mcp:
31
+ expose: true
32
+ tool_name: list_campaigns
33
+ description: List campaigns. Optional `status` filter (e.g. 'sent', 'draft', 'running').
34
+ query_args:
35
+ - name: limit
36
+ type: int
37
+ default: 50
38
+ help: Max number of campaigns to return.
39
+ - name: status
40
+ type: str
41
+ optional: true
42
+ help: Optional status filter.
43
+
44
+ - id: get_campaign
45
+ path: /campaigns/{campaign_id}
46
+ method: GET
47
+ risk: read
48
+ paginated: false
49
+ mcp:
50
+ expose: true
51
+ tool_name: get_campaign
52
+ description: Fetch one campaign by id.
53
+ args:
54
+ - name: campaign_id
55
+ help: The campaign id.
56
+
57
+ - id: list_templates
58
+ path: /templates
59
+ method: GET
60
+ risk: read
61
+ paginated: true
62
+ mcp:
63
+ expose: true
64
+ tool_name: list_templates
65
+ description: List email templates.
66
+ query_args:
67
+ - name: limit
68
+ type: int
69
+ default: 50
70
+ help: Max number of templates to return.
71
+
72
+ - id: get_template
73
+ path: /templates/{template_id}
74
+ method: GET
75
+ risk: read
76
+ paginated: false
77
+ mcp:
78
+ expose: true
79
+ tool_name: get_template
80
+ description: Fetch one template by id.
81
+ args:
82
+ - name: template_id
83
+ help: The template id.
84
+
85
+ - id: list_lists
86
+ path: /lists
87
+ method: GET
88
+ risk: read
89
+ paginated: true
90
+ mcp:
91
+ expose: true
92
+ tool_name: list_lists
93
+ description: List contact lists.
94
+ query_args:
95
+ - name: limit
96
+ type: int
97
+ default: 50
98
+ help: Max number of lists to return.
99
+
100
+ - id: list_journeys
101
+ path: /journeys
102
+ method: GET
103
+ risk: read
104
+ paginated: true
105
+ mcp:
106
+ expose: true
107
+ tool_name: list_journeys
108
+ description: List automation journeys.
109
+ query_args:
110
+ - name: limit
111
+ type: int
112
+ default: 50
113
+ help: Max number of journeys to return.
114
+
115
+ - id: list_users
116
+ path: /users
117
+ method: GET
118
+ risk: read
119
+ paginated: true
120
+ mcp:
121
+ expose: true
122
+ tool_name: list_users
123
+ description: List users (contacts). Optional exact-email filter.
124
+ query_args:
125
+ - name: limit
126
+ type: int
127
+ default: 100
128
+ help: Max number of users to return.
129
+ - name: email
130
+ type: str
131
+ optional: true
132
+ help: Optional exact-email filter.
133
+
134
+ - id: get_user
135
+ path: /users/{user_id}
136
+ method: GET
137
+ risk: read
138
+ paginated: false
139
+ mcp:
140
+ expose: true
141
+ tool_name: get_user
142
+ description: Fetch one user by id.
143
+ args:
144
+ - name: user_id
145
+ help: The user id.
146
+
147
+ - id: get_user_events
148
+ path: /users/{user_id}/events
149
+ method: GET
150
+ risk: read
151
+ paginated: true
152
+ mcp:
153
+ expose: true
154
+ tool_name: get_user_events
155
+ description: Recent events for one user (sends, opens, clicks).
156
+ args:
157
+ - name: user_id
158
+ help: The user id.
159
+ query_args:
160
+ - name: limit
161
+ type: int
162
+ default: 100
163
+ help: Max number of events to return.
ermes_cli/rules.py ADDED
@@ -0,0 +1,54 @@
1
+ """Normalization for rule trees sent to the API.
2
+
3
+ The platform accepts arbitrary JSON scalars as rule ``value``s and the rule
4
+ engine coerces them at evaluation time (``NumberRule`` does ``Number(value)``,
5
+ ``BooleanRule`` never reads ``rule.value``), but the admin UI builder always
6
+ stores strings. Hand-authored payloads naturally write ``"value": 156004``,
7
+ which crashed the journey editor's rule renderer (DEV-1374). Canonicalize
8
+ CLI-authored payloads to the string form the UI produces so stored rules stay
9
+ uniform regardless of who wrote them.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any
15
+
16
+ RULE_TYPES = {"wrapper", "string", "number", "boolean", "date", "array"}
17
+
18
+
19
+ def _is_rule_node(node: dict) -> bool:
20
+ return node.get("type") in RULE_TYPES and "path" in node and "operator" in node
21
+
22
+
23
+ def _to_string(value: Any) -> Any:
24
+ # json.dumps matches JS literal semantics: True -> "true", 156004 -> "156004",
25
+ # 1.5 -> "1.5". Strings and null pass through untouched.
26
+ if isinstance(value, (bool, int, float)):
27
+ return json.dumps(value)
28
+ return value
29
+
30
+
31
+ def normalize_rule_values(payload: Any) -> Any:
32
+ """Recursively stringify scalar ``value``s on every rule node in ``payload``.
33
+
34
+ Works on any JSON shape (a bare rule, a journey step map, a list body) by
35
+ detecting rule nodes structurally (``type``/``path``/``operator``). Mutates
36
+ dicts and lists in place and returns ``payload`` for convenience.
37
+ """
38
+ if isinstance(payload, dict):
39
+ # Date rules are exempt: DateRule casts with `new Date(value)`, and a
40
+ # numeric epoch is a valid Date while its string form is not
41
+ # ("Invalid Date" -> rule evaluates false). The UI tolerates
42
+ # non-string values since DEV-1374, so numeric dates stay numeric.
43
+ if _is_rule_node(payload) and payload.get("type") != "date":
44
+ value = payload.get("value")
45
+ if isinstance(value, list):
46
+ payload["value"] = [_to_string(item) for item in value]
47
+ elif value is not None:
48
+ payload["value"] = _to_string(value)
49
+ for child in payload.values():
50
+ normalize_rule_values(child)
51
+ elif isinstance(payload, list):
52
+ for item in payload:
53
+ normalize_rule_values(item)
54
+ return payload
@@ -0,0 +1,178 @@
1
+ """Write-mode guard: block mutating CLI/MCP calls by default.
2
+
3
+ Why: most CLI sessions are read-only investigation ("what campaigns ran
4
+ last week?"). The mutating commands (`campaigns create`, `lists delete`,
5
+ `templates update`) only exist for the maintenance use case, and a
6
+ typo'd flag or copy-pasted-from-docs command shouldn't be one Enter-press
7
+ away from deleting production data.
8
+
9
+ Behavior:
10
+ - Default: writes BLOCKED. POST/PATCH/PUT/DELETE through `client.py` raise
11
+ WriteBlockedError before hitting the network.
12
+ - `ermes write allow` → unblocks for 30 min (default).
13
+ `ermes write allow --minutes 5` for shorter windows.
14
+ `ermes write allow --persistent` for permanent (until `write deny`).
15
+ - `ermes write deny` → revoke immediately.
16
+ - `ermes write status` → show current state.
17
+ - `ERMES_ALLOW_WRITES=1` env override (for CI/scripts that consciously
18
+ want unguarded behavior).
19
+
20
+ State lives in `~/.ermes/state.yml` (chmod 600). It's deliberately
21
+ separate from projects.yml so refreshing projects doesn't accidentally
22
+ clobber the write window.
23
+
24
+ The MCP server's tool set is read-only by design (see PR #395 TODO), so
25
+ this guard primarily protects the `ermes` CLI surface. When MCP gains
26
+ mutating tools (v0.4), they'll be gated through the same helper.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ import yaml
37
+
38
+ from ermes_cli.config import _config_dir
39
+ from ermes_cli.errors import WriteBlockedError
40
+
41
+
42
+ def _state_path() -> Path:
43
+ return _config_dir() / "state.yml"
44
+
45
+
46
+ def _load_state() -> dict[str, Any]:
47
+ p = _state_path()
48
+ if not p.exists():
49
+ return {}
50
+ try:
51
+ with open(p) as f:
52
+ data = yaml.safe_load(f)
53
+ return data if isinstance(data, dict) else {}
54
+ except (OSError, yaml.YAMLError):
55
+ return {}
56
+
57
+
58
+ def _save_state(state: dict[str, Any]) -> None:
59
+ p = _state_path()
60
+ p.parent.mkdir(parents=True, exist_ok=True)
61
+ with open(p, "w") as f:
62
+ yaml.dump(state, f, default_flow_style=False, sort_keys=False)
63
+ p.chmod(0o600)
64
+
65
+
66
+ def _now() -> datetime:
67
+ return datetime.now(timezone.utc)
68
+
69
+
70
+ def _parse_iso(value: Any) -> datetime | None:
71
+ if not isinstance(value, str):
72
+ return None
73
+ try:
74
+ # Tolerate the trailing "Z" form.
75
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
76
+ except ValueError:
77
+ return None
78
+
79
+
80
+ # WriteBlockedError is defined in ermes_cli.errors so both client.py and
81
+ # this module can import it without a circular dependency. Re-exporting
82
+ # here keeps the old import path (`from ermes_cli.write_mode import
83
+ # WriteBlockedError`) working for any caller that already used it.
84
+ __all__ = [
85
+ "WriteBlockedError",
86
+ "env_override_active",
87
+ "status",
88
+ "is_writes_allowed",
89
+ "allow",
90
+ "deny",
91
+ "assert_writes_allowed",
92
+ ]
93
+
94
+
95
+ def env_override_active() -> bool:
96
+ """True if the operator opted in via env var (CI / automation path)."""
97
+ return os.environ.get("ERMES_ALLOW_WRITES", "").lower() in {"1", "true", "yes"}
98
+
99
+
100
+ def status() -> dict[str, Any]:
101
+ """Structured status: {allowed, mode, until, source}."""
102
+ if env_override_active():
103
+ return {
104
+ "allowed": True,
105
+ "mode": "env",
106
+ "until": None,
107
+ "source": "ERMES_ALLOW_WRITES env",
108
+ }
109
+ state = _load_state()
110
+ if state.get("write_mode_persistent"):
111
+ return {
112
+ "allowed": True,
113
+ "mode": "persistent",
114
+ "until": None,
115
+ "source": "state.yml",
116
+ }
117
+ until = _parse_iso(state.get("write_mode_until"))
118
+ if until and until > _now():
119
+ return {
120
+ "allowed": True,
121
+ "mode": "timed",
122
+ "until": until.isoformat(),
123
+ "source": "state.yml",
124
+ }
125
+ return {
126
+ "allowed": False,
127
+ "mode": "denied",
128
+ "until": until.isoformat() if until else None,
129
+ "source": "default" if not state else "state.yml",
130
+ }
131
+
132
+
133
+ def is_writes_allowed() -> bool:
134
+ return bool(status()["allowed"])
135
+
136
+
137
+ def allow(minutes: int = 30, persistent: bool = False) -> dict[str, Any]:
138
+ """Open the write window.
139
+
140
+ `persistent=True` ignores `minutes` and unblocks indefinitely. Otherwise
141
+ the window opens for `minutes` and closes automatically when it expires.
142
+ """
143
+ state = _load_state()
144
+ if persistent:
145
+ state["write_mode_persistent"] = True
146
+ state.pop("write_mode_until", None)
147
+ else:
148
+ until = _now() + _delta(minutes)
149
+ state["write_mode_persistent"] = False
150
+ state["write_mode_until"] = until.isoformat()
151
+ _save_state(state)
152
+ return status()
153
+
154
+
155
+ def deny() -> dict[str, Any]:
156
+ """Close the write window immediately."""
157
+ state = _load_state()
158
+ state["write_mode_persistent"] = False
159
+ state.pop("write_mode_until", None)
160
+ _save_state(state)
161
+ return status()
162
+
163
+
164
+ def _delta(minutes: int):
165
+ from datetime import timedelta
166
+
167
+ return timedelta(minutes=int(minutes))
168
+
169
+
170
+ def assert_writes_allowed(method: str, path: str) -> None:
171
+ """Guard called from `client.py` before sending mutating requests."""
172
+ if is_writes_allowed():
173
+ return
174
+ raise WriteBlockedError(
175
+ f"Write blocked: {method} {path}. Writes are disabled by default. "
176
+ "Run `ermes write allow` (30 min) or `ermes write allow --persistent`, "
177
+ "or set ERMES_ALLOW_WRITES=1 in your environment."
178
+ )