Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Self-hosted MCP manager/gateway: one streamable-HTTP `/mcp` endpoint federating

- `index.ts` — CLI entry: refuses to start with no auth configured; boots DB → secrets → OIDC → upstreams → HTTP
- `config.ts` — flags/env + `mspstack.config.json` (`ConfigError`); parses `MCP_TOKENS_<ROLE>` lists (duplicate labels are a `ConfigError` — labels are /me identities), OIDC (`OIDC_ISSUER`/`ENTRA_TENANT_ID` + required `OIDC_AUDIENCE`), `BAO_*`
- `db/backup.ts` — online snapshots (`VACUUM INTO`, never a file copy of a live db), retention, optional Azure Blob shipping (lazy SDK, DefaultAzureCredential); scheduler unref'd, off for `:memory:`; `GET/POST /api/backups`; restore is manual (docs/backups.md)
- `mcp/self-tools.ts` — admin-only `gw_*` toolset (issue #2): status, list servers/tools (values REDACTED), bulk enable by tier/group, tier overrides, grants by role name, preset install, refresh, backup-now. Hidden from non-admins AND re-checked per call with the unknown-tool wording; `gw` namespace reserved in config.ts; `GATEWAY_SELF_TOOLS=off` disables. Target resolution shared with both HTTP APIs via `domain/tool-targets.ts`
- `db/` — `node:sqlite` schema (roles/upstreams/grants/tool_overrides/tool_settings/users/group_mappings, seeded viewer/editor/admin) + typed `Repo`
- `domain/catalog.ts` — namespacing (`${namespace}_${tool}`, no double-prefix), routing map (no string-splitting), annotation-derived tiers (port of mcp-itglue `tierOf`), `derivedGroupOf(tool)` — the category for the group switches: `_meta.group`/`_meta.toolset` first (how the family servers tag their toolsets), else a bracketed description prefix (`[Identity > …]`, CIPP); an explicit `group_label` always wins
- `domain/presets.ts` — one-click upstream presets: builtin family configs (itglue/cwpsa/planner/cipp — full specs incl. BYOK headers, per-user mode, userConnect, personalCredentials, the `auth` mint block) + optional `mspstack.presets.json` (file overrides builtin ids); `{{param}}` templating rendered server-side and validated via `parseUpstreamSpec`; recommended grants by role NAME resolved at install (`GET /api/presets`, `POST /api/presets/:id/install` with `dryRun`). Spec's `personalCredentials` metadata drives the /me guided credential forms (`credentialFields` in `/api/me/access`)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Point Claude (Code, Desktop, or any MCP client) at a single URL; the gateway con
- **Install from the UI** — one-click **presets** for the MSPStack family (IT Glue, ConnectWise PSA, Planner) and CIPP that fill BYOK headers, per-user session mode, Connect wiring, and apply recommended role grants (extend with your own via `mspstack.presets.json`); or add any MCP server by URL, npm package (npx), or Docker image; search the official MCP registry; preflight-test before saving; crashed stdio servers restart with backoff
- **Guided user setup** — upstreams declare their personal-credential fields, so `/me` renders labeled forms (not raw header names), plus ready-to-copy connect snippets: Claude Code CLI (user-scope by default) and JSON config for Desktop/Cursor/VS Code
- **Manageable at scale** — the admin catalog nests tools under server → category, and every level has one-click switches for the whole set or just its read / write / destructive tools; the same switches appear on `/me`. Categories come from the server itself (`_meta.group` on the tool, or a `[Category > …]` description prefix) and can be relabelled per tool. Long lists page in on demand, tool lists can be re-read from the servers at any time, and any upstream can be set to opt-in (`userDefault: "off"`, also a control in the add form) so users pick what they need instead of receiving hundreds of tools
- **Administer it conversationally** — an admin-only `gw_*` MCP toolset (status, tool/tier/group switches, grants, preset installs, backups) so routine changes happen in your MCP client; credential values are never read back ([docs](docs/self-management.md))
- **Backed up** — periodic online SQLite snapshots with retention and optional off-instance copies to Azure Blob ([docs](docs/backups.md))
- **Admin UI** at `/admin` — status, server management, tool toggles, role matrix, users & group mappings (with live Entra group search when the login app holds the directory-read Graph roles), OAuth client management, secret writes

## Quick start
Expand Down
69 changes: 69 additions & 0 deletions docs/backups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Backups and restore

The gateway's SQLite database is not reproducible from configuration. It holds
upstream specs, roles, grants and per-tool settings, users and group mappings,
dynamically registered OAuth clients, refresh-token families, and the
references to every user's personal credentials. Losing it means rebuilding all
of that by hand — so back it up.

## What runs by default

On every boot the gateway starts a periodic snapshot loop:

| Env | Default | Meaning |
| --- | --- | --- |
| `BACKUP_INTERVAL_HOURS` | `24` | snapshot cadence; `0` disables the loop (on-demand still works) |
| `BACKUP_DIR` | `<dir of DB_PATH>/backups` | where snapshots land |
| `BACKUP_KEEP` | `7` | how many local snapshots to keep — oldest pruned first |
| `BACKUP_BLOB_CONTAINER_URL` | — | Azure Blob container for off-instance copies (`DefaultAzureCredential`) |

Snapshots are taken with SQLite's `VACUUM INTO`, which writes a consistent
copy while the gateway keeps serving. Copying `gateway.db` with `cp` while the
process runs is **not** a backup — it can capture a torn page mid-WAL.

Files are named `gateway-backup-<ISO timestamp>.db`, so they sort
chronologically and never overwrite each other.

An in-memory database (`DB_PATH=:memory:`) has nothing durable to snapshot, so
the loop stays off there regardless of the interval.

## Off-instance copies

A backup living next to the database is not a backup: the App Service instance
that loses the disk loses both. Set `BACKUP_BLOB_CONTAINER_URL` to a container
URL and each snapshot is uploaded with the gateway's managed identity (needs
**Storage Blob Data Contributor** on the container). Upload failures are logged
loudly but never fail the snapshot itself — a local copy still beats none.

