Using the Qvasa MCP Tools Over Plain HTTP (No MCP Client Required)
This is a technical reference for developers and AI agents who use Qvasa's MCP tools without an MCP client library. There are two ways to do it, both with a User Access Token and any HTTP client:
The MCP server over JSON-RPC (
POST /mcp): the same nine tools Claude sees, in plain JSON. The two freeform tools take an endpoint's name and its params, and the server builds the request.The User Access API directly (
/user_access/api/*): the HTTP endpoints behind the tools, one request each.
Responses name the related endpoints as steps: {tool, name, params}. Over /mcp a step is a tool call as it stands. Over the API directly, a step's name is looked up in the endpoint table (below) for its method and route.
At a glance
Base URL |
|
MCP endpoint |
|
API namespace |
|
Credential | A User Access Token, prefix |
Session | Over |
Rate limits | 120 calls per minute per session, 20 per minute for expensive calls (metric kickoffs, AI jobs); a |
Before you start
You need a User Access Token. A human generates one in Qvasa under User Settings → User Access Tokens; the raw token is shown exactly once. See User Access Tokens and the Qvasa MCP for enabling the feature, granting the permission, and choosing scopes.
A token can never do more than its owning user can do in Qvasa. Scopes are intersected with the user's live permissions on every request, so a
403is a permissions boundary, not a fault a retry fixes.Send
Content-Type: application/jsonon requests with a body. The examples below usecurl; any HTTP client works.For the security model behind all of this (token storage, scopes, tenancy, auditing), see Qvasa MCP Server — Security and Integration Reference.
Way 1: the MCP server over JSON-RPC
The hosted server at POST https://www.qvasa.com/mcp is a stateless Streamable HTTP endpoint that returns plain JSON. It takes the qva_uat_ token as a Bearer credential and opens or reuses the token's session itself, so there is no session header. OAuth is only for clients that connect by URL with a Qvasa sign-in.
curl -X POST https://www.qvasa.com/mcp \
-H "Authorization: Bearer qva_uat_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
The result lists nine tools, each with its inputSchema:
MCP tool | What it runs | Arguments |
|
| None |
|
|
|
|
|
|
|
|
|
| A metric's kickoff, then |
|
| One read endpoint by its name (lists, shows, searches). Read-only. |
|
| One write endpoint by its name (creates, updates, deletes, starts a job). Marked destructive. |
|
|
|
|
|
| All optional: |
Invoke a tool with tools/call. A freeform read, by endpoint name:
curl -X POST https://www.qvasa.com/mcp \
-H "Authorization: Bearer qva_uat_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {
"name": "qvasa_read_api",
"arguments": { "name": "dynamic_dashboards.show", "params": { "id": 42 } }
}
}'
A metric:
curl -X POST https://www.qvasa.com/mcp \
-H "Authorization: Bearer qva_uat_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {
"name": "qvasa_query_metric",
"arguments": { "metric_kind": "quick_stat", "metric_name": "tickets_created" }
}
}'
How the server answers:
A tool's result is a JSON string in
result.content[0].text. When the underlying call failed, the result is flagged as an error and the text is the error envelope described under Error envelopes.A response's
next_step(and each entry ofnext_steps) is a step:toolis the MCP tool, andnameandparamsare its arguments. A step forqvasa_query_metriccarries that tool's arguments inparams.qvasa_query_metricwaits up to 12 seconds for the result. A metric still calculating after that returnsstatus: "processing"with its request id and a step formetric_requests.poll, whichqvasa_read_apiruns.The freeform tools refuse a call before anything is sent: a path or template in place of a name (
not_an_endpoint_name), a name no endpoint has (unknown_endpoint_name, with the nearest names), a write sent to the read tool or the reverse (not_a_read,not_a_write), a metric kickoff (metric_kickoff, whichqvasa_query_metricruns), or a missing path param (missing_param).An
initializehandshake is accepted but not required; each request is independent. Server-Sent Events are not supported: keep theAcceptheader as shown and the body is plain JSON.An unauthenticated request returns
401with aWWW-Authenticateheader pointing at/.well-known/oauth-protected-resource, for clients that sign in; a Bearer token needs none of it.
Way 2: the User Access API directly
Every MCP tool runs one or two endpoints of the User Access API, and each endpoint is an ordinary HTTP route under /user_access/api/. Calling the routes yourself takes a session first.
Step 1: Create a session with your token
The long-lived User Access Token proves who you are; a short-lived session token authorizes every other call. sessions.create (POST /user_access/api/sessions) is the only endpoint that takes the User Access Token alone.
curl -X POST https://www.qvasa.com/user_access/api/sessions \ -H "Authorization: Bearer qva_uat_YOUR_TOKEN" \ -H "Content-Type: application/json"
A 200 response has this shape (the toolkit object is large and is abbreviated here):
{
"session_id": 1234,
"session_token": "qva_uas_...",
"session_token_expires_at": "2026-11-04T18:48:33Z",
"toolkit_version": "...",
"toolkit": {
"description": "...",
"headers_required": {
"Authorization": "Bearer <your qva_uat_... token>",
"X-User-Access-Session": "<the session_token returned with this toolkit>"
},
"tools": [ ... ]
},
"capability_profile": { "shape": "full_authoring", ... }
}
What the response holds:
session_token: theX-User-Access-Sessionheader for every later call. It expires after 24 hours.toolkit.tools: the tool groups this token can call, already filtered to the token's effective scopes and the user's permissions. A group that is not listed is unavailable. The list can change between sessions.capability_profile: the token's boundaries in plain language.shapeisfull_authoringfor users who can build metrics from scratch, ordashboard_scopedfor users limited to the dashboards they were granted.
Session rules:
One active session per token. A new session invalidates the previous one. A body of
{"attach_to_session_id": <session_id>}refreshes an existing session instead.Sessions pin the toolkit version. When Qvasa ships an agent-visible change, existing sessions return
401witherror_code: "session_stale"; a new session picks up the change.sessions.destroy(DELETE /user_access/api/sessions/:id, token header only) revokes a session and returns204.Without an
Authorizationheader the answer is401witherror_code: "missing_user_access_token". An invalid, expired, or revoked token gets401with the messageYou must provide a Bearer token to access this resource. UAT401.Python and httpx: session creation answers
426mcp_package_upgrade_requiredto httpx's defaultUser-Agent, which is how copies of the Qvasa MCP package from before version 0.12.1 are recognised. A script that sends its ownUser-Agentheader is not affected.
Step 2: Send both headers on every other call
Every endpoint other than sessions.create takes both headers; without the session header the answer is 401 with error_code: "missing_session_token". me.show (GET /user_access/api/me) says which account and user the token belongs to and which scopes are actually effective:
curl https://www.qvasa.com/user_access/api/me \ -H "Authorization: Bearer qva_uat_YOUR_TOKEN" \ -H "X-User-Access-Session: qva_uas_YOUR_SESSION_TOKEN"
{
"account": { "id": 2, "name": "acme" },
"user": { "id": 179, "email": "[email protected]", "name": "Agent Owner" },
"token": {
"id": 23,
"name": "Claude MCP",
"expires_at": "2026-12-16T18:46:53.401Z",
"granted_scopes": [ "can_read_all_endpoints", "can_write_all_endpoints", "can_inspect_objects", ... ],
"effective_scopes": [ "can_read_all_endpoints", "can_write_all_endpoints", ... ],
"scopes_filtered_by_user_permissions": [ ... ]
}
}
effective_scopes shows a gate before a call meets it as a 403. scopes_filtered_by_user_permissions lists grants the token carries but the user cannot exercise.
Step 3: The endpoints behind the tools
MCP tool | Endpoint name | Route |
|
|
|
|
|
|
|
|
|
|
|
|
| The metric kind's kickoff, then | See Metrics, below; the poll is |
| Any read endpoint | The endpoint's own route (the endpoint table, below) |
| Any write endpoint | The endpoint's own route (the endpoint table, below) |
|
|
|
|
|
|
These need only the baseline can_read_all_endpoints scope, except that metrics also need the user to have dashboard authoring access, and the freeform endpoints need whatever scope each endpoint declares.
Tool search, for example:
curl -X POST https://www.qvasa.com/user_access/api/tools/search \
-H "Authorization: Bearer $UAT" -H "X-User-Access-Session: $SESSION" \
-H "Content-Type: application/json" \
-d '{"query": "how many tickets were created"}'
{
"query": "how many tickets were created",
"tool_results": [
{
"name": "count_tickets",
"summary": "Get a single number — how many tickets match a given filter scope.",
"score": 25,
"final_endpoint": { "tool": "qvasa_query_metric", "params": { "metric_kind": "quick_stat" } },
"next_step": { "tool": "qvasa_read_api", "name": "agentic_tools.show", "params": { "name": "count_tickets" } }
},
...
],
"metric_routing": { ... },
"fallback_results": { ... }
}
tool_resultsare ranked tool definitions. A hit'snext_stepreads its full definition, with its params and playbook.metric_routingappears when the phrase asks for a metric. Metrics are widgets, not tools:widgets_discovery.search(POST /user_access/api/tools/widgets/search) finds one by name.saved_widget_resultsare the user's saved dashboard widgets, whichdashboard_widgets.query(POST /user_access/api/dashboard_widgets/:id/query) re-runs.runbook_resultsare multi-step recipes Qvasa provides, read withrunbooks_discovery.show.fallback_resultslists widgets and filters that matched loosely when no tool did.agentic_tools.index(GET /user_access/api/tools) lists every tool this token can call.
From a step's name to a request
Every step the API returns names its endpoint: {"tool": "qvasa_read_api", "name": "dynamic_dashboards.show", "params": {"id": 42}}. The request is that endpoint's method and route: the path params (id, uuid and the like) fill the route, and the other params are the query string of a GET or the JSON body of any other method. That step is:
curl https://www.qvasa.com/user_access/api/dynamic_dashboards/42 \ -H "Authorization: Bearer $UAT" -H "X-User-Access-Session: $SESSION"
The table of every endpoint name with its method and route ships in the qvasa-mcp package on PyPI: endpoints.table in qvasa_mcp/tools.json, one row per name with its kind (read, write or kickoff), verb, template and path_params. The latest release has the current table:
pip download qvasa-mcp --no-deps -d qvasa-mcp
unzip -p qvasa-mcp/qvasa_mcp-*.whl qvasa_mcp/tools.json | jq '.endpoints.table[] | select(.name == "dynamic_dashboards.show")'
{ "name": "dynamic_dashboards.show", "kind": "read", "verb": "GET", "template": "/user_access/api/dynamic_dashboards/:id", "path_params": ["id"] }
Metrics: kickoff, then poll
A metric is calculated in the background. qvasa_query_metric does both steps in one call; over the API they are two.
1. Kick off. The kickoff endpoint depends on the metric's kind; its body is the widget's name (metric_name) and optional filters_json:
metric_kind | Kickoff endpoint | Route |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Agent scorecard views have kickoffs of their own; metric_kinds_discovery.index (GET /user_access/api/tools/metric_kinds) lists every kind this token can run, with its kickoff and inspect steps.
curl -X POST https://www.qvasa.com/user_access/api/quick_stats \
-H "Authorization: Bearer $UAT" -H "X-User-Access-Session: $SESSION" \
-H "Content-Type: application/json" \
-d '{"metric_name": "tickets_created"}'
HTTP 202 Accepted
{
"async_request_uuid": "aabc73b0-4751-49bf-94cd-fec757a39a10",
"metric_kind": "quick_stat",
"request_kind": "metric",
"status": "enqueued",
"enqueued_at": "2026-11-03T18:48:38.982Z",
"next_step": {
"tool": "qvasa_read_api",
"name": "metric_requests.poll",
"params": { "uuid": "aabc73b0-4751-49bf-94cd-fec757a39a10" },
"first_poll_after_ms": 5000,
"retry_after_ms": 5000,
"description": "Reads the request; it holds the result once status is 'completed' ..."
},
"cancel_step": { "tool": "qvasa_write_api", "name": "metric_requests.cancel", "params": { "uuid": "..." } },
"summary_step": { "tool": "qvasa_read_api", "name": "metric_requests.summary", "params": {} }
}
2. Poll. metric_requests.poll (GET /user_access/api/metric_requests/:uuid) answers with the request's status until it is completed, errored, or cancelled. Most quick stats finish in a few seconds; the kickoff's next_step gives the interval.
{
"async_request_uuid": "aabc73b0-4751-49bf-94cd-fec757a39a10",
"metric_kind": "quick_stat",
"status": "completed",
"completed_at": "2026-11-03T18:48:42.468Z",
"result": {
"metric_name": "tickets_created",
"metric_type": "quick_stat",
"widget_name": "Tickets Created Last 7 Days",
"current_value": 268,
"display_value": 268,
...
}
}
How metrics behave:
metric_nameis a widget name from this account's library:widgets_discovery.search(POST /user_access/api/tools/widgets/search,{"query": "tickets created"}) finds it, andwidgets_discovery.widget_full(GET /user_access/api/tools/widgets/:widget_name) gives its type, required filters and component configurations. An unknown name returns404unknown_metric, with that search as itsnext_step.Without
filters_json, a metric covers the last 7 days (reported inapplied_defaults).A date range is a filter fragment:
filters.date_and_time_discover(POST /user_access/api/filters/date_and_time/discover,{"phrase": "last 30 days"}) returns afilters_fragmentthat goes intofilters_jsonas it is. Other filters come fromfilters_discovery.search(POST /user_access/api/tools/filters/search). A fragment's keys and values are opaque.The tickets behind a number come from the inspect kickoffs (
POST /user_access/api/quick_stats_inspect,/basic_charts_inspect,/donut_charts_inspect), which take the same body. The completed poll carries aticket_set_idandticket_count;ticket_sets.show,ticket_sets.tickets_table.indexandticket_sets.summaries.indexread the set.metric_requests.summary(GET /user_access/api/metric_requests) lists this session's requests;metric_requests.cancel(POST /user_access/api/metric_requests/:uuid/cancel) cancels one still enqueued.
Conventions every endpoint shares
Params are strict. A key the endpoint does not declare is refused with
422unknown_params, listingunknown_keysandallowed_keys(paramsays where they sit), and nothing is written. A tool's definition atagentic_tools.show(GET /user_access/api/tools/:name) lists its params.Lists are usually
POST .../indexwith an optionalfiltersobject and cursor pagination (pagination.next_cursor,has_more).Batch writes:
batch.create(POST /user_access/api/batch) runs up to 50 batch-safe operations in one call:{"mode": "all_or_nothing" | "best_effort", "operations": [{"name": "...", "params": {...}}, ...]}. It needscan_write_all_endpoints.Every success envelope carries the resource, an
explanation, and usually anext_step. Every error envelope carrieserror,error_code, and oftennext_stepanddiagnosis.
curl -X POST https://www.qvasa.com/user_access/api/dynamic_dashboards \
-H "Authorization: Bearer $UAT" -H "X-User-Access-Session: $SESSION" \
-H "Content-Type: application/json" \
-d '{"name": "x", "bogus": 1}'
HTTP 422
{
"error": "Unknown param(s) [\"bogus\"]. UAF422K",
"error_code": "unknown_params",
"param": null,
"unknown_keys": [ "bogus" ],
"allowed_keys": [ "name", "description", ... ],
"explanation": "Params are strict: every key must be one this endpoint declares. Nothing was written. ...",
"next_step": { "tool": "qvasa_read_api", "name": "agentic_tools.show", "params": { ... } }
}
Support tickets
A two-way support loop with the Qvasa team, for a missing tool, an error that won't clear (its support_code goes in context.support_code), or a capability request. Only can_read_all_endpoints is required.
POST /user_access/api/qvasa_support_tickets
{
"ticket_type": "bug_report",
"subject": "quick_stats returns 500 for widget X",
"body": "Steps to reproduce ...",
"context": { "support_code": "UAA500X" }
}
ticket_type is one of bug_report, unexpected_behavior, friction_point, feature_request, impossibility_report, question, other. Then:
qvasa_support_tickets.index(POST /user_access/api/qvasa_support_tickets/index) with{"filters": {"unread_replies_only": true}}lists the tickets with a reply not yet read. Other filters:status,ticket_type,open_only,mine_only,search.qvasa_support_tickets.show(GET /user_access/api/qvasa_support_tickets/:id) returns the full thread.qvasa_support_ticket_messages.create(POST /user_access/api/qvasa_support_tickets/:qvasa_support_ticket_id/messages,{"body": "..."}) replies, andqvasa_support_tickets.status(PATCH /user_access/api/qvasa_support_tickets/:id/status,{"status": "open" | "closed"}) reopens or closes.
Error envelopes
Every error is JSON with a human error string that ends in a support code (for example UAS401M) and a machine error_code; most carry a next_step too.
HTTP | error_code | Meaning |
401 |
| No |
401 | (message | Token invalid, expired, or revoked. A new token is needed. |
401 |
| No |
401 |
| Session expired, revoked, or superseded by a newer one. A new session clears it. |
401 |
| The toolkit changed since this session started. A new session picks up the change. |
403 |
|
|
404 |
|
|
404 |
| No endpoint at that route, or a tool this token cannot see. |
422 |
| The params did not match the endpoint. |
426 |
| A copy of the Qvasa MCP package too old for the server; |
429 |
| More than 120 calls a minute, or 20 expensive calls a minute, in this session. |
500 |
| Carries a |
guidance.error (GET /user_access/api/guidance/errors/:error_code) explains the domain-specific codes that can appear inside a completed metric's result (for example a dashboard widget's error banner); the transport-level codes are the ones in this table.
A complete minimal sequence
Over /mcp, one call per tool:
MCP=(-H "Authorization: Bearer $UAT" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream")curl -s -X POST https://www.qvasa.com/mcp "${MCP[@]}" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "qvasa_search_tools", "arguments": {"query": "tickets created"}}}'
curl -s -X POST https://www.qvasa.com/mcp "${MCP[@]}" \
-d '{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "qvasa_query_metric", "arguments": {"metric_kind": "quick_stat", "metric_name": "tickets_created"}}}'
Over the API directly:
# 1. Session
SESSION=$(curl -s -X POST https://www.qvasa.com/user_access/api/sessions \
-H "Authorization: Bearer $UAT" | jq -r .session_token)
AUTH=(-H "Authorization: Bearer $UAT" -H "X-User-Access-Session: $SESSION" -H "Content-Type: application/json")# 2. Orientation and identity
curl -s https://www.qvasa.com/user_access/api/guidance/overview "${AUTH[@]}"
curl -s https://www.qvasa.com/user_access/api/me "${AUTH[@]}"# 3. Find the metric
curl -s -X POST https://www.qvasa.com/user_access/api/tools/widgets/search "${AUTH[@]}" \
-d '{"query": "tickets created"}'# 4. Kick off, then poll
UUID=$(curl -s -X POST https://www.qvasa.com/user_access/api/quick_stats "${AUTH[@]}" \
-d '{"metric_name": "tickets_created"}' | jq -r .async_request_uuid)
sleep 5
curl -s https://www.qvasa.com/user_access/api/metric_requests/$UUID "${AUTH[@]}"
Related articles
User Access Tokens and the Qvasa MCP: Connecting Claude to Your Account: enabling the feature, generating a token, and connecting Claude or other MCP clients.
Qvasa MCP Server — Security and Integration Reference: authentication, scopes, tenancy, and auditing, for security reviewers.
