Skip to content
Open
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
92 changes: 92 additions & 0 deletions internal/clientid/clientid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Package clientid resolves the client identity the MCP sends to the agent.
//
// The formae CLI persists a per-machine ID at ~/.pel/formae/cli_client_id,
// created by the CLI itself. The MCP sends the same ID so commands issued
// through it are attributed to the same client as the user's own CLI. This
// package never creates the file: when it is missing or unreadable, it
// degrades to the Fallback constant.
package clientid

import (
"os"
"path/filepath"
"strings"
"sync"
)

// Fallback is sent when no CLI client ID can be resolved. It matches the
// constant the MCP historically sent, so agents see no new identity when
// resolution fails.
const Fallback = "formae-mcp"

// maxIDLen bounds accepted IDs. A KSUID is 27 bytes; the bound is generous so
// a future formae ID format still passes without coupling to the exact shape.
const maxIDLen = 64

// Resolver resolves the CLI client ID. Filesystem access is injected so the
// logic is unit-testable. Safe for concurrent use.
type Resolver struct {
Home func() (string, error)
ReadFile func(string) ([]byte, error)

mu sync.Mutex
cached string
}

// NewResolver wires a Resolver to the real filesystem.
func NewResolver() *Resolver {
return &Resolver{
Home: os.UserHomeDir,
ReadFile: os.ReadFile,
}
}

// Resolve returns the CLI client ID, or Fallback when it cannot be read. It
// never fails a caller: an unresolvable ID degrades to Fallback. A
// successfully read ID is cached for the process lifetime (the file never
// changes once written); a fallback is not cached, so a later call picks up
// the real file once it exists.
func (r *Resolver) Resolve() string {
r.mu.Lock()
defer r.mu.Unlock()
if r.cached != "" {
return r.cached
}
if id, ok := r.read(); ok {
r.cached = id
return id
}
return Fallback
}

func (r *Resolver) read() (string, bool) {
home, err := r.Home()
if err != nil {
return "", false
}
data, err := r.ReadFile(filepath.Join(home, ".pel", "formae", "cli_client_id"))
if err != nil {
return "", false
}
id := strings.TrimSpace(string(data))
if !validID(id) {
return "", false
}
return id, true
}

// validID reports whether id is safe to send as an HTTP header value: 1-64
// bytes of printable ASCII with no whitespace or control characters. Go's
// HTTP transport rejects requests whose header values contain control
// characters, so an unvalidated corrupt file would fail every command.
func validID(id string) bool {
if len(id) == 0 || len(id) > maxIDLen {
return false
}
for i := 0; i < len(id); i++ {
if id[i] <= 0x20 || id[i] >= 0x7f {
return false
}
}
return true
}
149 changes: 149 additions & 0 deletions internal/clientid/clientid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package clientid

import (
"os"
"path/filepath"
"strings"
"testing"
)

// newTestResolver returns a Resolver rooted at dir.
func newTestResolver(dir string) *Resolver {
return &Resolver{
Home: func() (string, error) { return dir, nil },
ReadFile: os.ReadFile,
}
}

// writeIDFile creates <dir>/.pel/formae/cli_client_id with the given content.
func writeIDFile(t *testing.T, dir, content string) {
t.Helper()
idDir := filepath.Join(dir, ".pel", "formae")
if err := os.MkdirAll(idDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(idDir, "cli_client_id"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}

func TestResolveReturnsTrimmedFileContent(t *testing.T) {
dir := t.TempDir()
writeIDFile(t, dir, "2N3x8aQdLmVp0rGhTzYwBcKfJe1\n")
r := newTestResolver(dir)
if got := r.Resolve(); got != "2N3x8aQdLmVp0rGhTzYwBcKfJe1" {
t.Fatalf("got %q", got)
}
}

func TestResolveAcceptsExactly64Bytes(t *testing.T) {
dir := t.TempDir()
id := strings.Repeat("a", 64)
writeIDFile(t, dir, id)
r := newTestResolver(dir)
if got := r.Resolve(); got != id {
t.Fatalf("got %q, want the 64-byte id accepted", got)
}
}

func TestResolveRejectsInvalidContent(t *testing.T) {
cases := map[string]string{
"empty": "",
"whitespace only": " \n\t",
"embedded newline": "abc\ndef",
"embedded space": "abc def",
"control char": "abc\x01def",
"DEL byte": "abc\x7fdef",
"non-ascii": "abc\xc3\xa9def",
// printable so only the length bound fails
"over 64 bytes": strings.Repeat("a", 65),
}

for name, content := range cases {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
writeIDFile(t, dir, content)
r := newTestResolver(dir)
if got := r.Resolve(); got != Fallback {
t.Fatalf("got %q, want fallback", got)
}
})
}
}

