Skip to content

feat(server): connect an Azure subscription from the conversation - #39

Open
JeroenSoeters wants to merge 21 commits into
mcp-gcp-connectfrom
mcp-azure-connect
Open

feat(server): connect an Azure subscription from the conversation#39
JeroenSoeters wants to merge 21 commits into
mcp-gcp-connectfrom
mcp-azure-connect

Conversation

@JeroenSoeters

Copy link
Copy Markdown
Contributor

Summary

Adds the Azure arm of the connect flow: an MCP tool, the skill's Azure branch, and the target-creation step. This is how a person actually reaches the feature — through a conversation rather than by typing CLI flags.

Ninth in the held stack; targets mcp-gcp-connect.

The detail relay, which is the part that matters

When Azure credentials are missing, the CLI returns a typed failure carrying an az login command. connectFailureView decodes only an allowlist of detail keys per code, and this exact relay has silently failed twice on this vertical — both times a fix worked on the CLI path and did nothing for the MCP, because details never crossed the decode boundary and a canned string was substituted.

So the test drives the real handler through the real decode path with a real CLI failure envelope, not a hand-built struct. Removing the allowlist entry turns it red.

While in that file: AWS's sso_login_required had the same gap. It carries aws sso login --profile <p> and was not relayed, so the caller was told to "sign in again" without ever being told the command. Fixed here, one line from the Azure entry, rather than left to surface as the same bug a third time.

The producer's raw message stays withheld throughout: it is built from plugin error strings and a Pkl failure quotes profile source, which for a classic profile can hold an inline password. Only named keys from named codes cross.

Target step

Writes an azure.Config with OidcAuth, taking both coordinates from the registration rather than from user input, and sets discoverable explicitly — it defaults to false, and a target that discovers nothing gives no error to explain itself.

TestAzureTargetRoundTrips evaluates the rendered template against the real plugin schema, and reads the pkl block out of SKILL.md rather than keeping a second copy, so it guards the artifact the harness actually writes. It is opt-in through an env var: it skips when unset and fails when set but unusable, so a green run is never silent about not having run. Nothing hermetic replaces it — a vendored schema copy would only prove the copy agrees with itself, which is the error class it exists to catch.

Two pre-existing fixes, both required by this work

  • credentials_required and project_unreachable had GCP-specific prose. Azure reuses both codes, so shipping without this would ship a tool telling Azure users to run gcloud. Verified no GCP path loses its remedy.
  • The GCP skill's step 2G routed to step 3, which is the AWS-only "a profile was chosen" branch. The Azure section needed a continuation and copying the wrong number would have propagated the bug.

Azure gets one tool, the same shape as GCP: formae obtains the operator's
ambient Azure credentials, provisions the managed identity, and registers
it in a single call. There is no register-only counterpart here - a
customer who deploys the ARM template themselves runs `formae connect
azure --tenant-id --client-id` in their own terminal, never through this
tool.

Extends registeredDoc with the Azure coordinate (tenant and client id)
alongside the existing role/workload-identity-provider fields.
… names

The CLI never spawns an `az login`; when there are no usable Azure
credentials it fails naming the exact command under the "command" detail
key, the same key GCP's own non-interactive path already uses. This build
only relayed "output", so the command never reached the caller and the
static credentials_required description - hardcoded to gcloud - took over
instead, telling an Azure operator to run the wrong CLI entirely.

Adds "command" to the relayed detail keys and makes the shared
credentials_required and project_unreachable descriptions cloud-neutral,
since both codes are now used by GCP and Azure alike. Tests drive the real
handler with a real CLI failure document, per the pattern this vertical
has already needed twice: a fix that only works one decode level down and
never reaches the tool's own result is not a fix.
Azure gets the same one-call shape as GCP: ask for the subscription id,
say what near-owner access is about to be granted, call
connect_azure_subscription. Unlike GCP formae never spawns a sign-in -
credentials_required now names the exact `az login` command, which the
skill relays verbatim and waits on rather than running.

Includes the credential-less option: an operator who will not give the
agent provisioning credentials deploys the embedded ARM template
themselves and runs `formae connect azure --tenant-id ... --client-id ...`
in their own terminal, the same "never for you" rule the other terminal
commands in step 7 already carry.

Step 6 gets an Azure branch for target creation: label only, no region -
azure.Config carries none - with subscriptionId and the registration's
tenant/client id filling the OidcAuth block.

Also fixes GCP's own step 2G, which pointed the flow at step 3 (AWS's
"provision directly" continuation) instead of step 6, where every
registration path actually converges; the same mistake would have
propagated into the new Azure section otherwise.
…the real schema

skills/formae-connect/SKILL.md's step 6 hands the harness a vars.pkl
template to write by hand; nothing in this build renders it. Adds
TestAzureTargetRoundTrips, which renders that same template, evaluates it
with the real formae and azure plugin schemas (local checkouts, no
network), and deserializes the result back - checking the object built in
memory would miss a schema-generation error the same way it did here: the
template's imports resolve against the invoking process's own working
directory, not the module file's path, and pkl project resolve's target
directory is a positional argument rather than a --project-dir flag.

