Metadata-Version: 2.5
Name: qvasa-mcp
Version: 0.14.0
Summary: MCP server that exposes the Qvasa user-access agentic API to Claude.
Author: Qvasa
License: Proprietary
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: mcp<2,>=1.2.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Description-Content-Type: text/markdown

# qvasa-mcp

MCP server that exposes the Qvasa user-access agentic API to Claude.

This is a thin proxy: Claude sends MCP tool calls over stdio, the server translates each call into one or more HTTP requests against the Qvasa Rails app, and returns the result. It wires up the two-token auth headers, but the agent must establish a session explicitly first (see **Authentication & sessions** below) — the server no longer logs in silently.

This package lives in the `qvasa_app` repo at `packages/qvasa_mcp/` (it was the `qvasa_mcp_server` repo until 0.10.0). The API it calls is `/user_access/api/*` in the same repo; the toolkit version constant lives at `app/services/user_access/api/toolkit_version.rb`.

**Tool definitions are shared with the hosted connector.** Every tool's name, title, read-only / destructive hint, description and input schema, and the constants both MCPs use, are defined once in `qvasa_app`'s `config/reference_data/mcp/tools.yml`, which has a `hosted` and a `package` target. `bin/rails mcp:write_package_tools` writes this package's target to `src/qvasa_mcp/tools.json` (never edit it by hand); `catalog.py` reads it. What differs here (`qvasa_create_session`, the `inspect`, `params` and `context` arguments, the longer metric wait) is the `package:` overrides in that file. This package's code holds only how each tool runs over HTTP.

---

## Authentication & sessions

Qvasa's User Access API uses a **two-token** model:

| Token | Lifetime | Header | Role |
| --- | --- | --- | --- |
| **User access token (UAT)** | long-lived (`qva_uat_…`) | `Authorization: Bearer` | Identifies you. Generated in the Qvasa UI; supplied to the MCP via `QVASA_API_KEY`. |
| **Session token** | short-lived (`qva_uas_…`, 24h TTL) | `X-User-Access-Session` | Authorizes each tool call. Obtained by exchanging the UAT. |

**Why sessions exist:** they keep the powerful UAT out of per-request traffic, pin the toolkit version so the tool catalog can't drift mid-conversation, and scope concurrency + rate limits per agent run.

**The agent must call `qvasa_create_session` first.** It's the only tool that works without a session (the UAT-only bootstrap, `POST /user_access/api/sessions`). Every other tool requires a session: if called first, it returns a recoverable `error_code: "no_active_session"` result that redirects the agent to `qvasa_create_session`. Once a session exists, an expired/stale session (natural 24h expiry or a `session_stale` toolkit bump) is refreshed transparently on the next call. Re-call `qvasa_create_session` any time to force a refresh.

---

## How customers install this

There are two shipping paths, sharing the same source. Both are public — even though the `qvasa_app` repo is private, the published artifacts are not.

| Path | What customers do | Behind the scenes |
| --- | --- | --- |
| **Claude Desktop (one-click)** | Download `qvasa-mcp.mcpb` from the user-access-tokens page (`/downloads/qvasa-mcp.mcpb`), double-click, paste their UAT in the install dialog. | Claude Desktop unpacks the bundle, uses its embedded `uv` runtime to install deps from `pyproject.toml`, and runs the `qvasa-mcp` console script. |
| **Claude Code / Cursor / manual** | `claude mcp add qvasa --env QVASA_API_KEY=… -- uvx qvasa-mcp@latest`, or paste an `mcpServers` JSON snippet. | `uvx` fetches the newest `qvasa-mcp` from PyPI at each start (`@latest`; a bare name reuses the copy cached at install), and runs the same console script. |

The "Connect this token to Claude" section on `/user_settings/user_access_tokens` in the Rails app renders the snippet for the manual path (with the raw token baked in immediately after creation).

---

## Configuration

