---
title: How it works
description: For developers building or debugging an MCP client against GPS - transport, OAuth, pagination, results, previews, idempotency, refusals and rate limits.
product: gps
section: mcp
order: 3
---

# How it works

This page is for developers who build or debug an MCP client against GPS. If you only want to connect Claude or ChatGPT, see [Connecting](connecting.md).

## Endpoint and transport

| | |
|---|---|
| Connector URL | `https://plan.empiraa.app/mcp` |
| Transport | MCP streamable HTTP, stateless |
| Methods | `POST` only. `GET` and `DELETE` answer `405` with `Allow: POST`, because there's no session to open or close. |
| Responses | One JSON body per request. GPS doesn't stream (no server-sent events), and no tool sends progress notifications. |
| Authentication | `Authorization: Bearer <access token>` only. Cookies are never read. |
| Server info | name `empiraa-gps`, title `Empiraa GPS` |
| Capabilities | tools and prompts, plus resources when the connection is shown any tool with a widget |

GPS builds a fresh MCP server for each request, from the connection that made it, and answers statelessly. That holds for the current protocol revision and for clients still on the 2025 revisions.

The server sends no instructions block. What a model needs to know is in each tool's description, and in the [prompts](prompts-resources-widgets.md#prompts).

### Unauthenticated and refused requests

| Situation | Status | Body and headers |
|---|---|---|
| No token | `401` | `WWW-Authenticate: Bearer resource_metadata="https://plan.empiraa.app/.well-known/oauth-protected-resource/mcp", scope="gps:read gps:write offline_access"` |
| A token GPS doesn't accept (expired, revoked, for another resource) | `401` | the same challenge with `error="invalid_token"`, and `{ "error": "invalid_token", "error_description": "…" }` |
| The connection is paused | `403` | `{ "error": "connection_paused", "error_description": "…" }`. Its person resumes it in GPS, and the same tokens then work again. |

## Discovery

A client finds everything from the connector URL.

| Document | Paths |
|---|---|
| Protected resource metadata (RFC 9728) | `/.well-known/oauth-protected-resource/mcp` and `/.well-known/oauth-protected-resource` |
| Authorization server metadata (RFC 8414) | `/.well-known/oauth-authorization-server` and `/.well-known/oauth-authorization-server/mcp` |

The protected resource metadata says:

```json
{
  "resource": "https://plan.empiraa.app/mcp",
  "authorization_servers": ["https://plan.empiraa.app"],
  "scopes_supported": ["gps:read", "gps:write", "offline_access"],
  "bearer_methods_supported": ["header"],
  "resource_name": "Empiraa GPS"
}
```

The authorization server metadata says:

| Field | Value |
|---|---|
| `issuer` | `https://plan.empiraa.app` |
| `authorization_endpoint` | `https://plan.empiraa.app/oauth/authorize` |
| `token_endpoint` | `https://plan.empiraa.app/oauth/token` |
| `registration_endpoint` | `https://plan.empiraa.app/oauth/register` |
| `revocation_endpoint` | `https://plan.empiraa.app/oauth/revoke` |
| `response_types_supported` | `["code"]` |
| `grant_types_supported` | `["authorization_code", "refresh_token"]` |
| `token_endpoint_auth_methods_supported` | `["none"]` |
| `revocation_endpoint_auth_methods_supported` | `["none"]` |
| `code_challenge_methods_supported` | `["S256"]` |
| `scopes_supported` | `["gps:read", "gps:write", "offline_access"]` |
| `client_id_metadata_document_supported` | `true` |
| `authorization_response_iss_parameter_supported` | `true` |

GPS publishes no OpenID Connect discovery document. Other discovery paths a client may probe, such as `/.well-known/openid-configuration`, answer a JSON `404`.

## Registering a client

GPS accepts **public clients only**: there are no client secrets, and every client uses PKCE. A client identifies itself in one of two ways.

**Dynamic client registration** (RFC 7591): `POST /oauth/register` with a JSON body.

- `token_endpoint_auth_method` must be `none` (or left out).
- Up to 10 `redirect_uris`, and a `client_name` of up to 100 characters.
- The answer's `client_id` starts with `gpsc_`.

**A client ID metadata document:** use an `https` URL as the `client_id`, and GPS fetches the document there instead of asking you to register.