func TestResolveMissingFileFallsBack(t *testing.T) {
dir := t.TempDir()
r := newTestResolver(dir)
if got := r.Resolve(); got != Fallback {
t.Fatalf("got %q, want fallback", got)
}
}

func TestResolveFallsBackWhenHomeUnavailable(t *testing.T) {
r := &Resolver{
Home: func() (string, error) { return "", os.ErrNotExist },
ReadFile: os.ReadFile,
}
if got := r.Resolve(); got != Fallback {
t.Fatalf("got %q, want fallback", got)
}
}

func TestResolveCachesSuccessfulRead(t *testing.T) {
dir := t.TempDir()
writeIDFile(t, dir, "2N3x8aQdLmVp0rGhTzYwBcKfJe1")
reads := 0
r := newTestResolver(dir)
realRead := r.ReadFile
r.ReadFile = func(p string) ([]byte, error) {
reads++
return realRead(p)
}
first := r.Resolve()
second := r.Resolve()
if first != second || first != "2N3x8aQdLmVp0rGhTzYwBcKfJe1" {
t.Fatalf("got %q then %q", first, second)
}
if reads != 1 {
t.Fatalf("file read %d times, want 1", reads)
}
}

func TestResolveDoesNotCacheFallback(t *testing.T) {
dir := t.TempDir()
r := newTestResolver(dir)
if got := r.Resolve(); got != Fallback {
t.Fatalf("got %q, want fallback", got)
}
// the file appearing later must win over a previous fallback
writeIDFile(t, dir, "2N3x8aQdLmVp0rGhTzYwBcKfJe1")
if got := r.Resolve(); got != "2N3x8aQdLmVp0rGhTzYwBcKfJe1" {
t.Fatalf("got %q, want file content", got)
}
}

func TestResolveConcurrentUse(t *testing.T) {
dir := t.TempDir()
writeIDFile(t, dir, "2N3x8aQdLmVp0rGhTzYwBcKfJe1")
r := newTestResolver(dir)
done := make(chan string, 8)
for i := 0; i < 8; i++ {
go func() { done <- r.Resolve() }()
}
for i := 0; i < 8; i++ {
if got := <-done; got != "2N3x8aQdLmVp0rGhTzYwBcKfJe1" {
t.Fatalf("got %q", got)
}
}
}

