Repo: Bandwidth/mcp-server
Proposed title: AGENTS.md documents a {error, code, recovery} failure contract that no code in the repo implements
Summary
src/specs/AGENTS.md documents a structured failure contract for every tool:
{
"error": "human-readable message",
"code": "feature_limit | auth | not_found | rate_limited | conflict | timeout",
"recovery": "what to try next"
}
with a full code-semantics table (auth → 401, feature_limit → 402/403, not_found → 404, conflict → 409, rate_limited → 429, timeout → polling deadline) telling agents to "branch on code, not on error text."
In practice, every OpenAPI-generated tool (getStatistics, listApplications, getCallState, listCalls, etc.) raises a plain string on any HTTP error — never the documented shape. This traces to fastmcp's OpenAPITool.run (fastmcp/server/providers/openapi/components.py, ~lines 180–254 in fastmcp 3.4.3) unconditionally doing:
except httpx.HTTPStatusError as e:
error_message = f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
...
raise ValueError(error_message) from e
except httpx.TimeoutException as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except httpx.RequestError as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
There's no branch anywhere — in bandwidth-mcp or in fastmcp itself — that maps a status code to auth/feature_limit/not_found/conflict/rate_limited/timeout, or that ever emits a recovery field. A repo-wide grep for those literal strings (feature_limit, not_found, rate_limited, recovery) turns up zero hits outside AGENTS.md itself.
Note for maintainers: this is not related to the 4xx/5xx response stripping in _clean_openapi_spec (src/server_utils.py) — that was our first hypothesis, but we confirmed OpenAPITool.run never consults the response schema for error formatting at all (it's only used for successful 2xx JSON responses via output_schema). A fully unstripped spec would produce identical raw errors. Flagging so nobody spends time down that branch.
Repro
Using a placeholder account ID (8827364 — not a real account):
listCalls(accountId="8827364")
against an account/credential without access returns something like:
HTTP error 403: Forbidden - {'type': 'authorization-error', 'description': 'Access is denied'}
— a raw string, not {"error": "...", "code": "auth", "recovery": "..."} as documented.
Environment
bandwidth-mcp — current main, post-v0.3.0
fastmcp 3.4.3 (fastmcp~=3.2 pin)
Suggested fix — reference implementation available
We built and validated a normalization layer that delivers exactly the documented contract:
# monkeypatches OpenAPITool.run to catch the raised ValueError, classify
# the embedded HTTP status/timeout, and return a normal (non-error)
# ToolResult whose structured_content is {error, code, recovery}
_STATUS_TO_CODE = {401: "auth", 402: "feature_limit", 403: "feature_limit",
404: "not_found", 409: "conflict", 429: "rate_limited"}
async def _patched_run(self, arguments):
try:
return await _original_run(self, arguments)
except ValueError as e:
message = str(e)
code = _classify(message) # parses "HTTP error NNN:" or "timed out"
return ToolResult(structured_content={
"error": message, "code": code, "recovery": _RECOVERY[code],
})
Validated:
- Unit tests across every documented code (401/402/403/404/409/429/500/timeout/connection-error)
- An integration test that builds a real server instance, mocks a 403 on a real tool, and confirms the structured shape comes back
- Live testing against a real account: a real 403 now returns
{"error", "code": "feature_limit", "recovery"} instead of a raw string, and a real successful call is completely untouched (no wrapper injected on success)
Happy to open a PR with the full implementation if a maintainer confirms this is the direction you'd want — the wrapping point is a single hook at tool-registration time, so it covers all six specs (Voice, Messaging, Lookup, Insights, TFV, End-user-management) without per-tool changes. The alternative is that AGENTS.md's failure-contract section gets corrected to describe what actually happens today instead.
Repo: Bandwidth/mcp-server
Proposed title:
AGENTS.mddocuments a{error, code, recovery}failure contract that no code in the repo implementsSummary
src/specs/AGENTS.mddocuments a structured failure contract for every tool:{ "error": "human-readable message", "code": "feature_limit | auth | not_found | rate_limited | conflict | timeout", "recovery": "what to try next" }with a full code-semantics table (
auth→ 401,feature_limit→ 402/403,not_found→ 404,conflict→ 409,rate_limited→ 429,timeout→ polling deadline) telling agents to "branch oncode, not onerrortext."In practice, every OpenAPI-generated tool (
getStatistics,listApplications,getCallState,listCalls, etc.) raises a plain string on any HTTP error — never the documented shape. This traces tofastmcp'sOpenAPITool.run(fastmcp/server/providers/openapi/components.py, ~lines 180–254 in fastmcp 3.4.3) unconditionally doing:There's no branch anywhere — in
bandwidth-mcpor infastmcpitself — that maps a status code toauth/feature_limit/not_found/conflict/rate_limited/timeout, or that ever emits arecoveryfield. A repo-wide grep for those literal strings (feature_limit,not_found,rate_limited,recovery) turns up zero hits outsideAGENTS.mditself.Note for maintainers: this is not related to the 4xx/5xx response stripping in
_clean_openapi_spec(src/server_utils.py) — that was our first hypothesis, but we confirmedOpenAPITool.runnever consults the response schema for error formatting at all (it's only used for successful 2xx JSON responses viaoutput_schema). A fully unstripped spec would produce identical raw errors. Flagging so nobody spends time down that branch.Repro
Using a placeholder account ID (
8827364— not a real account):against an account/credential without access returns something like:
— a raw string, not
{"error": "...", "code": "auth", "recovery": "..."}as documented.Environment
bandwidth-mcp— currentmain, post-v0.3.0fastmcp3.4.3 (fastmcp~=3.2pin)Suggested fix — reference implementation available
We built and validated a normalization layer that delivers exactly the documented contract:
Validated:
{"error", "code": "feature_limit", "recovery"}instead of a raw string, and a real successful call is completely untouched (no wrapper injected on success)Happy to open a PR with the full implementation if a maintainer confirms this is the direction you'd want — the wrapping point is a single hook at tool-registration time, so it covers all six specs (Voice, Messaging, Lookup, Insights, TFV, End-user-management) without per-tool changes. The alternative is that
AGENTS.md's failure-contract section gets corrected to describe what actually happens today instead.