Skip to content

feat(mcp): OpenAPI contract + MCP SSE pod, de-prefixed slug - #65

Open
github-actions[bot] wants to merge 3 commits into
masterfrom
claude/fuze-4pod-convergence
Open

feat(mcp): OpenAPI contract + MCP SSE pod, de-prefixed slug#65
github-actions[bot] wants to merge 3 commits into
masterfrom
claude/fuze-4pod-convergence

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What this is

FuzeKeys had no OpenAPI contract, which made an MCP pod impossible — the gateway derives its tools from a spec. This adds the contract, wires the MCP SSE pod into the chart, and de-prefixes the registration slug.

There is no A2A pod, and that is a correction, not a deferral. I built one and deleted it. See "Why there is no A2A pod" below — the published image cannot serve a per-product deployment.

The API I found, and the spec I wrote

70 operations across 65 paths, and the backend already publishes them: FastAPI serves /openapi.json and /docs (verified against the running app), and GET /health already existed.

contracts/openapi.yaml is derived, not hand-written. scripts/export-openapi.py exports it straight out of the FastAPI application, so "the contract describes the real endpoints" is a property of the build rather than a promise in a review. --check re-derives and compares the (method, path) set against the committed file — the failure that actually matters downstream, because the gateway turns every operation in the contract into a tool: one missing from the contract is an invisible tool, one only in the contract is a tool that 404s.

Two findings, reported not fixed:

  1. The live app emits duplicate operationIds for two accounts routes — FastAPI warns about it at startup. FastAPI's auto-generated ids also run to 76 characters while the MCP gateway truncates tool names at 64, so two operations could collide into one tool. The contract therefore assigns its own deterministic METHOD + path ids (unique by construction, all ≤ 61 chars). The application keeps FastAPI's. That divergence is narrow and deliberate: operationId is metadata, not API shape, and fixing it in the app would mean editing routers another workstream is currently rewriting.
  2. scripts/export-openapi.py --check is not wired into CI. It would go red inside the security-migration PR that is rewriting auth.py, which is not this PR's call to make.

Irreversible operations, and why

The classification is on effect, not verb.

Operation Why
post_integrations_signup, post_google_signup_* Creates a real account on a third-party site. FuzeKeys has no operation that deletes it — the write happened on someone else's system.
post_integrations_apikey Creates a real API key at a third-party site; typically shown once, and not revocable from here.
post_broker_redeem Grants default to single-use (redemption_count check in broker/service.py), so this both burns the grant and discloses the secret. Neither half is undoable.
post_broker_revoke Final for that grant id — a revoked grant is never re-armed.
post_chat_signup Starts the automated signup against a live site.
post_infrastructure_mobile_send_command A delivered command has already executed; there is no recall.
delete_identities_*, delete_llm_scraper_* The gateway's default, recorded explicitly with reasons so nobody "simplifies" them later.

get_credentials_account_by_account_id_credentials is a GET, and it is declared mutates: true. It delegates to request_account_credentials and returns decrypted credentials. Disclosing a secret is not a read — a secret that has been handed out cannot be un-handed-out. The gateway forbids calling a GET irreversible (invariant 3: if a GET really changed state, the spec is what needs fixing), so mutates: true is the strongest correct signal: it removes the operation from the read-only set, so a client that auto-approves reads can never reach it.

Deliberately not marked: post_broker_grant and post_broker_mint_tokenrevoke is exactly their undo, which is what "reversible" means.

Pod state — honestly

Pod State
Backend Already deployed (backend.enabled: true). Serves /health and /openapi.json. ✅
Frontend Already deployed (React SPA on nginx). Not touched — the landing page is a minimum, not a rewrite. ✅
MCP SSE New. templates/mcp-gateway.yaml, shared @fuzefront/mcp-gateway image pointed at fuzekeys-backend:8002. Enabled in values-contabo.yaml, matching FuzeService. ✅
A2A Not a pod. Handed off as a tenant registrationdocs/a2a-tenant-registration.md. ❌ by design