func TestNewResolverUsesRealHomeAndReadFile(t *testing.T) {
r := NewResolver()
if r.Home == nil || r.ReadFile == nil {
t.Fatal("NewResolver must wire Home and ReadFile")
}
// NewResolver must never crash even against a real, likely-fileless home.
if got := r.Resolve(); got == "" {
t.Fatal("Resolve must never return an empty string")
}
}
5 changes: 4 additions & 1 deletion internal/server/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ Queries use field:value pairs separated by spaces. Multiple pairs are AND-combin
| Field | Type | Description | Example |
|-------|------|-------------|---------|
| id | string | Command ID | id:abc123 |
| client | string | Client ID | client:me |
| client | string | Client ID (this machine's formae client id, shared with the CLI when its id file exists, else 'formae-mcp'; not the human) | client:me |
| user | string | User (human); 'me' resolves to the bearer token's subject, a UUID matches the subject id, anything else matches the display name | user:me |
| command | string | Command type | command:apply |
| status | string | Command state | status:in_progress |
| stack | string | Stack name | stack:production |
Expand All @@ -204,6 +205,8 @@ Queries use field:value pairs separated by spaces. Multiple pairs are AND-combin
- S3 buckets in production: type:AWS::S3::Bucket stack:production
- Failed commands: status:failed
- Running commands: status:in_progress
- Commands sent from this machine (CLI or MCP, same client id): client:me
- Commands from the human on the other end of this session: user:me
`

const conceptsDoc = `# Formae Core Concepts
Expand Down
17 changes: 10 additions & 7 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/platform-engineering-labs/formae-mcp/internal/clientid"
"github.com/platform-engineering-labs/formae-mcp/internal/config"
"github.com/platform-engineering-labs/formae-mcp/internal/featuregate"
"github.com/platform-engineering-labs/formae-mcp/internal/profile"
Expand All @@ -33,7 +34,8 @@ func implementation() *mcp.Implementation {
type Server struct {
mcpServer *mcp.Server
hub *HubClient
forcedEndpoint string // when set, empty-profile calls use this (tests / explicit)
forcedEndpoint string // when set, empty-profile calls use this (tests / explicit)
clientID *clientid.Resolver // resolves the Client-ID header value
}

// New creates a new formae MCP server connected to the given agent endpoint.
Expand All @@ -49,6 +51,7 @@ func New(endpoint string) *Server {
mcpServer: mcpServer,
hub: NewHubClient(),
forcedEndpoint: endpoint,
clientID: clientid.NewResolver(),
}

s.registerTools()
Expand Down Expand Up @@ -312,7 +315,7 @@ func (s *Server) handleGetCommandStatus(_ context.Context, _ *mcp.CallToolReques
if err != nil {
return errorResult(err), nil, nil
}
result, err := c.GetCommandStatus(input.CommandID, "formae-mcp")
result, err := c.GetCommandStatus(input.CommandID, s.clientID.Resolve())
if err != nil {
return errorResult(err), nil, nil
}
Expand All @@ -328,7 +331,7 @@ func (s *Server) handleListCommands(_ context.Context, _ *mcp.CallToolRequest, i
if err != nil {
return errorResult(err), nil, nil
}
result, err := c.ListCommands(input.Query, maxResults, "formae-mcp")
result, err := c.ListCommands(input.Query, maxResults, s.clientID.Resolve())
if err != nil {
return errorResult(err), nil, nil
}
Expand Down Expand Up @@ -558,7 +561,7 @@ func (s *Server) handleApplyForma(_ context.Context, _ *mcp.CallToolRequest, inp
if err != nil {
return errorResult(err), nil, nil
}
result, err := c.SubmitCommand("apply", input.Mode, input.Simulate, input.Force, formaJSON, "formae-mcp")
result, err := c.SubmitCommand("apply", input.Mode, input.Simulate, input.Force, formaJSON, s.clientID.Resolve())
if err != nil {
return errorResult(err), nil, nil
}
Expand Down Expand Up @@ -587,7 +590,7 @@ func (s *Server) handleDestroyForma(_ context.Context, _ *mcp.CallToolRequest, i
}

if input.Query != "" {
result, err := c.DestroyByQuery(input.Query, input.Simulate, "formae-mcp")
result, err := c.DestroyByQuery(input.Query, input.Simulate, s.clientID.Resolve())
if err != nil {
return errorResult(err), nil, nil
}
Expand All @@ -599,7 +602,7 @@ func (s *Server) handleDestroyForma(_ context.Context, _ *mcp.CallToolRequest, i
return errorResult(fmt.Errorf("failed to evaluate forma file: %w", err)), nil, nil
}

result, err := c.SubmitCommand("destroy", "", input.Simulate, false, formaJSON, "formae-mcp")
result, err := c.SubmitCommand("destroy", "", input.Simulate, false, formaJSON, s.clientID.Resolve())
if err != nil {
return errorResult(err), nil, nil
}
Expand All @@ -611,7 +614,7 @@ func (s *Server) handleCancelCommands(_ context.Context, _ *mcp.CallToolRequest,
if err != nil {
return errorResult(err), nil, nil
}
result, err := c.CancelCommands(input.Query, "formae-mcp")
result, err := c.CancelCommands(input.Query, s.clientID.Resolve())
if err != nil {
return errorResult(err), nil, nil
}
Expand Down
Loading