Skips rather than fails when the local formae and formae-plugin-azure
checkouts (or the oidc-auth branch that carries OidcAuth, ahead of its
merge to that plugin's main) are not present, since most CI runners won't
have either.
…ready names

sso_login_required carries the exact `aws sso login --profile <p>` command
under the same "command" detail key Azure's credentials_required and GCP's
non-interactive path already use - a named key on a named code, naming a
literal command and a profile. The allowlist never relayed it, so an
expired SSO session reached the caller as "sign in again and retry" with
no way to know how. Same defect this PR closed for Azure, one line away
and one cloud later.

Test drives the real handler (provision_cloud_role) with a real CLI
failure document, confirmed red against the prior allowlist before the
fix.
…schema check on env vars

Two problems in the round-trip test:

- The vars.pkl template was a hand copy of the SKILL.md block, guarded only
  by a comment. The artifact the harness actually writes is the SKILL.md
  block; a drift between the two would leave the test green against the
  stale copy, which inverts what it's for. renderAzureTargetVarsPKL now
  reads the fenced ```pkl block straight out of skills/formae-connect/
  SKILL.md and substitutes its `"<...>"` placeholders by field name.
  Confirmed it still catches a real drift: corrupting the shipped
  subscriptionId field name fails the test immediately, restored after.

- The schema checkouts were hardcoded to this org's private `~/dev/pel`
  layout and named formae-plugin-azure's internal oidc-auth branch - both
  the kind of thing that must never be written down in a public repo. Now
  gated on two env vars (FORMAE_SCHEMA_PKL_PROJECT, FORMAE_AZURE_SCHEMA_PKL):
  skip when unset, fail (not skip) when set to something unusable, so a
  configured machine's green run keeps meaning something even after the
  local checkout moves or breaks.

Verified all three states by hand: unset skips, both real local checkouts
set passes, a bad path set fails outright.
…e command

connect_azure_subscription's own description and step 7's terminal-command
list both dropped --subscription from the register-only example, which the
CLI rejects outright - handing the user a command that fails. Step 2A
already had it right; made the other two match.
…ment was written to avoid naming

The comment explaining why the schema checkout paths are env vars instead
of hardcoded still spelled out both examples it was warning against - the
org's ~/dev/pel layout and formae-plugin-azure's in-flight oidc-auth
branch. A comment naming them is the same disclosure as code naming them.
States the reason without the examples.
…Auth

azure@0.1.10, the version this file pinned, contains zero occurrences of
OidcAuth - the schema addition this whole vertical writes against. The
plugin bumps to 0.1.11 in its own PR (0.1.11-dev.0 already exists on that
branch). The two must land together: this pin is wrong on its own until
that release ships.
…sabled

orphaned_trust: provisioning succeeded and registration did not, so the
subscription already grants a near-owner identity to an installation the
control plane does not know about. The CLI's details (resourceGroup,
identity, clientId) exist because a review demanded automated callers be
able to see and act on that identity - but the code had no description and
no relay entry, so the MCP path (the only machine-mode caller) got
"formae connect failed (orphaned_trust)" and none of the three
coordinates the fix was for. Third time a fix has landed on the CLI path
and not reached this one.

api_disabled: shared with GCP, and the description still said "a Google
API" unconditionally - so an Azure operator whose resource provider is not
registered was told to enable a Google API instead of being told which
Azure provider to register, the same sibling defect already fixed for
credentials_required and project_unreachable in this diff. Description is
now cloud-neutral; "provider" is relayed.

Both tests confirmed red against the prior map before the fix: orphaned_trust
via the unmodified allowlist, api_disabled by temporarily reverting the new
relay entry and rerunning.
renderRegistered, renderGcpRegistered, and renderAzureRegistered differed only
in the noun for the connected thing, the already-registered wording, and the
trust coordinates shown (one line for AWS and GCP, two for Azure, since
neither of its coordinates identifies the trust alone). Pull the shared shape
into renderRegisteredDoc and have each cloud pass its own wording and
pre-formatted coordinate lines.

A characterisation test pins the exact byte output for all three clouds
across the first-connection and already-registered paths, so the wording a
person already reads after connecting stays untouched.
ConnectAzureSubscriptionInput had no way to pass --tenant-id, so guest
or multi-tenant Azure accounts that need an explicit tenant to sign in
at all could never succeed through this tool, and the relayed az login
remedy was always the bare form instead of the tenant-qualified one the
CLI already knows how to produce. Add an optional tenant_id, forwarded
only when supplied so the CLI's automatic derivation stays the default
path for everyone else.
…mple

Step 7 offered "Create an S3 bucket in <label>" whatever cloud had just
been connected, so a GCP or Azure user finished the journey holding a
prompt for a resource their plugin does not have. The example exists to be
pasted, so it now names a storage bucket for GCP and a blob container for
Azure.

Azure's is a small tree: a blob container needs a storage account, which
needs a resource group. The example names the container anyway, because it
is the thing an Azure user recognises as the bucket, and declaring the
recognisable thing while formae brings its parents along is the point
rather than a cost. It also carries the storage account's naming rule,
which is strict enough to reject a label with a hyphen in it.
A caller that needs a command's outcome had no way to ask for it, so it
polled: call get_command_status, shell out to `sleep 5`, call again. That
is slower than waiting server-side, and it puts a run of commands in front
of the user that have nothing to do with the work they asked for.

get_command_status now takes `wait`, which blocks until the command
reaches Success, Failed, or Canceled, and `timeout_seconds` to bound it
(default 300, capped at 900). The tool description tells callers to prefer
it over their own delay loop.

Running out of budget returns the latest status rather than an error, so a
slow command stays distinguishable from a broken one and the caller can
simply wait again. An unreadable or empty payload yields no state, which is
not terminal, so a wait continues rather than reporting a command as
finished on the strength of an answer it could not read. Canceled is
terminal: a caller waiting on a command someone else cancelled has to be
released rather than left polling to its own timeout.
Cursor was the one harness told to install by hand. Its instructions ran
`go install ...@latest` for the server and then asked the reader to fetch
formae and the oidc plugin themselves, while the Claude Code plugin,
OpenCode and Codex all start `scripts/start-mcp.sh`, which downloads a
matched set on first launch. Three consequences, none of them intended:

`go install @latest` builds from the default branch, so a Cursor user got
whatever main happened to hold rather than the release the skills were
written against. Every tool added since the last time main was released was
simply absent, and nothing said so.

The two manual package installs were separate steps that a reader could
skip or half-complete, and the oidc one is the difference between a hosted
sign-in working and failing.

Least visibly, a server started outside the launcher does not get formae's
own bin directory on PATH. formae shells out to `pkl` by bare name to read
a plugin's manifest, which is how an auth plugin is recognised as one, so
that server reports the oidc plugin as missing while it sits on disk - the
exact failure the manual oidc step was there to prevent.

The PATH section shrinks rather than growing: with the launcher resolving
its own binaries, the FORMAE_BIN workaround it used to prescribe is not
needed at all.
…ing none

A machine without the az CLI got "formae connect failed (az_missing)" - a
bare code - while the producer's own message named both remedies: install
the CLI, or take the template path, which needs no CLI and no credential at
all. The code had no row in the description map, so it fell through to the
branch that prints the code and nothing else, and the template path became
invisible to anyone who only saw that.

The guard against this was a hardcoded list of one cloud's codes, which
cannot catch the omission it exists to catch: it was written after four GCP
codes went missing and froze exactly those four, so Azure adding a fifth
code failed nothing. It now covers every code the CLI can emit, so a new
one has to be given prose or the test says so.
The credential-less path existed but could only be described, not driven.
Establishing trust without provisioning credentials meant the caller told
the user to run two formae commands themselves, which is unusable from a
harness: an MCP-native user has no terminal in the loop, has no reason to
know a formae binary is on the machine, and frequently does not have one on
PATH. A command handed to that user is a dead end wearing the costume of a
next step.

get_azure_trust_template returns the portal deep link, asking the producer
for it rather than rebuilding the URL here where it would drift from the
CLI's. register_azure_trust completes the flow with the tenant and client id
the deployment outputs.

Registration is its own tool rather than three optional fields on
connect_azure_subscription: the two paths are not variants of one operation,
since this one provisions nothing and needs no Azure credential, and folding
them together would let a client id arrive without a tenant id, which the
CLI rejects outright. Required inputs make that unrepresentable instead of a
rule in prose.
az_missing's description told the caller that "the user runs `formae connect
azure template` themselves". That was written before the tool which does it
existed, and survived its arrival. It is the one thing this path must never
say: an MCP caller's user has no terminal in the loop, no reason to know a
formae binary is on the machine, and usually no PATH that reaches it, so a
formae command reads as a next step and is a dead end.

It now names get_azure_trust_template and register_azure_trust, which is
what the harness should call, and says outright not to pass a formae command
on. The install link stays, because the az CLI is the user's own tool and
theirs to install.

A test pins the rule across the whole description map rather than this one
row, since the failure mode is a description written when a tool did not
exist yet and left alone afterwards.

Note this was never visible to a CLI user: the producer's own message, which
correctly names `formae connect azure template` for someone who has the
binary, never crosses into the MCP. Only this map's prose reaches an MCP
caller, and only it was wrong.
Making the closing example cloud-specific turned a one-line instruction
into a bolded directive with three sub-bullets and six lines of rationale
hanging off the Azure one. A run through the journey then produced step 7's
first bullet verbatim and dropped the second entirely: the action was buried
under argument about why the action was right.

It is one line again, naming all three examples inline. The rationale is
gone because it was arguing with a hypothetical rather than telling anyone
what to do.

The storage-account naming rule goes too. It was added on the theory that a
label with a hyphen would produce an invalid account name, but the journey
had already generated a valid one twice before the rule existed, so it was
guidance for a failure that does not happen — and it sat in the step that
writes a closing message rather than the one that authors a forma.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant