Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4d244b8
fix(config): identify installations by ksuid
JeroenSoeters Aug 16, 2026
19c0192
feat(secret): add a credential type that masks in every rendering path
JeroenSoeters Aug 16, 2026
7f1a6a8
feat(config): resolve the connection and its credential in one read
JeroenSoeters Aug 16, 2026
b88eb09
feat(server): route hosted requests to their installation
JeroenSoeters Aug 16, 2026
98078c4
feat(server): declare retryability and handle a hosted 401
JeroenSoeters Aug 16, 2026
efabc53
feat(server): stop reporting a hosted routing 404 as an empty list
JeroenSoeters Aug 16, 2026
bb95f2e
test(server): pin that hosted ambiguity reaches the caller as an inst…
JeroenSoeters Aug 16, 2026
ab4dc7e
feat(server): report which installation answered
JeroenSoeters Aug 16, 2026
71426e9
docs(changelog): record hosted support
JeroenSoeters Aug 16, 2026
10f614e
fix(server): scrub credentials from responses, bound them, and stop o…
JeroenSoeters Aug 16, 2026
f7366d5
fix(server): bound and scrub extract output, count a partial write as…
JeroenSoeters Aug 16, 2026
3686a3b
fix(server): scrub extract output before truncating it
JeroenSoeters Aug 16, 2026
04a7271
fix(server): explain a hosted 404 everywhere, not just on collections
JeroenSoeters Aug 16, 2026
3705bbc
Merge branch 'mcp-one-install' into mcp-hosted-credentials
JeroenSoeters Aug 25, 2026
e856f83
refactor(routing): drop the unused withEndpoint helper
JeroenSoeters Sep 1, 2026
9120ee7
Merge branch 'mcp-one-install' into mcp-hosted-credentials
JeroenSoeters Sep 4, 2026
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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,19 @@ Install via the

### Added

- Hosted formae support. A profile whose `cli.connection` is a `Hosted` connection now routes to its installation behind the shared endpoint and carries a credential, so every tool works against a hosted installation the way it does against a self-hosted agent. Requires formae 0.89.0 or newer.
- Hosted results say which installation answered, in a separate block alongside the payload. When a change fails after it was already sent, the result says so rather than implying nothing happened, so you know whether to go and check.
- The MCP now warns when the connected formae agent is newer than your local `formae`, so you can tell when authoring may not reflect the agent's latest capabilities. The notice points at `/formae:upgrade`, which fetches the newer `formae` after you confirm (never silently in classic mode).

### Changed

