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:

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.
  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.

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.

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.

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 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.

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:

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.

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.