Metadata-Version: 2.5
Name: huaweicloud-open-mcp
Version: 0.1.0a2
Summary: 华为云 MCP server：本地 stdio 通用网关，openapi 模式 7 工具直连华为云全量 OpenAPI，discover 模式 8 工具发现连接云端 MCP server，data 模式 2 工具（DataFusion 只读 SQL 分析与转换落盘）
Project-URL: Repository, https://github.com/huaweicloud-mate/huaweicloud-open-mcp
Project-URL: Issues, https://github.com/huaweicloud-mate/huaweicloud-open-mcp/issues
Author-email: CheneyYin <cheneyyin@hotmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agent,huaweicloud,llm,mcp,mcp-server,model-context-protocol,openapi
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: jsonschema>=4.10
Requires-Dist: mcp>=1.2.0
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: tabulate>=0.9.0
Provides-Extra: datafusion
Requires-Dist: datafusion>=43; extra == 'datafusion'
Requires-Dist: sqlparse>=0.5; extra == 'datafusion'
Description-Content-Type: text/markdown

<div align="center">
  <img src="logo.png" alt="Huawei Cloud Open MCP" width="240"/>
  <p><sub>English | <a href="README.zh-CN.md">中文</a></sub></p>
  <p>
    <a href="https://pypi.org/project/huaweicloud-open-mcp/">
      <img src="https://img.shields.io/pypi/v/huaweicloud-open-mcp" alt="PyPI">
    </a>
  </p>
</div>

# Huawei Cloud Open MCP

**Open Connect. Explore What's Next.**