The backend is genuinely deployed, so the MCP pod has a real upstream to call. Worth stating plainly: a sibling in this family ships a chart that deploys only its MFE, which leaves its MCP pod able to enumerate tools and unable to execute a single one. That is not the shape here — backend.enabled: true in both values.yaml and values-contabo.yaml, and backend/Dockerfile exists and is built by build-and-push.yml on push to master.

Verified: booted the real @fuzefront/mcp-gateway from the exact bytes helm template renders into the ConfigMap — 70 tools enumerate, and scripts/smoke.mjs passes over the real SSE transport (handshake, no read-only tool on an unsafe method, every irreversible tool is a write, unauthenticated call fails closed with zero upstream requests). Every override key was also checked to resolve to a real operationId — an override for a tool that does not exist is silently ignored by the gateway.

Why there is no A2A pod

I wrote one, shipped it disabled, and then deleted it. The reason is not a policy preference — it is in the image's source. agent-templates/a2a/card_generator.py line 29:

IN_CLUSTER_URL = "http://a2a-shared.fuzeagent.svc.cluster.local:8080/rpc"

_interface() returns that constant as the callable endpoint of every non-external Agent Card. It is not values-driven and no environment variable overrides it; the only branch is external: true, which swaps in a public tunnel URL. So a per-product pod would boot, pass its health check, and publish a card advertising the shared server's address. Every caller dials a2a-shared.fuzeagent; the per-product pod never receives a request. A pod that looks healthy and is functionally dead is worse than no pod, because nothing goes red.

I also had the premise wrong and want it on the record: A2A is not an "LLM → REST/OpenAPI" bridge. It reads no OpenAPI and proxies no REST. It is a JSON-RPC 2.0 agent-delegation server whose Agent Card is projected from .fuze/manifest.json + agent-templates/roles/<role>/role.json. The OpenAPI-to-tools job is the MCP gateway's — and that one is genuinely per-product, and stays.

docs/a2a-tenant-registration.md is the real deliverable: the exact tenants[] entry to add to FuzeAgent's a2a-shared values, and the one precondition FuzeKeys still fails — agent-templates/roles/keys-broker/role.json has summary but no description, and the projection fails rather than shipping a placeholder (card-projection.md §3). FuzeKeys is ahead of its siblings on the other precondition: its providesTo is already populated with 18 entries, where an empty list would mean deny-all.

Slug

registration/manifest.json: slug fuzekeyskeys, name FuzeKeysKeys. Nothing else. routing.path and integration.remoteEntry still contain fuzekeys because those are real nginx serving paths, not the registration slug.

keys is 4 characters, so it clears the contract's Slug pattern (which requires ≥3 — short de-prefixed slugs like bi do not). Validated rather than assumed.

slug is immutable once registered, so the live migration, the delete-after-verify ordering and the Permit grant question are owned by a separate workstream — nothing here calls a registry API.

Argo CD — a FuzeInfra delegation

No Application manifest is added. deploy/argocd/README.md carries the adoption spec instead: repoURL, path, targetRevision, values layering, destination namespace, sync options (including why ServerSideApply is now required — the chart mounts a ~110 KB spec into a ConfigMap), the workload inventory with the values key gating each pod, and the Secrets the release expects.

It also records, without changing them, that this repo still carries in-repo Application manifests under deploy/argocd/applications/. Deleting those is a deploy-affecting change owned by the workstream that did FuzeMarket #61.

