Skip to content
Merged
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ somewhere that is **not** the managed cluster (PRD §13 #10).
```sh
medea serve \
--listen 0.0.0.0:7600 \
--mcp-listen 127.0.0.1:7601 \
--store /var/lib/medea/medea.db \
--creds-dir /var/lib/medea/creds \
--token-file /etc/medea/token \
Expand All @@ -64,9 +65,38 @@ Notes:
the server read-only.
- **Credentials** (talosconfig/kubeconfig) live under `--creds-dir`, **not** in
the bbolt store, and are never exported. See [Credentials](#credentials).
- **MCP** is disabled unless `--mcp-listen` is set. It serves a curated,
read-only Streamable HTTP endpoint at `/mcp`; bind it only to loopback or a
trusted private network. It does not expose credential retrieval or mutation
tools. See [`design/mcp.md`](design/mcp.md).

For a systemd unit and a container image, see [`deploy/`](deploy/).

## Iris MCP integration

Start Medea with an MCP listener reachable from Iris, then add the server to
Iris policy:

```yaml
mcpServers:
medea:
url: http://medea.example.internal:7601/mcp
users: [martin]
tools:
- list_clusters
- get_cluster
- list_node_pools
- list_machines
- list_hosts
- get_rollout
- list_rollouts
approval: never
```

The endpoint currently has no application-layer authentication. Keep it on a
private interface or place it behind an authenticated TLS reverse proxy. Its
catalog is read-only, but it still reveals cluster inventory and topology.

## Credentials

Per managed cluster, drop the admin credentials under the creds dir (mode 0600,
Expand Down
38 changes: 36 additions & 2 deletions cmd/medea/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strings"
Expand All @@ -19,6 +20,7 @@ import (
pb "github.com/crunchloop/medea/gen/medea/v1"
"github.com/crunchloop/medea/internal/auth"
"github.com/crunchloop/medea/internal/creds"
"github.com/crunchloop/medea/internal/delivery/mcpapi"
"github.com/crunchloop/medea/internal/provision"
"github.com/crunchloop/medea/internal/provision/matchbox"
"github.com/crunchloop/medea/internal/refresh"
Expand Down Expand Up @@ -87,6 +89,7 @@ var (

serveBootstrap bool
serveBootstrapIntv time.Duration
serveMCPListen string
)

func init() {
Expand Down Expand Up @@ -120,6 +123,7 @@ func init() {
f.DurationVar(&serveProvisionTO, "provision-timeout", 20*time.Minute, "how long a host may stay provisioning before it is marked failed")
f.BoolVar(&serveBootstrap, "bootstrap", false, "enable the cluster-bootstrap executor (Medea-driven new-cluster creation; default off)")
f.DurationVar(&serveBootstrapIntv, "bootstrap-interval", 30*time.Second, "how often the bootstrap executor advances in-flight cluster creations")
f.StringVar(&serveMCPListen, "mcp-listen", "", "serve read-only MCP over Streamable HTTP at /mcp (disabled when empty)")
rootCmd.AddCommand(serveCmd)
}

Expand Down Expand Up @@ -166,7 +170,8 @@ func runServe(_ *cobra.Command, _ []string) error {
grpc.UnaryInterceptor(auth.UnaryInterceptor(token)),
grpc.StreamInterceptor(auth.StreamInterceptor(token)),
)
pb.RegisterMedeaServer(srv, server.New(st, server.WithCreds(cs)))
api := server.New(st, server.WithCreds(cs))
pb.RegisterMedeaServer(srv, api)

lis, err := net.Listen("tcp", serveListen)
if err != nil {
Expand Down Expand Up @@ -224,15 +229,44 @@ func runServe(_ *cobra.Command, _ []string) error {
fmt.Fprintln(os.Stderr, "medea: bootstrap executor disabled (pass --bootstrap to enable)")
}

errCh := make(chan error, 1)
errCh := make(chan error, 2)
go func() { errCh <- srv.Serve(lis) }()
fmt.Fprintf(os.Stderr, "medea: serving on %s (store=%s, tls cert=%s)\n", serveListen, serveStore, serveCert)

var mcpHTTP *http.Server
if serveMCPListen != "" {
mcpServer, err := mcpapi.NewServer(api)
if err != nil {
return fmt.Errorf("MCP server: %w", err)
}
mux := http.NewServeMux()
mux.Handle("/mcp", mcpServer.Handler())
mcpHTTP = &http.Server{
Addr: serveMCPListen,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
err := mcpHTTP.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
fmt.Fprintf(os.Stderr, "medea: read-only MCP serving on http://%s/mcp\n", serveMCPListen)
}

select {
case err := <-errCh:
return err
case <-ctx.Done():
fmt.Fprintln(os.Stderr, "medea: shutting down")
if mcpHTTP != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := mcpHTTP.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shut down MCP server: %w", err)
}
}
srv.GracefulStop()
return nil
}
Expand Down
133 changes: 133 additions & 0 deletions cmd/medea/serve_mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package main

import (
"bytes"
"context"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"

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

// TestServeMCPProcess exercises the composition root rather than only the MCP
// package: a real Medea child process generates TLS, opens bbolt, starts gRPC
// and /mcp, serves an SDK client, and shuts both listeners down on interrupt.
func TestServeMCPProcess(t *testing.T) {
if os.Getenv("MEDEA_TEST_SERVE_MCP_HELPER") == "1" {
runServeMCPHelper(t)
return
}

grpcAddr := reserveAddress(t)
mcpAddr := reserveAddress(t)
dir := t.TempDir()
cmd := exec.Command(os.Args[0], "-test.run=^TestServeMCPProcess$")
cmd.Env = append(os.Environ(),
"MEDEA_TEST_SERVE_MCP_HELPER=1",
"MEDEA_TEST_GRPC_ADDR="+grpcAddr,
"MEDEA_TEST_MCP_ADDR="+mcpAddr,
"MEDEA_TEST_DIR="+dir,
)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()

endpoint := "http://" + mcpAddr + "/mcp"
session := waitForMCP(t, endpoint, done, &output)
listed, err := session.ListTools(context.Background(), nil)
if err != nil {
_ = cmd.Process.Kill()
t.Fatalf("list tools from child: %v\n%s", err, output.String())
}
if len(listed.Tools) != 7 {
_ = cmd.Process.Kill()
t.Fatalf("child advertised %d tools, want 7\n%s", len(listed.Tools), output.String())
}
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "list_clusters"})
if err != nil || result.IsError {
_ = cmd.Process.Kill()
t.Fatalf("call list_clusters through child: result=%+v err=%v\n%s", result, err, output.String())
}
if err := session.Close(); err != nil {
_ = cmd.Process.Kill()
t.Fatal(err)
}

if err := cmd.Process.Signal(os.Interrupt); err != nil {
_ = cmd.Process.Kill()
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("Medea child exited with error: %v\n%s", err, output.String())
}
case <-time.After(15 * time.Second):
_ = cmd.Process.Kill()
t.Fatalf("Medea child did not shut down gracefully\n%s", output.String())
}
}

func runServeMCPHelper(t *testing.T) {
dir := os.Getenv("MEDEA_TEST_DIR")
serveListen = os.Getenv("MEDEA_TEST_GRPC_ADDR")
serveMCPListen = os.Getenv("MEDEA_TEST_MCP_ADDR")
serveStore = filepath.Join(dir, "medea.db")
serveToken = "test-token"
serveTokenFile = ""
serveCert = filepath.Join(dir, "cert.pem")
serveKey = filepath.Join(dir, "key.pem")
serveCredsDir = filepath.Join(dir, "creds")
serveCredsBackend = "file"
serveRefreshIntv = time.Hour
serveRollouts = false
serveProvisioning = false
serveBootstrap = false
if err := runServe(nil, nil); err != nil {
t.Fatal(err)
}
}

func reserveAddress(t *testing.T) string {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := lis.Addr().String()
if err := lis.Close(); err != nil {
t.Fatal(err)
}
return addr
}

func waitForMCP(t *testing.T, endpoint string, done <-chan error, output *bytes.Buffer) *mcp.ClientSession {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
select {
case err := <-done:
t.Fatalf("Medea child exited before MCP became ready: %v\n%s", err, output.String())
default:
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
client := mcp.NewClient(&mcp.Implementation{Name: "medea-process-test", Version: "1"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: endpoint}, nil)
cancel()
if err == nil {
return session
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("MCP did not become ready at %s\n%s", endpoint, output.String())
return nil
}
1 change: 1 addition & 0 deletions design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ reasoning stays useful after the work ships.
| --- | --- |
| [`datastore.md`](datastore.md) | The embedded (bbolt) store: desired-vs-observed schema, proto-as-storage, revisions, watch, concurrency, crash recovery, desired-state export. |
| [`api-and-auth.md`](api-and-auth.md) | The gRPC service (intent verbs, thin watch), v1 auth (bearer token over TLS), and credential storage (separate from bbolt). Resolves #8/#9. |
| [`mcp.md`](mcp.md) | The Iris-facing read-only MCP adapter over the existing application API, Streamable HTTP boundary, curated tools, and the future SEP-2663 Tasks mapping for durable rollouts. |
| [`credentials.md`](credentials.md) | **(Phase A, Draft for review)** Making Medea the durable, off-host owner of a cluster's credentials + machine-secrets bundle so `home-cluster`'s `_out/` can be deleted: a 1Password-backed `CredentialStore` (built, was planned in api-and-auth §5), a guarded `GetCredentials` RPC, and the cutover. Prerequisite for Phase B (`cluster-bootstrap.md`). |
| [`talos-client.md`](talos-client.md) | Talos & kube clients (no shelling): small Medea interfaces over the `machinery` client (OS upgrade/snapshot/health/version) + client-go (readiness/drain); the quarantined main-module `upgrade-k8s`; installer-image/schematic derivation. |
| [`rollout-controller.md`](rollout-controller.md) | The v1 version-rollout reconciler: per-node state machine, OS vs K8s paths, halt-on-failure, resume-after-reboot, control-plane snapshot safety. (Trigger in §1 superseded by `rollout-safety.md`.) |
Expand Down
Loading
Loading