How it works
For developers building or debugging an MCP client against GPS - transport, OAuth, pagination, results, previews, idempotency, refusals and rate limits.
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.
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.
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:
{
"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_methodmust benone(or left out).- Up to 10
redirect_uris, and aclient_nameof up to 100 characters. - The answer's
client_idstarts withgpsc_.
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_idmust 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-Controlsays, between 5 minutes and 1 day (1 hour if it doesn't say).
Redirect URIs#
httpsto a public host, matched exactly: no case folding, no trailing-slash tolerance, no decoding.httpto a loopback address:127.0.0.1,[::1]orlocalhost. 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#
- The client sends the person to
/oauth/authorizewithresponse_type=code, itsclient_id, aredirect_uri,state, the scopes it wants,code_challengeandcode_challenge_method=S256, andresource=https://plan.empiraa.app/mcp(RFC 8707). - 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.
- The person signs in and answers GPS's consent screen, which lives for 10 minutes. See Connecting.
- GPS redirects back with
code,stateandiss(RFC 9207). Every response carriesiss, 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. - The client redeems the code at
/oauth/tokenwith itscode_verifierand the sameresource.
Rules worth knowing:
- PKCE is required, and S256 only.
resourcemust be this connector's URL. Tokens are bound to it.statemust 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.
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_iduses 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 ascandidates, 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.
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. openWorldHintis explicit on every tool. It'struefor the writes that reach people outside the conversation (the ones that email people) and forpreview_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.
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 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 asambiguous_reference, with up to a handful ofcandidates, each anidand alabel. Pass the chosen id. searchreturns ids in the form<type>:<id>, whichfetchopens.- 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.
{
"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 takeconcise(the default, the essentials) ordetailed(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.
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.
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.
Refusals#
A refusal is a tool result with isError: true, not a protocol error, so the model sees it and can recover:
{
"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.
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.

