MCP Server — Files, Limits, and Errors

M

Maciej Litwiniuk

Last updated on Sep 21, 2026

This is the third part of the MCP server guide. Part 1 is https://help.auditbadger.com/hc/auditbadger-help-center/articles/1775898378-mcp-server-connect-ai-assistants-to-humadroid — tokens, scopes, delegation, and client setup. Part 2 is https://help.auditbadger.com/hc/auditbadger-help-center/articles/1788254081-mcp-server-tools-review-flow-and-limits — what the assistant can read and propose, and how a proposal becomes a change.

This page is the reference an assistant works against once it is doing real work: how a file reaches a proposal, what the server will not let it do too often, what each error code means, and how you hear that a proposal is waiting.

Attaching files

An assistant can attach a screenshot or a PDF to evidence, or a SOC 2 report to a vendor assurance review. The flow is four steps:

  1. request_upload_url — the assistant declares the filename, content type, byte size, and SHA-256 checksum. It gets back a staged_upload_id, a source_ref, and a PUT target on AuditBadger's own host: a url, the method, empty fields, and the headers to send. The capability is valid for 5 minutes, so have the file ready before calling.

  2. PUT the bytes to that URL as the raw request body, with the returned headers and nothing else. The capability is bound to the exact byte size, SHA-256, and content type declared, so the file that arrives is the file that was described; anything else is refused on the spot, with the same reasons complete_staged_upload uses.

  3. complete_staged_upload — AuditBadger verifies the checksum, sniffs the real content type from the bytes rather than trusting the name, neutralizes the content, and seals it.

  4. Bind it by passing staged_upload_ids on the proposal, together with the source_ref from step 1 in the item's source_refs. Which operation an upload may be attached to is decided then, against that operation's own scopes.

Replacing a file. On draft evidence, evidence.update takes staged_upload_ids to add files and remove_attachment_ids to delete files; send both in one proposal to replace a file. operation_companion for evidence.update lists the current files with their ids. Evidence that is submitted or verified does not change in place: propose a new version with evidence.create and parent_evidence_id. Automated evidence and historical versions do not change in place either, and they take no new version from an assistant: the collector versions automated evidence, and a historical version is replaced by the current one.

The upload goes to AuditBadger, not to an object store. The url is on the same host as the MCP endpoint, so an assistant that can reach these tools can reach it, and nothing extra needs allowlisting. It carries its own authority: no bearer, session, or extra header is needed, which is what lets a sandboxed assistant, or a person acting for one, send the bytes. If the assistant cannot send bytes itself, it can hand a person one command and continue once it has run:

curl -X PUT -H "Content-Type: application/pdf" --data-binary @report.pdf "<url>"

A 204 means the bytes are in place; call complete_staged_upload next. A 401 means the five-minute window passed or the URL was altered; request a new one. A 422 names the mismatch. A 503 is the object store; PUT again.

Retrying complete_staged_upload. When it answers retryable: true (upload_storage_unavailable, upload_scan_unavailable), call it again with the same idempotency_key. The first call reserved the upload for that key; a retry under a new key is refused as upload_invalid with reason completion_reserved. When it answers upload_invalid, the reason says which check failed and guidance what to do — for every reason but completion_reserved the staged upload is finished and the fix is a new request_upload_url; next_action says which:

  • object_missing — nothing arrived at the upload URL. The PUT did not complete; upload again.

  • size_mismatch / checksum_mismatch — the stored bytes are not the file that was declared. Declare the exact size and SHA-256 of the file you PUT.

  • content_type_mismatch — the bytes are not the declared type, or the type is not allowed.

  • capability_expired — the five-minute window passed, or the upload was already completed or bound. Replaying request_upload_url with the original idempotency_key after the window answers this too, rather than handing back the dead URL; request again under a new key.

  • completion_reserved — a retry under a different idempotency_key; replay the original one instead.

  • object_version_missing — the store returned no immutable version for the file. Upload again; if it recurs, report it with send_feedback.

Provenance. The source_ref from request_upload_url is the one reference AuditBadger verifies itself: its content_sha256 is the checksum completion checks against the uploaded bytes, and binding requires that exact reference on the item. Pass it through unchanged.

Allowed types: PNG, JPEG, GIF, WebP, and PDF. Nothing else. Stricter than ordinary evidence upload on purpose: every extra format is another parser. SVG is excluded despite being an image, because it is a document format that executes script.

Size: 10 MB per file, up to 10 uploads and 50 MB per change set.

Content neutralization happens on every file:

  • A PDF is refused if its bytes carry a name that brings active content — script, open or page actions, launch actions, embedded files, rich media, XFA forms — or encryption, which could hide any of those. You get upload_active_content. Send a flattened PDF.

  • An image is decoded and written back in the same format with every metadata block dropped: EXIF, ICC, XMP, comments, and anything appended after the image data. A rotated photo stays upright because orientation is applied before it is stripped; an animated image keeps its first frame. If re-encoding pushes it past 10 MB you get upload_neutralized_too_large.

Uploads are not scanned for malware today. Neutralization is what stands in for it, and it cannot see inside a compressed PDF object stream.

Scopes: an upload capability needs at least one <module>:propose scope for a module with an operation that accepts attachments — evidence and vendor assurance. A vendors-only token can upload the report it attaches; a controls-only token cannot upload at all, and gets token_scope_missing.

Limits and errors

