Skip to content

token list: no last-used signal; derivable from /v2/storage/tokens/{id}/events but no CLI path #622

Description

@vojtechnovotny-heu

Context

kbagent token list answers "what tokens exist" but not "which of them are still in use". There is no last-used signal at any scope today, and the gap is not a CLI omission — the Storage API's token payloads genuinely do not carry one.

kbagent docs query against the Storage + Manage specs confirms the split:

Response Has lastUsed?
TokenVerifyResponse, TokenListResponse, TokenCreateResponse, TokenRefreshResponse no
ManageTokenVerifyResponse (Manage API / PAT) yes — ISO 8601 +HHMM, null if never used

So for project-scoped Storage tokens the value has to be derived. It is derivable: GET /v2/storage/tokens/{id}/events returns that token's event feed, and its newest entry is an effective last-used timestamp.

This issue asks for that derivation to live behind an opt-in flag on token list, because the raw endpoint has three sharp edges (below) that every caller would otherwise have to rediscover.

Why it matters

Token hygiene on a shared project is currently unauditable. On one project in our fleet, token list returns 25 tokens — a mix of per-user master tokens, MCP tokens, kbagent device tokens and [_internal] Token for triggering … orchestration tokens. Nothing in the output distinguishes a token used four minutes ago from one last used five months ago, so the questions you actually want to answer before revoking anything —

  • which tokens are dormant and safe to token delete?
  • which were minted and then never used at all (mis-provisioned, or a onboarding step that silently failed)?
  • who is still actively hitting this project, and with what kind of token?

— all require the web UI, one token at a time. #34 established that token identity on a shared project matters; this is the same hygiene problem one step later in the lifecycle.

Deriving it by hand is ~20 lines of Python plus two non-obvious correctness rules, which is exactly the shape of thing that belongs in the CLI rather than in everyone's local scratch script.

Current state (verified on d8f7a7b (v0.86.0), live API, europe-west3.gcp)

No path exists in-tree. rg lastUsed src/ → 0 matches; no client method touches /tokens/{id}/events:

  • client/tokens.py::list_tokens() (L162) returns GET /v2/storage/tokens verbatim, and its own docstring enumerates the available fields — id, description, created, expires, isExpired, isMasterToken, the can* grants, bucketPermissions, creatorToken. No usage data among them.
  • commands/token.py L86–91 renders a fixed 6-column table (ID, Description, Created, Expires, Master, Created by) with no column-selection flag.
  • permissions.py L64: "token.list": "read".
  • server/routers/token.py L35: GET /{project}/list already exists.

sortOrder is honoured on the events endpoint, though it is undocumented there. The public spec lists only limit, offset, sinceId, maxId, component for /tokens/{id}/events. Testing asc-vs-desc (rather than just checking for a 200) shows it is really applied, and that desc is already the default:

GET /v2/storage/tokens/{id}/events?limit=1                    -> 2026-08-20T09:13:33  storage.tablesListed
GET /v2/storage/tokens/{id}/events?limit=1&sortOrder=desc     -> 2026-08-20T09:13:33  storage.tablesListed   (identical)
GET /v2/storage/tokens/{id}/events?limit=1&sortOrder=asc      -> 2026-08-10T15:30:23  storage.tokenCreated

Reads do emit events (storage.tablesListed, storage.tableDetail, storage.bucketsListed, ext.keboola.mcp-server-tool.*), so coverage across token types is good. A full sweep over all 25 tokens produced a usable recency ranking, from 09:54:23 today down to one token with no events at all.

Three sharp edges the CLI should absorb

1. The feed mixes actions by the token with changes to the token. A freshly minted, never-used token's newest event is its own storage.tokenCreated, so a naive events[0].created reports it as "used today". Disambiguate on objectId. Verified both directions on the same event:

Feed being read event objectId token.id (who acted) Meaning
token 7414128 storage.tokenCreated 7414128 7257569 about itself — never used
token 7257569 storage.tokenCreated 7414128 7257569 action by it — it minted the other token

Rule: ignore events where objectId == <the token's own id> and event starts with storage.token. Applying it to 7414128 leaves 0 real uses — created, never used — which is the state you most want the audit to surface, and the one a naive read gets exactly backwards.

