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.
| Field | Type | Meaning |
|---|---|---|
page | integer | The page you asked for, 1-indexed |
page_size | integer | Rows per page, capped at 50 |
returned | integer | Rows in this response, after any truncation |
total | integer | Rows matching the filter. Absent when the underlying service does not count |
has_next_page | boolean | Whether another page exists |
next_page | integer or null | The 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"
}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.
| Field | Appears when | What to do |
|---|---|---|
hint | A list came back empty | Read it. It names the filter to widen, or the tool to use instead |
truncated | Rows were cut, either capped or trimmed to fit the budget | Treat the result as incomplete. Narrow the question or page with a smaller page_size |
total | The underlying service counts its rows | Absent means you cannot compute a page count. Page until has_next_page is false |
filtered_to | On list-recommendations | Echoes 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.
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" }get-identity for that.Next
- Tools is every tool, parameter, enum and default
- Ask it this has prompts that work as written
- Connecting a client covers credentials and per-client setup