Rate limits

  • 100 requests per minute per token. The bucket is keyed by a digest of the credential, never the credential itself.

  • 150 bearer-carrying requests per minute per IP address, across all tokens. This is what stops someone rotating bearer values to earn a fresh budget per guess. A team behind one shared egress address can reach it without any single token exceeding its own limit.

  • 20 requests per minute per IP for requests with no bearer header at all.

  • An IP that presents 30 rejected bearers within 10 minutes is banned for 30 minutes. A client still polling with a token you revoked can walk into this; fix the configuration and wait it out.

Exceeding a throttle returns 429 with a Retry-After header.

Authentication

Every rejection is the same 401 with agent_token_required. It does not say whether the subdomain, the token, or the credential kind was wrong — deliberately, so the endpoint cannot be used to enumerate accounts. A valid REST API token lands here too, and gets the same 401: it authenticates elsewhere but is not valid for this surface.

Check, in order: the subdomain in the URL, the token's expiry, whether it was revoked, whether your account still has API access enabled, and whether you are still an active member.

Maintenance pauses

If we pause the ingress for incident response, every request answers 503 headless_compliance_ingress_paused with retryable: true, a Retry-After: 60 header, and retry_after_seconds: 60 in the body. That tells a client to come back in a minute rather than re-authenticate or give up. Work already approved keeps applying.

Error codes

Errors are stable codes with a retryable flag and a next_action, so an assistant can act on them rather than parse prose. A refusal about the request itself also says where to look: item_position names the item (1-based), field is a JSON pointer into it (/params/owner_id, /observed_versions, /source_refs/2), and detail is one sentence about the rule it broke — so a 25-item set is fixed in one round rather than by bisection. Those three describe only what the assistant sent or what a read already returned to it; a domain refusal whose remedy is the assistant's — a subcontrol identifier that does not extend its parent, say — carries them too, and the rest stay a bare code. The ones you are most likely to see:

  • version_stale / version_required — the record moved, or the assistant did not send what it observed. next_action is repropose: read again and try once more.

  • token_scope_missing — the token lacks the module scope for that operation. Rotate with wider scopes, or narrow the task.

  • unauthorized — the sponsor cannot perform that action on that record. The agent inherits your permissions; it does not exceed them.

  • idempotency_key_reused — the same key with different content. A replay is fine; a second, different proposal needs a new key.

  • explicit_judgment_confirmation_required — delegated approval was asked to approve a judgment call. Open the review queue.

  • proposal_limit_reached — 20 open sets per token, 100 per account. Decide some.

  • control_cannot_hold_evidence — evidence was proposed on a criterion rather than a subcontrol beneath it. Only SOC 2 and HIPAA have criteria; ISO 27001 and the other frameworks list every control flat with criteria_root: false, and there is no hierarchy to walk.

  • upload_invalid — the staged upload failed a check; reason names it and guidance says what to do. See Attaching files.

  • source_refs_invalid — provenance was missing or malformed; field names the reference and detail the rule (https or urn without a query string, an explicitly zoned observed_at).

  • invalid_request — the request broke a structural rule; detail says which: an unknown field, a wrong type, compliance_project_id present on an account-scoped set or missing on a project-scoped one, an item mixing scopes, a subcontrol identifier that is not the parent identifier plus a numeric suffix (CC1.1.4 under CC1.1).

  • headless_compliance_ingress_unavailable — the pause above, seen from inside a tool result.

One error arrives without any of these fields, because it comes from the MCP protocol layer before AuditBadger sees the request: -32602 Invalid params with Tool not found: <name> in data. The name is not a tool. If it is a read key from the catalogue in part 2, call read with that key; if it is an operation key or one of the tools removed on 2026-09-19, call propose with the operation_key. Arguments never fix this one, and some clients show only the first line.

Workload limits

A single change set holds at most 25 items, with dependency chains at most 5 deep and 10 wide. Each item may carry 10 source references and 256 KB of serialized content; a set may carry 50 references and 1 MB. Titles are capped at 200 characters, rationales and decision notes at 5,000. Lists page 50 at a time, 100 at most. Proposals expire 14 days after submission by default, and never later than 30 days.

The assistant can read all of these from whoami rather than discovering them by failing. whoami also reports whether self-approval is enabled on the token, and both whoami and describe_operations give each operation its scope_kind (whether a set needs compliance_project_id) and approval_policy (explicit_judgment items are decided one by one by a person, so a set containing one is never approved through approve_change_set).

Notifications and webhooks

Seven lifecycle events are published, and both channels use the same list, so a subscription and a notification can never disagree about what exists:

  • agent.change_set_proposed

  • agent.change_set_decisions_finalized

  • agent.change_set_applied

  • agent.change_set_partially_applied

  • agent.change_set_failed

  • agent.change_set_cancelled

  • agent.change_set_expired

Notifications reach reviewers by email by default, linking straight to the review page. Each person tunes their own channels in notification preferences; in-app is selectable for the record it keeps, but nothing renders these in-app yet, so email is what a reviewer should rely on.

Webhooks are configured in Account Settings and can be account-wide or filtered to a single compliance project. Payloads carry the change set's identity, event, revision, state, item count, and review path — not the proposal's content, so a webhook can wake an external orchestrator without leaking what was proposed. Delivery is durable: it retries, and a redelivered event produces neither a second notification nor a second agent turn.

A webhook is the right way to build "propose overnight, review in the morning". Polling get_change_set works too, and is simpler if the assistant is already running.