- The document must name itself: its `client_id` must be the URL it's served from.
- It's fetched with no redirects, within 3 seconds, and must be at most 5 KiB.
- Same limits as registration: up to 10 redirect URIs, a name of up to 100 characters, and public clients only. A document that declares a secret is refused, not downgraded.
- GPS caches it for as long as its `Cache-Control` says, between 5 minutes and 1 day (1 hour if it doesn't say).

### Redirect URIs

- `https` to a public host, matched exactly: no case folding, no trailing-slash tolerance, no decoding.
- `http` to a loopback address: `127.0.0.1`, `[::1]` or `localhost`. For these, any port is accepted at sign-in, as native apps need (RFC 8252).
- A private-use scheme in reverse-DNS form, such as `com.example.app:/callback`.
- At most 2,048 characters each.

GPS badges a client **Verified** on its consent screen only for Claude or ChatGPT, and only from where the code goes: every redirect URI must be the brand's own, or a client ID metadata document must be on the brand's own domain. What a client calls itself never earns the badge.

## The authorization flow

1. The client sends the person to `/oauth/authorize` with `response_type=code`, its `client_id`, a `redirect_uri`, `state`, the scopes it wants, `code_challenge` and `code_challenge_method=S256`, and `resource=https://plan.empiraa.app/mcp` (RFC 8707).
2. GPS checks the client and the redirect URI before redirecting anywhere. Until both pass, an error is shown as a page in GPS, never sent to the redirect URI.
3. The person signs in and answers GPS's consent screen, which lives for 10 minutes. See [Connecting](connecting.md#approving-the-connection).
4. GPS redirects back with `code`, `state` and `iss` (RFC 9207). Every response carries `iss`, success and error alike. An error goes back to the client automatically only for a verified brand or a loopback client. For any other client the person gets a page with a link back, so GPS can't be used to bounce visitors to an arbitrary site.
5. The client redeems the code at `/oauth/token` with its `code_verifier` and the same `resource`.

Rules worth knowing:

- **PKCE is required, and S256 only.**
- **`resource` must be this connector's URL.** Tokens are bound to it.
- **`state`** must be printable ASCII.
- **Codes live 60 seconds and are redeemed once.** A code presented a second time revokes every token it produced, because a broken client and an intercepted code can't be told apart.

### Scopes

| Scope | What it allows |
|---|---|
| `gps:read` | The tools that read. Every connection has it. |
| `gps:write` | The tools that preview and make changes. Granted only when the person chooses **View and make changes**. |
| `offline_access` | Refresh tokens. Without it, the connection gets no refresh token. |

A token's scopes are checked against its connection on every call, so narrowing a connection to View only in GPS takes effect on the very next call.

A View only connection that calls a tool that previews or makes changes gets the refusal `insufficient_scope`. The tool result carries a `WWW-Authenticate` challenge in its `_meta` (`mcp/www_authenticate`), which clients such as ChatGPT use to offer the person a reconnect with more access.

<a id="tokens"></a>
### Tokens

| | Lifetime | Notes |
|---|---|---|
| Consent request | 10 minutes | Long enough to sign in, including single sign-on, and read the screen |
| Authorization code | 60 seconds | Single use |
| Access token | 1 hour | Opaque, not a JWT. GPS stores only its SHA-256 hash. |
| Refresh token | 30 days idle | Rotates on every use, and each rotation restarts the 30 days, so a connection in use never lapses |

Refresh token rotation:

- Each refresh returns a new access token and a new refresh token, with the scopes the original grant had (RFC 6749 §6).
- A retry by the same client within 30 seconds, before anything it was rotated into has been used, gets a sibling pair. That covers a lost response, or two refreshes sent at once.
- Any other reuse of a rotated refresh token revokes the whole family.

### Revocation

- `POST /oauth/revoke` (RFC 7009), with no client authentication, revokes a token.
- **Disconnect**, in the person's Connected apps in GPS, revokes the connection and every token at once.
- Changing a GPS password revokes every connection the person has.
- A paused connection keeps its tokens. They work again once it's resumed.

### OAuth endpoint limits

These are the current defaults, and they can change. A refusal is `429` with `Retry-After`. At `/oauth/authorize`, which is a page, the refusal is a redirect to a GPS error page instead.

| Endpoint | Limit |
|---|---|
| `/oauth/token` | 60 a minute per client per address, and 300 a minute per address |
| `/oauth/authorize` | 60 a minute per address |
| `/oauth/register` | 10 an hour per address and 200 a day in total. Registrations whose redirect URIs all belong to Claude or ChatGPT count against that brand per network instead (600 an hour). |
| `/oauth/revoke` | 300 a minute per address |

## Organizations

A connection reaches every organization its person can open in GPS, including myPortal client accounts. Each call acts in one of them:

- a tool that takes `organization_id` uses it;
- a tool given an item (an objective, a meeting) acts in the item's own organization;
- otherwise the connection's default organization, if the person can still reach it;
- otherwise the person's only organization, if they have one. With more than one, the call is refused as `ambiguous_reference`, with the organizations as `candidates`, so the assistant can ask which one.

`list_organizations` lists them, each with its id, the person's role, whether it's a client account reached through myPortal, and its AI switch (`full`, `read` or `off`). The switch is checked on every call. See [Admin controls](admin-controls.md).

## Tools

Each tool has a kind, and its MCP annotations come from that kind. They're never set by hand.

| Kind | Example | `readOnlyHint` | `destructiveHint` | `idempotentHint` |
|---|---|---|---|---|
| read | `get_plan_scorecard` | `true` | `false` | `true` |
| preview (a read) | `preview_actions_batch` | `true` | `false` | `true` |
| adds | `create_action` | `false` | `false` | `false` |
| replaces | `update_action` | `false` | `true` | `true` |
| check-in | `record_objective_progress` | `false` | `true` | `false` |
| removes | `remove_empty_position` | `false` | `true` | `true` |

- Every tool has a `title`.
- `openWorldHint` is explicit on every tool. It's `true` for the writes that reach people outside the conversation (the ones that email people) and for `preview_objective_sync`, which reads a connected account live and so doesn't claim to be read-only.
- Tool names are snake_case and at most 64 characters, and never change. See [Versioning](versioning.md).

A connection is listed only the tools it could use: View only connections never see the tools that preview or make changes, and the myPortal tools are listed only to consultants who run client accounts in myPortal. The [tool reference](tools.md) says who sees each tool.

### References and ids

- Most tools take an item by its id or its title, for example `objective: "Monthly revenue"`. A title that matches more than one item is refused as `ambiguous_reference`, with up to a handful of `candidates`, each an `id` and a `label`. Pass the chosen id.
- `search` returns ids in the form `<type>:<id>`, which `fetch` opens.
- People are named as `me`, a name, an email or an id.

## Results

Every tool result carries its data twice: as `structuredContent`, which the tool's output schema describes and widgets render, and as the same JSON in a `text` content item, which a model reads.

```json
{
  "content": [{ "type": "text", "text": "{\"organization_id\":\"…\",\"objectives\":[…],\"total_matching\":42,\"next_cursor\":\"…\",\"truncated\":true}" }],
  "structuredContent": { "organization_id": "…", "objectives": [], "total_matching": 42, "next_cursor": "…", "truncated": true }
}
```

- **`response_format`:** many reads take `concise` (the default, the essentials) or `detailed` (descriptions and every field).
- **Size:** a result is at most about 90,000 characters of JSON, about 25,000 tokens. A read whose answer would be longer is refused as `too_large`, with a hint saying how to ask for less. A write is never refused for size, because its change is already made.
- **Links:** results carry links that open GPS at the item. They never carry a stored file's address: file tools return a file's name, type and date only, never its contents.
- **Text people wrote** is returned as data. Tool descriptions say which fields hold it. See [Safety and privacy](safety.md#text-people-wrote).

### Pagination

List tools take `limit` (1 to 100, default 25) and `cursor`. A page answers:

| Field | Meaning |
|---|---|
| `total_matching` | How many match in all, across every page |
| `next_cursor` | Pass it back as `cursor` for the next page. `null` on the last page. |
| `truncated` | `true` when this answer doesn't hold everything that matched |
| `hint` | How to see the rest, when truncated, such as a narrower filter |

A cursor is opaque but not secret. It's bound to the tool, the organization and the filters it was issued for, so reusing it with other filters, another organization or another tool is refused (`invalid_input`) rather than returning a page of something else. Repeat the call without `cursor` to start again. A cursor is at most 512 characters.

### Widget results

Six reads return a versioned result that an in-chat view renders, such as `get_plan_scorecard`, whose result carries `"contract": "gps.scorecard/1"`. The tool's `_meta.ui.resourceUri` (and the flat `_meta["ui/resourceUri"]` for older hosts) names its view. See [Prompts, resources and widgets](prompts-resources-widgets.md#widgets).

## Previews and preview tokens

Six changes are previewed before they're made:

| Preview tool | Applied by |
|---|---|
| `preview_progress_batch` | `record_progress_batch` |
| `preview_actions_batch` | `create_actions_batch` |
| `preview_org_chart_positions` | `create_org_chart_positions` |
| `preview_timesheet_entries` | `fill_timesheet` |
| `preview_strategy_template` | `apply_strategy_template` |
| `preview_objective_sync` | `set_objective_sync` |

A preview is a read: it checks every item as the person, without changing anything, and returns what would happen with a `preview_token`. The write tool takes that token, not the items again, so what's applied is exactly what was previewed.

- A token looks like `gps_pv_` followed by 32 characters.
- It lasts **15 minutes** and is used **once**.
- It's bound to the connection, the organization and the one tool that applies it. Presented by anything else, it's refused, and not used up.
- If any item's target has changed since the preview, the whole apply is refused as `conflict`, with what changed, before anything is written. Preview again.
- Items apply in order, and the first one that can't apply stops the run. The answer reports every item: applied, skipped, refused in the preview, failed or not attempted.
- A token that another call already used is refused as `conflict`, naming that call.

`record_progress_batch` and `create_actions_batch` also take `skip`: the indexes of previewed items to leave out.

## Retries and idempotency

Retries are normal: a client's transport retries after a timeout, and a model retries after an answer it found unclear. So GPS keys every call that **adds** something (a new item, a check-in that adds, a batch apply):

- An identical add from the same connection, in the same organization, within **10 minutes**, is answered with the first call's result, marked as a replay, instead of being made twice.
- A duplicate that arrives while the first is still running is told an identical request is still being carried out, and to check before asking again.
- Changes that **set** or **replace** a value aren't keyed, because a replay would answer with a value someone may have changed since.

Check-ins carry their own guard: a record from another system (a paid invoice, a won deal) is sent with that system's id for it, and GPS adds each id only once, however often it's sent.

<a id="refusals"></a>
## Refusals

A refusal is a tool result with `isError: true`, not a protocol error, so the model sees it and can recover:

```json
{
  "error": {
    "code": "ambiguous_reference",
    "message": "More than one objective is called \"Revenue\".",
    "hint": "Ask which one, then pass its id.",
    "candidates": [{ "id": "…", "label": "Revenue (Growth goal)" }]
  }
}
```

`message` is one sentence a model can act on. `hint` says what to do instead, `fields` names per-input problems by their input names, and `candidates` comes with `ambiguous_reference`. The example's wording is illustrative.

| Code | Meaning |
|---|---|
| `invalid_input` | An input is missing, malformed or out of range, or GPS's own rules refuse the value |
| `not_found` | No such item, or one the person can't open (the two look the same) |
| `forbidden` | The person's role doesn't allow it |
| `no_seat` | The person has no seat in that organization to act from |
| `conflict` | The item changed, is locked, or the request clashes with what's there |
| `ambiguous_reference` | The name matches more than one item. Pick one of `candidates`. |
| `module_disabled` | The organization's GPS account doesn't include that area |
| `org_read_only` | The organization lets assistants view GPS but not make changes |
| `org_disabled` | The organization has turned assistants off |
| `rate_limited` | Too many calls. The hint says how many seconds to wait. |
| `connection_paused` | GPS paused the connection on this call. Its person resumes it in GPS. |
| `insufficient_scope` | A View only connection tried to preview or make a change |
| `too_large` | The answer is too long for one result. Ask for less. |
| `internal` | Something went wrong in GPS. The answer has a reference, never the error. |

Error codes are part of the contract and follow the [versioning rules](versioning.md).

<a id="rate-limits"></a>
## Rate limits

Tool calls are limited per connection and per organization. These are the current defaults, and they can change.

| Limit | Default |
|---|---|
| Calls per connection | 120 a minute |
| Changes per connection | 30 a minute |
| Changes per organization, across all connections | 600 an hour |

A change counts each item it makes: a 25-item batch is 25 changes, in one call. A call over a limit is refused as `rate_limited`, with how many seconds to wait.

Beyond the rate limits, GPS pauses a connection that behaves like a runaway. See [Automatic pause](safety.md#automatic-pause).
