# Tokens API
URL: https://openship.io/docs/api/tokens.md

Mint, list, and revoke personal access tokens, and manage the MCP clients authorized against your account.

import { TypeTable } from 'fumadocs-ui/components/type-table';

The Tokens API manages the credentials that authenticate you to Openship: **personal access tokens** (PATs)
for the CLI and direct API calls, and the **MCP client** bindings created when an AI assistant connects over
OAuth. In the dashboard this is the **Settings → Access** area; from the terminal it's the
[`openship token`](/docs/cli/access) command. Every route is self-scoped — you only ever see and manage your
own tokens and clients.

<Callout title="Base path & auth">
All paths are relative to your instance, under **`/api`** — e.g. `https://your-host/api/tokens`.
Send a personal access token as a bearer header (`Authorization: Bearer <token>`), created with
[`openship token create`](/docs/cli/access). The dashboard uses your session cookie instead. See the
[API overview](/docs/api) and the [auth model](/docs/security/auth) for details.
</Callout>

## Endpoints

| Method & path | Permission | What it does |
|---|---|---|
| `GET /api/tokens` | `settings:read` | List your own tokens (secrets never included). |
| `POST /api/tokens` | `settings:write` | Mint a token — returns the plaintext secret once. |
| `DELETE /api/tokens/:id` | `settings:write` | Revoke one of your tokens. |
| `POST /api/tokens/mcp-authorize` | `settings:write` | Record what an OAuth MCP client may access (consent step). |
| `GET /api/tokens/mcp-clients` | `settings:read` | List your connected MCP clients. |
| `DELETE /api/tokens/mcp-clients/:clientId` | `settings:write` | Disconnect an MCP client and revoke its issued tokens. |

The `settings:read` / `settings:write` gate means any organization member can manage their own tokens; the
handlers then scope every operation to the calling user, so members never see each other's credentials.

## Create a token

Mints a new PAT and returns the plaintext secret in `data.token` **exactly once** — it is hashed at rest and
can never be retrieved again. List responses only ever show the short `tokenPrefix`.

```
POST /api/tokens
```

<TypeTable
  type={{
    name: { type: 'string', description: 'Human label for the token (1–100 chars).', required: true },
    readOnly: { type: 'boolean', description: 'Read-only tokens reject mutation methods (POST/PUT/PATCH/DELETE).', default: 'false' },
    expiresInDays: { type: 'integer', description: 'Expire the token N days from now (1–365). Omit for a non-expiring token.' },
    grants: { type: 'TokenGrant[]', description: 'Optional resource scope. When present and non-empty, the token is restricted to exactly these resources — even below the minter’s own role. Each grant must be within the minter’s own access, or the request is rejected.' },
  }}
/>

Each entry in `grants` is a **TokenGrant**:

<TypeTable
  type={{
    resourceType: { type: 'string', description: 'One of: project, server, mail_server, backup_destination, billing, audit, github_installation, github_repository.', required: true },
    resourceId: { type: 'string', description: 'The resource id. For github_repository use "owner/repo".', required: true },
    permissions: { type: '("read" | "write" | "admin" | "create")[]', description: 'Actions the token may take on the resource. "create" is a collection-only capability — see the "projects it creates" scope below.', required: true },
  }}
/>

```bash
curl -X POST https://your-host/api/tokens \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"deploy-bot","readOnly":false,"grants":[{"resourceType":"project","resourceId":"proj_123","permissions":["read","write"]}]}'
```

```bash
# Same thing from the CLI:
openship token create "deploy-bot" --grant project:proj_123:read,write
```

<Callout title="Copy the secret immediately" type="warn">
The response's `data.token` (an `opsh_pat_…` string) is shown once and never again. `GET /api/tokens` only
returns the prefix. If you lose it, revoke the token and mint a new one.
</Callout>

<Callout title="A token can never exceed its minter" type="info">
Every grant is validated against your own access before the token is created. An unknown `resourceType`
returns `400 INVALID_RESOURCE_TYPE`; granting access you don't hold yourself returns `403 GRANT_EXCEEDS_ACCESS`.
</Callout>

### The "projects it creates" scope

To hand an agent (e.g. an MCP client) the ability to **create new projects and fully manage only the ones
it creates** — with zero visibility into anything else — give it a single `create` grant on the project
collection:

```json
{ "name": "agent", "grants": [{ "resourceType": "project", "resourceId": "*", "permissions": ["create"] }] }
```

`create` is collection-only: it authorizes `POST /api/projects` and lets the token **list** projects
(filtered to its own), but grants **no** read/write/admin on any existing project. Each project the token
creates auto-adds a `{ project, <id>, [read, write, admin] }` grant, so it fully controls its own work and
nothing else. In the dashboard this is the **"Only projects it creates"** option on the MCP authorize screen.

## List tokens

Returns your tokens with no secret material — each row carries `id`, `name`, `tokenPrefix`, `readOnly`,
`scoped`, `expiresAt`, `lastUsedAt`, `revokedAt`, and `createdAt`.

```
GET /api/tokens
```

```bash
openship token list
```

## Revoke a token

```
DELETE /api/tokens/:id
```

```bash
openship token revoke tok_abc123
```

<Callout title="404 — token not found" type="error">
The `:id` isn't one of your own tokens (or was already removed). List your tokens with `GET /api/tokens`
to get valid IDs.
</Callout>

## MCP clients

When an AI assistant connects to Openship over OAuth, the consent page records what that client is allowed to
do, then Openship issues it scoped access tokens that run through the **same** scoped-principal path as a PAT.
These routes back that flow and the **Settings → Access → Connected clients** list.

### Authorize a client

Called by the OAuth consent page (browser cookie session) before a token is issued. It persists the user's
chosen read-only flag and resource grants as the client's binding. Grants are validated `⊆` your own access,
identical to token creation. Re-authorizing overwrites the binding's grants wholesale.

```
POST /api/tokens/mcp-authorize
```

<TypeTable
  type={{
    clientId: { type: 'string', description: 'The OAuth client to bind. Required.', required: true },
    readOnly: { type: 'boolean', description: 'Confine the client to read-only access.', default: 'false' },
    organizationId: { type: 'string', description: 'Organization the client is confined to. Defaults to the active org; a different org must be one you belong to.' },
    grants: { type: 'TokenGrant[]', description: 'Optional resource scope, same shape as token grants. Empty means full (unscoped) access within the org.' },
  }}
/>

<Callout title="Consent errors" type="warn">
Missing `clientId` returns `400 CLIENT_ID_REQUIRED`; an `organizationId` you're not a member of returns
`403 ORG_NOT_A_MEMBER`. Invalid or over-broad grants fail the same way as token creation
(`400 INVALID_RESOURCE_TYPE` / `403 GRANT_EXCEEDS_ACCESS`).
</Callout>

### List connected clients

```
GET /api/tokens/mcp-clients
```

Returns one entry per OAuth binding with `clientId`, `name`, `organizationId`, `organizationName`,
`readOnly`, `scoped`, `grantCount`, `authorizedAt`, and `lastUsedAt`.

```bash
openship api /tokens/mcp-clients
```

### Disconnect a client

Revokes the client's issued tokens first (stopping it immediately), then drops the scope binding, its grants,
and the recorded consent so a reconnect re-prompts. Scoped to you — a client shared across users keeps working
for everyone else.

```
DELETE /api/tokens/mcp-clients/:clientId
```

```bash
openship api /tokens/mcp-clients/<clientId> -X DELETE
```
