Skip to main content

Using the Qvasa MCP Tools Over Plain HTTP (No MCP Client Required)

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

https://www.qvasa.com

MCP endpoint

POST /mcp: JSON-RPC 2.0 over Streamable HTTP, with plain JSON responses

API namespace

/user_access/api/* (JSON in, JSON out)

Credential

A User Access Token, prefix qva_uat_, sent as Authorization: Bearer <token>

Session

Over /mcp, none to manage. Over the API directly, a 24-hour session token, prefix qva_uas_, sent as X-User-Access-Session: <token> on every call except the one that creates it

Rate limits

120 calls per minute per session, 20 per minute for expensive calls (metric kickoffs, AI jobs); a 429 carries Retry-After

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 403 is a permissions boundary, not a fault a retry fixes.

  • Send Content-Type: application/json on requests with a body. The examples below use curl; 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

qvasa_overview

guidance.overview: what the API covers, its workflows and their intent ids

None

qvasa_get_workflow

guidance.workflow: the recipe for one kind of question

intent_id

qvasa_get_tool_guide

guidance.tool: one area of the API, its endpoints by name, their params and scopes

tool_name

qvasa_search_tools

agentic_tools.search: tools, metrics, filters, saved widgets and runbooks matching a phrase

query

qvasa_query_metric

A metric's kickoff, then metric_requests.poll until it finishes

metric_kind, metric_name, optional filters_json and inspect

qvasa_read_api

One read endpoint by its name (lists, shows, searches). Read-only.

name, params

qvasa_write_api

One write endpoint by its name (creates, updates, deletes, starts a job). Marked destructive.

name, params

qvasa_submit_support_ticket

qvasa_support_tickets.create: a ticket for the Qvasa team

message, optional ticket_type, subject, support_code

qvasa_list_support_tickets

qvasa_support_tickets.index: the account's tickets with the Qvasa team

All optional: unread_replies_only, open_only, status, ticket_type, search, mine_only

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 of next_steps) is a step: tool is the MCP tool, and name and params are its arguments. A step for qvasa_query_metric carries that tool's arguments in params.

  • qvasa_query_metric waits up to 12 seconds for the result. A metric still calculating after that returns status: "processing" with its request id and a step for metric_requests.poll, which qvasa_read_api runs.

  • 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, which qvasa_query_metric runs), or a missing path param (missing_param).

  • An initialize handshake is accepted but not required; each request is independent. Server-Sent Events are not supported: keep the Accept header as shown and the body is plain JSON.

  • An unauthenticated request returns 401 with a WWW-Authenticate header 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: the X-User-Access-Session header 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. shape is full_authoring for users who can build metrics from scratch, or dashboard_scoped for 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 401 with error_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 returns 204.

  • Without an Authorization header the answer is 401 with error_code: "missing_user_access_token". An invalid, expired, or revoked token gets 401 with the message You must provide a Bearer token to access this resource. UAT401.

  • Python and httpx: session creation answers 426 mcp_package_upgrade_required to httpx's default User-Agent, which is how copies of the Qvasa MCP package from before version 0.12.1 are recognised. A script that sends its own User-Agent header 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

qvasa_overview

guidance.overview

GET /user_access/api/guidance/overview

qvasa_get_workflow

guidance.workflow

GET /user_access/api/guidance/workflows/:intent_id

qvasa_get_tool_guide

guidance.tool

GET /user_access/api/guidance/tools/:tool_name

qvasa_search_tools

agentic_tools.search

POST /user_access/api/tools/search with {"query": "..."}

qvasa_query_metric

The metric kind's kickoff, then metric_requests.poll

See Metrics, below; the poll is GET /user_access/api/metric_requests/:uuid

qvasa_read_api

Any read endpoint

The endpoint's own route (the endpoint table, below)

qvasa_write_api

Any write endpoint

The endpoint's own route (the endpoint table, below)

qvasa_submit_support_ticket

qvasa_support_tickets.create

POST /user_access/api/qvasa_support_tickets

qvasa_list_support_tickets

qvasa_support_tickets.index

POST /user_access/api/qvasa_support_tickets/index with {"filters": {...}}

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_results are ranked tool definitions. A hit's next_step reads its full definition, with its params and playbook.

  • metric_routing appears 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_results are the user's saved dashboard widgets, which dashboard_widgets.query (POST /user_access/api/dashboard_widgets/:id/query) re-runs. runbook_results are multi-step recipes Qvasa provides, read with runbooks_discovery.show. fallback_results lists 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

quick_stat

metric_requests.kickoff_quick_stat

POST /user_access/api/quick_stats

basic_chart

metric_requests.kickoff_basic_chart

POST /user_access/api/basic_charts

donut_chart

metric_requests.kickoff_donut_chart

POST /user_access/api/donut_charts

multi_data_chart

metric_requests.kickoff_multi_data_chart

POST /user_access/api/multi_data_charts

heat_map

metric_requests.kickoff_heat_map

POST /user_access/api/heat_maps

timeline

metric_requests.kickoff_timeline

POST /user_access/api/timelines

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_name is a widget name from this account's library: widgets_discovery.search (POST /user_access/api/tools/widgets/search, {"query": "tickets created"}) finds it, and widgets_discovery.widget_full (GET /user_access/api/tools/widgets/:widget_name) gives its type, required filters and component configurations. An unknown name returns 404 unknown_metric, with that search as its next_step.

  • Without filters_json, a metric covers the last 7 days (reported in applied_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 a filters_fragment that goes into filters_json as it is. Other filters come from filters_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 a ticket_set_id and ticket_count; ticket_sets.show, ticket_sets.tickets_table.index and ticket_sets.summaries.index read 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 422 unknown_params, listing unknown_keys and allowed_keys (param says where they sit), and nothing is written. A tool's definition at agentic_tools.show (GET /user_access/api/tools/:name) lists its params.

  • Lists are usually POST .../index with an optional filters object 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 needs can_write_all_endpoints.

  • Every success envelope carries the resource, an explanation, and usually a next_step. Every error envelope carries error, error_code, and often next_step and diagnosis.

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, and qvasa_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

missing_user_access_token

No Authorization header on sessions.create.

401

(message UAT401)

Token invalid, expired, or revoked. A new token is needed.

401

missing_session_token

No X-User-Access-Session header. A session comes from Step 1.

401

invalid_session_token

Session expired, revoked, or superseded by a newer one. A new session clears it.

401

session_stale

The toolkit changed since this session started. A new session picks up the change.

403

permission_denied

denial_layer is foundational_gate (feature off for the account or user), token_scope (see missing_scopes), user_permission, group_access, or resource_permission (one dashboard or object). Not retryable.

404

unknown_metric

metric_name is not a widget on this account. next_step is the widget search.

404

route_not_found, agentic_tool_not_found

No endpoint at that route, or a tool this token cannot see. next_step names the nearest list; agentic_tools.index lists every tool.

422

unknown_params, missing_required_param, invalid_enum_value, invalid_filters_date_and_time

The params did not match the endpoint. allowed_keys or the tool's definition shows what it takes.

426

mcp_package_upgrade_required

A copy of the Qvasa MCP package too old for the server; next_step says how to update. See the httpx note in Step 1.

429

rate_limit_exceeded

More than 120 calls a minute, or 20 expensive calls a minute, in this session. Retry-After gives the wait in seconds.

500

unexpected_error

Carries a support_code, for a support ticket's context.support_code.

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[@]}"

Did this answer your question?