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.
- mova_bridge-0.3.0/.gitignore +62 -0
- mova_bridge-0.3.0/PKG-INFO +7 -0
- mova_bridge-0.3.0/README.md +79 -0
- mova_bridge-0.3.0/SKILL.md +133 -0
- mova_bridge-0.3.0/pyproject.toml +19 -0
- mova_bridge-0.3.0/src/mova_bridge/__init__.py +2 -0
- mova_bridge-0.3.0/src/mova_bridge/__main__.py +4 -0
- mova_bridge-0.3.0/src/mova_bridge/server.py +567 -0
|
@@ -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,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,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()
|