- Plugin renamed from `formae-mcp` to `formae`; added `/formae:setup` and `/formae:upgrade`.
- The plugin now downloads its prebuilt `formae-mcp` and a matched `formae` into `~/.formae-ai/opt` on first run (no build-from-source; set `FORMAE_MCP_DEV=1` for local dev builds).
- Commands issued through the MCP (apply, destroy, cancel, status, list) now identify with your CLI's client ID (`~/.pel/formae/cli_client_id`) instead of a fixed `formae-mcp` identity, so the agent attributes them to the same client as your own `formae` runs. When the ID file does not exist yet, the MCP runs `formae --version` once so formae creates it, and falls back to the old `formae-mcp` identity if it still cannot be read.
- Configuration now comes from the formae CLI (`formae profile show`) instead of a text scan of the profile file, so the MCP and your own `formae` runs always agree on where a profile points. Requires formae 0.89.0 or newer.
- Configuration and credentials now come from a single `formae connection resolve` per tool call, replacing `formae profile show`, so the MCP and your own `formae` runs always agree on where a profile points and a request can never combine one profile revision's endpoint with another's credential. Requires formae 0.89.0 or newer.
- An expired hosted credential is refreshed and the call retried once, but only for reads. A change that fails with an expired credential refreshes it for next time and reports the failure rather than being sent twice.
- On a hosted profile, "not found" from the shared endpoint is now explained rather than passed on. It answers that way for an installation it can no longer route to — one that was suspended or destroyed, or whose subscription lapsed — which can happen part-way through a long session. Every tool now says so, instead of reporting an empty result, an unhealthy agent, or a command that was never missing.
- When several profiles exist and none is named, a hosted call now lists the candidates and asks for the `profile` argument instead of guessing.
- Every agent request is built by one internal executor, so cancellation and timeouts apply uniformly across every tool.
- The plugin no longer installs a second `formae` alongside one you already have. On launch it looks for yours (`PATH`, then `/opt/pel/bin`, `/usr/local/bin`, `~/.local/bin`, `~/bin`) and uses it; it downloads one into `~/.formae-ai/opt` only when the machine has none. Previously it downloaded a copy on every launch and then ran whichever `formae` came first on `PATH`, so the downloaded one was usually dead weight — and `/formae:upgrade` could upgrade a copy the plugin was not running. Installs are compared by their resolved location, so a symlink pointing into the managed tree, or a home directory that is itself a symlink, is not mistaken for a second install that the plugin then declines to upgrade.
- The version-skew notice now says which upgrade applies: `/formae:upgrade` for the copy the plugin installed, or the path of your own install, which the plugin will not change.
Expand Down
18 changes: 13 additions & 5 deletions internal/config/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@ import (
// credentials may be sent is an escape hatch that outlives its reason.
const HostedOrigin = "https://cloud.formae.ai"

// installationRE matches the canonical lowercase UUID text form. The constraint
// is syntactic and mirrors what the edge accepts as a routing key; it says
// nothing about UUID version or variant bits.
var installationRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// installationRE is the routing-key grammar: 27 base62 characters, case
// sensitive, which is the text form of a KSUID. It mirrors byte for byte what
// the edge accepts as a routing key.
//
// It is a shape check and deliberately not a decode. 27 base62 digits span a
// wider range than the 160 bits a KSUID encodes, so a few strings this accepts
// would fail a KSUID parser. Refusing them would make this client stricter than
// the edge that does the routing, so we would refuse an identifier the router
// would have accepted, and gain nothing: nothing mints one that cannot be
// decoded, and a well-formed identifier that is not routable comes back from
// the edge as a 404 that says so.
var installationRE = regexp.MustCompile(`^[0-9A-Za-z]{27}$`)

// Connection is where the MCP sends agent requests. It has exactly two arms, so
// a resolved configuration cannot be both classic and hosted, and a hosted one
Expand Down Expand Up @@ -49,7 +57,7 @@ func ValidateHosted(h Hosted) error {
return err
}
if !installationRE.MatchString(h.Installation) {
return fmt.Errorf("installation %q is not a canonical lowercase UUID", h.Installation)
return fmt.Errorf("installation %q is not a well-formed installation id", h.Installation)
}
return nil
}
Expand Down
38 changes: 30 additions & 8 deletions internal/config/connection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import "testing"
func TestValidateHosted_AcceptsCanonicalEndpointAndInstallation(t *testing.T) {
h := Hosted{
Endpoint: "https://cloud.formae.ai",
Installation: "3f2b8c14-0000-4000-8000-000000000000",
Installation: "3HzFPXfPDGhwLJJVtaHbmFs6vLa",
}
if err := ValidateHosted(h); err != nil {
t.Fatalf("expected valid hosted connection, got error: %v", err)
Expand All @@ -29,7 +29,7 @@ func TestValidateHosted_RejectsBadEndpoints(t *testing.T) {
t.Run(name, func(t *testing.T) {
err := ValidateHosted(Hosted{
Endpoint: endpoint,
Installation: "3f2b8c14-0000-4000-8000-000000000000",
Installation: "3HzFPXfPDGhwLJJVtaHbmFs6vLa",
})
if err == nil {
t.Fatalf("expected %s endpoint %q to be rejected", name, endpoint)
Expand All @@ -40,12 +40,18 @@ func TestValidateHosted_RejectsBadEndpoints(t *testing.T) {

func TestValidateHosted_RejectsBadInstallations(t *testing.T) {
cases := map[string]string{
"uppercase": "3F2B8C14-0000-4000-8000-000000000000",
"braces": "{3f2b8c14-0000-4000-8000-000000000000}",
"too short": "3f2b8c14-0000-4000-8000-00000000000",
"not a uuid": "default",
"empty": "",
"with spaces": "3f2b8c14-0000-4000-8000-000000000000 ",
// The format installations used to carry. Nothing mints one now, so a
// profile naming one addresses an installation that cannot exist.
"the retired uuid form": "3f2b8c14-0000-4000-8000-000000000000",
"braces": "{3HzFPXfPDGhwLJJVtaHbmFs6vLa}",
"one short": "3HzFPXfPDGhwLJJVtaHbmFs6vL",
"one long": "3HzFPXfPDGhwLJJVtaHbmFs6vLaa",
"a hyphen": "3HzFPXfPDGhwLJJVtaHbmFs6v-a",
"an underscore": "3HzFPXfPDGhwLJJVtaHbmFs6v_a",
"not an installation": "default",
"empty": "",
"trailing space": "3HzFPXfPDGhwLJJVtaHbmFs6vL ",
"a newline": "3HzFPXfPDGhwLJJVtaHbmFs6vLa\n",
}
for name, id := range cases {
t.Run(name, func(t *testing.T) {
Expand All @@ -56,3 +62,19 @@ func TestValidateHosted_RejectsBadInstallations(t *testing.T) {
})
}
}

// The check is the routing key's grammar, not a decode. 27 base62 digits span a
// wider range than the 160 bits a KSUID encodes, so a few well-formed strings
// would fail a KSUID parser. Refusing them here would make this client stricter
// than the edge that does the routing, which validates the same grammar: we
// would refuse an identifier the router accepts and gain nothing, because
// nothing mints one that cannot be decoded. Pinned so the limit is a decision.
func TestValidateHosted_ChecksTheRoutingGrammarNotADecode(t *testing.T) {
err := ValidateHosted(Hosted{
Endpoint: HostedOrigin,
Installation: "zzzzzzzzzzzzzzzzzzzzzzzzzzz",
})
if err != nil {
t.Fatalf("a well-formed identifier must be accepted without decoding it: %v", err)
}
}
165 changes: 165 additions & 0 deletions internal/config/failure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package config

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"

"github.com/platform-engineering-labs/formae-mcp/internal/secret"
)

// failureSchemaVersion is the envelope shape this build understands. It is
// checked before any other field, exactly as the success document's is.
const failureSchemaVersion = 1

// diagnose is the invitation attached to every declared failure. It is the same
// command, run by hand, and its human output is deliberately not redacted the
// way this consumer is: a person looking at their own profile in their own
// terminal is not the exposure we are closing.
const diagnose = "run `formae connection resolve` to see why"

// declaredCodes is the closed namespace the producer promises. A code outside
// it is a protocol mismatch, not a message to pass along.
var declaredCodes = map[string]bool{
"ambiguous_profile": true,
"auth_failed": true,
"untrusted_issuer": true,
"no_connection": true,
"internal": true,
}

// declaredPluginCodes is what an auth plugin may report.
//
// This is validated rather than trusted, unlike the top-level code, because the
// producer does not close it: the plugin's error code is a bare string alias
// copied through without a membership check, so `details.pluginCode` is an open
// channel wearing a closed channel's name. A plugin that put a token there
// would otherwise route it straight into a tool result.
var declaredPluginCodes = map[string]bool{
"unsupported": true,
"not_logged_in": true,
"session_expired": true,
"issuer_unreachable": true,
}

// unrecognisedPluginCode replaces a plugin code we did not declare. It says a
// code was present and withheld, which is all a reader can safely be told.
const unrecognisedPluginCode = "unrecognised"

// ResolveError is a failure the CLI declared. It carries the code so a caller
// can branch on it, and never the producer's free-text message.
type ResolveError struct {
Code string
// PluginCode is the auth plugin's own code for an auth_failed, validated
// against the declared set. Empty when absent or unrecognised.
PluginCode string
}

func (e *ResolveError) Error() string {
switch e.Code {
case "auth_failed":
if e.PluginCode != "" {
return fmt.Sprintf("formae could not obtain a credential for this profile (%s); %s",
e.PluginCode, diagnose)
}
return fmt.Sprintf("formae could not obtain a credential for this profile; %s", diagnose)
case "untrusted_issuer":
return fmt.Sprintf(
"this profile's hosted connection names an issuer this build will not authenticate against; %s",
diagnose)
case "no_connection":
return fmt.Sprintf("this profile resolves no connection formae can use; %s", diagnose)
default:
return fmt.Sprintf("formae could not resolve the connection; %s", diagnose)
}
}

// AmbiguousProfileError is the CLI refusing to guess which installation was
// meant. Its message is the instruction the caller acts on, so a model reading
// it can retry with the argument rather than needing to be told separately.
type AmbiguousProfileError struct {
Candidates []string
Active string
}

func (e *AmbiguousProfileError) Error() string {
listed := make([]string, 0, len(e.Candidates))
for _, c := range e.Candidates {
if c == e.Active {
c += " (active)"
}
listed = append(listed, c)
}
return fmt.Sprintf(
"more than one profile exists and none was named, so formae cannot tell which "+
"installation you meant. Pass the profile argument on this call: %s",
strings.Join(listed, ", "))
}

// failureView is the envelope the producer emits on stdout when it fails.
type failureView struct {
SchemaVersion *int `json:"schemaVersion"`
Code string `json:"code"`
Details struct {
Candidates []string `json:"candidates"`
Active string `json:"active"`
PluginCode string `json:"pluginCode"`
} `json:"details"`
// Message is decoded so it is visibly accounted for, and deliberately never
// read: the producer builds it from an auth plugin's error string or an
// arbitrary err.Error(), and a Pkl failure quotes profile source lines,
// which for a classic profile can mean an inline password.
Message string `json:"message"`
}

// decodeFailure turns a non-zero exit into a typed error.
//
// exitStatus names the failure when the envelope cannot be read at all, which
// is a supported path rather than a defensive one: argv the command cannot
// parse fails before the flags that say how to render a failure exist, so it
// exits non-zero with no envelope. The raw bytes never reach the error.
func decodeFailure(stdout []byte, exitStatus int) error {
unreadable := fmt.Errorf("formae could not resolve the connection (exit %d); %s",
exitStatus, diagnose)

var v failureView
dec := json.NewDecoder(bytes.NewReader(stdout))
if err := dec.Decode(&v); err != nil {
return unreadable
}
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return unreadable
}
if v.SchemaVersion == nil || *v.SchemaVersion != failureSchemaVersion {
return unreadable
}
if !declaredCodes[v.Code] {
return unreadable
}

if v.Code == "ambiguous_profile" {
return &AmbiguousProfileError{
Candidates: v.Details.Candidates,
Active: v.Details.Active,
}
}

pluginCode := v.Details.PluginCode
if pluginCode != "" && !declaredPluginCodes[pluginCode] {
pluginCode = unrecognisedPluginCode
}
return &ResolveError{Code: v.Code, PluginCode: pluginCode}
}

// Resolved is one profile evaluation: the effective profile name the CLI
// reported, the connection it resolved, and the credential that reaches it.
type Resolved struct {
Profile string
Conn Connection
// Credential is the zero value for classic: the MCP sends a self-hosted
// agent none, and that is a non-goal rather than an omission.
Credential secret.Value
}
Loading