## On demand

Admin API (admin role required):

```bash
curl -s -X POST https://<gateway>/api/backups -H "Authorization: Bearer <admin token>"
```

```bash
curl -s https://<gateway>/api/backups -H "Authorization: Bearer <admin token>"
```

`GET` returns the configured directory, retention, interval, whether an
off-instance target is set, and the snapshots currently on disk.

## Restore

Deliberately manual — restoring is rare and destructive, so it is not a button.

1. Stop the gateway (App Service: stop the app, or `docker compose stop`).
2. Put the snapshot where `DB_PATH` points, e.g. `data/gateway.db`.
3. Delete any `gateway.db-wal` / `gateway.db-shm` siblings — they belong to the
old database and will confuse SQLite about the restored one.
4. Start the gateway. Schema migrations are idempotent and run on boot, so a
snapshot from an older version upgrades itself.
5. Verify: `GET /api/status` (upstream count and tool count) and `GET /api/roles`
(grants matrix). Personal credentials keep working because the database
stores only references — the values live in the secret store, untouched by
the restore.

Test the restore path on a scratch instance before you need it; an untested
backup is a hope, not a plan.
56 changes: 56 additions & 0 deletions docs/self-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Administering the gateway over MCP

The gateway exposes its own administration as an **admin-only** MCP toolset
(`gw_*`), so routine changes are a sentence in Claude Code instead of a browser
trip:

> "which CIPP groups are enabled?" · "turn off everything destructive in cwpsa"
> · "raise ExecGetRecoveryKey to destructive" · "close cipp for techs-ro"

## Access model

- Tools are listed **only** for principals whose role has `is_admin` — for
everyone else the names simply do not appear.
- Hiding is UX; every call re-checks `isAdmin` and a non-admin gets the same
"not available" text an unknown tool gets, so the toolset is not an oracle
for what exists.
- The `gw` namespace is reserved: `parseUpstreamSpec` refuses it, so a
federated server can never shadow these tools or be shadowed by them.
- Turn the whole thing off with `GATEWAY_SELF_TOOLS=off`.

## What it will not do

- **No secret reads.** `gw_list_servers` redacts header/env values: you see the
keys and whether each value is a `kv:`/`bao:` reference, a `${VAR}` env
reference, or a literal — never the literal itself.
- **No credential writes and no impersonation.** Personal credentials, secret
writes, and user/role administration stay on the HTTP surface where the
browser session gates them.
- **No silent destruction.** `gw_remove_server` requires `confirm: true`.

## The tools

| Tool | What it does |
| --- | --- |
| `gw_status` | version/mode, upstreams with tool counts and last errors, catalog size, secret-store scheme, auth mode, backup settings |
| `gw_list_servers` | configured upstreams and their mode flags, credential values redacted |
| `gw_list_tools` | catalog rows with effective tier, group and enabled state; filter by upstream/tier/group/enabled/name, or `groupsOnly` for a per-category summary |
| `gw_set_tools_enabled` | enable/disable for everyone — scope by tier and/or group, one tool, or the whole upstream |
| `gw_set_tool_tier` | set or clear a tier override (how a read-only tool that hands out secrets is kept away from low roles) |
| `gw_set_grant` | a role's ceiling on one upstream, by role name |
| `gw_list_presets` / `gw_install_preset` | the preset catalog and one-shot installs (`dryRun` renders without saving) |
| `gw_set_server_enabled` | enable/disable a whole upstream |
| `gw_remove_server` | remove an upstream and its settings/grants/overrides (`confirm: true`) |
| `gw_refresh_catalog` | re-read every upstream's tool list now |
| `gw_backup_now` | snapshot the database, prune to retention, ship off-instance when configured |

Changes broadcast `tools/list_changed` to live sessions, exactly like the same
change made from `/admin`.

## Secrets in preset installs

`gw_install_preset` takes parameters as strings, and a secret parameter must be
a **reference** — `kv:cipp-mcp-secret`, `bao:upstreams/itglue#token`, or
`${SOME_ENV}`. Pasting a raw secret would store it literally in the upstream
spec; write it with `PUT /api/secrets` (or the Secrets tab) first and pass the
ref you get back.
Loading
Loading