# Errors and limits

## How PorchQ answers when something is wrong

| HTTP | Where | Meaning | What to do |
|---|---|---|---|
| `400` | JSON-RPC `error` | The request breaks the protocol: a header that doesn't match the body, a missing `_meta` field, an unsupported version. | Fix the request; see [Protocol](/developers/mcp/protocol#errors). |
| `401` | `WWW-Authenticate` | No token, or one that isn't valid (`error="invalid_token"`). | Refresh, or sign the person in again. |
| `403` | `WWW-Authenticate` | The connection lacks a scope (`error="insufficient_scope"`), or a web page's `Origin` isn't allowed. | Ask for the scope named in the header. |
| `404` | JSON-RPC `error` | Unknown method, revision `2026-07-28`. | |
| `405` | `Allow: POST` | `GET` or `DELETE` on `/mcp`. | Use `POST`. |
| `429` | JSON body, `Retry-After` | Too many requests. | Wait the seconds in `Retry-After`. |
| `200` | `result.isError: true` | The tool ran into a problem it can explain. | Show the sentence to the person, or correct the arguments. |

### A tool call without a token

`tools/call` without a valid token is answered `401` with the `WWW-Authenticate` header and also a result that ChatGPT reads:

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "isError": true,
    "content": [{ "type": "text", "text": "Connect PorchQ via OAuth to run this tool." }],
    "_meta": { "mcp/www_authenticate": "Bearer realm=\"porchq\", resource_metadata=\"…\", scope=\"…\", error=\"invalid_token\", error_description=\"Connect PorchQ to use this tool.\"" },
    "resultType": "complete"
  }
}
```

A missing scope is answered the same way with `403` and `error="insufficient_scope"`.

## Tool errors

A tool that can't do what was asked answers HTTP `200` with `isError: true`, one sentence in `content`, and, for a refusal, `structuredContent.error` with a `code`:

```json
{
  "isError": true,
  "content": [{ "type": "text", "text": "You belong to more than one PorchQ community. Which one do you mean: Evergreen Estates or Sunset Hills?" }],
  "structuredContent": {
    "error": {
      "code": "property_required",
      "message": "…",
      "retryable": false,
      "communities": [
        { "property_id": "prop_web9z8y7x6w5v4u3t2s1r0q9p8o", "property_name": "Evergreen Estates" },
        { "property_id": "prop_web4k2m9q7x1v8c3n5b6z0a2s4d", "property_name": "Sunset Hills" }
      ]
    }
  }
}
```

| Code | Means |
|---|---|
| `property_required` | The connection reaches more than one community and the call named none. The sentence names them, so an assistant can ask the person as it is; `communities` has their ids. |
| `not_found` | The community, or the record the call names, isn't one this connection can see. |
| `forbidden_role` | The person's role there can't use this tool. |
| `conflict` | The change can't be made as asked. |
| `unavailable` | A service PorchQ depends on is down; `retryable` is `true`. |

These are the sentences to expect:

| Sentence | Why |
|---|---|
| This action is available only to owner and manager roles. | The person is a resident in that community (`forbidden_role`). |
| You belong to more than one PorchQ community. Which one do you mean: … | See `property_required` above. |
| Requested resource was not found. | The `property_id`, `event_id` or `meeting_note_id` doesn't exist, or isn't in a community this connection reaches. |
| No property membership found for this user. | The person has no role in any community the connection reaches. |
| Invalid tool arguments. Provide a valid JSON object. | `arguments` isn't a JSON object. |
| A validation message, such as "Title can't be blank" | PorchQ refused the change, for the same reason it would in the app. |
| Tool execution failed unexpectedly. Please retry. | Something failed on PorchQ's side. It is logged; retrying with the same `idempotency_key` is safe. |

A community the connection doesn't reach is answered exactly like one that doesn't exist, so an app can't learn which communities exist.

## Rate limits

PorchQ counts requests at its edge and answers `429` past these limits:

| What is counted | Limit | Per |
|---|---|---|
| Calls to `/mcp` per access token | 120 | minute |
| Calls to `/mcp` per access token | 5,000 | day |
| Calls to `/mcp` without a token, per address | 120 | minute |
| Token, revoke and introspect requests, per address | 300 | minute |
| Consent page loads, per address | 30 | minute |
| Client registrations, per address | 60 | hour |

ChatGPT, Claude and other assistants call PorchQ from a few shared server addresses, so `/mcp` is counted per access token, not per address.

A `429` looks like this:

```http
HTTP/2 429
Retry-After: 23
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1792188123
Content-Type: application/json

{"error":"Too many requests to PorchQ's MCP server — try again in a few seconds. Please slow down.","code":"rate_limited","retry_after":23}
```

## Limits on changes

Separately, each person can make at most **30 changes a minute** and **500 a day** through connected apps, all apps together. Reading is not counted. Past the limit, the tool answers (HTTP `200`):

```json
{
  "isError": true,
  "content": [{ "type": "text", "text": "Too many changes through connected apps for now. Try again in 41 seconds." }],
  "structuredContent": { "error": { "code": "rate_limited", "message": "…", "retryable": true, "retry_after_seconds": 41 } }
}
```

## Idempotency keys

Every tool that changes something accepts an optional `idempotency_key` argument: any string up to 200 characters. Use a new random value (a UUID) for each change the person asked for, and send the same value again when you retry it.

- A key is remembered per app and per person. A second call with a key already used does nothing and answers:

```json
{
  "content": [{ "type": "text", "text": "This request was already handled at 2026-11-02T18:04:11Z. Nothing was done again." }],
  "structuredContent": { "status": "duplicate", "original_call": { "id": "mcpcall_web…", "at": "2026-11-02T18:04:11Z", "outcome": "completed" } }
}
```

- Two copies of one request racing each other can't both act: one does, the other is answered as a duplicate.
- Without a key, every call acts. Retry a change without a key only when you know it didn't happen.

## The record of calls

PorchQ records every tool call: the person, the app, the tool, the community, its [effect](/developers/mcp/tools#effects), when it started and ended, how it ended (`completed`, `awaiting_confirmation`, `tool_error`, `refused`, `duplicate` or `rate_limited`), and the IP address the call came from. Free text is never kept: titles, descriptions, bodies and search words are stored as `[removed]`; only ids, settings and numbers are kept. See [Security and data](/developers/mcp/security).
