Skip to content

AGENTS.md documents a {error, code, recovery} failure contract that no code in the repo implements #11

Description

@deity3005

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions