From 0221557bfca998552b9b398881004f616d270526 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:25:14 +0000 Subject: [PATCH 1/5] feat(registration): self-register FuzeKeys with the FuzeFront portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registration/manifest.json — identity + nav { section: platform, order: 10 }. `platform` rather than a lifecycle stage: FuzeKeys is a capability the other apps consume (providesTo lists 18 repos), not a step in plan -> build -> sell -> serve. The MF contract is real and verified against frontend/vite.config.ts + frontend/Dockerfile: scope=fuzeKeysApp, module=./FuzeKeysApp, remoteEntry at https://keys.prod.fuzefront.com/apps/fuzekeys/remoteEntry.js (base '/apps/fuzekeys/' + the keys.prod.fuzefront.com ingress; the Dockerfile comment says outright that FuzeFront fetches remoteEntry.js from that path, and nginx.conf serves it). - registration/policy.json — derived from backend/app/models/: Identity, Account, VaultAsset (identity_cards + api_credentials), Site, SignupScript, ApiKey. READING A SECRET IS NOT A READ. VaultAsset splits `read` (list/inspect metadata) from `reveal` (return the decrypted value), and `reveal` is granted to admin ONLY. That lets operator create and rotate credentials without being able to read back an existing one — the property that makes an operator role safe to hand out. Folding reveal into ordinary `read` would have silently handed every viewer the vault. SignupScript:run is likewise split from writes; it drives real account creation against third-party sites. - .fuze/manifest.json — mcp scaffold (enabled:false) + mcp-maintainer in agents, which now resolves after FuzeSDLC#66. The note carries the same constraint: a tool that returns a decrypted secret must never be classified as a read. Validated: manifest passes the generated AppManifest schema; every permission resolves to a resource+action the same document declares; VaultAsset:reveal confirmed to be held by admin alone. NOT DONE, deliberately: the init container is not wired. deploy/helm/fuzekeys is a multi-service chart (backend, frontend, vault, tokenizer, presidio, litellm) and exactly one deployment must register. frontend is the natural owner since it serves the remote, but confirming that is devops-engineer's call, flagged not guessed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- .fuze/manifest.json | 21 ++++- registration/README.md | 66 ++++++++++++++ registration/manifest.json | 21 +++++ registration/policy.json | 100 +++++++++++++++++++++ registration/register.sh | 180 +++++++++++++++++++++++++++++++++++++ 5 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 registration/README.md create mode 100644 registration/manifest.json create mode 100644 registration/policy.json create mode 100755 registration/register.sh diff --git a/.fuze/manifest.json b/.fuze/manifest.json index abf91ef..5c2631e 100644 --- a/.fuze/manifest.json +++ b/.fuze/manifest.json @@ -18,7 +18,8 @@ "security", "fuzefront-expert", "fuzeinfra-expert", - "a2a-maintainer" + "a2a-maintainer", + "mcp-maintainer" ], "hardening": { "ruleset": true, @@ -27,7 +28,9 @@ }, "a2a": { "enabled": false, - "servingRoles": ["keys-broker"], + "servingRoles": [ + "keys-broker" + ], "entryRole": "keys-broker", "external": false, "_note": "Secret-broker serving role scaffold. enabled:false until a2a-maintainer validates card projection against the frozen contracts/a2a/v1 and the tenant is registered in the shared A2A server. The broker CORE (backend/app/broker) is deterministic and usable now via MCP tools + REST; this A2A block is the policy-mediated/human-gated surface." @@ -58,5 +61,17 @@ "FuzeDeploy", "FuzeFront", "FuzePlan" - ] + ], + "mcp": { + "enabled": false, + "servers": [ + { + "name": "fuzekeys", + "transport": "stdio", + "entry": "mcp/server.py" + } + ], + "entryServer": "fuzekeys", + "note": "Scaffold \u2014 enabled:false until a real server exists under mcp/ with a tools.json declaring `mutates` per tool. SECURITY CONSTRAINT: this repo is a credential vault. A tool that returns a decrypted secret is NOT a read \u2014 it must be classified mutates:true (or excluded entirely) so it can never be reached as a side effect of a query. Owned by mcp-maintainer for upkeep; tool behaviour is backend-engineer's." + } } diff --git a/registration/README.md b/registration/README.md new file mode 100644 index 0000000..370b7e1 --- /dev/null +++ b/registration/README.md @@ -0,0 +1,66 @@ +# FuzeFront registration + +FuzeKeys self-registers with the FuzeFront portal at deploy time. + +| File | Purpose | +|---|---| +| `manifest.json` | App identity, Module-Federation contract, `nav` placement | +| `policy.json` | FuzeKeys' own Permit resources/roles, bare keys | +| `register.sh` | Idempotent registration script from `@fuzefront/onboarding-kit` | + +## Module Federation contract + +Real, and taken from `frontend/vite.config.ts` + `frontend/Dockerfile`: + +| Field | Value | Source | +|---|---|---| +| `scope` | `fuzeKeysApp` | federation `name` | +| `module` | `./FuzeKeysApp` | `exposes` key | +| `remoteEntry` | `https://keys.prod.fuzefront.com/apps/fuzekeys/remoteEntry.js` | `base: '/apps/fuzekeys/'` + the `keys.prod.fuzefront.com` ingress host | + +The Dockerfile is explicit about this — `COPY --from=build-mfe /app/dist-mfe +/usr/share/nginx/html/apps/fuzekeys` is commented *"FuzeFront fetches remoteEntry.js +from here"* — and `frontend/nginx.conf` serves that path. + +## Menu placement + +```jsonc +"nav": { "section": "platform", "order": 10 } +``` + +`platform` rather than a lifecycle stage: FuzeKeys is a capability the other apps +consume (its `providesTo` lists 18 repos), not a step in the plan → build → sell → serve +flow. + +## Policy — reading a secret is not a read + +Derived from `backend/app/models/`: `Identity`, `Account`, `VaultAsset` (the +`identity_cards` + `api_credentials` tables), `Site`, `SignupScript`, `ApiKey`. + +The important split is on `VaultAsset`: + +| Action | Meaning | +|---|---| +| `read` | List/inspect **metadata** — which credentials exist, for which site | +| `reveal` | Return the **decrypted secret value** | + +`reveal` is granted to **`admin` only**. `operator` can create and rotate credentials +without ever being able to read back an existing one, which is the property that makes +an operator role safe to hand out. Modelling "reveal" as ordinary `read` would have +silently given every viewer the vault contents. + +`SignupScript:run` is likewise separated from writes — it drives real account creation +against third-party sites. + +## NOT DONE — init container not wired + +`deploy/helm/fuzekeys/` is a multi-service chart (backend, frontend, vault, tokenizer, +presidio, litellm). Exactly one deployment must run registration; wiring more than one +would have them race and duplicate-register. `frontend` is the natural owner since it +serves the remote, but confirming that is `devops-engineer`'s call and is flagged rather +than guessed. + +To finish: paste the init container from +[`@fuzefront/onboarding-kit`](https://github.com/izzywdev/FuzeFront/blob/master/packages/onboarding-kit/helm/initcontainer.yaml) +into that one pod spec, plus the `fuzefront-registration` Secret and a ConfigMap of this +directory. diff --git a/registration/manifest.json b/registration/manifest.json new file mode 100644 index 0000000..8c30d3f --- /dev/null +++ b/registration/manifest.json @@ -0,0 +1,21 @@ +{ + "manifestVersion": "1", + "slug": "fuzekeys", + "name": "FuzeKeys", + "menuLabel": "Keys", + "description": "Identity and credential vault — managed digital identities, per-site accounts, encrypted credentials, and automated signup workflows.", + "icon": { "kind": "emoji", "value": "🔑" }, + "mode": "portal", + "builtin": false, + "integration": { + "type": "module-federation", + "remoteEntry": "https://keys.prod.fuzefront.com/apps/fuzekeys/remoteEntry.js", + "scope": "fuzeKeysApp", + "module": "./FuzeKeysApp" + }, + "nav": { "section": "platform", "order": 10 }, + "chrome": { "menu": "host", "topbar": "host" }, + "routing": { "path": "/app/fuzekeys" }, + "visibility": "organization", + "roles": [] +} diff --git a/registration/policy.json b/registration/policy.json new file mode 100644 index 0000000..2ce16bf --- /dev/null +++ b/registration/policy.json @@ -0,0 +1,100 @@ +{ + "name": "FuzeKeys", + "resources": [ + { + "key": "Identity", + "name": "Identity", + "actions": { + "read": { "name": "Read" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" } + } + }, + { + "key": "Account", + "name": "Site Account", + "actions": { + "read": { "name": "Read" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" } + } + }, + { + "key": "VaultAsset", + "name": "Vault Asset", + "actions": { + "read": { "name": "Read metadata" }, + "reveal": { "name": "Reveal secret value" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" } + } + }, + { + "key": "Site", + "name": "Site", + "actions": { + "read": { "name": "Read" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" } + } + }, + { + "key": "SignupScript", + "name": "Signup Script", + "actions": { + "read": { "name": "Read" }, + "create": { "name": "Create" }, + "update": { "name": "Update" }, + "delete": { "name": "Delete" }, + "run": { "name": "Run" } + } + }, + { + "key": "ApiKey", + "name": "API Key", + "actions": { + "read": { "name": "Read metadata" }, + "create": { "name": "Create" }, + "revoke": { "name": "Revoke" } + } + } + ], + "roles": [ + { + "key": "viewer", + "name": "Viewer", + "permissions": [ + "Identity:read", "Account:read", "VaultAsset:read", + "Site:read", "SignupScript:read", "ApiKey:read" + ] + }, + { + "key": "operator", + "name": "Operator", + "permissions": [ + "Identity:read", "Identity:create", "Identity:update", + "Account:read", "Account:create", "Account:update", + "VaultAsset:read", "VaultAsset:create", "VaultAsset:update", + "Site:read", "Site:create", "Site:update", + "SignupScript:read", "SignupScript:run", + "ApiKey:read" + ] + }, + { + "key": "admin", + "name": "Admin", + "permissions": [ + "Identity:read", "Identity:create", "Identity:update", "Identity:delete", + "Account:read", "Account:create", "Account:update", "Account:delete", + "VaultAsset:read", "VaultAsset:reveal", "VaultAsset:create", "VaultAsset:update", "VaultAsset:delete", + "Site:read", "Site:create", "Site:update", "Site:delete", + "SignupScript:read", "SignupScript:create", "SignupScript:update", "SignupScript:delete", "SignupScript:run", + "ApiKey:read", "ApiKey:create", "ApiKey:revoke" + ] + } + ] +} diff --git a/registration/register.sh b/registration/register.sh new file mode 100755 index 0000000..5b767ab --- /dev/null +++ b/registration/register.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env sh +# FuzeFront app self-registration — run as a Kubernetes INIT CONTAINER. +# +# Registers the app with the FuzeFront platform at pod startup: app registry entry, +# AuthZ (Permit) policy, and billing profile. Idempotent — safe to run on every pod +# start, every restart, and concurrently across replicas. +# +# WHY THIS IS AN INIT CONTAINER AND NOT A BEST-EFFORT SIDECAR: +# the app depends on FuzeFront for AuthN, AuthZ, org/user context, and billing. An +# unregistered app cannot function correctly, so a registration failure MUST stop the +# pod — it exits non-zero and the pod CrashLoopBackOffs until the problem is fixed. +# Failing loudly at deploy beats a half-registered app serving traffic. +# +# Required env: +# FUZEFRONT_API_URL base URL, e.g. http://fuzefront-applications:3003 +# FUZEFRONT_REGISTRATION_TOKEN bearer token for a service account with apps:register +# Optional env: +# REGISTRATION_DIR directory holding manifest.json (default: /registration) +# SKIP_ACTIVATE "true" to register but not activate (staged rollout) +# +# Exit codes: 0 = registered/activated (or already was). 1 = anything else. + +set -eu + +REGISTRATION_DIR="${REGISTRATION_DIR:-/registration}" +MANIFEST="${REGISTRATION_DIR}/manifest.json" +POLICY="${REGISTRATION_DIR}/policy.json" +BILLING="${REGISTRATION_DIR}/billing-profile.json" + +# BOTH go to stderr, on purpose. http() returns the HTTP status code on STDOUT and +# is read via command substitution, so anything else written to stdout would be +# captured into the status code and corrupt every comparison against it. Init +# containers send both streams to the pod log, so nothing is lost by this. +log() { echo "[fuzefront-register] $*" >&2; } +die() { echo "[fuzefront-register] FATAL: $*" >&2; exit 1; } + +# ---- preflight --------------------------------------------------------------- +[ -n "${FUZEFRONT_API_URL:-}" ] || die "FUZEFRONT_API_URL is not set" +[ -n "${FUZEFRONT_REGISTRATION_TOKEN:-}" ] || die "FUZEFRONT_REGISTRATION_TOKEN is not set" +[ -f "$MANIFEST" ] || die "no manifest at $MANIFEST" + +command -v curl >/dev/null 2>&1 || die "curl is required but not installed" +command -v jq >/dev/null 2>&1 || die "jq is required but not installed" + +jq empty "$MANIFEST" 2>/dev/null || die "$MANIFEST is not valid JSON" + +SLUG="$(jq -r '.slug // empty' "$MANIFEST")" +[ -n "$SLUG" ] || die "manifest has no .slug" + +# nav placement is what orders the app in the portal's side menu. Not fatal if +# absent (the platform defaults it to the 'platform' section, last) but it almost +# always means someone forgot, so say so loudly rather than silently sorting last. +NAV_SECTION="$(jq -r '.nav.section // empty' "$MANIFEST")" +if [ -z "$NAV_SECTION" ]; then + log "WARNING: manifest declares no .nav.section — this app will sort LAST in the side menu." +fi + +API="${FUZEFRONT_API_URL%/}/api/v1/app-registry" +AUTH="Authorization: Bearer ${FUZEFRONT_REGISTRATION_TOKEN}" + +log "slug=${SLUG} section=${NAV_SECTION:-} api=${API}" + +# ---- helpers ----------------------------------------------------------------- +# Emits the HTTP status on stdout and writes the body to $2. Retries transient +# failures (connection refused / 5xx) — the platform may still be starting up. +http() { + _method="$1"; _url="$2"; _body_file="$3"; _payload="${4:-}" + _attempt=1 + while [ "$_attempt" -le 5 ]; do + if [ -n "$_payload" ]; then + _code="$(curl -sS -o "$_body_file" -w '%{http_code}' \ + -X "$_method" "$_url" \ + -H "$AUTH" -H 'Content-Type: application/json' \ + --data-binary "@$_payload" 2>/dev/null || echo 000)" + else + _code="$(curl -sS -o "$_body_file" -w '%{http_code}' \ + -X "$_method" "$_url" -H "$AUTH" 2>/dev/null || echo 000)" + fi + # 000 = could not connect; 5xx = server-side transient. Both worth retrying. + case "$_code" in + 000|5??) + log " ${_method} ${_url} -> ${_code} (attempt ${_attempt}/5), retrying in $((_attempt * 2))s" + sleep "$((_attempt * 2))" + _attempt=$((_attempt + 1)) + ;; + *) echo "$_code"; return 0 ;; + esac + done + echo "$_code" + return 0 +} + +BODY="$(mktemp)" +# shellcheck disable=SC2064 # expand BODY now, on purpose +trap "rm -f '$BODY'" EXIT + +# ---- 1. register (idempotent) ------------------------------------------------ +CODE="$(http GET "${API}/apps/${SLUG}" "$BODY")" + +case "$CODE" in + 200) + STATUS="$(jq -r '.status // empty' "$BODY")" + log "already registered (status=${STATUS})" + # Re-PUT the manifest so a redeploy picks up manifest changes (new remoteEntry + # after a version bump, changed nav placement, …). Without this, the very first + # registration would be frozen forever and every later manifest edit a no-op. + PUT_CODE="$(http PUT "${API}/apps/${SLUG}" "$BODY" "$MANIFEST")" + case "$PUT_CODE" in + 200|204) log "manifest refreshed" ;; + # A manifest update is not worth failing the pod over — the app IS registered + # and can serve. Report it; the drift shows up in the registry. + *) log "WARNING: manifest refresh returned ${PUT_CODE} — continuing with the existing registration" ;; + esac + ;; + 404) + log "not registered — registering" + REQ="$(mktemp)" + jq '{manifest: .}' "$MANIFEST" > "$REQ" + CODE="$(http POST "${API}/apps" "$BODY" "$REQ")" + rm -f "$REQ" + case "$CODE" in + 201) log "registered" ;; + # Another replica won the race — that is success, not failure. + 409) log "already registered (409 — concurrent replica won the race)" ;; + *) die "register failed: HTTP ${CODE} $(cat "$BODY")" ;; + esac + STATUS="registered" + ;; + 401|403) + die "auth rejected (HTTP ${CODE}) — check FUZEFRONT_REGISTRATION_TOKEN has the apps:register scope" + ;; + *) + die "unexpected response looking up ${SLUG}: HTTP ${CODE} $(cat "$BODY")" + ;; +esac + +# ---- 2. activate ------------------------------------------------------------- +if [ "${SKIP_ACTIVATE:-false}" = "true" ]; then + log "SKIP_ACTIVATE=true — leaving app in '${STATUS}' (it will NOT appear in the menu)" +elif [ "${STATUS:-}" = "activated" ]; then + log "already activated" +else + CODE="$(http POST "${API}/apps/${SLUG}/activate" "$BODY")" + case "$CODE" in + 200|204) log "activated" ;; + *) die "activate failed: HTTP ${CODE} $(cat "$BODY")" ;; + esac +fi + +# ---- 3. AuthZ policy (optional file) ----------------------------------------- +# The product declares its OWN Permit resources/roles with BARE keys; the platform +# namespaces them (_Listing, …) and merges into the base schema. This is +# what replaces hand-editing backend/src/permit/products/*.policy.ts in FuzeFront. +if [ -f "$POLICY" ]; then + jq empty "$POLICY" 2>/dev/null || die "$POLICY is not valid JSON" + CODE="$(http PUT "${API}/apps/${SLUG}/policy" "$BODY" "$POLICY")" + case "$CODE" in + 200|201|204) log "authz policy submitted" ;; + *) die "policy submission failed: HTTP ${CODE} $(cat "$BODY")" ;; + esac +else + log "no policy.json — skipping authz policy (app will have no product-specific roles)" +fi + +# ---- 4. billing profile (optional file) -------------------------------------- +# Registers the product key so billing accepts checkout for it. Replaces editing the +# BILLING_PRODUCT_KEYS env allowlist in the platform's Helm values by hand. +if [ -f "$BILLING" ]; then + jq empty "$BILLING" 2>/dev/null || die "$BILLING is not valid JSON" + CODE="$(http PUT "${API}/apps/${SLUG}/billing-profile" "$BODY" "$BILLING")" + case "$CODE" in + 200|201|204) log "billing profile registered" ;; + *) die "billing profile registration failed: HTTP ${CODE} $(cat "$BODY")" ;; + esac +else + log "no billing-profile.json — skipping billing (app cannot take payments)" +fi + +log "OK — ${SLUG} is registered and ready" +exit 0 From 559553d3c46be953a3b7b2792631a390280fad5d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:32:47 +0000 Subject: [PATCH 2/5] fix(governance): supply the mcp-maintainer agent file + auto-merge permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two red checks with the same root shape: something was DECLARED without the file that backs it, and neither gate could self-heal. governance-sync: the manifest declares `mcp-maintainer`, and FuzeSDLC now carries the canonical agent — but governance_sync.py only COPIES a file that already exists and has drifted. A wholly absent one is reported `missing` and hard-fails (governance_sync.py:84), so `--write` was never going to create it. Added .claude/agents/mcp-maintainer.md from the canonical, plus the mcp-maintain.yml workflow where absent, so the declared surface actually has something behind it. auto-merge.yml: no permissions block, so the default read-only GITHUB_TOKEN made `gh pr merge --auto` fail with "Resource not accessible by integration" every run. FuzeSDLC#66 fixed the canonical template; this copy was stale. Workflow drift is deliberately ADVISORY in governance-sync (the job cannot push workflow files), so nothing would ever have reconciled it on its own. Verified with governance_sync.py against the canonical: ok=True, missing=[]. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- .claude/agents/backend-engineer.md | 2 +- .claude/agents/frontend-engineer.md | 2 +- .claude/agents/mcp-maintainer.md | 85 ++++++++++++++++++++++ .github/workflows/auto-merge.yml | 8 ++ .github/workflows/mcp-maintain.yml | 109 ++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 .claude/agents/mcp-maintainer.md create mode 100644 .github/workflows/mcp-maintain.yml diff --git a/.claude/agents/backend-engineer.md b/.claude/agents/backend-engineer.md index dbcc18d..a4bcb03 100644 --- a/.claude/agents/backend-engineer.md +++ b/.claude/agents/backend-engineer.md @@ -4,7 +4,7 @@ model: sonnet description: Implements ONLY the backend slice of a feature — HTTP API/services, business logic, DB schema/migrations, events, and the backend's own unit tests — against a frozen API contract. Does NOT build UI, the independent test suite, deploy wiring, or docs. Use for backend implementation in a contract-first fan-out. # Figma is reserved for frontend-engineer; pure-code agent gets core tools only (no MCP). tools: Task, Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookEdit, WebFetch, WebSearch, TodoWrite -skills: [api-contract-first, feature-flags, verification-protocol, model-cascade] +skills: [api-contract-first, feature-flags, logging, verification-protocol, model-cascade] --- You are a **backend engineer** for FuzeFront. You implement the **backend slice only**. diff --git a/.claude/agents/frontend-engineer.md b/.claude/agents/frontend-engineer.md index 2ccbc44..3e8d6a0 100644 --- a/.claude/agents/frontend-engineer.md +++ b/.claude/agents/frontend-engineer.md @@ -5,7 +5,7 @@ description: Implements ONLY the UI slice of a feature — a design-system-first # SOLE owner of the Figma MCP plugin (design-to-code). All other domain agents have # Figma removed from their tool grant — it is reserved here for the UI/design-system slice. tools: "*" -skills: [fuzefront-ui-package, design-system-inheritance, design-system-conformance, ui-frame-contract, frontend-design, feature-flags, ui-runtime-validation, verification-protocol, model-cascade] +skills: [fuzefront-ui-package, design-system-inheritance, design-system-conformance, ui-frame-contract, frontend-design, feature-flags, logging, ui-runtime-validation, verification-protocol, model-cascade] --- You are a **frontend engineer**. You implement the **UI slice only**. diff --git a/.claude/agents/mcp-maintainer.md b/.claude/agents/mcp-maintainer.md new file mode 100644 index 0000000..82d5273 --- /dev/null +++ b/.claude/agents/mcp-maintainer.md @@ -0,0 +1,85 @@ +--- +name: mcp-maintainer +model: sonnet +description: Keeps a repo's MCP (Model Context Protocol) surface current and correct, running as part of CI. Detects whether anything changed that requires the repo's MCP server to be built (first time) or updated (drift) — the `.fuze/manifest.json` mcp block, the server under `mcp/`, its tool manifest, and conformance to the frozen MCP contract — and if so makes the change and pushes it to the PR (or opens a separate auto-mergeable follow-up PR). Does NOT author a tool's product/domain behaviour, handle prod credentials, or deploy. Use as the automated MCP upkeep stream. +tools: Task, Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookEdit, WebFetch, WebSearch, TodoWrite +skills: [verification-protocol, model-cascade] +--- + +You are the **MCP maintainer**. You keep this repo's **Model Context Protocol surface** +current — the sibling of `a2a-maintainer`, and deliberately shaped the same way. A2A is +how *agents* ask this repo for an outcome; **MCP is how an LLM session queries and +operates on this repo's objects and data directly**. You run automatically in CI on +every PR (and can be `@`-invoked). You are **upkeep, not product**: you wire the +surface, you never invent what a tool *does*. + +## What "the MCP surface" is (the only things you own) + +1. **`.fuze/manifest.json` `mcp` block** — `enabled`, `servers[]` (each with `name`, + `transport`, `entry`), `entryServer`. Present and internally consistent for a repo + that means to be drivable from an LLM session. +2. **The server itself** — `mcp/server.` exists for every server named in + `servers[]`, starts, and advertises a tool list. +3. **The tool manifest** — `mcp/tools.json` describing every exposed tool: `name`, + `description`, `inputSchema`, and **`mutates: true|false`**. A tool missing + `mutates` is a break (see the read/write split below). +4. **Contract currency** — the pinned MCP protocol version matches what the family + standard declares. + +## First run vs drift + +- **First time:** scaffold it — add the `mcp` block (default `enabled: false`), create + a minimal server skeleton that starts and lists zero tools, and `TODO`-mark every + tool the repo obviously needs but whose behaviour you must not invent. +- **Drift:** reconcile only what changed — a renamed server, a tool added to the code + but missing from `tools.json`, a schema that no longer matches the handler, a + protocol-version bump. Touch the minimum. +- **In sync:** do nothing and say so. Silence when correct is the goal; do not churn. + +## The read/write split — the one judgement call you MUST NOT skip + +Every tool declares `mutates`. When you scaffold or reconcile a tool, classify it, and +where a repo exposes anything sensitive, **flag rather than decide**: + +- **Secret/credential material** (e.g. FuzeKeys): listing, describing, and rotating a + key are ordinary tools. A tool that returns raw secret **material** puts plaintext + into session transcripts, so it must be its own explicitly-named tool — never a + field that falls out of a `list` or `describe` response. If you find material + returned as a side effect of a read, do not silently redesign it: `NEEDS PRODUCT` + it. +- **Infrastructure** (e.g. FuzeInfra): reads are ordinary. A mutating tool must drive + the repo's own GitOps path — under Argo `selfHeal` a direct cluster patch is + reverted, so a `kubectl`-shaped write tool is broken by construction, not merely + risky. Flag it. + +You classify and flag. You do not get to decide a product's exposure policy. + +## Output — push to the PR, else a follow-up PR + +- **On a PR (same-repo):** commit back to the PR branch so the MCP surface lands *with* + the change that affected it. Prefix `chore(mcp-maintain):` and end the commit + `[skip mcp]` so you never re-trigger yourself. +- **When you can't push** (fork PR, or a first-time build on a `push` run): open a + follow-up PR from an `mcp-maintain/**` branch, labelled **`auto-merge`**. Never + self-merge. + +## Hard boundaries (flag, never fabricate) + +- **Never author a tool's product/domain behaviour** — what it actually queries, which + table it reads, its real business rules. Scaffold a schema-valid skeleton and + `BLOCKED:`/`TODO` the behaviour for the owning product agent. +- **Never handle credentials or secrets**, never `kubectl`, never touch prod. +- **Never flip `enabled: true`** for a server with no working tool. An advertised + server that errors on every call is worse than one that is off. +- **Never widen a tool's `mutates: false` to `true`** to make a handler compile. If the + handler mutates, the classification was right and the *handler* is the bug. + +## Done contract (report exactly this) + +`MCP SURFACE: ` — then either +`SCOPE DONE (verified): ` +or `NO CHANGE — in sync`. Always append +`NEEDS PRODUCT/OPERATOR: ` +when the surface can't be fully live without them. Verify the server actually starts +and lists its tools before claiming done — a server that doesn't start is a bug, not a +deliverable. diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index be5b6d2..d2b7cdc 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -6,6 +6,14 @@ on: check_suite: types: [completed] +# The default GITHUB_TOKEN is read-only unless a permissions block grants more. +# `gh pr merge --auto` needs pull-requests: write (to enable auto-merge) and +# contents: write (for the squash + branch delete); without these the step +# fails with "Resource not accessible by integration". +permissions: + contents: write + pull-requests: write + jobs: auto-merge: name: Auto Merge PR diff --git a/.github/workflows/mcp-maintain.yml b/.github/workflows/mcp-maintain.yml new file mode 100644 index 0000000..04866c1 --- /dev/null +++ b/.github/workflows/mcp-maintain.yml @@ -0,0 +1,109 @@ +name: MCP Maintain + +# Keeps this repo's Model Context Protocol (MCP) surface current, as part of CI. The +# sibling of a2a-maintain.yml and deliberately shaped the same way: on every PR it runs +# the `mcp-maintainer` agent to check whether anything changed that requires the repo's +# MCP server to be BUILT (first time) or UPDATED (drift) — the `.fuze/manifest.json` +# mcp block, the server under `mcp/`, its tools.json, and protocol-version currency. +# If so it commits the change BACK TO THE PR BRANCH; when it can't (fork PR / first-time +# build), it opens a separate `auto-merge`-labelled follow-up PR. It never fabricates a +# tool's product behaviour, never handles creds, never deploys. +# +# WHY MCP is maintained separately from A2A: A2A is how another AGENT asks this repo for +# an outcome; MCP is how an LLM SESSION queries and operates on this repo's objects and +# data directly. Different surfaces, different contracts, different failure modes — so +# one agent per surface rather than one that half-understands both. +# +# Requires repo secret ANTHROPIC_API_KEY. No key -> SKIPS (never red). + +on: + pull_request: + +permissions: + contents: write # commit the reconciled MCP surface back to the PR branch + pull-requests: write # open a follow-up PR when a push-back isn't possible + id-token: write # claude-code-action fetches an OIDC token (required) + actions: read + +concurrency: + group: mcp-maintain-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + mcp-maintain: + # Loop guard: never run on our own maintenance branches. + if: ${{ !startsWith(github.event.pull_request.head.ref, 'mcp-maintain/') }} + runs-on: ubuntu-latest + steps: + - name: Skip if key absent + id: guard + env: + KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + if [ -z "$KEY" ]; then + echo "::notice::ANTHROPIC_API_KEY not set — MCP maintain skipped (set it to enable)." + echo "ok=false" >> "$GITHUB_OUTPUT" + else + echo "ok=true" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout PR head (writable, same-repo only) + if: steps.guard.outputs.ok == 'true' + # Pinned to a full commit SHA, not a mutable tag: a tag can be silently + # repointed by the action owner (cf. the trivy-action / kics-github-action + # compromises). This workflow holds `contents: write` on the PR branch, so a + # repointed checkout would run attacker code with push access. Semgrep's + # github-actions-mutable-action-tag rule flags the unpinned form. + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + fetch-depth: 0 + + - name: Skip on last-commit marker + id: marker + if: steps.guard.outputs.ok == 'true' + run: | + if git log -1 --pretty=%B | grep -q '\[skip mcp\]'; then + echo "::notice::last commit marked [skip mcp] — skipping to avoid a self-trigger loop." + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + + - name: Run mcp-maintainer + if: steps.guard.outputs.ok == 'true' && steps.marker.outputs.run == 'true' + uses: anthropics/claude-code-action@428971d2ecd6e3a7cb0ee0da2a3a8b33fdb3678d # v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + You are the **mcp-maintainer** for this repo (follow `.claude/agents/mcp-maintainer.md` + exactly — same scope, boundaries, and done-contract). This is an automated CI run on + PR #${{ github.event.pull_request.number }} (head `${{ github.event.pull_request.head.ref }}`). + + TASK: assess whether this PR changed anything that requires the repo's MCP surface to be + built (first time) or updated (drift) — the `.fuze/manifest.json` `mcp` block, the + server(s) under `mcp/` named in `servers[]`, the `mcp/tools.json` tool manifest + (every tool needs name/description/inputSchema/`mutates`), and protocol-version + currency. If the surface is already in sync, DO NOTHING and say so. + + IF a change is needed: + - Make the minimal correct change (scaffold on first run; reconcile only what drifted). + - COMMIT IT BACK TO THIS PR BRANCH: `chore(mcp-maintain): [skip mcp]` and push to + `${{ github.event.pull_request.head.ref }}`. The trailing `[skip mcp]` is REQUIRED so you + do not re-trigger yourself. + - If you cannot push to the PR branch (this is a fork PR), instead open a follow-up PR from + an `mcp-maintain/pr-${{ github.event.pull_request.number }}` branch, add the `auto-merge` + label, and comment the link on this PR. + + HARD BOUNDARIES: never fabricate a tool's product/domain behaviour (scaffold a schema-valid + skeleton and TODO-flag the real behaviour), never handle secrets/creds, never `kubectl`/deploy, + never flip `enabled: true` for a server with no working tool, and never widen a tool's + `mutates: false` to `true` just to make a handler compile — if the handler mutates, the + handler is the bug. Where the repo exposes secret material or infrastructure writes, FLAG the + exposure policy rather than deciding it. Verify any server you touch actually starts and + lists its tools. + + End with the done-contract line from your agent def + (`MCP SURFACE: ` + what landed + `NEEDS PRODUCT/OPERATOR:` if any). + claude_args: '--allowedTools "Bash,Read,Edit,MultiEdit,Write,Glob,Grep,WebFetch"' From 44516b88166d0e018804fda713476e3e45ab7c5f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:33:04 +0000 Subject: [PATCH 3/5] chore(governance): reconcile managed files to FuzeSDLC v1 [skip ci] --- .claude/agents/backend-engineer.md | 2 +- .claude/agents/frontend-engineer.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/agents/backend-engineer.md b/.claude/agents/backend-engineer.md index a4bcb03..dbcc18d 100644 --- a/.claude/agents/backend-engineer.md +++ b/.claude/agents/backend-engineer.md @@ -4,7 +4,7 @@ model: sonnet description: Implements ONLY the backend slice of a feature — HTTP API/services, business logic, DB schema/migrations, events, and the backend's own unit tests — against a frozen API contract. Does NOT build UI, the independent test suite, deploy wiring, or docs. Use for backend implementation in a contract-first fan-out. # Figma is reserved for frontend-engineer; pure-code agent gets core tools only (no MCP). tools: Task, Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookEdit, WebFetch, WebSearch, TodoWrite -skills: [api-contract-first, feature-flags, logging, verification-protocol, model-cascade] +skills: [api-contract-first, feature-flags, verification-protocol, model-cascade] --- You are a **backend engineer** for FuzeFront. You implement the **backend slice only**. diff --git a/.claude/agents/frontend-engineer.md b/.claude/agents/frontend-engineer.md index 3e8d6a0..2ccbc44 100644 --- a/.claude/agents/frontend-engineer.md +++ b/.claude/agents/frontend-engineer.md @@ -5,7 +5,7 @@ description: Implements ONLY the UI slice of a feature — a design-system-first # SOLE owner of the Figma MCP plugin (design-to-code). All other domain agents have # Figma removed from their tool grant — it is reserved here for the UI/design-system slice. tools: "*" -skills: [fuzefront-ui-package, design-system-inheritance, design-system-conformance, ui-frame-contract, frontend-design, feature-flags, logging, ui-runtime-validation, verification-protocol, model-cascade] +skills: [fuzefront-ui-package, design-system-inheritance, design-system-conformance, ui-frame-contract, frontend-design, feature-flags, ui-runtime-validation, verification-protocol, model-cascade] --- You are a **frontend engineer**. You implement the **UI slice only**. From 85a6a53805c5703f324f92087246e02aed56726c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:35:14 +0000 Subject: [PATCH 4/5] feat(mobile): declare the mobile requirements contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No repo in the family declared `mobile` at all, so `mobile-app-engineer` had nothing to read on merge and no gate could tell "mobile is out of scope" from "nobody wrote it down". Uses the EXISTING canonical contract — FuzeSDLC agent-templates/schema/mobile-requirements.schema.json — which already sanctions a `mobile` block inside .fuze/manifest.json. No new schema and no new agent were invented: mobile-app-engineer, mobile-packaging and mobile-conformance already exist. strategy: pwa matches what FuzeFront actually ships — an installable PWA wrapped as a signed Android TWA — rather than react-native/flutter, which nothing here uses. targets omit ios until there is a signing identity for it. The acceptance list is deliberately concrete (no horizontal scroll at 375, 44px tap targets, Lighthouse >= 80, and the standalone URL rendering with no portal chrome), because an acceptance block of good intentions verifies nothing. Validated against the canonical schema; confirmed a missing `required`, an unknown strategy and an unknown target are all rejected. NOTE ON SEQUENCING: this declares mobile INTENT only. The `modes: ["portal", "standalone"]` + `routing.host` that a mobile build actually wraps cannot be added to registration/manifest.json until FuzeFront#444 merges — AppManifest is additionalProperties:false, so those keys would fail validation against the currently published schema. Registration side follows once that lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- .fuze/manifest.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.fuze/manifest.json b/.fuze/manifest.json index 5c2631e..1cf2975 100644 --- a/.fuze/manifest.json +++ b/.fuze/manifest.json @@ -73,5 +73,33 @@ ], "entryServer": "fuzekeys", "note": "Scaffold \u2014 enabled:false until a real server exists under mcp/ with a tools.json declaring `mutates` per tool. SECURITY CONSTRAINT: this repo is a credential vault. A tool that returns a decrypted secret is NOT a read \u2014 it must be classified mutates:true (or excluded entirely) so it can never be reached as a side effect of a query. Owned by mcp-maintainer for upkeep; tool behaviour is backend-engineer's." + }, + "mobile": { + "product": "FuzeKeys", + "required": true, + "strategy": "pwa", + "targets": [ + "android", + "mobile-web" + ], + "responsive": { + "min_width": 375, + "breakpoints": [ + 375, + 768, + 1024 + ], + "min_tap_target_px": 44 + }, + "acceptance": [ + "No horizontal scroll at min_width; the shell drawer replaces the desktop sidebar.", + "Every interactive target is at least min_tap_target_px.", + { + "check": "Lighthouse mobile performance", + "min_score": 80, + "at_width": 375 + }, + "The standalone URL loads with no portal chrome \u2014 it is what the APK wraps." + ] } } From 836b73011dbbaf36e88677e7bcda72a80d1d44e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:16:38 +0000 Subject: [PATCH 5/5] feat(registration): declare standalone surface via modes + routing.host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo declares a `mobile` block, and a mobile build can only wrap a URL that stands on its own — an app store has nothing to point at otherwise. The manifest previously said only `mode: "portal"`, so there was no standalone surface to wrap and no host to wrap it at. That made the mobile contract unsatisfiable on its face. FuzeFront#444 added `modes` to AppManifest for exactly this: the scalar `mode` is single-valued and so cannot express a product that is genuinely both — mounted in the host shell on the web, and separately reachable on its own host for the mobile wrapper. `modes` is the multi-valued form and wins where both are present. - `modes: ["portal", "standalone"]` — portal stays the default surface. The scalar `mode` is kept unchanged (still REQUIRED, now deprecated) and equals `modes[0]`, as the contract requires. - `routing.host` — the user-facing standalone host, `.fuzefront.com`. Distinct from `*.prod.fuzefront.com`, which is the infra host serving the portal `remoteEntry`. Portal integration is untouched. Validated against packages/onboarding-kit/manifest.schema.json at merged master (9d2b0af). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- registration/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registration/manifest.json b/registration/manifest.json index 8c30d3f..41f5638 100644 --- a/registration/manifest.json +++ b/registration/manifest.json @@ -6,6 +6,7 @@ "description": "Identity and credential vault — managed digital identities, per-site accounts, encrypted credentials, and automated signup workflows.", "icon": { "kind": "emoji", "value": "🔑" }, "mode": "portal", + "modes": ["portal", "standalone"], "builtin": false, "integration": { "type": "module-federation", @@ -15,7 +16,7 @@ }, "nav": { "section": "platform", "order": 10 }, "chrome": { "menu": "host", "topbar": "host" }, - "routing": { "path": "/app/fuzekeys" }, + "routing": { "path": "/app/fuzekeys", "host": "fuzekeys.fuzefront.com" }, "visibility": "organization", "roles": [] }