feat(server): connect an Azure subscription from the conversation - #39
Open
JeroenSoeters wants to merge 21 commits into
Open
feat(server): connect an Azure subscription from the conversation#39JeroenSoeters wants to merge 21 commits into
JeroenSoeters wants to merge 21 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 logincommand.connectFailureViewdecodes 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, becausedetailsnever 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_requiredhad the same gap. It carriesaws 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
messagestays 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.ConfigwithOidcAuth, taking both coordinates from the registration rather than from user input, and setsdiscoverableexplicitly — it defaults to false, and a target that discovers nothing gives no error to explain itself.TestAzureTargetRoundTripsevaluates the rendered template against the real plugin schema, and reads the pkl block out ofSKILL.mdrather 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_requiredandproject_unreachablehad GCP-specific prose. Azure reuses both codes, so shipping without this would ship a tool telling Azure users to rungcloud. Verified no GCP path loses its remedy.