mova-bridge 0.3.0__tar.gz

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,62 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+
8
+ # Virtual environments
9
+ .venv/
10
+ venv/
11
+ env/
12
+
13
+ # Distribution / packaging
14
+ *.egg-info/
15
+ dist/
16
+ build/
17
+ *.egg
18
+
19
+ # Environment variables
20
+ .env
21
+ .env.*
22
+
23
+ # Testing & coverage
24
+ .pytest_cache/
25
+ .coverage
26
+ htmlcov/
27
+
28
+ # IDE
29
+ .vscode/
30
+ .idea/
31
+ *.iml
32
+
33
+ # Output artifacts (generated contracts, episodes)
34
+ output/
35
+ episodes/
36
+
37
+ # OS
38
+ .DS_Store
39
+ Thumbs.db
40
+
41
+ # Next.js
42
+ mova-ui/.next/
43
+ mova-ui/out/
44
+ mova-ui/node_modules/
45
+ mova-ui/.vercel/
46
+ mova-ui/next-env.d.ts
47
+
48
+ # Registry runtime data (DB-like, not source)
49
+ registry-data/runs/
50
+ registry-data/receipts/
51
+ registry-data/sessions/
52
+
53
+ # Runtime episode output
54
+ mova-runtime/output/
55
+
56
+ # Wrangler local state
57
+ .wrangler/
58
+ mova-runtime-rs/.wrangler/
59
+
60
+ # Local task/working files
61
+ TASK_*.md
62
+ *.png
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: mova-bridge
3
+ Version: 0.3.0
4
+ Summary: MOVA Contract Bridge — MCP client for OpenClaw and Claude Code
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: mcp[cli]>=1.0.0
@@ -0,0 +1,79 @@
1
+ # MOVA Bridge
2
+
3
+ A thin MCP client that connects any MCP-compatible agent (OpenClaw, Claude Code) to the MOVA contract execution platform.
4
+
5
+ The bridge contains zero contracts, policies, or runtime logic — it is a pure HTTP client over the MOVA API.
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.11+
10
+ - A MOVA API key — get one at https://mova.dev/keys
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install mova-bridge
16
+ ```
17
+
18
+ Or from source:
19
+
20
+ ```bash
21
+ git clone ...
22
+ cd mova-bridge
23
+ pip install -e .
24
+ ```
25
+
26
+ ## Connect with OpenClaw
27
+
28
+ ```bash
29
+ export MOVA_API_KEY=your_key_here
30
+ claw mcp add mova python -m mova_bridge
31
+ ```
32
+
33
+ Then load the skill:
34
+
35
+ ```bash
36
+ claw skill load SKILL.md
37
+ ```
38
+
39
+ ## Connect with Claude Code
40
+
41
+ ```bash
42
+ export MOVA_API_KEY=your_key_here
43
+ claude mcp add mova python -m mova_bridge
44
+ ```
45
+
46
+ ## Connect with any MCP client
47
+
48
+ The server runs over stdio. Start it with:
49
+
50
+ ```bash
51
+ MOVA_API_KEY=your_key python -m mova_bridge
52
+ ```
53
+
54
+ ## Environment variables
55
+
56
+ | Variable | Required | Default | Description |
57
+ |----------|----------|---------|-------------|
58
+ | `MOVA_API_KEY` | Yes | — | Your MOVA API key |
59
+ | `MOVA_API_URL` | No | `https://mova-api.fly.dev` | Override API base URL (e.g. for self-hosted) |
60
+
61
+ ## Available tools
62
+
63
+ | Tool | Description |
64
+ |------|-------------|
65
+ | `mova_list_contracts` | List available contracts, filter by domain or keyword |
66
+ | `mova_get_contract` | Full contract details including required inputs |
67
+ | `mova_price_estimate` | Cost estimate before execution |
68
+ | `mova_execute` | Execute a contract (billed on success) |
69
+ | `mova_get_episode` | Retrieve full audit trail by episode ID |
70
+ | `mova_list_episodes` | Browse execution history for your org |
71
+ | `mova_usage` | Spend and execution statistics |
72
+
73
+ ## Testing without a key
74
+
75
+ Every tool returns a clear error pointing to https://mova.dev/keys when `MOVA_API_KEY` is unset:
76
+
77
+ ```json
78
+ {"ok": false, "error": "MOVA_API_KEY is not set. Get your key at https://mova.dev/keys"}
79
+ ```
@@ -0,0 +1,133 @@
1
+ # MOVA Contract Skill
2
+
3
+ You have access to MOVA — a contract execution platform that runs AI-powered business tasks with a full audit trail. Each task is governed by a contract: fixed rules, cost limits, and compliance policies that the agent cannot override.
4
+
5
+ ## First time setup
6
+
7
+ If `MOVA_API_KEY` is not set, the user needs to register first:
8
+
9
+ 1. Call `mova_register` with their organisation name
10
+ 2. Show them the returned `api_key` and ask them to save it
11
+ 3. Tell them to add `MOVA_API_KEY=<key>` to their MCP environment config and restart
12
+ 4. After restart, all other tools will work
13
+
14
+ > "It looks like you haven't connected MOVA yet. Let me create an account for you — what should I call your organisation?"
15
+
16
+ New accounts start with **$1.00 free credit** to try the platform.
17
+
18
+ ## When to use MOVA
19
+
20
+ Use MOVA when the user asks to perform a structured business task such as:
21
+ - Reviewing an AML alert or suspicious transaction
22
+ - Handling a customer complaint
23
+ - Resolving an order dispute
24
+ - Assessing seller or counterparty risk
25
+ - KYB onboarding a new business partner
26
+ - Reviewing a loan application
27
+ - Reviewing an insurance claim
28
+ - Qualifying a sales lead
29
+ - Any task where a decision needs to be documented and auditable
30
+
31
+ ## Available tools
32
+
33
+ | Tool | When to call |
34
+ |------|-------------|
35
+ | `mova_register` | User has no API key yet — first-time setup |
36
+ | `mova_list_contracts` | User asks "what can you do?" or "what tasks are available?" |
37
+ | `mova_get_contract` | User wants details on a specific task before proceeding |
38
+ | `mova_price_estimate` | ALWAYS before executing — show the user the cost |
39
+ | `mova_execute` | After the user confirms the price and provides the inputs |
40
+ | `mova_get_episode` | User asks "show me the audit" or "what happened in ep-xxx" |
41
+ | `mova_list_episodes` | User asks for history or past executions |
42
+ | `mova_usage` | User asks about spend, balance, or how many tasks were run |
43
+
44
+ ### HITL Invoice tools (Human-in-the-Loop)
45
+
46
+ | Tool | When to call |
47
+ |------|-------------|
48
+ | `mova_hitl_start` | User sends an invoice image or says "process invoice" — call immediately, no confirmation needed |
49
+ | `mova_hitl_decide` | After `mova_hitl_start` returns `waiting_human` and user picks an option |
50
+ | `mova_hitl_status` | User asks about status of an in-progress invoice contract |
51
+ | `mova_hitl_audit` | User asks for the full audit receipt of a completed invoice |
52
+
53
+ ## Interaction flow — follow this every time
54
+
55
+ ### Step 1 — Check setup
56
+ If you don't know whether `MOVA_API_KEY` is set, call `mova_list_contracts`. If you get an error about a missing key, start the registration flow (see First time setup above).
57
+
58
+ ### Step 2 — Discover
59
+ When the user describes a task, call `mova_list_contracts` to find the right contract. Pass domain or keyword if obvious. Show the user a short list and confirm the match.
60
+
61
+ ### Step 3 — Show price
62
+ Call `mova_price_estimate` with the matched contract ID. Present in plain language:
63
+
64
+ > This task costs approximately **$0.003** (typical) and up to **$0.008** at maximum. It will query 2 external data sources during execution. You will only be billed if the task completes successfully. Shall I proceed?
65
+
66
+ Never proceed to Step 4 without explicit confirmation from the user.
67
+
68
+ ### Step 4 — Collect inputs
69
+ If the user hasn't provided all required inputs, ask for them now. Call `mova_get_contract` to see what fields are expected. Do not guess missing values — ask the user.
70
+
71
+ ### Step 5 — Execute
72
+ Call `mova_execute` with the contract ID and inputs. Tell the user:
73
+ > "Running the task — this may take up to 30–120 seconds as the agent queries external data sources."
74
+
75
+ ### Step 6 — Show result
76
+ After execution, present:
77
+ - **Decision**: the agent's answer in plain language
78
+ - **Verdict**: fulfilled ✓ / failed ✗
79
+ - **Episode ID**: "Your audit receipt is `ep-xxxxxxxx`. You can retrieve this anytime."
80
+ - **Cost**: the actual charge for this run
81
+ - **Connector calls**: if any external data was queried, briefly mention it (e.g., "The agent checked sanctions databases and the company registry")
82
+
83
+ If verdict is `failed`, explain what went wrong and offer to retry.
84
+
85
+ ## Rules you must never break
86
+
87
+ 1. **Always show price before executing.** No exceptions, even if the user says "just do it".
88
+ 2. **Never bypass contract policies.** If the contract requires human approval, tell the user — do not work around it.
89
+ 3. **Never share the API key.** Do not include `MOVA_API_KEY` in any message or tool response.
90
+ 4. **Never modify inputs to influence the outcome.** Pass the user's data exactly as provided.
91
+ 5. **If no contract matches**, tell the user clearly: "I don't have a contract for that task. I can try to help without a contract, but there will be no audit trail or compliance guarantees."
92
+ 6. **Always present the episode ID** after a successful execution. It is the user's proof of completion.
93
+
94
+ ## Error handling
95
+
96
+ | Error | What to tell the user |
97
+ |-------|-----------------------|
98
+ | `MOVA_API_KEY not set` | "You're not connected to MOVA yet. Let me register an account for you — what should I call your organisation?" → call `mova_register` |
99
+ | `Invalid API key` | "Your MOVA key appears to be invalid. Would you like me to register a new account?" |
100
+ | `Insufficient balance` | "Your MOVA balance is empty. Please contact your administrator to add credit." |
101
+ | `Rate limit` | "Too many requests right now. I'll wait a moment and retry." |
102
+ | `Not found` | "That contract or episode wasn't found. Check the ID." |
103
+
104
+ ## Language and tone
105
+
106
+ - Speak to the user in plain language. Do not mention JSON schemas, envelopes, agent IDs, or MOVA internals.
107
+ - Refer to contracts as **"tasks"** in conversation.
108
+ - Refer to episodes as **"receipts"** or **"audit records"** in conversation.
109
+ - Refer to connectors as **"external data sources"** in conversation.
110
+ - For regulatory/compliance tasks (AML, complaints, seller risk, KYB, loan), remind the user: "This result is for informational purposes. A qualified human reviewer should validate the final decision."
111
+
112
+ ## Example conversations
113
+
114
+ **"What tasks can you do?"**
115
+ → Call `mova_list_contracts`, present as a bullet list with one-line descriptions.
116
+
117
+ **"Set me up with MOVA"** / **"I want to try MOVA"**
118
+ → Ask for org name → call `mova_register` → show api_key → explain how to set MOVA_API_KEY.
119
+
120
+ **"How much does the AML triage cost?"**
121
+ → Call `mova_price_estimate` for `agent.aml.alert_triage_l1`, show the estimate.
122
+
123
+ **"Run the AML check on this alert: [data]"**
124
+ → Estimate → confirm → collect missing inputs → execute → show decision + episode ID.
125
+
126
+ **"Show me what happened in ep-abc123def456"**
127
+ → Call `mova_get_episode("ep-abc123def456")`, present the decision and verdict in plain language. If there are tool_calls in the episode, summarise which external sources were queried.
128
+
129
+ **"How much have I spent?"**
130
+ → Call `mova_usage`, present balance remaining and total spent.
131
+
132
+ **"Show my last 5 AML runs"**
133
+ → Call `mova_list_episodes(contract_id="agent.aml.alert_triage_l1", limit=5)`.
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mova-bridge"
7
+ version = "0.3.0"
8
+ description = "MOVA Contract Bridge — MCP client for OpenClaw and Claude Code"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "mcp[cli]>=1.0.0",
12
+ "httpx>=0.27",
13
+ ]
14
+
15
+ [project.scripts]
16
+ mova-bridge = "mova_bridge.server:main"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/mova_bridge"]
@@ -0,0 +1,2 @@
1
+ """MOVA Contract Bridge — thin MCP client over the MOVA API."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """Allow: python -m mova_bridge"""
2
+ from mova_bridge.server import main
3
+
4
+ main()
@@ -0,0 +1,567 @@
1
+ """
2
+ MOVA Contract Bridge — MCP server (thin HTTP client over MOVA API).
3
+
4
+ Contains ZERO contracts, policies, runtime logic, or episodes locally.
5
+ All operations are delegated to the MOVA API.
6
+
7
+ Environment variables:
8
+ MOVA_API_KEY Required (after registration). Your MOVA API key.
9
+ MOVA_API_URL Optional. Defaults to the production endpoint.
10
+ LLM_KEY OpenRouter key for AI steps.
11
+ LLM_MODEL Model for AI steps (default: openai/gpt-4o-mini).
12
+ OCR_LLM_MODEL Model for OCR step (default: qwen/qwen3-vl-32b-instruct).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import uuid
19
+ from typing import Any
20
+
21
+ import httpx
22
+ from mcp.server.fastmcp import FastMCP
23
+
24
+ _VERSION = "0.3.0"
25
+ _DEFAULT_API_URL = "https://api.mova-lab.eu"
26
+
27
+ mcp = FastMCP("mova-bridge")
28
+
29
+ # Force IPv4 — VPS has no working IPv6, httpx would wait 60s for timeout otherwise.
30
+ _client = httpx.Client(transport=httpx.HTTPTransport(local_address="0.0.0.0"))
31
+
32
+
33
+ # ── HTTP helpers ───────────────────────────────────────────────────────────────
34
+
35
+ def _api_url() -> str:
36
+ return os.environ.get("MOVA_API_URL", _DEFAULT_API_URL).rstrip("/")
37
+
38
+
39
+ def _headers(require_key: bool = True, with_llm: bool = False) -> dict[str, str]:
40
+ key = os.environ.get("MOVA_API_KEY", "")
41
+ if require_key and not key:
42
+ raise ValueError(
43
+ "MOVA_API_KEY is not set.\n"
44
+ "Call mova_register to create an account and get your key,\n"
45
+ "then set MOVA_API_KEY in your MCP configuration."
46
+ )
47
+ h = {"X-Client": f"mova-bridge/{_VERSION}", "Content-Type": "application/json"}
48
+ if key:
49
+ h["Authorization"] = f"Bearer {key}"
50
+ if with_llm:
51
+ llm_key = os.environ.get("LLM_KEY", "")
52
+ llm_model = os.environ.get("LLM_MODEL", "openai/gpt-4o-mini")
53
+ if llm_key:
54
+ h["X-LLM-Key"] = llm_key
55
+ if llm_model:
56
+ h["X-LLM-Model"] = llm_model
57
+ return h
58
+
59
+
60
+ def _get(path: str, params: dict | None = None, timeout: int = 30) -> Any:
61
+ try:
62
+ r = _client.get(
63
+ f"{_api_url()}{path}",
64
+ headers=_headers(),
65
+ params={k: v for k, v in (params or {}).items() if v},
66
+ timeout=timeout,
67
+ )
68
+ return _handle(r)
69
+ except ValueError as e:
70
+ return {"ok": False, "error": str(e)}
71
+
72
+
73
+ def _post(path: str, body: dict, require_key: bool = True, timeout: int = 30) -> Any:
74
+ try:
75
+ r = _client.post(
76
+ f"{_api_url()}{path}",
77
+ headers=_headers(require_key=require_key),
78
+ content=json.dumps(body),
79
+ timeout=timeout,
80
+ )
81
+ return _handle(r)
82
+ except ValueError as e:
83
+ return {"ok": False, "error": str(e)}
84
+
85
+
86
+ def _handle(r: httpx.Response) -> Any:
87
+ if r.status_code == 401:
88
+ return {"ok": False, "error": "Invalid API key. Call mova_register for a new key or check your MOVA_API_KEY setting."}
89
+ if r.status_code == 402:
90
+ return {"ok": False, "error": "Insufficient balance. Contact your MOVA administrator to top up."}
91
+ if r.status_code == 404:
92
+ return {"ok": False, "error": "Not found. Check the contract ID or episode ID."}
93
+ if r.status_code == 429:
94
+ return {"ok": False, "error": "Rate limit exceeded. Slow down and retry."}
95
+ if r.status_code >= 400:
96
+ try:
97
+ detail = r.json().get("error", r.json().get("detail", r.text))
98
+ except Exception:
99
+ detail = r.text
100
+ return {"ok": False, "error": f"API error {r.status_code}: {detail}"}
101
+ try:
102
+ return r.json()
103
+ except Exception:
104
+ return {"ok": True, "raw": r.text}
105
+
106
+
107
+ # ── MCP tools ──────────────────────────────────────────────────────────────────
108
+
109
+ @mcp.tool(name="mova_register")
110
+ def mova_register(org_name: str) -> str:
111
+ """Register a new MOVA account and get an API key.
112
+
113
+ Call this once to get started. Returns your api_key — save it and set it
114
+ as the MOVA_API_KEY environment variable in your MCP configuration.
115
+ Your account starts with $1.00 free credit to try the platform.
116
+
117
+ Args:
118
+ org_name: Your organisation or team name, e.g. "Acme Risk Team".
119
+
120
+ Returns JSON with your api_key, org_id, and initial balance.
121
+ """
122
+ result = _post("/api/v1/accounts/register", {"org_name": org_name}, require_key=False)
123
+ return json.dumps(result, ensure_ascii=False, indent=2)
124
+
125
+
126
+ @mcp.tool(name="mova_list_contracts")
127
+ def mova_list_contracts(domain: str = "", keyword: str = "") -> str:
128
+ """List available MOVA contracts (tasks).
129
+
130
+ Args:
131
+ domain: Filter by domain, e.g. 'finance', 'compliance', 'ecommerce', 'sales'.
132
+ keyword: Free-text search in contract titles and descriptions.
133
+
134
+ Returns JSON list of contracts with title, domain, and description.
135
+ """
136
+ result = _get("/api/v1/tasks", {"category": domain, "keyword": keyword})
137
+ return json.dumps(result, ensure_ascii=False, indent=2)
138
+
139
+
140
+ @mcp.tool(name="mova_get_contract")
141
+ def mova_get_contract(contract_id: str) -> str:
142
+ """Get full details for a specific contract including required inputs and cost estimate.
143
+
144
+ Args:
145
+ contract_id: Contract ID, e.g. 'agent.aml.alert_triage_l1'.
146
+ Call mova_list_contracts first to see available IDs.
147
+
148
+ Returns JSON with contract details, limits, connectors, and cost estimate.
149
+ """
150
+ result = _get(f"/api/v1/tasks/{contract_id}")
151
+ return json.dumps(result, ensure_ascii=False, indent=2)
152
+
153
+
154
+ @mcp.tool(name="mova_price_estimate")
155
+ def mova_price_estimate(contract_id: str) -> str:
156
+ """Get a cost estimate before executing a contract.
157
+ Always call this before mova_execute and show the result to the user.
158
+
159
+ Args:
160
+ contract_id: Contract ID to estimate, e.g. 'agent.aml.alert_triage_l1'.
161
+
162
+ Returns JSON with median and p95 cost in USD and estimated duration.
163
+ """
164
+ result = _get(f"/api/v1/tasks/{contract_id}")
165
+ if not result.get("ok"):
166
+ return json.dumps(result, ensure_ascii=False, indent=2)
167
+
168
+ contract = result.get("contract", {})
169
+ estimate = result.get("cost_estimate", {})
170
+ title = contract.get("meta", {}).get("title", contract_id)
171
+ connectors = contract.get("connector_refs", [])
172
+
173
+ return json.dumps({
174
+ "ok": True,
175
+ "contract_id": contract_id,
176
+ "title": title,
177
+ "estimate": {
178
+ "median_usd": estimate.get("median_usd"),
179
+ "p95_usd": estimate.get("p95_usd"),
180
+ "duration_p50_ms": estimate.get("duration_p50_ms"),
181
+ "currency": "USD",
182
+ },
183
+ "connectors_used": len(connectors),
184
+ "note": (
185
+ "Cost increases if the agent makes connector calls. "
186
+ "You are billed only after a fulfilled execution."
187
+ ),
188
+ }, ensure_ascii=False, indent=2)
189
+
190
+
191
+ @mcp.tool(name="mova_execute")
192
+ def mova_execute(contract_id: str, inputs: dict) -> str:
193
+ """Execute a MOVA contract.
194
+
195
+ IMPORTANT: Always call mova_price_estimate first and confirm the cost with the user.
196
+
197
+ Args:
198
+ contract_id: Contract ID to execute, e.g. 'agent.aml.alert_triage_l1'.
199
+ inputs: Input payload matching the contract's required fields.
200
+ Call mova_get_contract to see the expected input fields.
201
+
202
+ Returns JSON with the agent decision, verdict (fulfilled/failed), episode_id
203
+ (your audit receipt), cost, and connector calls made.
204
+ Save the episode_id — it is your permanent audit reference.
205
+ """
206
+ result = _post(
207
+ f"/api/v1/tasks/{contract_id}/run",
208
+ {"inputs": inputs},
209
+ timeout=120,
210
+ )
211
+ return json.dumps(result, ensure_ascii=False, indent=2)
212
+
213
+
214
+ @mcp.tool(name="mova_get_episode")
215
+ def mova_get_episode(episode_id: str) -> str:
216
+ """Retrieve the full audit record for a past execution by episode ID.
217
+
218
+ Args:
219
+ episode_id: Episode ID from a previous mova_execute call, e.g. 'ep-abc123def456'.
220
+
221
+ Returns JSON with the full decision, reasoning, all connector calls made,
222
+ budget usage, verdict, and timestamps.
223
+ """
224
+ result = _get(f"/api/v1/jobs/{episode_id}")
225
+ return json.dumps(result, ensure_ascii=False, indent=2)
226
+
227
+
228
+ @mcp.tool(name="mova_list_episodes")
229
+ def mova_list_episodes(
230
+ contract_id: str = "",
231
+ status: str = "",
232
+ limit: int = 20,
233
+ ) -> str:
234
+ """List past executions for your organisation.
235
+
236
+ Args:
237
+ contract_id: Filter by contract ID (optional).
238
+ status: Filter by verdict: 'fulfilled', 'failed' (optional).
239
+ limit: Max results to return (default 20, max 100).
240
+
241
+ Returns JSON list of episodes with verdict, cost, and timestamps.
242
+ """
243
+ key = os.environ.get("MOVA_API_KEY", "")
244
+ if not key:
245
+ return json.dumps({"ok": False, "error": "MOVA_API_KEY not set. Call mova_register first."})
246
+
247
+ # Derive org_id from auth — use a placeholder, server resolves from API key
248
+ result = _get(
249
+ "/api/v1/orgs/_me/jobs",
250
+ {"task_id": contract_id, "status": status, "limit": limit},
251
+ )
252
+ # Fallback: if _me is not supported, try via spend endpoint to discover org_id
253
+ if not result.get("ok"):
254
+ # Try getting org_id from the account
255
+ spend = _get("/api/v1/orgs/_me/spend")
256
+ org_id = spend.get("org_id", "")
257
+ if org_id:
258
+ result = _get(
259
+ f"/api/v1/orgs/{org_id}/jobs",
260
+ {"task_id": contract_id, "status": status, "limit": limit},
261
+ )
262
+ return json.dumps(result, ensure_ascii=False, indent=2)
263
+
264
+
265
+ @mcp.tool(name="mova_usage")
266
+ def mova_usage() -> str:
267
+ """Get spend and execution statistics for your organisation.
268
+
269
+ Returns JSON with total spend, execution count, and balance remaining.
270
+ """
271
+ result = _get("/api/v1/orgs/_me/spend")
272
+ return json.dumps(result, ensure_ascii=False, indent=2)
273
+
274
+
275
+ # ── HITL contract steps (invoice) ─────────────────────────────────────────────
276
+
277
+ _INVOICE_STEPS = [
278
+ {
279
+ "step_id": "analyze",
280
+ "step_type": "ai_task",
281
+ "title": "OCR Extract and Validate Invoice",
282
+ "next_step_id": "verify",
283
+ "config": {
284
+ "model": os.environ.get("OCR_LLM_MODEL", "qwen/qwen3-vl-32b-instruct"),
285
+ "api_key_env": "OCR_LLM_KEY",
286
+ "system_prompt": (
287
+ "You are an invoice OCR and validation agent. "
288
+ "The user message contains the invoice image. Extract all fields and validate. "
289
+ "Return ONLY a JSON object with: "
290
+ "document_id, vendor_name, vendor_iban, vendor_tax_id, "
291
+ "total_amount (number), currency (ISO-4217), "
292
+ "invoice_date (ISO-8601), due_date (ISO-8601), "
293
+ "po_reference (null if missing), subtotal (number), tax_amount (number), "
294
+ "line_items (array of {description, quantity, unit_price, amount}), "
295
+ "review_decision (pass_to_ap/hold_for_review/reject), "
296
+ "vendor_status (known/unknown/blocked), po_match (matched/partial/not_found), "
297
+ "duplicate_flag (bool), ocr_confidence (0.0-1.0), "
298
+ "risk_score (0.0-1.0), findings (list of {code, severity, summary}), "
299
+ "requires_human_approval (bool), decision_reasoning (string)."
300
+ ),
301
+ },
302
+ },
303
+ {
304
+ "step_id": "verify",
305
+ "step_type": "verification",
306
+ "title": "Risk Snapshot",
307
+ "next_step_id": "decide",
308
+ "config": {"recommended_action": "review"},
309
+ },
310
+ {
311
+ "step_id": "decide",
312
+ "step_type": "decision_point",
313
+ "title": "AP Decision Gate",
314
+ "config": {
315
+ "decision_kind": "invoice_approval",
316
+ "question": "Invoice processing complete. Select action:",
317
+ "required_actor": {"actor_type": "human"},
318
+ "options": [
319
+ {"option_id": "approve", "label": "Approve — process payment"},
320
+ {"option_id": "reject", "label": "Reject — notify vendor"},
321
+ {"option_id": "escalate_accountant", "label": "Escalate to accountant"},
322
+ {"option_id": "request_info", "label": "Request more information"},
323
+ ],
324
+ "route_map": {
325
+ "approve": "__end__",
326
+ "reject": "__end__",
327
+ "escalate_accountant": "__end__",
328
+ "request_info": "__end__",
329
+ "_default": "__end__",
330
+ },
331
+ },
332
+ },
333
+ ]
334
+
335
+
336
+ def _hitl_post(path: str, body: dict, timeout: int = 180) -> Any:
337
+ try:
338
+ r = _client.post(
339
+ f"{_api_url()}{path}",
340
+ headers=_headers(with_llm=True),
341
+ content=json.dumps(body),
342
+ timeout=timeout,
343
+ )
344
+ return _handle(r)
345
+ except ValueError as e:
346
+ return {"ok": False, "error": str(e)}
347
+
348
+
349
+ def _hitl_get(path: str, timeout: int = 30) -> Any:
350
+ try:
351
+ r = _client.get(
352
+ f"{_api_url()}{path}",
353
+ headers=_headers(),
354
+ timeout=timeout,
355
+ )
356
+ return _handle(r)
357
+ except ValueError as e:
358
+ return {"ok": False, "error": str(e)}
359
+
360
+
361
+ def _run_steps(contract_id: str) -> dict:
362
+ """Execute analyze → verify → decide steps in sequence.
363
+ Returns final status dict. Stops early if waiting_human."""
364
+ for step_id in ["analyze", "verify", "decide"]:
365
+ result = _hitl_post(
366
+ f"/api/v1/contracts/{contract_id}/step",
367
+ {"envelope": {
368
+ "kind": "env.step.execute_v0",
369
+ "envelope_id": f"env-{uuid.uuid4().hex[:8]}",
370
+ "contract_id": contract_id,
371
+ "actor": {"actor_type": "system", "actor_id": "mova_runtime"},
372
+ "payload": {"step_id": step_id},
373
+ }},
374
+ )
375
+ if not result.get("ok"):
376
+ return result
377
+ status = result.get("status", "")
378
+ if status == "waiting_human":
379
+ # Get decision point details
380
+ dp_resp = _hitl_get(f"/api/v1/contracts/{contract_id}/decision")
381
+ dp = dp_resp.get("decision_point", {})
382
+ return {
383
+ "ok": True,
384
+ "status": "waiting_human",
385
+ "contract_id": contract_id,
386
+ "question": dp.get("question", "Select action:"),
387
+ "options": dp.get("options", []),
388
+ "recommended": dp.get("recommended_option_id"),
389
+ }
390
+ # All steps done — return audit
391
+ audit = _hitl_get(f"/api/v1/contracts/{contract_id}/audit")
392
+ receipt = audit.get("audit_receipt", {})
393
+ return {
394
+ "ok": True,
395
+ "status": "completed",
396
+ "contract_id": contract_id,
397
+ "audit_receipt": receipt,
398
+ }
399
+
400
+
401
+ # ── HITL MCP tools ─────────────────────────────────────────────────────────────
402
+
403
+ @mcp.tool(name="mova_hitl_start")
404
+ def mova_hitl_start(file_url: str, document_id: str = "") -> str:
405
+ """Start a HITL invoice processing contract (OCR → validate → human decision).
406
+
407
+ Call this immediately when the user sends an invoice image or asks to process
408
+ an invoice. Do NOT ask for confirmation — just call it.
409
+
410
+ Args:
411
+ file_url: Direct HTTPS URL to the invoice image (JPEG or PNG).
412
+ document_id: Optional invoice ID (auto-generated if not provided).
413
+
414
+ Returns JSON. If status is "waiting_human" — show the user the decision options
415
+ and wait for their choice, then call mova_hitl_decide.
416
+ If status is "completed" — show the audit_receipt summary.
417
+ """
418
+ doc_id = document_id or f"INV-{uuid.uuid4().hex[:8].upper()}"
419
+ contract_id = f"ctr-invoice-{uuid.uuid4().hex[:8]}"
420
+
421
+ # Build and start contract
422
+ body = {
423
+ "envelope": {
424
+ "kind": "env.contract.start_v0",
425
+ "envelope_id": f"env-{uuid.uuid4().hex[:8]}",
426
+ "contract_id": contract_id,
427
+ "actor": {"actor_type": "human", "actor_id": "user"},
428
+ "payload": {
429
+ "template_id": "tpl.finance.invoice_ocr_hitl_v0",
430
+ "policy_profile_ref": "policy.hitl.finance.invoice_ocr_v0",
431
+ "initial_inputs": [
432
+ {"key": "document_id", "value": doc_id},
433
+ {"key": "document_type", "value": "invoice"},
434
+ {"key": "file_url", "value": file_url},
435
+ ],
436
+ },
437
+ },
438
+ "steps": _INVOICE_STEPS,
439
+ }
440
+
441
+ start = _hitl_post("/api/v1/contracts", body)
442
+ if not start.get("ok"):
443
+ return json.dumps(start, ensure_ascii=False, indent=2)
444
+
445
+ result = _run_steps(contract_id)
446
+ return json.dumps(result, ensure_ascii=False, indent=2)
447
+
448
+
449
+ @mcp.tool(name="mova_hitl_decide")
450
+ def mova_hitl_decide(contract_id: str, option: str, reason: str = "") -> str:
451
+ """Submit a human decision for a HITL invoice contract.
452
+
453
+ Call this after mova_hitl_start returns status "waiting_human".
454
+
455
+ Args:
456
+ contract_id: Contract ID from mova_hitl_start.
457
+ option: Decision option ID: "approve", "reject", "escalate_accountant", "request_info".
458
+ reason: Reason for the decision (brief plain text).
459
+
460
+ Returns JSON with final audit receipt.
461
+ """
462
+ dp_resp = _hitl_get(f"/api/v1/contracts/{contract_id}/decision")
463
+ dp = dp_resp.get("decision_point", {})
464
+
465
+ result = _hitl_post(
466
+ f"/api/v1/contracts/{contract_id}/decision",
467
+ {"envelope": {
468
+ "kind": "env.decision.submit_v0",
469
+ "envelope_id": f"env-{uuid.uuid4().hex[:8]}",
470
+ "contract_id": contract_id,
471
+ "decision_point_id": dp.get("decision_point_id", ""),
472
+ "actor": {"actor_type": "human", "actor_id": "user"},
473
+ "payload": {
474
+ "selected_option_id": option,
475
+ "selection_reason": reason or "decision via mova-bridge",
476
+ },
477
+ }},
478
+ )
479
+ if not result.get("ok"):
480
+ return json.dumps(result, ensure_ascii=False, indent=2)
481
+
482
+ audit = _hitl_get(f"/api/v1/contracts/{contract_id}/audit")
483
+ receipt = audit.get("audit_receipt", {})
484
+ return json.dumps({
485
+ "ok": True,
486
+ "status": "completed",
487
+ "contract_id": contract_id,
488
+ "decision": option,
489
+ "audit_receipt": receipt,
490
+ }, ensure_ascii=False, indent=2)
491
+
492
+
493
+ @mcp.tool(name="mova_hitl_status")
494
+ def mova_hitl_status(contract_id: str) -> str:
495
+ """Get the current status of a HITL invoice contract.
496
+
497
+ Args:
498
+ contract_id: Contract ID from mova_hitl_start.
499
+ """
500
+ result = _hitl_get(f"/api/v1/contracts/{contract_id}")
501
+ return json.dumps(result, ensure_ascii=False, indent=2)
502
+
503
+
504
+ @mcp.tool(name="mova_hitl_audit")
505
+ def mova_hitl_audit(contract_id: str) -> str:
506
+ """Get the full audit receipt for a completed HITL invoice contract.
507
+
508
+ Args:
509
+ contract_id: Contract ID from mova_hitl_start.
510
+ """
511
+ result = _hitl_get(f"/api/v1/contracts/{contract_id}/audit")
512
+ return json.dumps(result, ensure_ascii=False, indent=2)
513
+
514
+
515
+ # ── CLI call mode ──────────────────────────────────────────────────────────────
516
+
517
+ def _cli_call(args: list[str]) -> None:
518
+ """CLI call mode: mova-bridge call <tool> [--key value ...]
519
+ Calls a tool function and prints JSON result to stdout.
520
+ """
521
+ import sys
522
+ if not args:
523
+ print(json.dumps({"ok": False, "error": "Usage: mova-bridge call <tool> [--key value ...]"}))
524
+ sys.exit(1)
525
+
526
+ tool_name = args[0]
527
+ kwargs: dict = {}
528
+ i = 1
529
+ while i < len(args):
530
+ if args[i].startswith("--"):
531
+ key = args[i][2:].replace("-", "_")
532
+ val = args[i + 1] if i + 1 < len(args) else ""
533
+ kwargs[key] = val
534
+ i += 2
535
+ else:
536
+ i += 1
537
+
538
+ dispatch = {
539
+ "mova_hitl_start": lambda: mova_hitl_start(**kwargs),
540
+ "mova_hitl_decide": lambda: mova_hitl_decide(**kwargs),
541
+ "mova_hitl_status": lambda: mova_hitl_status(**kwargs),
542
+ "mova_hitl_audit": lambda: mova_hitl_audit(**kwargs),
543
+ "mova_execute": lambda: mova_execute(kwargs.get("contract_id", ""), json.loads(kwargs.get("inputs", "{}"))),
544
+ "mova_list_contracts": lambda: mova_list_contracts(),
545
+ "mova_usage": lambda: mova_usage(),
546
+ }
547
+
548
+ fn = dispatch.get(tool_name)
549
+ if fn is None:
550
+ print(json.dumps({"ok": False, "error": f"Unknown tool: {tool_name}"}))
551
+ sys.exit(1)
552
+
553
+ print(fn())
554
+
555
+
556
+ # ── Entry point ────────────────────────────────────────────────────────────────
557
+
558
+ def main() -> None:
559
+ import sys
560
+ if len(sys.argv) > 1 and sys.argv[1] == "call":
561
+ _cli_call(sys.argv[2:])
562
+ else:
563
+ mcp.run(transport="stdio")
564
+
565
+
566
+ if __name__ == "__main__":
567
+ main()