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.
- qvasa_mcp-0.1.0/.gitignore +10 -0
- qvasa_mcp-0.1.0/PKG-INFO +79 -0
- qvasa_mcp-0.1.0/README.md +64 -0
- qvasa_mcp-0.1.0/pyproject.toml +33 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/__init__.py +1 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/client.py +175 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/server.py +257 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tool_descriptions.py +244 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/__init__.py +0 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/_helpers.py +33 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/builders.py +69 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/component_configurations.py +102 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/feedback.py +65 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/filter_values.py +166 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/filters.py +78 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/metrics.py +180 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/saved_widgets.py +34 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/session.py +123 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/ticket_sets.py +105 -0
- qvasa_mcp-0.1.0/src/qvasa_mcp/tools/widgets.py +75 -0
- qvasa_mcp-0.1.0/tests/__init__.py +0 -0
- qvasa_mcp-0.1.0/tests/test_client.py +146 -0
- qvasa_mcp-0.1.0/tests/test_contract.py +68 -0
- qvasa_mcp-0.1.0/tests/test_discovery_additions.py +93 -0
- qvasa_mcp-0.1.0/tests/test_feedback.py +90 -0
- qvasa_mcp-0.1.0/tests/test_overview.py +58 -0
- qvasa_mcp-0.1.0/tests/test_phase2_tools.py +348 -0
- qvasa_mcp-0.1.0/tests/test_phase3_tools.py +386 -0
- qvasa_mcp-0.1.0/tests/test_tools.py +157 -0
qvasa_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -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()
|