One open, local [Model Context Protocol](https://modelcontextprotocol.io) server connects code agents — opencode, Codex, Cursor, and any other MCP-capable client — to Huawei Cloud in natural language. No per-service wrappers: the agent explores the full catalog (300+ products, 17,000+ APIs) step by step, narrowing it down to one concrete API call, executed with locally signed requests. This is a personal, local deployment: the gateway runs entirely on your machine — your AK/SK never leave it.

Three composable modes via `--mode` (comma-separated, e.g. `openapi,data`): `openapi` (default) talks to Huawei Cloud OpenAPI, `discover` connects to cloud-hosted Huawei Cloud MCP servers (experimental, not documented yet), and `data` runs read-only SQL analytics and transformations over inline/local data with DataFusion — local compute tools that need no credentials and are not governed by the safety policy. The typical closed loop (`openapi,data`): pull a large dataset via `execute_api`, save it to a file, aggregate with `query_data` or reshape it to a new dataset with `transform_data` — only aggregated results or artifact metadata enter the model context.

## How it works

- **Progressive workflow** — the agent explores step by step: `list_products → get_product → list_apis → get_api → (get_api_examples) → execute_api`, narrowing 17,000+ APIs to one concrete call. Each step keeps the LLM context bounded; the full guide is baked into the server instructions.
- **Metadata-driven, zero SDK** — API metadata is fetched live from Huawei Cloud API Explorer and cached in memory; requests are signed locally (SDK-HMAC-SHA256, self-implemented) and sent straight to Huawei Cloud.
- **Secure by default** — every `execute_api` must pass a safety policy (allowlist/denylist); with no policy configured, everything is denied. Rules hot-reload, and the agent can request minimal grants via `manage_policy`.

## Quick start

Connect once — your agent explores the rest.

### Prerequisites

- Python 3.10+ and [uv](https://docs.astral.sh/uv/) on your PATH (or `pip`)
- A code agent: [opencode](https://opencode.ai) or [Codex](https://developers.openai.com/codex/) — any MCP-capable client works
- A Huawei Cloud Access Key (AK/SK) from a **minimal-privilege IAM sub-user** (recommended: read-only permissions for what you plan to query)
- Network access to `apiexplorer.cn-north-4.myhuaweicloud.com`

### Install

The quick start runs the gateway via [uvx](https://docs.astral.sh/uv/) — no install step: the first invocation fetches the package automatically. For a persistent install:

```bash
pip install huaweicloud-open-mcp                  # or: uv tool install / pipx install — then replace `uvx huaweicloud-open-mcp` with `huaweicloud-open-mcp` in Step 3
pip install "huaweicloud-open-mcp[datafusion]"    # optional extra: data-mode SQL engine
```

### Step 1 — Provide credentials

The gateway reads your AK/SK from `~/.huaweicloud/credentials` (INI format, `[basic]` section) — on Windows that is `%USERPROFILE%\.huaweicloud\credentials`. See [Credentials](#credentials) for the alternative inline-environment-variable way.

Create a `.huaweicloud` directory in your home directory, then a `credentials` file inside it with the following content:

```ini
[basic]
ak = your-access-key-id
sk = your-secret-access-key

# optional — uncomment as needed:
# security_token = <temporary-security-token>
# project_id = <project-id>
# domain_id = <domain-id>
```

Optional keys (uncomment as needed): `security_token` (temporary credentials), `project_id` (auto-resolved when unset), `domain_id` (global-level services; full support in progress). Keep the file private — it holds your secret: run `chmod 600 ~/.huaweicloud/credentials` on macOS/Linux; on Windows a file in your user profile is only readable by your account by default. The server reads this file at startup; the log line `server start: ... credentials=configured` (see `--log-file`) confirms it was picked up.

### Step 2 — Create a read-only safety policy

The gateway refuses every `execute_api` call unless a policy file explicitly allows it; with no policy configured, everything is denied.

Create a policy file — e.g. `hwc-policy.json` in your home directory — with the following content:

```json
[
  "ECS:*List*=allow",
  "*=deny"
]
```

Each rule reads `product:apiPattern=allow|deny` — fnmatch-style wildcards, case-insensitive, `#` lines are comments. Rules are evaluated top-down and the first match wins, so this file allows every ECS API whose name contains `List` and denies everything else. Clients need the **absolute** path to this file — e.g. `/home/you/hwc-policy.json` on macOS/Linux or `C:\Users\you\hwc-policy.json` on Windows — because they spawn the server with their own working directory.

### Step 3 — Register the gateway with your code agent

**opencode** — add to `opencode.json` (project-level, works on every OS) or the global config (`~/.config/opencode/opencode.json` on macOS/Linux; on Windows, prefer the project-level file or the `OPENCODE_CONFIG` environment variable pointing to an absolute path):

```json
{
  "mcp": {
    "huaweicloud": {
      "type": "local",
      "command": [
        "uvx", "huaweicloud-open-mcp",
        "--policy", "/home/you/hwc-policy.json"
      ],
      "enabled": true
    }
  }
}
```

**Codex** — add to `~/.codex/config.toml` (on Windows: `%USERPROFILE%\.codex\config.toml`) or run (single line, works in any shell):

```
codex mcp add huaweicloud -- uvx huaweicloud-open-mcp --policy /home/you/hwc-policy.json
```

```toml
[mcp_servers.huaweicloud]
command = "uvx"
args = ["huaweicloud-open-mcp", "--policy", "/home/you/hwc-policy.json"]
```

Replace the example path with your own absolute path from Step 2 (on Windows, e.g. `C:\Users\you\hwc-policy.json`; inside JSON/TOML strings write it with escaped backslashes: `"C:\\Users\\you\\hwc-policy.json"`).

**Output:** start your agent — the seven gateway tools appear, prefixed with your server name (`huaweicloud_list_products`, `huaweicloud_get_product`, `huaweicloud_list_apis`, `huaweicloud_get_api`, `huaweicloud_get_api_examples`, `huaweicloud_execute_api`, `huaweicloud_manage_policy`). In Codex, `codex mcp list` shows the server and `/mcp` in the TUI confirms it is connected.

Credentials come from Step 1 — no secrets in the client config. If you prefer inline environment variables instead, see [Credentials](#credentials).

### Step 4 — Browse the catalog (first real call)

**Input** (say to your agent):

> List the available Huawei Cloud products.

The agent calls `list_products`, which fetches live metadata from Huawei Cloud API Explorer.

**Output** (abridged):

```json
{
  "ok": true,
  "total": 310,
  "products": [
    { "product": "ECS", "name": "弹性云服务器", "category": "计算",
      "link": "https://www.huaweicloud.com/product/ecs.html" },
    { "product": "EVS", "name": "云硬盘", "category": "存储",
      "link": "https://www.huaweicloud.com/product/evs.html" }
  ]
}
```

### Step 5 — Execute a real read-only API

**Input** (say to your agent):

> List my ECS servers in cn-north-4.

The agent runs the progressive workflow — `list_apis(ECS)` to find the API, `get_api` to read its parameters, then `execute_api`, which signs the request with your AK/SK and sends it to real Huawei Cloud.

**Output** (abridged):

```json
{
  "ok": true,
  "product": "ECS",
  "api": "ListServersDetails",
  "status": 200,
  "body": {
    "servers": [
      { "name": "ecs-01", "status": "ACTIVE", "id": "1d4e…" }
    ],
    "count": 2
  }
}
```

`"count": 0` with an empty `servers` list is also a success — your account simply has no instances in that region; ask again with another `region` (for example `cn-east-3`).

On security: requests are signed locally and your SK never leaves your machine; the policy file confines the agent to read-only ECS `List*` APIs. Widen it deliberately, one pattern at a time — see [Safety policy](#safety-policy).

### No account yet? Mock mode

Run the same flow without credentials: skip Step 1, and add `--mock` to the server command in Step 3 (`uvx huaweicloud-open-mcp --mock --policy <policy-path>`, e.g. `/home/you/hwc-policy.json` or `C:\Users\you\hwc-policy.json`).

**Output:** Step 4 works identically — the product catalog is real metadata. Step 5 returns simulated server data shaped exactly like the real response (mock endpoint, no Huawei Cloud account involved).

## OBS uploads & downloads

OBS object APIs (`PutObject` / `GetObject` / `AppendObject` / `UploadPart`) never stream data through the gateway. In real mode, `execute_api` **always** answers with a presigned-URL envelope — no flag needed — and the client moves the bytes directly to/from OBS, with no size limit:

```json
{
  "ok": true,
  "presign": {
    "url": "https://<bucket>.obs.<region>.myhuaweicloud.com/<key>?AccessKeyId=...&Expires=...&Signature=...",
    "method": "PUT",
    "expires_in": 900,
    "signed_content_type": "application/octet-stream",
    "headers": { "Content-Type": "application/octet-stream" }
  }
}
```

Pick up the URL with any HTTP client — the gateway never sees the data:

```bash
curl -X PUT --upload-file big.dat '<url>' -H 'Content-Type: application/octet-stream'
```

Rules that matter:

- Content-Type is part of the signature. For uploads, pass `_presign_content_type` to lock it and send exactly the headers listed in `headers`; if you don't lock it, the signature assumes no Content-Type — the direct request must not send one (`curl -H 'Content-Type:'`). The envelope's `note` field warns about this case.
- `_presign_expires` tunes validity in seconds (default 900).
- All other OBS APIs (bucket management, tagging, ACL, …) execute through the gateway as usual; pass `_presign=true` explicitly if you want a URL for one of them. Non-OBS products reject `_presign`. Mock mode keeps hitting the mock endpoint.

## Tools (openapi mode)

| Tool | Purpose |
| --- | --- |
| `list_products` | Full Huawei Cloud product catalog — identifier, display name, category, product link; `keyword`/`category` filter |
| `get_product` | One product's details (classification, API count, global vs regional) |
| `list_apis` | A product's API directory with `tag_groups` overview; `tag`/`search`/`limit`/`offset` to narrow |
| `get_api` | One API's full documentation (parameters, required fields, enums, constraints) — read before executing. Oversized docs (>200k chars) spill the full envelope to disk and stub the heaviest fields in the response |
| `get_api_examples` | Official request examples for one API |
| `execute_api` | Execute one API: path/query params flattened, request body under `body`; errors come back structured, 429 retried with backoff. Oversized responses (>200k chars) are spilled to disk automatically: the result carries a `spill` envelope (`path`/`format`/`bytes`/`note`) and `body` keeps a truncated preview; `_spill=false` opts out per call |
| `manage_policy` | Read/add/remove safety-policy rules at runtime (hot effect, no restart) |

## Tools (data mode)

Local analytics and transformation on DataFusion (optional extra: `pip install "huaweicloud-open-mcp[datafusion]"`; the tools return a friendly install hint when missing).

| Tool | Purpose |
| --- | --- |
| `query_data` | Read-only SQL over named tables: `{"name": {"data": [objects]}}` (inline) or `{"name": {"path": "file"}}` (local csv/parquet/jsonl/json-array, format auto-detected by extension); returns column schema + JSON-safe rows with row-count/char-budget truncation |
| `transform_data` | Persist a read-only SQL transformation to a new data file: `out = {"path", "format"?}` (csv/parquet/jsonl), atomic write, refuse-overwrite by default (`overwrite=true` to allow); returns artifact metadata (path/format/rows/bytes) + a small preview |

Strictly read-only SQL: only `SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`DESCRIBE` statements pass the guard; multi-statement and write statements (`INSERT`/`CREATE`/`COPY TO`/…) are rejected — in `transform_data` the write is applied by the engine *after* the guard, via the structured `out` parameter (audit NDJSON records the write path), never via SQL. Both tools touch no cloud APIs, need no credentials and are **not** subject to the safety policy — deploy them only where the agent session is trusted to read (and, for `transform_data`, write) local files.

## Safety policy

A policy file is a JSON array (or plain text) of rules, evaluated top-down, first match wins:

```json
[
  "ECS:*List*=allow",
  "VPC:*Show*=allow",
  "*=deny"
]
```

- Rule format `product:apiPattern=allow|deny` — fnmatch-style wildcards, case-insensitive product/API, `#` lines are comments.
- No `--policy` configured → every execution denied.
- Grant scopes (via `manage_policy` add): `once` (single execution, burned after use) · `session` (default; this agent session only) · `temporary` (TTL) · `permanent` (written to the policy file).
- Hot everywhere: external edits to the file apply immediately; add/remove via `manage_policy` too. Grant minimal rules first (`once`/`session`), product-wide only when justified.
- Denials return an actionable reason; with `--elicitation auto|required` the server proposes a grant over MCP elicitation (four choices: `api` = minimal rule, one-shot / `api_session` = minimal rule, session-scoped / `product` = product-wide, session-scoped / `none`). Default is `off` for predictable cross-client behavior.

A richer example ships with the package: `configs/safety-policy.example.json`.

## Custom hints (optional)

A hints file lets a deployment inject its own guidance into the discovery chain: a global `instructions` block appended to the server instructions, plus per-product `notes` and per-API texts attached to discovery results (`list_products` / `get_product` / `list_apis` / `get_api`).

```json
{
  "instructions": "This deployment targets ops inspection: prefer List*/Show* APIs for batch lookups.",
  "products": {
    "ECS": {
      "notes": "Prefer ListServersDetails for listing servers.",
      "apis": {
        "ResizeServer": "Check flavor availability with ListFlavors first."
      }
    },
    "OBS": "Object upload/download always returns a presign envelope; the gateway never moves bytes."
  }
}
```

- Official metadata is never replaced — hints ride along in an extra `hints` field (product + API notes are merged, product first).
- Injected only on successful discovery results, never on denials (gate/policy rejections stay untouched); `get_api_examples` and `execute_api` are never annotated.
- Product keys and `apis` keys are case-insensitive; a product value may be a plain string (product note only) or an object with `notes` / `apis`.
- Loaded at startup (no hot reload); invalid configs fail fast at startup. Without `--hints`, behavior is byte-for-byte unchanged.

Example: `configs/openapi-hints.example.json`.

## Configuration

### CLI flags

| Flag | Default | Description |
| --- | --- | --- |
| `--mode <modes>` | `openapi` | Run mode(s), comma-separated (`openapi`/`discover`/`data`, e.g. `openapi,data`; env `HUAWEICLOUD_MCP_MODE`) |
| `--mock` | off | Point `execute_api` at the API Explorer mock endpoint (no credentials needed) |
| `--mock-base <url>` | — | Mock endpoint base URL override (env `HUAWEICLOUD_MCP_MOCK_BASE`) |
| `--mock-passthrough` | off | Mock mode: forward execute business params to the mock endpoint (env `HUAWEICLOUD_MCP_MOCK_PASSTHROUGH`) |
| `--policy <file>` | — | Safety policy file; missing → all executions denied |
| `--region <id>` | `cn-north-4` | Default region |
| `--gate <file>` | — | Optional product gate (allowlist; unlisted products are hidden from the agent) |
| `--hints <file>` | — | Optional custom-hints file (deploy-side guidance injected into instructions and discovery results) |
| `--elicitation auto\|required\|off` | `off` | MCP-elicitation confirmation for policy changes |
| `--spill-dir <dir>` | system temp dir (`hwc-mcp-spill`) | Where oversized responses/envelopes are spilled (empty or `off` disables spilling; pure truncation returns) |
| `--audit-file <file>` | disabled | Audit trail (NDJSON): one `{ts, tool, input, ok}` line per tool call |
| `--log-level` / `--log-file` | `INFO` / `logs/huaweicloud-open-mcp.log` | Logging (rotating file; stderr mirrors WARNING+) |

### Environment variables

| Variable | Purpose |
| --- | --- |
| `HUAWEICLOUD_SDK_AK` / `HUAWEICLOUD_SDK_SK` | Access key / secret key (real mode); see [Credentials](#credentials) for the profile-file alternative |
| `HUAWEICLOUD_SDK_SECURITY_TOKEN` | Optional temporary-security-credential token |
| `HUAWEICLOUD_SDK_PROJECT_ID` | Optional; resolved automatically when unset |
| `HUAWEICLOUD_SDK_DOMAIN_ID` | Optional; loaded for global-level services (full support in progress) |
| `HUAWEICLOUD_MCP_MODE` | Same as `--mode` |
| `HUAWEICLOUD_MCP_REGION` | Same as `--region` |
| `HUAWEICLOUD_MCP_MOCK` | Same as `--mock` (`1`/`true`/`yes`) |
| `HUAWEICLOUD_MCP_MOCK_BASE` | Mock endpoint base URL override |
| `HUAWEICLOUD_MCP_MOCK_PASSTHROUGH` | Same as `--mock-passthrough` |
| `HUAWEICLOUD_MCP_POLICY_FILE` | Same as `--policy` |
| `HUAWEICLOUD_MCP_OPENAPI_GATE` | Same as `--gate` |
| `HUAWEICLOUD_MCP_OPENAPI_HINTS` | Same as `--hints` |
| `HUAWEICLOUD_MCP_AUDIT_FILE` | Same as `--audit-file` |
| `HUAWEICLOUD_MCP_SPILL_DIR` | Same as `--spill-dir` |
| `HUAWEICLOUD_MCP_ELICIT` | Same as `--elicitation` |
| `HUAWEICLOUD_MCP_LOG_LEVEL` / `HUAWEICLOUD_MCP_LOG_FILE` | Same as `--log-level` / `--log-file` |

## Credentials

The gateway loads AK/SK from two sources, checked in order: **environment variables → `~/.huaweicloud/credentials`** (on Windows: `%USERPROFILE%\.huaweicloud\credentials`). When both are configured, environment variables win.

### Option A — Profile file (quick-start main path)

`~/.huaweicloud/credentials` — exactly what [Step 1](#step-1--provide-credentials) creates:

```ini
[basic]
ak = your-access-key-id
sk = your-secret-access-key

# optional — uncomment as needed:
# security_token = <temporary-security-token>
# project_id = <project-id>
# domain_id = <domain-id>
```

- `[basic]` section with `ak` and `sk` is required; the three optional keys mirror the environment variables below.
- A missing file is silently skipped — the server simply runs without credentials (metadata tools keep working; see below).
- Keep it private — the file holds your secret: run `chmod 600 ~/.huaweicloud/credentials` on macOS/Linux; on Windows a file in your user profile is only readable by your account by default.

### Option B — Environment variables (inline in client registration)

Set them in the client registration instead of the profile file:

**opencode** — add an `environment` block next to `command`:

```json
"environment": {
  "HUAWEICLOUD_SDK_AK": "your-access-key-id",
  "HUAWEICLOUD_SDK_SK": "your-secret-access-key"
}
```

**Codex** — add `--env` flags before `--` (single line, works in any shell):

```
codex mcp add huaweicloud --env HUAWEICLOUD_SDK_AK=your-access-key-id --env HUAWEICLOUD_SDK_SK=your-secret-access-key -- uvx huaweicloud-open-mcp --policy /home/you/hwc-policy.json
```

| Variable | Purpose |
| --- | --- |
| `HUAWEICLOUD_SDK_AK` / `HUAWEICLOUD_SDK_SK` | Required pair |
| `HUAWEICLOUD_SDK_SECURITY_TOKEN` | Optional temporary-security-credential token |
| `HUAWEICLOUD_SDK_PROJECT_ID` | Optional; resolved automatically when unset |
| `HUAWEICLOUD_SDK_DOMAIN_ID` | Optional; loaded for global-level services (full support in progress) |

### Behavior notes

- Without credentials, metadata tools (`list_products`, `list_apis`, `get_api`, …) keep working — their data comes from the public API Explorer. Only `execute_api` needs credentials.
- Use a dedicated minimal-privilege IAM sub-user's AK/SK — the gateway can then only do what that user could do anyway.
- Signing happens locally; the SK never leaves your machine, and credentials never appear in logs.

## Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Client shows "failed to connect" or the server exits immediately | `--policy` path is relative or the file is missing — the server fails fast on a bad policy file | Use an absolute path to a file that exists |
| The seven tools never appear in the agent | `uvx` is not on the client's PATH | Find it with `which uvx` (macOS/Linux) or `where uvx` (Windows) and use the absolute path in place of `uvx` in the command |
| Metadata tools work but `execute_api` fails | Credentials not loaded (empty `[basic]` section, or env vars shadow an empty file) | Fix `~/.huaweicloud/credentials` (see [Credentials](#credentials)) or set the `HUAWEICLOUD_SDK_*` environment variables |
| `execute_api` returns `{"ok": false, "reason": ...}` mentioning policy | The API is not allowed by the policy file (the quick-start file allows only `ECS:*List*`) | Edit the policy file — changes hot-reload without restarting the server — or, after confirming with the user, have the agent add a rule via `manage_policy` |
| 401 / SignatureDoesNotMatch | Wrong AK or SK | Recheck the credentials source in use (env wins over profile file) |
| 403 from Huawei Cloud with a permission error | The IAM user lacks the permission | Grant the minimal IAM policy needed for that API |
| `"count": 0` but you have servers | Resources live in another region | Ask again with an explicit `region`, e.g. `cn-east-3` |
| Mock calls hang or time out | No network route to the API Explorer endpoint | Check proxy/firewall access to `apiexplorer.cn-north-4.myhuaweicloud.com` |

For deeper diagnosis, add `--log-level DEBUG --log-file <log>` to the registration command — e.g. `/tmp/hwc-mcp.log` on macOS/Linux or `%TEMP%\hwc-mcp.log` on Windows — and inspect the log file.

## Documentation

Explore the design behind the gateway:

| Document | Type | Language |
| --- | --- | --- |
| [docs/architecture.md](docs/architecture.md) | Design overview (layers, modules, logging, tests) | 中文 |
| [docs/mcp-openapi.md](docs/mcp-openapi.md) | openapi-mode design (workflow, signing, OBS lane) | 中文 |
| [AGENTS.md](AGENTS.md) | Contributor conventions (TDD seams, release flow) | 中文 |
| [benchmarks/README.md](benchmarks/README.md) | Workflow-benchmark design | 中文 |

## Development

```bash
uv sync                                  # deps (incl. dev)
uv run huaweicloud-open-mcp              # run from source
uv run pytest                            # unit + integration (e2e skipped by default)
uv run pytest -m e2e                     # real-credential E2E
uv run ruff check src tests              # lint
uv run mypy src                          # type check
```

Companion CLIs: `api-refresh` (offline APIE pipeline: fetch API Explorer → OpenAPI 2.0 docs) and `api-docs` (metadata queries from the terminal). Details in [AGENTS.md](AGENTS.md).

### Publishing

Releases distinguish TestPyPI from the production PyPI index; both upload URLs are pinned as named indexes in `pyproject.toml` (`[[tool.uv.index]]`):

```bash
scripts/publish test                      # TestPyPI (token: UV_PUBLISH_TOKEN_TEST)
scripts/publish prod                      # PyPI (token: UV_PUBLISH_TOKEN_PROD, confirmation gate; --yes for CI)
scripts/publish <test|prod> --skip-build  # republish existing dist/ artifacts
```

Notes:

- TestPyPI and PyPI accounts/API tokens are independent — request each token from the corresponding site's Account Settings; the script strictly uses the target-specific env var with no fallback, so credentials can never be mixed up.
- Each build clears `dist/` first (`uv build` + `uvx twine check`), so no stale artifacts can be uploaded.
- `prod` prints the target URL, project version, and artifact list, then requires typing `yes`.
- Version numbers are unique per index: never reuse a version already uploaded (verify on TestPyPI, bump the version, then publish to PyPI).

## License

[Apache-2.0](LICENSE)