2. Retention is 6 months (per the token-detail Events tab docs). "Unused for >6 months" and "never used" both come back []. These should not collapse into the same output — see open questions.

3. Events carry uuid, not id. "id" in event is False; the key is uuid (e.g. 01a01e1f-5c53-725a-9fe7-56945f67487a). The documented sinceId/maxId params therefore have nothing in the payload to pair with. Harmless at limit=1, a trap if anyone later pages this feed.

Separately, and upstream rather than CLI: sortOrder=garbage returns HTTP 500 ({"error":"Application error.","exceptionId":"…"}), not 400, while limit=abc is silently accepted (200). The param is parsed but unvalidated. Worth stating explicitly if the CLI is going to depend on it; I can file that with the Storage API team separately.

Proposal

Add an opt-in --with-last-used flag to token list.

kbagent --json token list --project <alias> --with-last-used
  • Opt-in, never default. It is N+1 requests — 25 tokens on the project above means 25 extra calls. Fanning out via the existing max_parallel_workers keeps it acceptable, but it must not tax the plain token list path.
  • JSON: add lastUsed (ISO 8601, or null) and lastUsedEvent (the event name, useful for telling ext.keboola.mcp-server-tool.* traffic from storage.* traffic) per token. Absent entirely without the flag, so the default shape is unchanged.
  • Human mode: one extra Last used column when the flag is set.
  • Sort by recency under the flag — dormant-first (or --sort if that is preferred); the whole point is scanning for staleness, and creation order does not serve that.

Secondary, and separable if you would rather keep this issue single-purpose: the fixed 6-column table has no way to reach refreshed, which --json does return. A --columns flag (or simply adding Refreshed) would close that. Terminal width is already handleable via COLUMNS=200 kbagent token list, so this is only about column selection, not truncation.

Implementation notes

Per the 3-layer boundaries and the new-command checklist:

  • Client (client/tokens.py): new list_token_events(token_id, limit=1)GET /v2/storage/tokens/{id}/events, quote(..., safe='') on the id like the neighbouring delete_token/refresh_token. Endpoint path into constants.py if reused.
  • Service (services/token_service.py): the parallel fan-out, the objectId self-lifecycle filter, and the []null mapping. This is the business logic and none of it belongs in the command.
  • Command (commands/token.py): the Typer flag, the extra column, formatter.json_mode branch. Thin.
  • Permissions: token.list stays read — the flag adds no side effects, so no new OPERATION_REGISTRY entry, only a re-check that the existing one still covers it.
  • REST (server/routers/token.py): GET /{project}/list gains a matching with_last_used query param, per the 1:1 convention.
  • Tests: service-layer (mock the client — cover self-lifecycle-only, empty feed, mixed feed), CLI-layer (CliRunner, JSON shape + default-shape-unchanged), and an E2E case in tests/test_e2e.py.
  • Docs: AGENT_CONTEXT in commands/context.py, CLAUDE.md command list, make skill-gen, plus the hand-maintained plugin surfaces — references/commands-reference.md and a references/gotchas.md entry tagged (since vX.Y.Z) for the objectId rule and the 6-month retention, since both are things an agent will otherwise get wrong by default.

Happy to open a PR for this if the shape looks right.

Open questions

  1. null vs "unknown". Should a token with [] events report lastUsed: null, or distinguish "never used" from "outside the 6-month window"? created is available to disambiguate: created inside the retention window + no events ⇒ genuinely never used; created older than the window ⇒ unknown. Encoding that as two states seems more honest than one null, but it is a shape decision.
  2. Depending on an undocumented param. Given desc is already the default, the CLI could simply omit sortOrder and rely on default ordering — fewer moving parts, no dependency on an unspecified behaviour that 500s on bad input. Preference?
  3. Master-token scope. A non-master token carrying canManageTokens was sufficient to read events for other tokens, including master ones. Worth asserting in the E2E test so a future API-side permission tightening surfaces as a test failure rather than as silently empty columns?
  4. Is token list the right home, or would this be better as a separate token audit / token events command? The flag keeps the surface small; a dedicated command would give room for --stale-since, --never-used and similar filters.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions