qvasa-mcp 0.1.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,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ .env
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ *.mcpb
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: qvasa-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server that exposes the Qvasa user-access agentic API to Claude.
5
+ Author: Qvasa
6
+ License: Proprietary
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: mcp>=1.2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: respx>=0.21; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # qvasa-mcp
17
+
18
+ MCP server that exposes the Qvasa user-access agentic API to Claude.
19
+
20
+ 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 owns session lifecycle (login, refresh on 401 SessionStale, two-token auth) so Claude doesn't have to.
21
+
22
+ Rails counterpart: `one_qvasa_app` repo, namespace `/user_access/api/*`. The toolkit version constant lives at `app/services/user_access/api/toolkit_version.rb`.
23
+
24
+ ## Configuration
25
+
26
+ Set these env vars (Claude Desktop injects them from the `.mcpb` config block on install):
27
+
28
+ | var | required | default | purpose |
29
+ | --- | --- | --- | --- |
30
+ | `QVASA_API_KEY` | yes | — | A user-access token (UAT). Generate at `/user_settings/user_access_tokens` in the Qvasa UI. Format `qva_uat_…`. |
31
+ | `QVASA_API_BASE_URL` | no | `https://qvasa.com` | Override for staging or local dev (e.g. `http://localhost:3000`). |
32
+
33
+ ## Local dev
34
+
35
+ ```sh
36
+ python3 -m venv .venv
37
+ source .venv/bin/activate
38
+ pip install -e '.[dev]'
39
+
40
+ export QVASA_API_KEY=qva_uat_…
41
+ export QVASA_API_BASE_URL=http://localhost:3000
42
+
43
+ # Run the server (it will read MCP requests on stdin, write responses on stdout).
44
+ qvasa-mcp
45
+
46
+ # Run the tests.
47
+ pytest
48
+ ```
49
+
50
+ ## Tool list (17 total)
51
+
52
+ **Orientation**
53
+ - `qvasa_overview` — call this FIRST. Returns positioning, intent triggers, intent-keyed workflows, a feedback guide, and the cached session manifest. One call per session; no HTTP round-trip after the first.
54
+ - `qvasa_submit_feedback` — frictionless channel for filing bug reports, feature requests, friction points, unexpected_behavior notes, and impossibility reports back to Qvasa engineering.
55
+ - `qvasa_search_saved_widgets` — search the user's personal-dashboard saved queries (the agent's memory layer). Call BEFORE rebuilding a metric from scratch.
56
+
57
+ **Discovery — widgets** (unified: search by phrase, browse a group, or list all groups)
58
+ - `qvasa_search_widgets` — pass `query` to search (strict → fuzzy fallback), `group_name` to browse a group, or neither for the groups manifest.
59
+ - `qvasa_describe_widget` — bulk schema fetch (widget_type, component_configurations, inspect block).
60
+
61
+ **Discovery — filters** (unified: same shape as widgets)
62
+ - `qvasa_search_filters` — pass `query` to search (strict → fuzzy fallback), `group_name` to browse a group, or neither for the groups manifest.
63
+ - `qvasa_describe_filter` — bulk schema fetch.
64
+
65
+ **Filter values & date range**
66
+ - `qvasa_search_filter_values` — value/parent-record search across 19 filter kinds.
67
+ - `qvasa_search_values_across_attributes` — reverse-lookup a value across static attributes.
68
+ - `qvasa_resolve_date_range` — unified date-range tool. Pass `phrase` for natural-language resolution (e.g. 'yesterday', 'last 7 days') or `strategy` for explicit construction.
69
+ - `qvasa_search_component_configuration_values` — value lookup for widget component_configuration_* fields (9 endpoints).
70
+
71
+ **Metric execution**
72
+ - `qvasa_query_metric` — kickoff + block-until-done across all 5 chart types (quick_stat, basic_chart, donut_chart, multi_data_chart, heat_map)
73
+ - `qvasa_inspect_metric` — same pattern but materialises a TicketSet (quick_stat / basic_chart / donut_chart only)
74
+ - `qvasa_list_in_flight` — show queued/processing metric requests
75
+ - `qvasa_cancel_metric` — cancel an enqueued request
76
+
77
+ **Ticket sets** (drill-down on inspect results)
78
+ - `qvasa_describe_ticket_set` — show set metadata + tools_available
79
+ - `qvasa_get_ticket_set_summaries` — paginated AI ticket summaries
@@ -0,0 +1,64 @@
1
+ # qvasa-mcp
2
+
3
+ MCP server that exposes the Qvasa user-access agentic API to Claude.
4
+
5
+ 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 owns session lifecycle (login, refresh on 401 SessionStale, two-token auth) so Claude doesn't have to.
6
+
7
+ Rails counterpart: `one_qvasa_app` repo, namespace `/user_access/api/*`. The toolkit version constant lives at `app/services/user_access/api/toolkit_version.rb`.
8
+
9
+ ## Configuration
10
+
11
+ Set these env vars (Claude Desktop injects them from the `.mcpb` config block on install):
12
+
13
+ | var | required | default | purpose |
14
+ | --- | --- | --- | --- |
15
+ | `QVASA_API_KEY` | yes | — | A user-access token (UAT). Generate at `/user_settings/user_access_tokens` in the Qvasa UI. Format `qva_uat_…`. |
16
+ | `QVASA_API_BASE_URL` | no | `https://qvasa.com` | Override for staging or local dev (e.g. `http://localhost:3000`). |
17
+
18
+ ## Local dev
19
+
20
+ ```sh
21
+ python3 -m venv .venv
22
+ source .venv/bin/activate
23
+ pip install -e '.[dev]'
24
+
25
+ export QVASA_API_KEY=qva_uat_…
26
+ export QVASA_API_BASE_URL=http://localhost:3000
27
+
28
+ # Run the server (it will read MCP requests on stdin, write responses on stdout).
29
+ qvasa-mcp
30
+
31
+ # Run the tests.
32
+ pytest
33
+ ```
34
+
35
+ ## Tool list (17 total)
36
+
37
+ **Orientation**
38
+ - `qvasa_overview` — call this FIRST. Returns positioning, intent triggers, intent-keyed workflows, a feedback guide, and the cached session manifest. One call per session; no HTTP round-trip after the first.
39
+ - `qvasa_submit_feedback` — frictionless channel for filing bug reports, feature requests, friction points, unexpected_behavior notes, and impossibility reports back to Qvasa engineering.
40
+ - `qvasa_search_saved_widgets` — search the user's personal-dashboard saved queries (the agent's memory layer). Call BEFORE rebuilding a metric from scratch.
41
+
42
+ **Discovery — widgets** (unified: search by phrase, browse a group, or list all groups)
43
+ - `qvasa_search_widgets` — pass `query` to search (strict → fuzzy fallback), `group_name` to browse a group, or neither for the groups manifest.
44
+ - `qvasa_describe_widget` — bulk schema fetch (widget_type, component_configurations, inspect block).
45
+
46
+ **Discovery — filters** (unified: same shape as widgets)
47
+ - `qvasa_search_filters` — pass `query` to search (strict → fuzzy fallback), `group_name` to browse a group, or neither for the groups manifest.
48
+ - `qvasa_describe_filter` — bulk schema fetch.
49
+
50
+ **Filter values & date range**
51
+ - `qvasa_search_filter_values` — value/parent-record search across 19 filter kinds.
52
+ - `qvasa_search_values_across_attributes` — reverse-lookup a value across static attributes.
53
+ - `qvasa_resolve_date_range` — unified date-range tool. Pass `phrase` for natural-language resolution (e.g. 'yesterday', 'last 7 days') or `strategy` for explicit construction.
54
+ - `qvasa_search_component_configuration_values` — value lookup for widget component_configuration_* fields (9 endpoints).
55
+
56
+ **Metric execution**
57
+ - `qvasa_query_metric` — kickoff + block-until-done across all 5 chart types (quick_stat, basic_chart, donut_chart, multi_data_chart, heat_map)
58
+ - `qvasa_inspect_metric` — same pattern but materialises a TicketSet (quick_stat / basic_chart / donut_chart only)
59
+ - `qvasa_list_in_flight` — show queued/processing metric requests
60
+ - `qvasa_cancel_metric` — cancel an enqueued request
61
+
62
+ **Ticket sets** (drill-down on inspect results)
63
+ - `qvasa_describe_ticket_set` — show set metadata + tools_available
64
+ - `qvasa_get_ticket_set_summaries` — paginated AI ticket summaries
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qvasa-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server that exposes the Qvasa user-access agentic API to Claude."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "Qvasa" }]
13
+ dependencies = [
14
+ "mcp>=1.2.0",
15
+ "httpx>=0.27",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0",
21
+ "pytest-asyncio>=0.23",
22
+ "respx>=0.21",
23
+ ]
24
+
25
+ [project.scripts]
26
+ qvasa-mcp = "qvasa_mcp.server:main"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/qvasa_mcp"]
30
+
31
+ [tool.pytest.ini_options]
32
+ asyncio_mode = "auto"
33
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,175 @@
1
+ """HTTP client + session lifecycle for the Qvasa user-access agentic API.
2
+
3
+ Owns the two-token auth dance so Claude-facing tools don't have to:
4
+
5
+ - UAT (long-lived, from QVASA_API_KEY env var) goes in `Authorization: Bearer …`.
6
+ - Session token (short-lived, 24h TTL) goes in `X-User-Access-Session`.
7
+ - Session is created lazily on first call and cached in process memory along
8
+ with the toolkit manifest and version string.
9
+ - On 401 the client re-creates the session and retries the call once. This
10
+ covers both natural 24h expiry and `ToolkitVersion::CURRENT` bumps on the
11
+ Rails side (which surface as `error_code: "session_stale"`).
12
+
13
+ Anything that needs to hit the API goes through `QvasaClient.request`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ DEFAULT_BASE_URL = "https://qvasa.com"
26
+ SESSIONS_PATH = "/user_access/api/sessions"
27
+
28
+
29
+ class QvasaApiError(Exception):
30
+ """Raised when the API returns a non-2xx response we can't recover from."""
31
+
32
+ def __init__(self, status_code: int, body: Any, *, method: str, path: str):
33
+ self.status_code = status_code
34
+ self.body = body
35
+ self.method = method
36
+ self.path = path
37
+ super().__init__(f"{method} {path} → {status_code}: {body!r}")
38
+
39
+
40
+ class MissingApiKeyError(Exception):
41
+ """Raised at startup if QVASA_API_KEY is unset."""
42
+
43
+
44
+ @dataclass
45
+ class SessionState:
46
+ session_id: int
47
+ session_token: str
48
+ toolkit_version: str
49
+ manifest: dict[str, Any]
50
+
51
+
52
+ @dataclass
53
+ class QvasaClient:
54
+ api_key: str
55
+ base_url: str = DEFAULT_BASE_URL
56
+ timeout_seconds: float = 30.0
57
+
58
+ _http: httpx.AsyncClient | None = field(default=None, init=False, repr=False)
59
+ _session: SessionState | None = field(default=None, init=False, repr=False)
60
+ _session_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
61
+
62
+ @classmethod
63
+ def from_env(cls) -> "QvasaClient":
64
+ api_key = os.environ.get("QVASA_API_KEY", "").strip()
65
+ if not api_key:
66
+ raise MissingApiKeyError(
67
+ "QVASA_API_KEY env var is required. Generate a token at "
68
+ "/user_settings/user_access_tokens in the Qvasa UI."
69
+ )
70
+ base_url = os.environ.get("QVASA_API_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
71
+ return cls(api_key=api_key, base_url=base_url)
72
+
73
+ async def aclose(self) -> None:
74
+ if self._http is not None:
75
+ await self._http.aclose()
76
+ self._http = None
77
+
78
+ async def session(self) -> SessionState:
79
+ """Return the cached session, creating one if needed."""
80
+ if self._session is not None:
81
+ return self._session
82
+ async with self._session_lock:
83
+ if self._session is None:
84
+ self._session = await self._create_session()
85
+ return self._session
86
+
87
+ async def request(
88
+ self,
89
+ method: str,
90
+ path: str,
91
+ *,
92
+ json: Any | None = None,
93
+ params: dict[str, Any] | None = None,
94
+ ) -> Any:
95
+ """Make an authenticated request, refreshing the session once on 401."""
96
+ response = await self._send(method, path, json=json, params=params)
97
+ if response.status_code == 401 and self._is_session_recoverable_401(response):
98
+ async with self._session_lock:
99
+ self._session = await self._create_session()
100
+ response = await self._send(method, path, json=json, params=params)
101
+ if response.status_code >= 400:
102
+ raise QvasaApiError(
103
+ response.status_code,
104
+ _safe_json(response),
105
+ method=method,
106
+ path=path,
107
+ )
108
+ return _safe_json(response)
109
+
110
+ async def _send(
111
+ self,
112
+ method: str,
113
+ path: str,
114
+ *,
115
+ json: Any | None,
116
+ params: dict[str, Any] | None,
117
+ ) -> httpx.Response:
118
+ session = await self.session()
119
+ return await self._client().request(
120
+ method,
121
+ path,
122
+ json=json,
123
+ params=params,
124
+ headers={
125
+ "Authorization": f"Bearer {self.api_key}",
126
+ "X-User-Access-Session": session.session_token,
127
+ "Accept": "application/json",
128
+ },
129
+ )
130
+
131
+ async def _create_session(self) -> SessionState:
132
+ """POST /sessions with UAT only — bootstrap path, no session token yet."""
133
+ response = await self._client().post(
134
+ SESSIONS_PATH,
135
+ json={},
136
+ headers={
137
+ "Authorization": f"Bearer {self.api_key}",
138
+ "Accept": "application/json",
139
+ },
140
+ )
141
+ if response.status_code >= 400:
142
+ raise QvasaApiError(
143
+ response.status_code,
144
+ _safe_json(response),
145
+ method="POST",
146
+ path=SESSIONS_PATH,
147
+ )
148
+ body = response.json()
149
+ return SessionState(
150
+ session_id=body["session_id"],
151
+ session_token=body["session_token"],
152
+ toolkit_version=body["toolkit_version"],
153
+ manifest=body["toolkit"],
154
+ )
155
+
156
+ def _client(self) -> httpx.AsyncClient:
157
+ if self._http is None:
158
+ self._http = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout_seconds)
159
+ return self._http
160
+
161
+ @staticmethod
162
+ def _is_session_recoverable_401(response: httpx.Response) -> bool:
163
+ body = _safe_json(response)
164
+ if not isinstance(body, dict):
165
+ # Opaque 401 — treat as recoverable, the worst case is one wasted refresh.
166
+ return True
167
+ # Don't retry on missing-UAT 401 — refreshing the session won't help.
168
+ return body.get("error_code") != "missing_user_access_token"
169
+
170
+
171
+ def _safe_json(response: httpx.Response) -> Any:
172
+ try:
173
+ return response.json()
174
+ except ValueError:
175
+ return response.text
@@ -0,0 +1,257 @@
1
+ """FastMCP server entry point — registers 17 tools, runs over stdio.
2
+
3
+ Tools are registered against a shared QvasaClient that owns session lifecycle.
4
+ The `build_server` factory takes the client as an argument so tests can
5
+ substitute a mock; `main()` is the production entry point that reads
6
+ configuration from the environment.
7
+
8
+ Each tool wrapper runs the inner coroutine through `as_recoverable_result`
9
+ so that Rails-side AgentError envelopes (which carry next_step / fallback_step
10
+ hints designed for agent recovery) are surfaced as normal dict results
11
+ instead of opaque MCP error strings.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from typing import Any
18
+
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ from qvasa_mcp import tool_descriptions
22
+ from qvasa_mcp.client import MissingApiKeyError, QvasaClient
23
+ from qvasa_mcp.tools import (
24
+ builders,
25
+ component_configurations,
26
+ feedback,
27
+ filter_values,
28
+ filters,
29
+ metrics,
30
+ saved_widgets,
31
+ session,
32
+ ticket_sets,
33
+ widgets,
34
+ )
35
+ from qvasa_mcp.tools._helpers import as_recoverable_result
36
+
37
+
38
+ def build_server(client: QvasaClient) -> FastMCP:
39
+ mcp = FastMCP("qvasa")
40
+
41
+ # Orientation — the elegant front door. Registered first so MCP hosts that
42
+ # surface tools in registration order put it at the top of the list. ----
43
+
44
+ @mcp.tool(description=tool_descriptions.QVASA_OVERVIEW)
45
+ async def qvasa_overview() -> dict[str, Any]:
46
+ return await session.get_overview(client)
47
+
48
+ @mcp.tool(description=tool_descriptions.SUBMIT_FEEDBACK)
49
+ async def qvasa_submit_feedback(
50
+ feedback_type: str,
51
+ content: str,
52
+ context: dict[str, Any] | None = None,
53
+ ) -> dict[str, Any]:
54
+ if feedback_type not in feedback.FEEDBACK_TYPES:
55
+ raise ValueError(
56
+ f"feedback_type must be one of {feedback.FEEDBACK_TYPES}, "
57
+ f"got {feedback_type!r}"
58
+ )
59
+ return await as_recoverable_result(
60
+ feedback.submit_feedback(
61
+ client,
62
+ feedback_type=feedback_type, # type: ignore[arg-type]
63
+ content=content,
64
+ context=context,
65
+ )
66
+ )
67
+
68
+ # Memory layer: personal-dashboard saved-widget search ----------------
69
+
70
+ @mcp.tool(description=tool_descriptions.SEARCH_SAVED_WIDGETS)
71
+ async def qvasa_search_saved_widgets(query: str) -> dict[str, Any]:
72
+ return await as_recoverable_result(saved_widgets.search_saved_widgets(client, query))
73
+
74
+ # Discovery: widgets (unified search / browse-by-group / list-all) -----
75
+
76
+ @mcp.tool(description=tool_descriptions.SEARCH_WIDGETS)
77
+ async def qvasa_search_widgets(
78
+ query: str | None = None,
79
+ group_name: str | None = None,
80
+ ) -> dict[str, Any]:
81
+ return await as_recoverable_result(
82
+ widgets.search_widgets(client, query=query, group_name=group_name)
83
+ )
84
+
85
+ @mcp.tool(description=tool_descriptions.DESCRIBE_WIDGET)
86
+ async def qvasa_describe_widget(widget_names: list[str]) -> dict[str, Any]:
87
+ return await as_recoverable_result(widgets.describe_widget(client, widget_names))
88
+
89
+ # Discovery: filters (unified search / browse-by-group / list-all) -----
90
+
91
+ @mcp.tool(description=tool_descriptions.SEARCH_FILTERS)
92
+ async def qvasa_search_filters(
93
+ query: str | None = None,
94
+ group_name: str | None = None,
95
+ ) -> dict[str, Any]:
96
+ return await as_recoverable_result(
97
+ filters.search_filters(client, query=query, group_name=group_name)
98
+ )
99
+
100
+ @mcp.tool(description=tool_descriptions.DESCRIBE_FILTER)
101
+ async def qvasa_describe_filter(kind_keys: list[str]) -> dict[str, Any]:
102
+ return await as_recoverable_result(filters.describe_filter(client, kind_keys))
103
+
104
+ # Filter values ---------------------------------------------------------
105
+
106
+ @mcp.tool(description=tool_descriptions.SEARCH_FILTER_VALUES)
107
+ async def qvasa_search_filter_values(
108
+ kind_key: str,
109
+ query: str,
110
+ parent_id: int | str | None = None,
111
+ ) -> dict[str, Any]:
112
+ return await as_recoverable_result(
113
+ filter_values.search_filter_values(
114
+ client, kind_key=kind_key, query=query, parent_id=parent_id
115
+ )
116
+ )
117
+
118
+ @mcp.tool(description=tool_descriptions.SEARCH_VALUES_ACROSS_ATTRIBUTES)
119
+ async def qvasa_search_values_across_attributes(value: str) -> dict[str, Any]:
120
+ return await as_recoverable_result(
121
+ filter_values.search_values_across_attributes(client, value)
122
+ )
123
+
124
+ # Date range (unified discover + build) ---------------------------------
125
+
126
+ @mcp.tool(description=tool_descriptions.RESOLVE_DATE_RANGE)
127
+ async def qvasa_resolve_date_range(
128
+ phrase: str | None = None,
129
+ strategy: str | None = None,
130
+ time_unit: str | None = None,
131
+ time_length: int | None = None,
132
+ start_at: str | None = None,
133
+ end_at: str | None = None,
134
+ ) -> dict[str, Any]:
135
+ return await as_recoverable_result(
136
+ builders.resolve_date_range(
137
+ client,
138
+ phrase=phrase,
139
+ strategy=strategy,
140
+ time_unit=time_unit,
141
+ time_length=time_length,
142
+ start_at=start_at,
143
+ end_at=end_at,
144
+ )
145
+ )
146
+
147
+ # Component configuration values ---------------------------------------
148
+
149
+ @mcp.tool(description=tool_descriptions.SEARCH_COMPONENT_CONFIGURATION_VALUES)
150
+ async def qvasa_search_component_configuration_values(
151
+ configuration_kind: str,
152
+ query: str,
153
+ parent_id: int | str | None = None,
154
+ ) -> dict[str, Any]:
155
+ return await as_recoverable_result(
156
+ component_configurations.search_component_configuration_values(
157
+ client,
158
+ configuration_kind=configuration_kind,
159
+ query=query,
160
+ parent_id=parent_id,
161
+ )
162
+ )
163
+
164
+ # Metrics ---------------------------------------------------------------
165
+
166
+ @mcp.tool(description=tool_descriptions.QUERY_METRIC)
167
+ async def qvasa_query_metric(
168
+ metric_kind: str,
169
+ metric_name: str,
170
+ filters_json: dict[str, Any] | None = None,
171
+ ) -> dict[str, Any]:
172
+ if metric_kind not in metrics.METRIC_KIND_TO_KICKOFF_PATH:
173
+ raise ValueError(
174
+ f"metric_kind must be one of "
175
+ f"{sorted(metrics.METRIC_KIND_TO_KICKOFF_PATH)}, got {metric_kind!r}"
176
+ )
177
+ return await as_recoverable_result(
178
+ metrics.query_metric(
179
+ client,
180
+ metric_kind=metric_kind, # type: ignore[arg-type]
181
+ metric_name=metric_name,
182
+ filters_json=filters_json,
183
+ )
184
+ )
185
+
186
+ @mcp.tool(description=tool_descriptions.INSPECT_METRIC)
187
+ async def qvasa_inspect_metric(
188
+ metric_kind: str,
189
+ metric_name: str,
190
+ filters_json: dict[str, Any] | None = None,
191
+ ) -> dict[str, Any]:
192
+ if metric_kind not in metrics.INSPECT_KIND_TO_KICKOFF_PATH:
193
+ raise ValueError(
194
+ f"metric_kind must be one of "
195
+ f"{sorted(metrics.INSPECT_KIND_TO_KICKOFF_PATH)} for inspect "
196
+ f"(heat_map and multi_data_chart are not inspectable), "
197
+ f"got {metric_kind!r}"
198
+ )
199
+ return await as_recoverable_result(
200
+ metrics.inspect_metric(
201
+ client,
202
+ metric_kind=metric_kind, # type: ignore[arg-type]
203
+ metric_name=metric_name,
204
+ filters_json=filters_json,
205
+ )
206
+ )
207
+
208
+ @mcp.tool(description=tool_descriptions.LIST_IN_FLIGHT)
209
+ async def qvasa_list_in_flight() -> dict[str, Any]:
210
+ return await as_recoverable_result(metrics.list_in_flight(client))
211
+
212
+ @mcp.tool(description=tool_descriptions.CANCEL_METRIC)
213
+ async def qvasa_cancel_metric(uuid: str) -> dict[str, Any]:
214
+ return await as_recoverable_result(metrics.cancel_metric(client, uuid))
215
+
216
+ # Ticket sets -----------------------------------------------------------
217
+
218
+ @mcp.tool(description=tool_descriptions.DESCRIBE_TICKET_SET)
219
+ async def qvasa_describe_ticket_set(ticket_set_id: int) -> dict[str, Any]:
220
+ return await as_recoverable_result(ticket_sets.describe_ticket_set(client, ticket_set_id))
221
+
222
+ @mcp.tool(description=tool_descriptions.GET_TICKET_SET_SUMMARIES)
223
+ async def qvasa_get_ticket_set_summaries(
224
+ ticket_set_id: int,
225
+ summary_kind: str = "short",
226
+ max_results: int = ticket_sets.DEFAULT_MAX_SUMMARIES,
227
+ after_id: int | None = None,
228
+ ) -> dict[str, Any]:
229
+ if summary_kind not in ("short", "long"):
230
+ raise ValueError(
231
+ f"summary_kind must be 'short' or 'long', got {summary_kind!r}"
232
+ )
233
+ return await as_recoverable_result(
234
+ ticket_sets.get_ticket_set_summaries(
235
+ client,
236
+ ticket_set_id=ticket_set_id,
237
+ summary_kind=summary_kind, # type: ignore[arg-type]
238
+ max_results=max_results,
239
+ after_id=after_id,
240
+ )
241
+ )
242
+
243
+ return mcp
244
+
245
+
246
+ def main() -> None:
247
+ try:
248
+ client = QvasaClient.from_env()
249
+ except MissingApiKeyError as exc:
250
+ print(f"qvasa-mcp: {exc}", file=sys.stderr)
251
+ sys.exit(1)
252
+ server = build_server(client)
253
+ server.run()
254
+
255
+
256
+ if __name__ == "__main__":
257
+ main()