Skip to content

Console and MCP server

Both sidecars talk to the server over its APIs. Neither holds a database connection, a signing key or a secret of the server, so each can be added and removed without touching the data directory, and every action they take is in the audit log.

A page with the live numbers of the server and the firehose subscribers, the accounts and the sessions of each, the audit log, the blocked email domains and the operator roles. It takes an account down and lifts the takedown, ends an account’s sessions, disconnects a subscriber, blocks an email domain, signs a checkpoint, and removes expired sessions. Each form has a field for the reason, which goes into the audit entry.

An operator signs in with their own account on the server, through OAuth, and the account needs a role. The console is a loopback client: reach it through an SSH tunnel, never under a public name.

On the server’s side, in pds.env:

PDS_OPS_ADDRESS=0.0.0.0:2584

In compose.yml, a service beside pds:

console:
image: ghcr.io/linkjar/pds-console@sha256:…
restart: unless-stopped
environment:
CONSOLE_ADDRESS: 0.0.0.0:2585
CONSOLE_PUBLIC_URL: http://127.0.0.1:2585
CONSOLE_PDS_PUBLIC_URL: https://pds.example.com
CONSOLE_PDS_INTERNAL_URL: http://pds:3000
CONSOLE_OPS_URL: http://pds:2584
ports:
- "127.0.0.1:2585:2585"

Start it and read its client id from its first log lines:

Terminal window
docker compose up -d console && docker compose logs console

Put the id into pds.env as PDS_OPS_CLIENT_IDS and restart the server. The ops listener takes a token only from a client on that list.

Roles are operator, which takes every action but a role grant, and moderator, which reads, changes the status of a subject, ends sessions and disables invites. Only the admin password grants a role. Publish the ops listener on the host’s loopback, beside the console’s port:

ports:
- "127.0.0.1:2584:2584"

Then, with PDS_ADMIN_PASSWORD in the environment and a reason, which every action on the ops listener requires:

Terminal window
curl -s -u "admin:$PDS_ADMIN_PASSWORD" -H 'X-Ops-Reason: first operator' -H 'content-type: application/json' -d '{"did": "did:plc:…", "role": "operator"}' http://127.0.0.1:2584/xrpc/internal.ops.grantRole

From your workstation, a tunnel to the host:

Terminal window
ssh -L 2585:127.0.0.1:2585 operator@pds.example.com

Open http://127.0.0.1:2585, and sign in with the account that holds the role. A restart of the console signs every operator out: it keeps the sessions in memory.

An MCP server that AI clients connect to with OAuth, in order to read and write an account’s records with the account’s consent. It is a public service under a name of its own, such as mcp.example.com, behind Caddy. Toward the PDS it is an OAuth client with a key of its own; its own tokens never reach the PDS, and each write names the AI client in the audit entry.

Its configuration is MCP_ADDRESS, MCP_PUBLIC_URL, MCP_PDS_PUBLIC_URL, MCP_PDS_INTERNAL_URL, MCP_CLIENT_KEY_P256_PRIVATE_KEY_HEX and MCP_WRITE_COLLECTIONS, the collections an AI client may ask to write. Its client id is MCP_PUBLIC_URL followed by /oauth-client-metadata.json, and it logs it at start. On the server’s side, PDS_MCP_CLIENT_IDS names it, and PDS_OAUTH_KNOWN_CLIENTS_FILE can hold its client metadata so that the server fetches nothing. Sessions live in the process’s memory, so one instance runs and a restart ends every session.

The design and the state of both sidecars are in docs/sidecars.md in the repository.