Content
<div align="center">
# Zuar Portal — MCP Server
**Let Claude operate your [Zuar Portal](https://www.zuar.com/) (zPortal) for you** — author HTML blocks, build pages, manage data sources, queries, themes and users, explore real data, and keep a git-versioned, revertible history of every change, all through natural language.
[](https://github.com/zuarbase/cust-zuar-portal-mcp/releases/latest)
[-5A45FF)](https://modelcontextprotocol.io)
[](https://www.zuar.com/)
[](https://nodejs.org)
[](#install--claude-desktop-one-click)
[](LICENSE)
</div>
---
An [MCP](https://modelcontextprotocol.io) server that exposes a Zuar Portal's REST + auth APIs to any MCP client (Claude Desktop, Claude Code, …). It turns *"build me a sales dashboard"* into the right sequence of authenticated calls — discover data sources → write a saved query → author a **validated** HTML block → bind it → place it on a page — with bundled authoring guidance, layered write-safety, and a revertible history.
> [!TIP]
> **Install (Claude Code)** — clone, build, register:
> ```bash
> git clone https://github.com/zuarbase/cust-zuar-portal-mcp.git ~/zuar-portal-mcp
> cd ~/zuar-portal-mcp && npm install && npm run build
> claude mcp add zuar-portal --scope user -- node ~/zuar-portal-mcp/dist/index.js
> ```
> Then `cd` to a project folder and run **`/portal-setup`** to connect it to a portal. [Details ↓](#install--claude-code--other-mcp-clients)
>
> **No terminal (Claude Desktop):** download **`zuar-portal-mcp.mcpb`** from the [latest release](https://github.com/zuarbase/cust-zuar-portal-mcp/releases/latest) and double-click it. [Details ↓](#install--claude-desktop-one-click)
## At a glance
```mermaid
flowchart TB
CD["<b>MCP Client</b><br/>Claude Desktop · Claude Code · any MCP client"]
CD -- "JSON-RPC / stdio" --> S
subgraph server["zuar-portal-mcp server"]
direction TB
S["index.ts → buildServer()"]
S --> BT["🧱 <b>Block tools</b><br/>typed authoring + place_blocks"]
S --> RT["📦 <b>Resource tools</b><br/>generic CRUD · 18 kinds (blocks included)"]
S --> AT["⚡ <b>Action tools</b><br/>query · profile · users · config"]
S --> VC["🕓 <b>Version-control tools</b><br/>snapshot · history · diff · restore"]
S --> EL["🪄 <b>Setup & design</b><br/>configure_project · synthesize_theme"]
G{{"🛡️ <b>Safety & integrity gates</b><br/>write-domain · structure · refs · impact · SQL"}}
BT & RT & AT & VC & EL --> G
end
G --> HTTP["portalClient.ts<br/>login · X-Api-Key · retry · circuit breaker"]
HTTP -- "/api + /auth · HTTPS" --> P[("Zuar Portal")]
BT -. "mirrors every content write" .-> GIT[("git VC repo<br/>revertible")]
classDef gate fill:#fde68a,stroke:#b45309,color:#000;
class G gate
```
Every write is tagged with a **risk domain** and passes the safety gates *before* anything reaches the portal; every successful **content** write is mirrored to a git repo so it can be reverted.
## Contents
- [Highlights](#highlights)
- [What Claude can do with it](#what-claude-can-do-with-it) — the 38-tool catalog
- [The Claude Code agent ecosystem](#the-claude-code-agent-ecosystem) — pipeline, agents, model/effort routing
- [Guided onboarding & theming](#guided-onboarding--theming)
- [Requirements & credentials](#requirements)
- [🖱️ Install — Claude Desktop (one-click)](#install--claude-desktop-one-click)
- [⌨️ Install — Claude Code & other clients](#install--claude-code--other-mcp-clients)
- [Per-project configuration (multiple portals)](#per-project-configuration-multiple-portals)
- [Getting started](#getting-started)
- [Write safety & tool gating](#write-safety--tool-gating)
- [Resilience, observability & hardening](#resilience-observability--hardening)
- [Troubleshooting](#troubleshooting) · [Security](#security) · [License](#license)
> Working **on** the server rather than with it? See [`CONTRIBUTING.md`](CONTRIBUTING.md).
> [!NOTE]
> **📚 Full documentation** lives in **[`docs/`](docs/README.md)** — a [5-minute quickstart](docs/00-quickstart.md), install & config, a **generated** reference for all 40 tools, block authoring, the design system, version control, the in-block `zPortal` API, the [agent ecosystem & model routing](docs/13-agents-and-workflows.md), [tool gating](docs/14-tool-gating-and-guidance.md), [safety gates](docs/16-safety-and-integrity.md), and troubleshooting.
## Highlights
| | |
|---|---|
| 🧰 **One uniform surface** *(v3.0.0)* | **One model for everything** — blocks are a validated registry kind, one declarative `place_blocks`, uniform `dry_run` on every write, paged lists, and new reads (`find_resource`, `get_references`, `vc_diff`). Breaking; `PORTAL_COMPAT_TOOLS=1` bridges the old v2 names. |
| ⌨️ **Install once, use everywhere** | Clone + build + `claude mcp add … -- node …/dist/index.js`, then `/portal-setup` per project. Or a one-click `.mcpb` for Claude Desktop — no terminal at all. |
| 🧱 **Validated authoring** | HTML blocks go through rule-checked tools (`create_block`/`update_block`/`validate_block`) — and `create_resource (block)` passes the *same* per-kind validator at the registry chokepoint, so footguns are caught *before* the portal is touched. |
| 🏢 **Multi-portal, multi-repo** *(v2.4.0)* | One install drives a **different portal + git repo per folder** via `./.zuar-portal/config.json`. |
| 🪄 **Browser setup, no JSON, no key in the model** | `configure_project` serves a local **loopback web form** — you type the API key in your browser, so it never passes through the model; it validates live and writes a `0600` config. Falls back to elicitation / arguments. The **`design_intake` prompt** walks theming and drives `synthesize_theme`. |
| 🤝 **A team of agents** | In Claude Code, a gated **build → style → responsive → debug → adversary → advisor** pipeline of specialist subagents builds blocks for you — each on a [right-sized model](#model--effort-routing). |
| 🔒 **Enterprise safety** *(v2.5–2.6)* | Risk-domain write gating, least-privilege tool scoping, structural + referential integrity gates, pre-delete impact analysis, and an opt-in audit log. |
| 🕓 **Revertible history** *(v2.2.0)* | Every content write mirrors to a git repo — revert any change with `restore_resource`. |
---
## What Claude can do with it
**40 tools** — one uniform surface (*"a resource is a resource"*) across 11 capability groups. The full per-tool reference is **generated from the live server** (`npm run gen:docs`, so it can't drift): **[docs/03 · Tools Reference](docs/03-tools-reference.md)**.
> [!IMPORTANT]
> **Upgrading from 2.x?** v3.0.0 is a **breaking** redesign (48 → 37 tools). Every removed v2 name maps to a v3 primitive — see [`CHANGELOG.md`](CHANGELOG.md). Set **`PORTAL_COMPAT_TOOLS=1`** to temporarily register the old names as deprecated aliases that forward to the same gated v3 handlers.
### 🧱 Block tools — typed + validated
Blocks are now a **first-class registry kind**: list, fetch, and delete them with the generic resource tools (`resource: "block"`), and the block-authoring rules run as a per-kind validator at the registry write chokepoint — `create_resource (block)` is validated exactly like `create_block`. The typed fronts remain for ergonomic authoring:
| Tool | What it does |
|------|--------------|
| `validate_block` | Run the authoring rules against a block payload **without writing** — iterate until clean. |
| `create_block` | Create an HTML block (validated against authoring rules). |
| `update_block` | Update an HTML block — merged over the current block so untouched fields survive. |
> **File input** `[4.2.0]` — `create_block`, `update_block`, and `validate_block` take `html_file` and
> `css_file`. The **server** reads the bytes, so a model never carries them. Retyping a large block into a
> tool call is not a copy but a **re-transcription**: it burns tokens and silently normalizes characters
> (an em dash inside a regex character class arriving as a hyphen changes what the pattern matches — and
> passes review). Pass a path instead, edit the file in place, and only the delta is ever transcribed.
> Every gate runs identically either way. Reads are contained to the CWD, the VC dir, and any
> `PORTAL_FILE_ROOTS` entry.
| `bind_block_query` | Bind a block to a datasource/query (auto-creates the query); sets `ui_queries`. |
| `place_blocks` | **One declarative placement primitive** — add, update, hide, or remove blocks on a page grid in a single atomic write. `mode: "merge"` appends/updates (and honours `remove: [...]`); `mode: "replace"` + `confirm: true` makes the page exactly the given list while preserving survivors' customized `grid.layouts` and hidden flags. |
<details>
<summary><b>📦 Generic resource tools</b> — one CRUD surface over 18 resource kinds (blocks included)</summary>
Pass `resource` plus a `body`/`id`. Call `describe_resource` to see each resource's fields, required-to-create fields, supported verbs, and risk domain.
| Tool | What it does |
|------|--------------|
| `describe_resource` | List resources, or describe one (fields, verbs, domain). |
| `list_resource` | List records — **always returns the paged envelope** `{total, offset, limit, returned, truncated, records}` (default `limit` 100, max 500). |
| `get_resource` | Get one record by id — e.g. `resource: "user", id: "me"` for the current user's profile. |
| `find_resource` | Search by name (case-insensitive substring, or exact id) across kinds — optional `kinds`, `tag`, `limit`/`offset`; defaults to all non-admin kinds. |
| `get_references` | Read-only dependency query, both directions: `dependents` (who breaks if this is deleted — the same analysis the delete gate runs) and `references` (what this record points at, each flagged `exists: false` when dangling). |
| `create_resource` | Create a record (write-gated by domain; per-kind validators — blocks get the full authoring rules). |
| `update_resource` | Update a record (merged over current; write-gated). |
| `delete_resource` | Delete a record (write-gated; pre-delete impact analysis; `confirm`/`force`). |
| `validate_portal` | Read-only sweep for malformed records, dangling refs, and risky SQL. |
**Covered resources:** `block`, `layout` (pages), `datasource`, `query`, `db_modification`, `partial`, `theme`, `snippet`, `translation`, `dashboard`, `tag`, `user`, `group`, `permission`, `access_policy`, `api_key`, `credential`, `system`.
> Every write tool accepts **`dry_run: true`** — every gate runs (domain, structure, per-kind rules, references, impact), nothing is written, and the response carries `applied: false` plus what *would* have been written.
</details>
<details>
<summary><b>⚡ Action tools</b> — data exploration, users, config, naming, setup, design & migration</summary>
| Tool | What it does | Domain |
|------|--------------|--------|
| `profile_datasource` | Per-column stats (type, distinct values, min/max) **plus raw sample rows** (`sample.columns` / `sample.rows`; `sample_rows` default 10, max 50) to design filters + charts against real columns. | read |
| `execute_query` | Run a saved query by id and return results (optional row `limit`). | read |
| `run_db_modification` | Run a saved DB write by name. Needs `confirm: true`. | data |
| `change_password` | Change the current user's password. | admin |
| `get_user_access` | Read a user's group membership **and** permissions in one call. | read |
| `set_user_access` | Replace a user's groups and/or permissions — each provided list is a full replace; needs `confirm: true`, supports `dry_run`. | admin |
| `get_config` / `update_config` | Read / set portal config by path. | read / admin |
| `get_version` | Portal version + about (capability check). | read |
| `get_rules` | Show active block-authoring rules. | read |
| `naming` | The `scope · kind · subject` naming grammar — `action: "suggest"` proposes names, `action: "parse"` decomposes them. | read |
| `check_connection` | **Start here** — confirm the connection in one authenticated round-trip: portal, version, who you're signed in as, binding state and write posture, in ~160 chars. Bad credentials return the reason and the fix, not a false success *(always available)*. | read |
| `get_capabilities` | Report the current posture — enabled/disabled tool groups, write-safety, VC + audit status, and the active config (portal / VC repo, secrets redacted) under its `config` key *(always available)*. | read |
| `get_metrics` | Per-tool call count, error rate, latency, uptime, breaker state *(always available)*. | read |
| **`configure_project`** | Connect this folder to a portal. By default it opens a local **loopback setup page** (API key typed in the browser, never through the model), validates live, writes a `0600` config; falls back to elicitation then arguments (pass `ui:false`, or `portal_url` + `api_key` + `user_id`). Also collects write-safety toggles + access scope + optional GitHub VC. **Pins** the folder to the portal so writes can't cross portals; writes `./.zuar-portal/config.json` + `design.md` + a managed `CLAUDE.md` block. | setup |
| `reload_config` | Re-read config from disk (project/bundle/env) without a restart; resets the portal session. | setup |
| `synthesize_theme` | **Pure theme synthesis** — preferences (± an SSRF-guarded website color fetch) → a token map plus the exact `create_resource` call (`create_with`); creates nothing itself. Orchestrated by the **`design_intake` prompt**. | design |
| `migration_preflight` | Read-only migration audit — every block classified by *function*, all code-bearing fields scanned (comment/string-aware), placement via layouts/partials/snippets, hardcoded-origin sweep, bound-query preflight, browser-probe checklist. | migration |
| `repair_query_metadata` | Refresh a saved query's stored column metadata from a live execution — SQL untouched, dry-run by default, round-trip verified. | migration |
> The current user's profile is plain resource CRUD now: `get_resource` / `update_resource` with `resource: "user", id: "me"`. Guided migration scoping is the **`migration_kickoff` prompt** (see Prompts below).
</details>
<details>
<summary><b>🕓 Version-control tools</b> — snapshot, history, restore <i>(v2.2.0)</i></summary>
| Tool | What it does |
|------|--------------|
| `vc_status` | Show whether VC is configured and the repo state. |
| `snapshot_portal` | Commit the full current portal state to the git repo — a durable checkpoint. |
| `vc_log` | Show the commit history of content changes. |
| `vc_diff` | Unified diff between two committed versions — record-scoped (`resource` + `id`) or repo-wide; defaults to the previous commit touching the record vs `HEAD`. Inspect before `restore_resource` — a revert is never blind. |
| `restore_resource` | Restore a resource to a previous committed version. |
See **[docs/07 · Version Control](docs/07-version-control.md)**.
</details>
**Resources** (`zportal://guide/*`) — authoring guidance Claude reads *before* building, so blocks follow zPortal conventions even on a fresh machine: `block-structure`, `currentblock`, `zportal-api`, `charting`, `conventions`, `design-system`, `visual-verification`, `migration-1.18`, `loading-overlay`, `migration-playbook`, and `block-performance`.
**Prompts** — guided workflows now live here rather than in the tool surface (9): **`zuar_portal_start`** (the cheapest session opener — confirm the connection in one call, report one line, ask what's next), `zuar_portal_quickstart` (orient → route), `create_zportal_block` (discover → build → create), `setup_zuar_project` (connect this folder, routes to `configure_project`), `migrate_block_to_118` (one legacy block → the 1.18 lifecycle), `add_loading_overlay` (the sanctioned spinner + fade-out), **`block_perf_pass`** (the large-dataset perf audit: measure → trim `SELECT *` → chart-lib prefetch → 60 s honest timeouts), **`design_intake`** (guided theming — walks brand/website/density/radius, drives `synthesize_theme`, then creates the theme via `create_resource`), and **`migration_kickoff`** (guided migration scoping — batches the scope decisions, writes `.zuar-portal/migration-scope.json`, then runs `migration_preflight`).
---
## The Claude Code agent ecosystem
When this repo is your Claude Code working directory, the MCP tools come with a **team of specialists** in [`.claude/`](.claude/README.md). You don't drive `create_block`/`bind_block_query` by hand — you describe what you want, and a gated pipeline builds, styles, hardens, and reviews it. Full guide: **[docs/13 · Agents & Workflows](docs/13-agents-and-workflows.md)**.
### The block pipeline
Blocks are **never shipped raw**. A spec flows through quality gates, each a focused subagent:
```mermaid
flowchart LR
spec([spec]) --> B["🏗️ builder"] --> St["🎨 stylist"] --> R["📱 responsive"] --> D["🔧 debugger"]
D --> A{"🚨 adversary<br/><b>CODE GATE</b>"}
A -- "blocking (≤2 rounds)" --> D
A -- "clean" --> V{"👁️ visual<br/><b>GATE</b>"}
V -- "blocking (≤2 rounds)" --> D
V -- "clean / skipped" --> Ad["🧭 advisor"] --> ship([ship ✅])
classDef gate fill:#fde68a,stroke:#b45309,color:#000;
classDef ro fill:#dbeafe,stroke:#1d4ed8,color:#000;
class A,V gate
class Ad ro
```
The **adversary** (gate) red-teams the block and proves each finding with evidence; while it returns blocking findings the pipeline loops back to the **debugger**. The **visual gate** (the adversary with browser eyes) then *opens the rendered block in Claude for Chrome* — screenshot, console, network — and a blank render, console error, sample-not-live data, or overflow loops back to the debugger too; it's **best-effort** and skips with a note when the extension isn't connected or the block isn't on a page. The **advisor** asks "is this the *right* block?" All gates are **read-only** — they carry no write tools and physically cannot mutate the portal (browsing/screenshotting is read-only). Beyond the six pipeline agents, four **specialists** handle broader jobs: `portal-data-expert`, `portal-theme-designer`, `portal-bulk-operator` (snapshot-first), and `portal-onboarding`.
### Seeing the portal (Claude for Chrome)
Every gate above reasons about a block from its **code and query rows** — but a block can validate, bind, and still render blank, throw a runtime console error, overflow its grid cell, or silently show its hardcoded **sample** fallback instead of live data. With the **[Claude for Chrome](https://www.anthropic.com/claude-in-chrome)** extension connected, the agents can *see* the portal: open the page, screenshot the block, and read the browser console/network — for **visual debugging** and a final **visual sign-off**.
- **Recorded at setup.** `configure_project` asks whether you use Claude for Chrome and stores `browser.claudeInChrome` in `./.zuar-portal/config.json`; `get_capabilities` reports it.
- **Used where it pays.** The debugger looks before it guesses; the adversary owns the **visual gate**; the stylist and responsive-specialist screenshot their work (the latter steps widths with `resize_window`); the advisor checks it reads at a glance.
- **Sign-in caveat.** The MCP authenticates with an **API key**, but the browser needs a **logged-in session** — to view private pages you must be **signed into your portal in Chrome**. The MCP can't log you in.
- **Graceful by design.** No extension, or the block isn't on a page? Every agent falls back to code-only review and says so. The doctrine lives in the `zportal://guide/visual-verification` resource.
### Slash commands
| Command | What it runs |
|---|---|
| `/portal-setup` | First-time per-folder setup + alignment Q&A → config + project brief. |
| `/portal-build <spec>` | The full build→style→responsive→debug→adversary→advisor pipeline for one block. |
| `/portal-theme <goal>` | Design or apply a portal-wide theme. |
| `/portal-bulk <change>` | A guarded bulk change across many blocks/pages (snapshot → dry-run → atomic apply). |
| `/portal-audit [filter]` | Read-only audit of existing blocks — bugs, a11y, responsiveness, design fit. |
| `/portal-improve` | One bounded improvement pass: score → fix worst blocks → verify → sweep → provable delta. Safe to schedule nightly. |
| `/portal-align` | Run the alignment Q&A on its own. |
### Model & effort routing
Each agent runs on the **model and reasoning effort** that fit its job — sharp where judgment matters, cheap where the work is mechanical. Three composing layers:
**1 · Agent defaults** (`model:`/`effort:` frontmatter) — for a *direct* call (a fast surgical edit, or one agent dispatched from a command):
| Tier | Agents | Model · effort |
|---|---|---|
| 🧠 **Judgment / data** | data-expert, adversary, advisor | **`opus` · high** |
| 🛠️ **Authoring** | builder, stylist, debugger, bulk-operator, theme-designer, onboarding | **`sonnet` · medium** |
| ⚡ **Mechanical** | responsive-specialist | **`haiku` · low** |
**2 · Workflow `tier` toggle** — `portal-block-pipeline.js` and `portal-audit.js` take `args:{ …, tier }` and set each stage's model/effort explicitly:
| `tier` | For… | Builders | Judgment gates |
|---|---|---|---|
| **`fast`** | cheap iteration, throwaway drafts, triage | sonnet/haiku · low | sonnet · medium |
| **`standard`** *(default)* | a normal build / audit | sonnet · medium | **opus · high** |
| **`max`** | production / executive build, pre-release audit | **opus · high** | **opus · xhigh** |
**3 · Commands** pin to **`sonnet` · medium** — they only orchestrate (pre-flight → dispatch → synthesize); quality lives in the agents/workflow they call. `/portal-build` and `/portal-audit` infer the `tier` from your phrasing.
> The MCP **server** never selects a model — only the agents, commands, and workflows that drive it do. Re-tier via agent frontmatter or a workflow's `ROUTING` table; see [`.claude/README.md`](.claude/README.md).
---
## Guided onboarding & theming
By default **`configure_project`** serves a tiny **loopback web form** (`http://127.0.0.1:<random-port>`)
from the MCP process, best-effort opens your browser, and returns the link immediately — you type the
URL, API key, write-safety toggles, access scope, and version control **in the browser**, so the API
key never passes through the model. On Save it validates live, writes the gitignored `0600` config,
and applies the change live. It falls back to MCP **elicitation** (field-by-field prompts) and then
to **arguments** when a browser can't be used — pass `ui:false`, or pass `portal_url` + `api_key` +
`user_id` as arguments. Guided theming is the **`design_intake` MCP prompt**, which orchestrates the pure
`synthesize_theme` tool. See [The browser setup form](docs/02-install-and-config.md#the-browser-setup-form-default).
```mermaid
flowchart TB
subgraph setup["🔌 configure_project — connect a portal"]
direction TB
s1["Portal URL"] --> s2["API key 🔒"] --> s3["User ID"] --> s4{"add GitHub VC?<br/>(optional)"}
s4 --> s4b{"use Claude for Chrome?<br/>👁️ visual checks"}
s4b --> s5["✓ live portal login<br/>✓ GitHub token + repo (API)"] --> s6[["writes .zuar-portal/config.json<br/>+ .gitignore"]]
end
subgraph intake["🎨 design_intake prompt — theme the portal"]
direction TB
d1["brand + website"] --> d2["fetch site 🛡️ SSRF-guarded<br/>→ suggest brand colors"] --> d3["palette · density · radius"]
d3 --> d4["header + sidebar style"] --> d5{"confirm?"} --> d6[["synthesize_theme →<br/>create_resource (theme)"]]
end
```
- **`configure_project`** refuses to clobber an existing config, validates with a real login, and writes a gitignored `./.zuar-portal/`. It also asks whether you use **Claude for Chrome** (stored as `browser.claudeInChrome`) so the build pipeline can *see* your blocks render — visual debugging + a final visual gate (see [Seeing the portal](#seeing-the-portal-claude-for-chrome)). The `setup_zuar_project` prompt and `/portal-setup` route to it; pass `interactive: false` for the direct, no-prompt path. *(Replaces v2's `setup_portal` and `init_project_config`.)*
- **The `design_intake` prompt** fetches the brand's website through `synthesize_theme`'s **SSRF-guarded** fetch to suggest a palette, then walks density/radius/header/sidebar and, on your confirmation, creates a `theme` resource via `create_resource`. `synthesize_theme` itself is pure — it returns the token map and the exact `create_with` call, and never writes.
---
## Requirements
- A **Zuar Portal** reachable over HTTPS, with an account that can manage blocks (admin recommended).
- **Node.js 18+** — for Claude Code and any other MCP client. (Claude Desktop's one-click `.mcpb` bundles its own Node, so you need nothing.)
### Getting your portal credentials
You need three values, entered once during install.
| # | Value | Where |
|---|-------|-------|
| 1 | **Portal URL** | The base URL, no trailing path — e.g. `https://your-portal.zuarbase.net`. |
| 2 | **Portal API Key** | **Admin → Auth → API Keys** → create/copy a key. It inherits its user's permissions — that user must be able to create/edit/delete blocks. |
| 3 | **Portal User ID** | **Admin → Users** → your user → copy the **UUID** from the page URL. |
> [!IMPORTANT]
> Keep the API Key and User ID private. In the Claude Desktop bundle they're declared **sensitive** (masked, stored securely) and never leave the machine running the server.
---
## Install — Claude Desktop (one-click)
1. Download **`zuar-portal-mcp.mcpb`** from the [latest release](https://github.com/zuarbase/cust-zuar-portal-mcp/releases/latest).
2. Double-click it, or drag it onto the Claude Desktop window. An install dialog appears.
3. Fill in **Portal URL**, **Portal API Key**, **Portal User ID** (and optionally the write-safety toggles).
4. Confirm. The tools, resources, and prompts are now available to Claude.
To update later, install a newer `.mcpb` over the old one.
## Install — Claude Code & other MCP clients
This server speaks MCP over **stdio**, so any MCP-capable client can use it. Clone it, build it once, and register the built entry point — you need **Node ≥ 18** and `git`.
**Register it once, for every project:**
```bash
git clone https://github.com/zuarbase/cust-zuar-portal-mcp.git ~/zuar-portal-mcp
cd ~/zuar-portal-mcp
npm install
npm run build
claude mcp add zuar-portal --scope user -- node ~/zuar-portal-mcp/dist/index.js
```
> [!IMPORTANT]
> **Keep the clone where it is.** `claude mcp add` records the absolute path to `dist/index.js`, so moving
> or deleting the folder breaks the server with a `spawn ENOENT`. Pick a permanent home for it — not
> `/tmp`, not `~/Downloads`.
**To update:** pull and rebuild in place. The path doesn't change, so there's nothing to re-register — but restart your client, since the tool list is only fetched at handshake.
```bash
cd ~/zuar-portal-mcp && git pull && npm install && npm run build
```
Then, **in each portal project folder**, connect it to that portal:
```bash
mkdir ~/work/acme-portal && cd ~/work/acme-portal
claude
> /portal-setup
```
`/portal-setup` asks for your three values, checks them with a real login, and writes a gitignored `./.zuar-portal/config.json`. Every folder can point at a different portal — see [Per-project configuration ↓](#per-project-configuration-multiple-portals).
<details>
<summary>Prefer to write the config file by hand?</summary>
`.mcp.json` in the project (or `claude_desktop_config.json`) — point `args` at your clone's built entry point, as an **absolute path** (`~` is not expanded here):
```json
{
"mcpServers": {
"zuar-portal": {
"command": "node",
"args": ["/Users/you/zuar-portal-mcp/dist/index.js"]
}
}
}
```
</details>
> [!IMPORTANT]
> **Leave `env` empty. Don't put `PORTAL_URL` / `PORTAL_API_KEY` / `PORTAL_USER_ID` in your client config.**
> Credentials in the client's `env` map create **one global portal that every project silently inherits** — so a
> folder you believe is pointed at staging quietly publishes to production. Let `/portal-setup` write credentials
> **per project** instead. Each project then carries a binding fingerprint, and the server refuses a write aimed at
> a portal the folder isn't bound to. Env vars still work (useful for CI, or a single-portal install), but the
> project file is the path that can't surprise you.
---
## Per-project configuration (multiple portals)
One MCP install can drive **a different portal — and a different git state-repo — in every folder**. At startup the server resolves credentials in layers, highest priority first:
```mermaid
flowchart LR
A["1 · Project config<br/><code>./.zuar-portal/config.json</code><br/>(walks up from cwd)"] --> R{{"resolved<br/>credentials"}}
B["2 · Environment<br/><code>PORTAL_*</code> env vars<br/>(Desktop / MCPB)"] --> R
C["3 · Bundle config<br/><code>config.json</code> beside bundle"] --> R
```
Most settings resolve **per field**, so a project file can set just `vc.dir` and inherit the rest. Empty values are ignored, so a blank Desktop field never shadows a project value.
> [!WARNING]
> **Portal credentials are the exception — they resolve all-or-nothing from ONE layer** *(v4.0.0)*. A layer naming
> **any** of `url` / `apiKey` / `userId` must supply **all three**, or startup fails with an explicit error.
> Per-field layering here was a cross-portal hazard: a project with a `url` but no `apiKey` silently borrowed
> `PORTAL_API_KEY` from the environment — **one portal's address paired with another's key**.
The file uses one schema for both the portal and its VC repo:
```json
{
"portal": { "url": "https://team-a.zuarbase.net", "apiKey": "…", "userId": "…" },
"vc": { "dir": "/path/to/team-a-state", "push": true,
"remote_url": "https://github.com/you/team-a-portal-state.git", "token": "…" }
}
```
**Set it up without hand-editing JSON:** ask Claude to run **`configure_project`** (see [Guided onboarding ↑](#guided-onboarding--theming)). `get_capabilities` shows which portal/repo is in effect under its `config` key (secrets redacted). `./.zuar-portal/` is gitignored, so credentials are never committed.
---
## Getting started
**New portal? Start here.** From the folder you want to work in:
```bash
cd ~/work/acme-portal
claude
> /portal-setup
```
One command. It connects the folder to the portal (writing gitignored credentials + a binding fingerprint), profiles your datasources, interviews you about the business, and writes a project brief the other agents read. Everything below assumes it's done.
Then just talk to Claude:
1. **Confirm the connection** — *"List the datasources on my portal."* → `list_resource (datasource)`.
2. **Look at real data** — *"Show me a few sample rows from the Sales datasource."* → `profile_datasource` (per-column stats + raw sample rows, so Claude sees the real column names first).
3. **Create a block** — *"Create a stat-card block 'Total Orders' showing the order count from Sales."* → reads `zportal://guide/*`, builds the two-field block, `create_block`, reports the UUID.
4. **Iterate** — *"Make the number bigger and use the portal's primary color."* / *"Turn it into a bar chart of orders by state."* → `update_block`.
> [!TIP]
> In **Claude Code**, run **`/portal-build "a stat card of total orders from Sales"`** to push the spec through the whole gated pipeline, or invoke the **`create_zportal_block`** prompt for a structured discover → build → create flow.
---
## Write safety & tool gating
Every write is tagged with a **risk domain**, gated independently:
| Domain | Covers | Default | Enable with |
|--------|--------|---------|-------------|
| `content` | blocks, layouts, partials, themes, queries, snippets, translations, dashboards, tags | **on** | (on unless read-only) |
| `data` | datasources, db_modifications, `run_db_modification` | **off** | `PORTAL_ALLOW_DATA_WRITES=1` |
| `admin` | users, groups, permissions, access policies, API keys, credentials, system, config, passwords | **off** | `PORTAL_ALLOW_ADMIN_WRITES=1` |
- **`PORTAL_READONLY=1`** disables *every* write — reads and discovery still work.
- A blocked write returns a clear message naming the flag to set; nothing reaches the portal.
- `run_db_modification` additionally requires `confirm: true` on every call.
- Deletes and user/password mutations are marked **destructive** to MCP clients.
- **Uniform `dry_run: true`** *(v3.0.0)* on every write tool — all gates run (domain, structure, per-kind rules, references, impact) but nothing is written; the response reports `applied: false` and what would have changed. A dry run never bypasses a gate.
**Least-privilege tool scoping** *(v2.5.0)* — disable whole capability groups with `PORTAL_DISABLE_TOOLS=users,config`, or stand up a build-only allowlist with `PORTAL_ENABLE_TOOLS=blocks,resources,data` (deny wins).
**Upgrading from 2.x** — **`PORTAL_COMPAT_TOOLS=1`** (off by default) registers the removed v2 tool names as deprecated aliases in a `compat` group; each alias forwards to the same gated v3 handler, so aliases confer no extra capability.
**Integrity gates** *(v2.5–2.6, server-side, cannot be bypassed)* — every content write is checked for portal-compatible **structure** (a page missing `grid.layouts` is repaired or rejected) and dangling **references**; deletes run pre-delete **impact** analysis and refuse to orphan dependents unless `force=true`; user deletes refuse to remove the last admin; unscoped mass SQL (a destructive verb without a *real* WHERE — `1=1` doesn't count — plus `MERGE`/`TRUNCATE`/`DROP`/`ALTER…DROP`/`GRANT`) needs `allow_unfiltered=true`, and the check **fails closed** when the SQL can't be inspected. Run the read-only **`validate_portal`** anytime to sweep for problems. Full guide: [docs/16 · Safety & Integrity](docs/16-safety-and-integrity.md).
**Unattended-loop safety** *(unreleased)* — the guarantees that make parallel/overnight agent loops safe to leave alone:
- **No duplicate creates**: a create that fails ambiguously (network died after the request may have landed) is verified by name and **adopted** if it already exists — never blindly resent. `run_db_modification` is never auto-retried at all.
- **No lost updates**: pass `expected_updated_at` (from your read) to `update_resource` / `update_block` / `place_blocks` and a mid-flight change by anyone else refuses with a conflict instead of silently overwriting.
- **No stranded cruft**: name loop temporaries `TMP · <purpose>` (or tag them `scratch`), then sweep with **`cleanup_scratch`** — dry-run by default, deletes only unreferenced non-recent candidates through the gated path, content stays VC-restorable.
- **Provable improvement**: **`score_portal`** scores every block/page 0–100 on the mechanically checkable and diffs against a saved baseline — a loop's regression is a per-record delta, not an anecdote.
- **Admin can't self-destruct**: stripping your *own* admin access needs `allow_self_lockout=true`; `update_config` and `change_password` require `confirm` (config edits return `previous_at_path` for one-call revert).
In the Claude Desktop bundle these are install-dialog toggles; for other clients set them as env vars. Deeper dive: [docs/14 · Tool Gating & Guidance](docs/14-tool-gating-and-guidance.md).
---
## Resilience, observability & hardening
Production-grade behaviour for a local, single-user server — safe defaults, no configuration required.
**Resilience** (the portal HTTP client every tool calls through):
| Behaviour | Default | Tune with |
|-----------|---------|-----------|
| Per-attempt timeout | 30 s | `PORTAL_TIMEOUT_MS` |
| Retries on transient failure (network, 408/425/429/5xx), exp. backoff + jitter, honouring `Retry-After` | 2 | `PORTAL_MAX_RETRIES`, `PORTAL_BACKOFF_BASE_MS`, `PORTAL_BACKOFF_MAX_MS` |
| Circuit breaker — fail fast while the upstream is down | opens after 5 failures, 15 s cooldown | `PORTAL_BREAKER_THRESHOLD`, `PORTAL_BREAKER_COOLDOWN_MS` |
| Max request body size | 5 MB | `PORTAL_MAX_BODY_BYTES` |
| Max tool input size (rejected at the MCP boundary) | 2 MB | `PORTAL_MAX_INPUT_BYTES` |
| `execute_query` returned-row cap | 1,000 rows (`limit:0` = all) | per-call `limit` |
| `list_resource` page byte cap (auto-projects to `{id,name}` + note) | 60k chars | `PORTAL_LIST_BYTE_CAP` (0 disables) |
| Result serialization | compact JSON | `PORTAL_PRETTY_JSON=1` for pretty |
| Binding re-verification cadence | 10 min + on config reload | `PORTAL_BINDING_REVERIFY_MS` |
| Read cache (GETs; any write clears it; write-critical reads bypass it) | 5 s TTL | `PORTAL_READ_CACHE_MS` (0 disables) |
Retry safety: `GET` retries on any transient signal; writes retry only on explicit `429`/`503` back-pressure or a network failure that provably never connected — never on an ambiguous `502`/`504`, and a **create** whose network error may have *delivered* the request is verified by name and adopted rather than resent (no silent duplicates).
**Observability** — every call gets a request id, latency, and an error tally. **`get_metrics`** (always-on) reports per-tool counts, error rate, latency, uptime, and the breaker state — metadata only, no payloads or secrets. Set **`PORTAL_LOG_FORMAT=json`** for structured stderr logs; **`PORTAL_AUDIT_LOG`** appends metadata-only JSONL for every content/data/admin write.
**Output secret redaction** — secret-bearing fields (`password`, `secret`, `token`, `api_key`, …) are masked as `[redacted]` on resource **reads**, so they never flow into the model's context, and secrets are also caught **by shape inside any value or name** — connection-string passwords (`postgresql://user:[redacted]@host`), JWTs, PEM private keys, AWS key ids — including in `execute_query` / `profile_datasource` results. Generic hex and `key=value` shapes are deliberately *not* masked (uuids, git shas and SQL params are legitimate content that round-trips back into writes). Identifier `*_id` fields are never masked; create/update responses are intact (so a freshly generated secret can be seen once). Disable with **`PORTAL_REDACT_SECRETS=0`**. Portal-authored content additionally returns inside an **untrusted-data envelope**, so a poisoned record name reads as data, not instructions.
---
## Troubleshooting
| Symptom | Likely cause / fix |
|---------|--------------------|
| **"failed to connect" / `spawn ENOENT`** | The client couldn't spawn the command. Either (a) the clone moved or was deleted — `claude mcp add` stored an **absolute** path to `dist/index.js`; re-check it with `claude mcp list`; (b) you registered it before running `npm run build`, so `dist/index.js` doesn't exist — build, then restart the client; or (c) `node` isn't on the PATH your client sees (common with nvm + Claude Desktop, which doesn't load your shell profile — use the `.mcpb` there, it bundles its own runtime). |
| **An old guide says to run `npx -y zuar-portal-mcp-server`** | That package is **not on npm** — the command can't work and fails as `ENOENT`. Install from a clone: [Install — Claude Code ↓](#install--claude-code--other-mcp-clients). |
| **Reads work, but every write is refused as `unbound`** | This folder isn't bound to a portal. Run **`/portal-setup`** (or `configure_project`) in it. Binding is what stops one project's block being published to another's portal, so it's deliberate — see [Per-project configuration](#per-project-configuration-multiple-portals). |
| "Missing portal credentials: …" | One of `PORTAL_URL` / `PORTAL_API_KEY` / `PORTAL_USER_ID` is blank. Re-enter it. |
| "…must supply all three of url/apiKey/userId" | A config layer names **some** credentials but not all. That's refused on purpose — a `url` without an `apiKey` used to silently borrow the key from your environment, pairing one portal's address with another's key. Supply all three in **one** place. |
| "Portal login failed: HTTP 401/403" | Wrong API key or user ID, or the user lacks permission. Regenerate the key; confirm the user can manage blocks. |
| `list_resource (query)` says the endpoint isn't available | Your portal predates the saved-queries API (1.18+). Use `resource: "datasource"` — expected, not an error. |
| Tools don't appear in Claude | Restart the client — the tool list is fetched once at handshake, so a newly added server (or a new version) won't show up in a running session. For the `.mcpb`, reinstall it. |
| You upgraded but the new tools/rules aren't there | Same cause: the client is still running the **old process**. Restart it. |
| A v2 tool name is missing (`list_blocks`, `setup_portal`, …) | v3.0.0 removed/renamed it — see [`CHANGELOG.md`](CHANGELOG.md) for the replacement, or set `PORTAL_COMPAT_TOOLS=1` for deprecated forwarding aliases. |
| Want to see what it's doing | Set `PORTAL_DEBUG=1` (or `PORTAL_LOG_FORMAT=json`). Logs go to **stderr** only. |
| "circuit breaker is open" | The upstream failed repeatedly; it auto-recovers after a short cooldown. `get_metrics` shows breaker state. |
| A stored secret returns `[redacted]` | Read redaction is on. Set `PORTAL_REDACT_SECRETS=0` for that session. |
More: [docs/12 · Troubleshooting](docs/12-troubleshooting.md).
---
## Security
- Credentials are **never logged**. Debug output (gated by `PORTAL_DEBUG=1`) goes to stderr only, so it never corrupts the MCP stdio stream.
- The API Key and User ID are declared **sensitive** in the bundle manifest.
- The server talks only to the Portal URL you configure; the base URL is validated as a well-formed `http(s)` origin at startup. `synthesize_theme`'s website fetch (driven by the `design_intake` prompt) is **SSRF-guarded**.
- `create_block` / `update_block` are restricted to `type: "html"` and reject other types before any portal call.
- Secret-bearing fields are **redacted** from reads; tool inputs and request bodies are **size-capped**.
See **[`SECURITY.md`](SECURITY.md)** for the full posture: the per-tool-group data-touch matrix, credential handling, network egress, and data retention.
## License
[MIT](LICENSE).
Connection Info
You Might Also Like
ai-native-pm-os
The exhaustive guide to mastering Claude for Product Managers. Build your...
Train-in-Silence
The first Task-Aware MCP server and automated VRAM calculator for LLM...
stacklit
108,000 lines of code. 4,000 tokens of index. One command makes any repo...
AppClaw
AI-powered mobile automation agent — describe what you want in plain...
pdf-mcp
Production-ready MCP server for PDF processing with intelligent caching....
kotadb
Local-only code intelligence API for AI developer workflows (Bun +...