Skip to content
Draft
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
85 changes: 85 additions & 0 deletions .claude/agents/mcp-maintainer.md
Original file line number Diff line number Diff line change
@@ -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.<ext>` 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: <in-sync | scaffolded | reconciled>` — then either
`SCOPE DONE (verified): <what changed + where it landed + server starts and lists tools>`
or `NO CHANGE — in sync`. Always append
`NEEDS PRODUCT/OPERATOR: <named items you TODO-flagged (tool behaviour, exposure policy, creds)>`
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.
49 changes: 46 additions & 3 deletions .fuze/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"security",
"fuzefront-expert",
"fuzeinfra-expert",
"a2a-maintainer"
"a2a-maintainer",
"mcp-maintainer"
],
"hardening": {
"ruleset": true,
Expand All @@ -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."
Expand Down Expand Up @@ -58,5 +61,45 @@
"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."
},
"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."
]
}
}
8 changes: 8 additions & 0 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions .github/workflows/mcp-maintain.yml
Original file line number Diff line number Diff line change
@@ -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): <what> [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: <in-sync|scaffolded|reconciled>` + what landed + `NEEDS PRODUCT/OPERATOR:` if any).
claude_args: '--allowedTools "Bash,Read,Edit,MultiEdit,Write,Glob,Grep,WebFetch"'
66 changes: 66 additions & 0 deletions registration/README.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions registration/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"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",
"modes": ["portal", "standalone"],
"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", "host": "fuzekeys.fuzefront.com" },
"visibility": "organization",
"roles": []
}
Loading
Loading