Assumptions flagged

  • ghcr.io/izzywdev/fuze-mcp-gateway:0.1.0 is assumed published from FuzeFront packages/mcp-gateway. I could not confirm the tag — an anonymous GHCR manifest probe returns 403 for a private package, indistinguishable from "does not exist". If it is not there, the MCP pod sits in ImagePullBackOff and nothing else in the release is affected; flip mcp.enabled: false until it is.
  • build-and-push.yml triggers on backend/**, frontend/**, pii-tokenizer/** and Chart.yaml. This PR touches none of those, so no image rebuild is expected or needed — the MCP inputs are chart-side, so Argo picks them up without one.

Verified vs not

Verified: helm lint (default and values-contabo.yaml) — 0 failed. helm template with both. Every rendered Service selects exactly one workload (backend, frontend, mcp) — a selector collision would load-balance API traffic into nginx with nothing going red. Gateway boots from the rendered ConfigMap bytes: 70 tools, smoke passes, no override references a missing operation. Manifest validates against packages/onboarding-kit/manifest.schema.json; policy passes validate-policy.mjs --slug keys. FastAPI's /openapi.json route confirmed on the running app. All three Dockerfiles referenced by build-and-push.yml exist.

NOT verified: no cluster operations of any kind — nothing was applied, and no pod was ever scheduled. I did not run the backend test suite (backend/tests): the full dependency set is heavy and I changed no backend code, only added contracts/, mcp/, scripts/, docs/ and chart templates. The GHCR tag above.

Related

.fuze/manifest.json still has mcp.enabled: false. Per the gateway README that flag should flip only once the pod has run against the real spec in the cluster — the local smoke run is necessary, not sufficient.

izzywdev and others added 2 commits August 3, 2026 10:06
Brings FuzeKeys to the platform's 4-pod standard (backend, frontend, MCP,
A2A) and gives it the OpenAPI contract it had never had.

contracts/openapi.yaml — DERIVED, not hand-written
--------------------------------------------------
scripts/export-openapi.py exports the document straight out of the FastAPI
application, so "the contract describes the real endpoints" is a property of
the build rather than a promise in a review. 70 operations across 65 paths.
`--check` re-derives and compares the (method, path) set against the committed
file, which is the failure that matters downstream: the MCP gateway turns every
operation in the contract into a tool, so an operation missing from the
contract is an invisible tool and one that is only in the contract is a tool
that 404s.

The backend already serves the document over HTTP: FastAPI publishes
/openapi.json and /docs, verified against the running app.

mcp/tools.overrides.yaml — the part the HTTP method gets wrong
--------------------------------------------------------------
Two FuzeKeys-specific consequences drive the classification, and neither is
visible from the verb:

  * Disclosing a secret is not a read. GET /api/credentials/account/{id}/
    credentials delegates to request_account_credentials and returns DECRYPTED
    credentials; it is declared mutates:true so a client that auto-approves
    reads can never reach it. (The gateway forbids calling a GET irreversible —
    if a GET really changed state the spec would be what needs fixing — so
    mutates:true is the strongest correct signal.)
  * This API reaches out of the product. signup / apikey / google-signup create
    a real account or a real API key on somebody else's system; FuzeKeys has no
    operation that undoes them. Marked irreversible.
    broker/redeem is single-use (redemption_count check in broker/service.py)
    and releases the secret, so it both burns the grant and discloses — neither
    half is undoable. broker/revoke is final for that grant id.
    grant and mint-token stay reversible: revoke is exactly their undo.

Verified by booting the real @fuzefront/mcp-gateway against this spec:
70 tools enumerate, scripts/smoke.mjs passes (handshake, no read-only tool on
an unsafe method, every irreversible tool is a write, unauthenticated call
fails closed).

Pods
----
* MCP SSE — templates/mcp-gateway.yaml, the shared gateway image configured
  with this product's spec + in-cluster backend URL. Holds no credential of its
  own; forwards the caller's Authorization header, which on a credential vault
  is the whole point. Enabled in values-contabo.yaml, matching FuzeService.
* A2A — templates/a2a.yaml, the pre-built ghcr.io/izzywdev/fuzeagent-a2a image
  with exactly the env surface that image declares. Rendered values.json parses
  with FuzeAgent's own a2a.config.load_config and validates against the frozen
  values-interface.schema.json. Left DISABLED: the family contract specifies one
  shared A2A server with tenants[] entries rather than a pod per product, the
  keys-broker role has no `description` so card projection fails by design, and
  the OIDC issuer and JWS signing key do not exist for FuzeKeys yet. Those are
  reported, not invented.

Slug
----
registration/manifest.json: slug fuzekeys -> keys, name FuzeKeys -> Keys, and
nothing else. slug is immutable once registered, so the live migration (and the
Permit grant question) is owned by a separate workstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
…ing one

FuzeInfra owns Argo Application and AppProject objects — they are the
destination and security boundary for the shared cluster, and a product repo
that ships its own competes with it. FuzeMarket PR #61 made that move for
FuzeMarket a day ago.

deploy/argocd/README.md is the handoff: repoURL, path, targetRevision, the
values files to layer, destination namespace, sync options (including WHY
ServerSideApply is needed — the chart now mounts a ~110 KB OpenAPI document into
a ConfigMap), the full workload inventory with the values key that gates each
one, and the Secrets the release expects to already exist.

It also records, without changing them, that this repo STILL carries in-repo
Application manifests under deploy/argocd/applications/. Deleting those is a
deploy-affecting change owned by the workstream that did FuzeMarket #61, so they
are flagged rather than removed here.

One thing worth stating explicitly, because the failure is silent: the FuzeKeys
chart deploys the backend, so the MCP gateway pod has a real upstream. A sibling
in this family ships a chart that deploys only its MFE, which leaves its MCP pod
able to enumerate tools and unable to execute any of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
@izzywdev
izzywdev marked this pull request as ready for review August 3, 2026 10:30
@izzywdev
izzywdev self-requested a review as a code owner August 3, 2026 10:30
@izzywdev izzywdev added the auto-merge label Aug 3, 2026 — with Claude
The A2A Deployment/Service I added is deleted, and the values block with it.
It could not have worked, and it is worth being exact about why rather than
just deferring it.

agent-templates/a2a/card_generator.py line 29:

    IN_CLUSTER_URL = "http://a2a-shared.fuzeagent.svc.cluster.local:8080/rpc"

`_interface()` returns that CONSTANT as the callable endpoint of every
non-external Agent Card. It is not values-driven and no env var overrides it;
the only branch is `external: true`, which swaps in a public tunnel URL. So a
per-product pod would boot, pass its health check, and publish a card
advertising the SHARED server's address. Every caller would dial
a2a-shared.fuzeagent and the per-product pod would never receive a request — a
pod that looks healthy and is functionally dead, which is worse than no pod,
because nothing goes red.

FuzeAgent's docs/a2a/enable-your-pod.md says the same in prose: one shared
server, onboarding is data, "never a new pod". I had flagged that as a design
tension and shipped the pod disabled; the constant makes it not a tension but a
fact, so the templates go rather than sit there waiting for someone to enable
them.

Also correcting the premise: A2A is not an "LLM to REST/OpenAPI" bridge. It
reads no OpenAPI and proxies no REST. It is a JSON-RPC 2.0 agent-delegation
server whose Agent Card is PROJECTED from .fuze/manifest.json plus
agent-templates/roles/<role>/role.json. The OpenAPI-to-tools job is the MCP
gateway's, and that one IS genuinely per-product and stays.

docs/a2a-tenant-registration.md replaces the templates with the real handoff:
the exact `tenants[]` entry to add to FuzeAgent's a2a-shared values, and the one
precondition this repo still fails — agent-templates/roles/keys-broker/role.json
has `summary` but no `description`, and the projection fails rather than
shipping a placeholder. (FuzeKeys is ahead of its siblings on the other one: its
`providesTo` is already populated, where an empty list would mean deny-all.)

Also verified, since a Service that selects two workloads load-balances API
traffic into the wrong pod and nothing goes red: every Service this chart
renders selects exactly one workload (backend, frontend, mcp).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
@github-actions
github-actions Bot enabled auto-merge (squash) August 3, 2026 10:41
@izzywdev izzywdev changed the title feat(4pod): OpenAPI contract, MCP SSE pod, A2A pod, de-prefixed slug feat(mcp): OpenAPI contract + MCP SSE pod, de-prefixed slug Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant