Metadata-Version: 2.4
Name: pursers-central
Version: 0.1.8
Summary: Pursers Central: the multi-board MCP service that owns board state
License-Expression: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp[cli]==2.2.0
Requires-Dist: pursers-client==0.1.9
Requires-Dist: PyJWT[crypto]<3,>=2.10
Requires-Dist: uvicorn<1,>=0.30
Dynamic: license-file

# Pursers Central

<!-- mcp-name: io.github.swisspra/pursers -->

## Quickstart

Install the packaged service, create private local credentials, and start it:

```bash
python -m pip install pursers-central
pursers-central init ./pursers-local
pursers-central run ./pursers-local
```

`init` creates a signing key, JWKS, admin token, board-bound worker token, and
`profile.env` with mode `0600`; it prints paths, never credential values. It
refuses to replace credential files unless you pass `--force`. Configure a
Streamable HTTP MCP client for `http://127.0.0.1:8766/mcp`, loading its Bearer
token from `./pursers-local/worker.jwt`, and call `board_onboard` with board
`pursers-local`, a new agent name, and role `worker`. Use `admin.jwt` for the
first connection so that it creates the local board before worker onboarding.

`init --force` is a hard cutover: every token signed by the previous issuer key
stops working immediately. For overlapping keys and uninterrupted clients,
follow the [issuer-key rotation manual](https://github.com/swisspra/Pursers/blob/main/docs/operations/issuer-key-rotation.md)
and use `pursers-central rotate-key` followed by `retire-key`.

Pursers Central is the loopback MCP service that owns board state. Run it with
the console script or the equivalent Python module:

```bash
export CENTRAL_JWT_ISSUER='https://issuer.example'
export CENTRAL_JWT_AUDIENCE='http://127.0.0.1:8766/mcp'
export CENTRAL_JWKS_PATH=/PATH/TO/private/credential.jwks.json

pursers-central \
  --host 127.0.0.1 \
  --port 8766 \
  --data-dir /PATH/TO/private/central-data \
  --log-level info
```

`python -m pursers_central` accepts the same arguments. The runtime also reads
`ONBOARD_CENTRAL_HOST`, `ONBOARD_CENTRAL_PORT`,
`ONBOARD_CENTRAL_DATA_DIR`, and `ONBOARD_CENTRAL_LOG_LEVEL`; command-line
arguments override those environment defaults.

At startup the runtime selects JWT authentication, the SQLite store, and
invite-only admission. `CENTRAL_JWT_ISSUER`, `CENTRAL_JWT_AUDIENCE`, and
`CENTRAL_JWKS_PATH` must describe the credential issuer used by this Central
instance. Keep the JWKS and data directory private.

Modern MCP multi-round trips seal `requestState` with a restart-safe keyring.
Central creates `request-state.keys` in its private data directory with mode
0600, or reads the path in `CENTRAL_REQUEST_STATE_KEY_FILE`. Each non-empty
line is one key of at least 32 bytes: the first key seals and every key
unseals. For zero-downtime rotation, fully deploy `[OLD, NEW]`, then
`[NEW, OLD]`, then remove `OLD` one 3600-second request-state TTL after the
second phase is fully deployed. Never store the keyring in the repository.

The startup banner prints the MCP bind URL, data directory, and health URL.
Check the service without a credential:

```bash
curl --fail --silent http://127.0.0.1:8766/healthz
```

A healthy response has `"status":"ok"` and `"store_backend":"sqlite"`.

Exact-value `board_state_update` calls from coordinator/intake principals are
idempotent storage no-ops. Central still verifies membership and any supplied
CAS precondition, but preserves the existing entry metadata and board version
instead of copying and encoding the complete board document. Worker writes keep
the ordinary path because they may renew an active work or review lease.

## Serving other machines

Central binds loopback by default. To serve agents on other machines, for
example over a Tailscale tailnet, supply a TLS certificate and key and allow
the exact host name clients will use:

```bash
pursers-central \
  --host 0.0.0.0 \
  --port 8766 \
  --data-dir /PATH/TO/private/central-data \
  --tls-certfile /PATH/TO/private/central.crt \
  --tls-keyfile /PATH/TO/private/central.key \
  --allowed-host central.example.ts.net
```

`--allowed-host` may be repeated, or set as the comma-separated
`ONBOARD_CENTRAL_ALLOWED_HOSTS`; the TLS paths may also come from
`ONBOARD_CENTRAL_TLS_CERTFILE` and `ONBOARD_CENTRAL_TLS_KEYFILE`. The
certificate and key must be supplied together. Remote clients connect to
`https://central.example.ts.net:8766/mcp` with tokens issued for the configured
`CENTRAL_JWT_AUDIENCE`; clients that do not already trust the certificate's
issuer need its CA file.

## Tool response views

Central keeps complete tickets, memories, and journal events in its SQLite
ledger, but its default model-facing response view is `compact`. Successful
calls to `ticket_update`, `ticket_progress_update`, `ticket_annotate`,
`ticket_claim`, `ticket_unclaim`, `lease_renew`, `memory_write`, and
`memory_checkpoint` return a small mutation receipt instead of repeating the
complete affected record and its histories.
Use the corresponding read tool, such as `ticket_get` or `memory_read`, when the
complete current state is needed.

Ticket mutation receipts contain `ok`, `ticket_id`, current `status` and
`parked` state, board `generation`, a bounded `dispatch_state`, the last
`revoked_offer` when applicable, and `at`; annotation receipts also contain
`annotation_id`. Lease renewal receipts contain `ok`, `ticket_id`,
`lease_expires_at`, and `at`. Memory write and checkpoint receipts contain
`ok`, `memory_id`, `scope`, `generation`, and `at`. Progress receipts contain
`ok`, `ticket_id`, `attempt`, `revision`, `fresh_until`, and `at`. Unsuccessful
structured results retain their error details instead of being projected as
successful receipts. Routing-only
`recipient_identities` fields are never included in MCP responses, including
when the full response view is selected. They remain in the durable journal so
Central can authorize and filter events before returning them.

A board administrator can restore the legacy response shape for compatibility:

```text
board_response_view_set(
  board_id="BOARD_ID",
  agent_name="ADMIN_AGENT_NAME",
  response_view="full"
)
```

Set `response_view="compact"` to restore the default. The setting is durable and
reported by `board_list`, `board_snapshot`, and `board_status`.

Structured schema-v2 checkpoint and handoff memories are returned through their
named fields (`summary`, `remaining_tasks`, `next_steps`, `files`, `blockers`,
and `warnings` as applicable). Their legacy rendered `content` copy remains in
storage but is omitted from memory read projections, so the same information is
not sent twice.

## Ticket and retained-history pagination

`ticket_list` keeps its existing defaults and response keys. It also accepts an
optional opaque `cursor` and returns `next_cursor`, `has_more`,
`returned_count`, `read_consistency`, and `watermark`. Continue with the exact
same filters, view, archive setting, and authenticated principal. A changed
filter or caller deliberately invalidates the cursor; restart without a cursor
instead of treating that error as an empty page. Only `next_cursor=null` marks
the end of that traversal.

The order is priority followed by immutable ticket ID. Reads are live rather
than snapshot-isolated: priority, status, or archive changes between pages may
move a ticket across the cursor boundary. Consumers should deduplicate ticket
IDs and restart after relevant journal events. On an unchanged board, following
the cursor to the end returns every matching ID exactly once. A bounded page or
truncated board snapshot is never proof that no matching work exists.

`ticket_history_list` pages one named retained history (`annotations`,
`dispatch`, `submissions`, `reviews`, or `progress`) from oldest to newest. It
returns stable entry IDs, archive and unavailable counts, explicit retention
completeness, and its own cursor. Large items remain available through the
ticket detail reference; list reads do not destructively shorten stored text.

Ticket cursors are integrity-protected with the private request-state keyring,
and current board authorization is checked on every page. Key rotation follows
the same overlap order described above. Removing an old key invalidates cursors
sealed by that key; clients restart the read without a cursor. Rolling back to
an older Central leaves cursor-free `ticket_list` behavior intact, while newer
clients report pagination as unsupported and must not infer a complete board
when `total_matching` exceeds the returned rows.

## Lifecycle activity projection

Ticket reads include an additive `activity` schema version 1. It is a read-only
projection of existing ticket, review, and delivery facts; it is not a second
workflow state machine. The record reports the current `stage`, `state`,
`attempt_id`, stable `actor_id`, meaningful `updated_at`, `freshness`, bounded
`evidence_refs`, `next_action`, optional `blocking_reason` and `estimate`, and a
truthful `completion_boundary`. Journal transitions include scalar activity cues
plus an `activity_ref`, so reconnecting consumers can reload the durable ticket
instead of reconstructing state from browser memory.

The activity projection is always additive and needs no configuration switch.
Its optional estimate appears only when a valid current `ticket_progress_v1`
record exists; disabling that capability preserves older records read-only and
does not remove lifecycle facts. Lease renewal and heartbeat paths never update
activity freshness. Older clients may ignore `activity`; newer dashboards fall
back to legacy status/progress rendering when connected to an older server.

Rollback is therefore non-destructive: restore the older dashboard/server code
while retaining stored ticket, progress, review, and delivery records. No data
migration or backfill is required.

## Ticket model-usage accounting

`ticket_create`, `ticket_submit`, and `ticket_review` accept a content-free
`model_usage` object with turn counts and provider-reported input/output token
totals. Central derives the host, provider, model, role, actor, and timestamp;
unknown fields are rejected so prompt and completion text cannot enter the
record. Missing host counters remain `null` rather than being estimated.

`ticket_get` exposes the durable per-role aggregate at `model_usage`, including
all rejection rounds, `total_tokens`, and `orchestrator_token_share`. The total
and share remain `null` until orchestrator, worker, and reviewer token totals
are all reported.