Set these env vars (Claude Desktop injects them from the `.mcpb` install dialog; the manual path expects them in the JSON snippet's `env` block):

| var | required | default | purpose |
| --- | --- | --- | --- |
| `QVASA_API_KEY` | yes | — | A user-access token (UAT). Generate at `/user_settings/user_access_tokens` in the Qvasa UI. Format `qva_uat_…`. |
| `QVASA_API_BASE_URL` | no | `https://www.qvasa.com` | Override for staging (`https://www.qvasastaging.com`) or local dev (`http://localhost:3000`). |

---

## Local dev

```sh
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'

export QVASA_API_KEY=qva_uat_…
export QVASA_API_BASE_URL=http://localhost:3000

# Run the server (it will read MCP requests on stdin, write responses on stdout).
qvasa-mcp

# Run the tests.
pytest
```

From the app root, `bin/mcp_package_test` runs the same tests with `uv`.

---

## Releasing

How to cut a release (PyPI and the Desktop bundle) is in `RELEASING.md`, which the bundle leaves out.

---

## How the `.mcpb` bundle works

The bundle is just a zip with a manifest. Three concepts:

1. **`server.type = "uv"`** in `manifest.json` tells Claude Desktop to use its embedded `uv` runtime to manage the Python environment. No need to bundle wheels — Claude Desktop reads our `pyproject.toml`, installs deps on the customer's machine, and runs the `qvasa-mcp` console script.

2. **`user_config`** declares the form fields the install dialog shows. We expose:
   - `qvasa_api_key` (sensitive — masked + stored in the OS keychain)
   - `qvasa_api_base_url` (defaults to production, useful for staging/self-hosted customers)

3. **`mcp_config.env`** maps those user-config values to env vars the server reads on startup. The interpolation is `${user_config.<key>}` → `<env var>`. Our server reads `QVASA_API_KEY` and `QVASA_API_BASE_URL`; the manifest wires them up.

Files included in the bundle (per `.mcpbignore`):
- `manifest.json`
- `pyproject.toml`
- `uv.lock`
- `src/qvasa_mcp/**`
- `README.md`

Excluded: `tests/`, `scripts/`, `RELEASING.md`, `.env` files, `.venv/`, `dist/`, caches, the bundle itself.

The bundle is built by `scripts/build_mcpb.sh` and committed into the app's `public/downloads/`; `spec/packages/qvasa_mcp_bundle_spec.rb` and `tests/test_bundle.py` guard it against drifting from source.

---

## Tools

The MCP is a **portal**, not a wrapper per Rails endpoint: the wider API is reached through `qvasa_search_tools` → `qvasa_get_tool_guide` → `qvasa_read_api` / `qvasa_write_api`. The tool list, and what each tool does, is `tools.yml` (above); it isn't repeated here.

History:
- **0.13.0:** `qvasa_read_api` and `qvasa_write_api` take an endpoint's `name` (`dynamic_dashboards.show`) and its `params`, in place of a path, a method and a body. `tools.json` carries qvasa_app's endpoint table (every User Access API route by name, its kind, verb and path template), and the package builds each request from it as the hosted connector does: the path params fill the template, the rest go as the query of a read or the body of a write. Each tool runs one kind of endpoint and refuses the other kind, a metric kickoff (`qvasa_query_metric` runs those), a path in place of a name, an unknown name (with the nearest names) and the earlier arguments, before anything is sent. Nothing a model reads names an HTTP verb or an API path. `qvasa_list_support_tickets` names its tickets (the ones filed with the Qvasa team) and the list's other filters; `qvasa_query_metric` says how to pass a date range. The descriptions no longer describe side effects or retention. The API refuses every earlier release a session (426 `mcp_package_upgrade_required`, with the steps to update), since their tools send paths.
- **0.12.1:** every request carries the package's version in `X-Qvasa-Mcp-Version`, and `qvasa_create_session` passes on the API's `mcp_package_update`: a newer release is out (the copy keeps working), or this one no longer works (no session; the steps to update). Copies from before 0.12.1 send no header; the API recognises them by httpx's user agent and tells them in the overview. Released to PyPI by the app's publish workflow after the deploy (`RELEASING.md`).
- **0.12.0:** `qvasa_query_metric` reads the metric kinds this token can run, and each kickoff path, from `GET /user_access/api/tools/metric_kinds` instead of a list in `tools.json`. The agent scorecard views run through it now, and a metric kind the API adds later works without a new release.
- **0.11.0:** `qvasa_read_api` also runs the read-only searches and lists, which are POST routes: a path whose last segment is a read suffix (`index`, `search`, ...), or one of the routes reviewed as reads in qvasa_app's `config/reference_data/mcp/read_only_post_routes.yml`, is sent as a POST, and a new `body` argument carries its JSON body. Because the rule goes by the path's shape, a search added to the API later works without a new release. `qvasa_write_api` still accepts them.
- **0.10.0:** tool definitions come from the shared `tools.yml`. Titles and read-only / destructive hints on every tool; descriptions say what each tool does, as the hosted connector's do, and that the tool needs a session; `qvasa_query_metric` takes the `timeline` metric kind. A refused call (a bad path, method, metric kind or ticket type) comes back as the same error envelope the hosted connector returns. `qvasa_submit_support_ticket`'s response no longer carries a map of the follow-up endpoints; the shared guidance names them.
- **0.9.1:** paths with `.` / `..` segments or a `#` are refused; redirects are not followed; a metric still running after the wait comes back as its request id; network failures and an empty `QVASA_API_BASE_URL` are handled.
- **0.9.0:** `qvasa_read_api` and `qvasa_write_api` replaced `qvasa_call_api`, which took any method. Claude's connector directory rejects a tool that takes both safe (GET) and unsafe HTTP methods. Installs on 0.8.0 and earlier keep working with `qvasa_call_api`; the guidance the API serves says to use it for both where that is the tool an install has.
- **0.8.0:** `qvasa_list_support_tickets` added, for tool-for-tool parity with the hosted connector.
- **0.7.0:** `qvasa_submit_support_ticket` replaced `qvasa_submit_feedback`, which posted to the write-only `/agent_feedback` endpoint. That endpoint is now a deprecated alias that writes a real support ticket, so installs on 0.6.1 and earlier keep working; they just can't read the replies.
