Model Context Protocol (MCP)

Reading a result

Every tool returns JSON, serialized into a text block, not a structured content block. Read it as the tool's Returns line describes, and as the shapes below show.

The values on this page are invented. The field names, their nesting and their types are the ones the server sends.

A single result

get-cost-summary with no arguments returns the current position, per provider, plus the monthly series behind it:

{
  "summary": {
    "overall_monthly_costs": 48210.55,
    "overall_monthly_costs_percentage": -12.4,
    "forecasted_monthly_costs": 61880.0,
    "forecasted_monthly_costs_percentage": -9.1,
    "last_month_spend": 55040.12,
    "last_month_spend_percentage": 6.3,
    "savings_recommendations": 7420.9,
    "savings_recommendations_percentage": 15.4,
    "total_savings": 21360.0,
    "providers": [
      {
        "provider_id": "aws",
        "provider_name": "AWS",
        "monthly_spending": 48210.55,
        "monthly_spending_percentage": -12.4,
        "monthly_spending_change": -6829.57,
        "previous_month_spending": 55040.12,
        "potential_savings": 7420.9,
        "potential_savings_percentage": 15.4,
        "account_count": 12
      }
    ]
  },
  "monthly": {
    "data_points": [
      { "month": "2026-08", "amount": 48210.55 },
      { "month": "2026-07", "amount": 55040.12 }
    ]
  }
}

The current month is partial, so overall_monthly_costs is a month-to-date figure and the newest point in data_points is too. Comparing it against a complete month reports a fall that has not happened. forecasted_monthly_costs is the projection for the end of the current month, and it is the only forward-looking figure here.

Every list shares one envelope

Seven tools page, and all seven close with the same fields. Five put the rows and the envelope at the top level, under items: get-cost-breakdown, get-usage-costs, list-accounts, list-commitments and list-recommendations.

Two nest it, because they return a summary beside the list. get-realized-savings puts both under breakdown, and list-anomalies under anomalies. Read anomalies.items and anomalies.next_page there, not the top level.

FieldTypeMeaning
pageintegerThe page you asked for, 1-indexed
page_sizeintegerRows per page, capped at 50
returnedintegerRows in this response, after any truncation
totalintegerRows matching the filter. Absent when the underlying service does not count
has_next_pagebooleanWhether another page exists
next_pageinteger or nullThe page number to ask for next, null on the last page

list-recommendations adds filtered_to, echoing the status filter it applied, and provider, naming the provider it reported on:

{
  "total_savings": 7420.9,
  "total_savings_count": 34,
  "items": [
    {
      "recommendation_id": "REC-1234",
      "service": "RDS",
      "environment": "production",
      "account": "123456789012",
      "tag": null,
      "monthly_savings": 1180.0,
      "savings_percentage": 22.0,
      "status": "available",
      "actions": {
        "confidence": "HIGH",
        "description": "Right-size two over-provisioned instances to the class their peak supports"
      },
      "implementation_method": null,
      "implementation_status": null,
      "implementation_tags": ["Automated Savings", "IaC"],
      "risk_assessment": { "level": "MODERATE" },
      "created_at": "2026-06-08T14:06:29.086054Z",
      "linked_to": null
    }
  ],
  "page": 1,
  "page_size": 10,
  "returned": 1,
  "total": 34,
  "has_next_page": true,
  "next_page": 2,
  "filtered_to": ["available"],
  "provider": "aws"
}
Do not assume total is there. A service that does not count its rows omits it, and has_next_page then means "this page came back full", which is a guess rather than a fact. Page until has_next_page is false rather than computing a page count from total.

Paging correctly is therefore: call, use items, and if has_next_page is true call again with next_page. Filter in the call instead wherever you can. A narrower question costs one round trip; paging a wide one costs many and can still be truncated.

Fields that appear only sometimes

Four fields are absent from a normal response and appear when something is worth telling you. Build against their absence, because a shape you saw once is not the shape you will always get.

FieldAppears whenWhat to do
hintA list came back emptyRead it. It names the filter to widen, or the tool to use instead
truncatedRows were cut, either capped or trimmed to fit the budgetTreat the result as incomplete. Narrow the question or page with a smaller page_size
totalThe underlying service counts its rowsAbsent means you cannot compute a page count. Page until has_next_page is false
filtered_toOn list-recommendationsEchoes the status filter applied, including the default. An empty-looking backlog is often this filter rather than an empty backlog

An empty list and a broken tool look identical, which is why hint exists:

{
  "items": [],
  "page": 1,
  "page_size": 10,
  "returned": 0,
  "total": 0,
  "has_next_page": false,
  "next_page": null,
  "filtered_to": ["available"],
  "hint": "No recommendations match those filters. Drop a filter, or widen display_status: this returns only open opportunities unless asked for more."
}

Not every row in items is a record

list-recommendations returns synthesized group headings alongside real opportunities. A heading carries linked_to.order of "group" and a status of null, and it exists to title the rows beneath it.

{ "recommendation_id": "REC-1200", "linked_to": { "order": "group" }, "status": null }

Skip any row whose linked_to.order is "group" before you count rows, sum monthly_savings, or present a list. Do not skip on linked_to alone: a row with "order": "first" is the parent of a dependent group and is a real recommendation with real savings.

Truncation is reported in the payload

A result that would exceed what one tool call may return does not fail and does not silently shorten. The server drops rows and adds a truncated field saying so, which is the only signal that what you are holding is incomplete.

{
  "items": ["..."],
  "truncated": "Showing 25 of 200 rows in items because the full result exceeded the size a single tool result may return. Ask a narrower question, or page through with a smaller page_size."
}

A second form appears where a tool caps an unpaginated list instead of trimming one to fit a budget:

{
  "truncated": "Capped at the 200 largest of 4310. Narrow the date range or add a filter rather than asking for more rows."
}

Two numbers bound this. A page is at most 50 rows, and one serialized result is at most 40,000 characters. The character budget is the one that bites on wide rows, and it is set below the point where a client writes a large tool result to disk and makes the model read it back.

If you are parsing results in code, test for truncated and do not match its text. The wording differs between the two cases above and is not an API contract.

Errors

A tool that fails returns an error result in place of a payload, and the message is written for whoever reads it next.

An argument the server can tell you is wrong comes back with the reason, so it can be corrected and retried:

Unknown provider 'azrue'. This organization has data for: aws, gcp.

An unexpected failure is reported, not narrated, and the detail stays server-side:

LevelFour could not complete that request. The failure has been reported.
Try a narrower question, or a different tool.

Rate limiting happens at the transport, before any tool runs, so it arrives as an HTTP response, not a tool result:

HTTP/1.1 429 Too Many Requests
Retry-After: 37
Content-Type: application/json

{
  "error": "rate_limit_exceeded",
  "message": "Too many requests. The limit is 120 per minute. Retry in 37 seconds."
}

Retry-After is in seconds. The limit is 120 requests per minute per credential over a sliding 60 second window, with a second ceiling of 1200 per minute per source address as an abuse backstop. One tool call fans out to several service calls, which is why this ceiling sits below the REST API's.

A rejected credential returns 401 with a WWW-Authenticate header pointing at the server's protected resource metadata, which is how a client discovers where to authorize. Connecting a client separates the causes.

Checking the service is up

GET https://mcp.levelfour.ai/health is unauthenticated and exempt from both rate limits:

{ "status": "ok", "service": "levelfour-mcp" }
This says the service is up, not that your connection works. Call get-identity for that.

Next