a73x

7ab204ff

feat(mcp): one surface — the plane serves /mcp, the stdio binary retires

a73x   2026-08-11 15:04

Commit message
feat(mcp): one surface — the plane serves /mcp, the stdio binary retires

eitri speaks MCP in exactly one place: /mcp on the control plane, authenticated
by a bearer PAT. The local stdio binary is gone.

Three arrangements — a binary you run yourself, the hosted endpoint, and a
public SSO front — are three credential stories to keep honest, and the extra
two earn nothing. The stdio binary held its own SSH user CA, its own config
file and its own self-registration, while the plane already authenticates PATs
and holds no signing key at all. Somebody who wants their own MCP server gets
one by self-hosting the plane; a public front would target this same endpoint.

Gone with it: cmd/eitri-mcp, the config file and its loader, the load-or-create
user CA, and the client-side jump-gate dialer that reached guests with certs it
signed itself. The toolset and the server constructor are untouched — /mcp
builds the same tools over the same seams, reaching guests through the host's
sync tunnel as it already did. Runner's tests now drive it through the plainest
dialer there is, since reaching a VM and verifying its host certificate belong
to the transport and are pinned in internal/server/vmssh.

docs/mcp.md reads as one endpoint: point a client at it with a PAT, delegate
access, here is what the tools do. The gate stays what it always was — how a
human reaches a guest with their own key.

Makefile
Old New
@@ -38,7 +38,6 @@ build: web
38 go build $(GO_LDFLAGS) -o $(BIN)/eitri-server ./cmd/eitri-server 38 go build $(GO_LDFLAGS) -o $(BIN)/eitri-server ./cmd/eitri-server
39 go build $(GO_LDFLAGS) -o $(BIN)/eitri-oidc ./cmd/eitri-oidc 39 go build $(GO_LDFLAGS) -o $(BIN)/eitri-oidc ./cmd/eitri-oidc
40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent 40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent
41 go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp
42 go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke 41 go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke
43 go build $(GO_LDFLAGS) -o $(BIN)/eitri-site ./cmd/eitri-site 42 go build $(GO_LDFLAGS) -o $(BIN)/eitri-site ./cmd/eitri-site
44 go build $(GO_LDFLAGS) -o $(BIN)/eitri ./cmd/eitri 43 go build $(GO_LDFLAGS) -o $(BIN)/eitri ./cmd/eitri
README.md
Old New
@@ -45,9 +45,8 @@ decoded to raw once, then reflink-copied per guest).
45 45
46 | Binary | Role | 46 | Binary | Role |
47 | --- | --- | 47 | --- | --- |
48 | `eitri-server` | Control plane: HTTP API, QUIC sync stream, and the SSH-CA jump gate. | 48 | `eitri-server` | Control plane: HTTP API, the MCP endpoint at `/mcp`, QUIC sync stream, and the SSH-CA jump gate. |
49 | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives the host's VMM. | 49 | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives the host's VMM. |
50 | `eitri-mcp` | MCP server exposing create/control/destroy VM tools to Claude. |
51 | `eitri` | Client CLI: signs an ephemeral cert with a tenant CA and reaches a guest through the gate. | 50 | `eitri` | Client CLI: signs an ephemeral cert with a tenant CA and reaches a guest through the gate. |
52 51
53 `eitri-shape` (regenerate the architecture graph) rounds out the binaries. 52 `eitri-shape` (regenerate the architecture graph) rounds out the binaries.
@@ -99,7 +98,7 @@ it's built this way ([decisions](docs/decisions.md)). What ships next is in
99 ## Repository layout 98 ## Repository layout
100 99
101 ``` 100 ```
102 cmd/ entrypoints (eitri-server, eitri-agent, eitri-mcp, tooling) 101 cmd/ entrypoints (eitri-server, eitri-agent, eitri, tooling)
103 internal/ 102 internal/
104 agent/ reconcile loop, VMM drivers (cloudhv, vfkit), DHCP, image cache, netenv 103 agent/ reconcile loop, VMM drivers (cloudhv, vfkit), DHCP, image cache, netenv
105 server/ API, QUIC sync service, SSH gate/CA, store, registry, hub 104 server/ API, QUIC sync service, SSH gate/CA, store, registry, hub
cmd/eitri-mcp/main.go
Old New
@@ -1,27 +0,0 @@
1 // Command eitri-mcp is an MCP server exposing eitri VM tools to Claude:
2 // create/list/info/exec/write_file/read_file/destroy. It is a client of the
3 // eitri API plus SSH; it embeds no control-plane code. It holds its OWN
4 // persistent user CA, uploads that CA's public key to its tenant once at
5 // startup, and self-signs short-lived user certs locally (BYO model — the
6 // server no longer mints user certs). All behavior lives in internal/mcpserver
7 // (RunCLI); this package is wiring only (arch R14).
8 package main
9
10 import (
11 "fmt"
12 "os"
13
14 "github.com/a73x/eitri/internal/mcpserver"
15 "github.com/a73x/eitri/internal/version"
16 )
17
18 func main() {
19 if len(os.Args) > 1 && os.Args[1] == "--version" {
20 fmt.Println(version.Version)
21 return
22 }
23 if err := mcpserver.RunCLI(os.Args[1:]); err != nil {
24 fmt.Fprintln(os.Stderr, "eitri-mcp:", err)
25 os.Exit(1)
26 }
27 }
docs/README.md
Old New
@@ -25,7 +25,7 @@ By what you're trying to do:
25 25
26 - [ssh-access.md](ssh-access.md)—reaching a guest through the jump gate with 26 - [ssh-access.md](ssh-access.md)—reaching a guest through the jump gate with
27 your own tenant CA 27 your own tenant CA
28 - [mcp.md](mcp.md)—`eitri-mcp`, the MCP server that lets Claude drive VMs 28 - [mcp.md](mcp.md)—`/mcp`, the endpoint that lets Claude drive VMs
29 29
30 **Why it's this way** 30 **Why it's this way**
31 31
docs/assumptions.md
Old New
@@ -215,7 +215,7 @@ server discarded it. What that costs was established by experiment on
215 and the agent then dials the address in its OWN record 215 and the agent then dials the address in its OWN record
216 (`internal/agent/syncclient`), which vfkit's `Address` does populate. What 216 (`internal/agent/syncclient`), which vfkit's `Address` does populate. What
217 breaks is every consumer of the stored `assigned_ip` — the console's IP column, 217 breaks is every consumer of the stored `assigned_ip` — the console's IP column,
218 eitri-mcp's wait-for-address (`internal/mcpserver/tools.go`) and the deploy 218 the MCP tools' wait-for-address (`internal/mcpserver/tools.go`) and the deploy
219 boot-gate (`internal/smoke/scenario.go`), and the last two HANG rather than 219 boot-gate (`internal/smoke/scenario.go`), and the last two HANG rather than
220 fail, because both poll for a field that will never arrive. A host whose OS owns 220 fail, because both poll for a field that will never arrive. A host whose OS owns
221 addressing has to tell the fleet which subnet its guests are on. 221 addressing has to tell the fleet which subnet its guests are on.
docs/decisions.md
Old New
@@ -138,3 +138,15 @@ until a genuinely foreign backend wants in — a versioned plugin wire is a
138 binding point, and this repo has already paid once for building a binding 138 binding point, and this repo has already paid once for building a binding
139 point before its parties existed. And the deploy smoke is the seam's 139 point before its parties existed. And the deploy smoke is the seam's
140 conformance suite: a Provisioner is whatever passes it. 140 conformance suite: a Provisioner is whatever passes it.
141
142 ### One MCP surface: `/mcp` on the plane
143
144 eitri speaks MCP in exactly one place: `/mcp` on the control plane, authenticated
145 by a bearer PAT. Instead of a local stdio binary beside it and a public SSO front
146 ahead of it — three arrangements are three credential stories to keep honest, and
147 the extra two earn nothing. The stdio binary held its own user CA and its own
148 config file while the plane already authenticates PATs and holds no signing key
149 at all; a self-hoster gets an MCP server by self-hosting the plane, and a public
150 front targets this same endpoint rather than being a fourth thing. Reversed by a
151 genuine air-gapped need: guests to drive with no plane to reach.
152 Details in [mcp.md](mcp.md).
docs/mcp.md
Old New
@@ -1,16 +1,13 @@
1 # eitri-mcp: Claude ↔ eitri VMs 1 # MCP: Claude ↔ eitri VMs
2 2
3 eitri gives Claude explicit tools for creating and controlling VMs on a fleet, 3 eitri gives Claude explicit tools for creating and controlling VMs on a fleet.
4 over two transports: 4 There is one place it speaks MCP: `/mcp`, served by the control plane itself.
5 MCP streamable HTTP, authenticated with a personal access token. No install, no
6 config file, no CA of your own: a PAT is enough.
5 7
6 - **Local stdio** — `eitri-mcp`, a binary you run yourself. It 8 On the hosted plane that endpoint is `https://api.eitri.sh/mcp`. A plane you run
7 is an API client of the control plane plus SSH; it embeds no control-plane or 9 yourself serves the same endpoint on its own address, from the same binary — a
8 agent code, and it holds its own SSH user CA. 10 self-hosted eitri is a self-hosted MCP server, with nothing extra to install.
9 - **Remote HTTP** — `https://api.eitri.sh/mcp`, served by the control plane
10 itself. MCP streamable HTTP, authenticated with a personal access token. No
11 install, no config file, no CA of your own: a PAT is enough.
12
13 Both serve the same tools.
14 11
15 ## Tools 12 ## Tools
16 13
@@ -28,59 +25,15 @@ Both serve the same tools.
28 | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. | 25 | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. |
29 | `ca_upload` | Register your SSH user CA's public key with your tenant, with an optional label. Only the public half is sent. A guest trusts the CA set it was created with, so VMs that already exist will not accept certificates from a CA uploaded now. | 26 | `ca_upload` | Register your SSH user CA's public key with your tenant, with an optional label. Only the public half is sent. A guest trusts the CA set it was created with, so VMs that already exist will not accept certificates from a CA uploaded now. |
30 | `tenant_info` | Show this tenant's setup: registered CAs (fingerprint and label), whether a delegation is live and when it expires, and the gate address. Read-only. | 27 | `tenant_info` | Show this tenant's setup: registered CAs (fingerprint and label), whether a delegation is live and when it expires, and the gate address. Read-only. |
31 | `delegate_begin` | *(remote only)* Get the ephemeral public key eitri will authenticate with, the principal your certificate must carry, and the `ssh-keygen` line that signs it. | 28 | `delegate_begin` | Get the ephemeral public key eitri will authenticate with, the principal your certificate must carry, and the `ssh-keygen` line that signs it. |
32 | `delegate_complete` | *(remote only)* Hand back the signed certificate. eitri can then reach your VMs until it expires. | 29 | `delegate_complete` | Hand back the signed certificate. eitri can then reach your VMs until it expires. |
33 30
34 Deliberately absent: any host or fleet-level operation (enroll, decommission, 31 Deliberately absent: any host or fleet-level operation (enroll, decommission,
35 power management, image/firmware knobs). The worst case from a confused model 32 power management, image/firmware knobs). The worst case from a confused model
36 is VM churn, never fleet damage—and Claude Code's per-tool permission 33 is VM churn, never fleet damage—and Claude Code's per-tool permission
37 prompts gate every call regardless. 34 prompts gate every call regardless.
38 35
39 ## Setup: local stdio 36 ## Setup
40
41 1. Build the `eitri-mcp` binary from the eitri source tree with `make build`;
42 it lands in `bin/`.
43
44 2. Create `~/.config/eitri-mcp/config.json`:
45
46 ```json
47 {
48 "server_url": "http://127.0.0.1:8080",
49 "token_file": "~/.config/eitri-mcp/token",
50 "gate": "127.0.0.1:2223",
51 "vm_user": "ubuntu"
52 }
53 ```
54
55 Fields:
56 - `server_url`—required, the eitri API base URL.
57 - `token_file`—required, path to a file holding a personal access token
58 (mint one in the console Settings page; read at startup, held in memory,
59 never surfaced in a tool result or error).
60 - `gate`—the SSH-CA jump gate address, `<gate-domain>:<port>`; the MCP
61 reaches all VMs by name through it. The host part must match the
62 gate's host certificate principal (the server's `ssh_gate_domain`,
63 which defaults to the `ssh_listen` host).
64 - `vm_user`—guest SSH user; defaults to `ubuntu` if omitted.
65 - `ca_key_path`—optional path to this client's persistent user CA
66 (load-or-create); defaults to a `user_ca` file next to the config. Its
67 public key self-registers with the tenant on first gate use, so a fresh
68 install needs only a PAT—no manual `eitri ca upload`.
69 - `tenant`—optional. The credential names the tenant (VM connect names are
70 derived from it), so leave this unset. Set it only when the PAT's user
71 CA/tenant mapping is ambiguous, e.g. a human or CA belonging to more than
72 one tenant.
73
74 The config path can be overridden with `--config` or `$EITRI_MCP_CONFIG`;
75 it defaults to `~/.config/eitri-mcp/config.json`.
76
77 3. Register with Claude Code:
78
79 ```
80 claude mcp add eitri -- /path/to/repo/bin/eitri-mcp
81 ```
82
83 ## Setup: remote endpoint
84 37
85 Mint a personal access token in the console Settings page and point an MCP 38 Mint a personal access token in the console Settings page and point an MCP
86 client at the endpoint with that token as a bearer credential: 39 client at the endpoint with that token as a bearer credential:
@@ -125,31 +78,21 @@ notifications while it waits (for clients that ask for progress).
125 78
126 ## Access model 79 ## Access model
127 80
128 Both transports reach VMs by name over SSH, with certificates in both 81 eitri reaches VMs by name over SSH, with certificates in both directions and no
129 directions and no TOFU anywhere. In both, the user signing key stays with you: 82 TOFU anywhere. The user signing key stays with you: eitri is delegated a
130 they differ only in who does the signing and which path the connection takes. 83 credential, never a key.
131 84
132 **Local stdio** holds its own user CA (`ca_key_path`, load-or-create) and 85 eitri generates an ephemeral ed25519 keypair per tenant, in memory only, and
133 self-registers that CA's public key with its tenant on first use; eitri never 86 hands you the public half; you sign it with your own CA, on your own TTL and
134 sees the private half. Per connection it signs a short-lived user certificate 87 principals, and post the certificate back. eitri then authenticates as that
135 locally (principal `ubuntu`) with that CA, fetches the eitri host CA's public 88 key-plus-certificate: it reaches the guest's sshd over the VM host's own sync
136 key once via `GET /api/v1/ssh-ca`, dials the configured gate, and opens a tunnel 89 tunnel and verifies the guest's host certificate under `<tenant>.<vm>:22`
137 to `<tenant>.<vm>:22`. Host identity is verified on both hops with 90 against the host CA. No signing key ever exists on the server, the certificate
138 `ssh.CertChecker` against the host CA: the gate's certificate must carry its 91 expires, and a restart drops it.
139 configured domain as principal, and each VM's must carry the VM's connect name.
140
141 **The remote endpoint** is delegated a credential instead. eitri generates an
142 ephemeral ed25519 keypair per tenant, in memory only, and hands you the public
143 half; you sign it with your own CA, on your own TTL and principals, and post the
144 certificate back. eitri then authenticates as that key-plus-certificate: it
145 reaches the guest's sshd over the VM host's own sync tunnel and verifies the
146 guest's host certificate under `<tenant>.<vm>:22` against the host CA. No
147 signing key ever exists on the server, the certificate expires, and a restart
148 drops it.
149 92
150 Because the certificate chains to a CA the tenant already registered, it is 93 Because the certificate chains to a CA the tenant already registered, it is
151 accepted by every guest that trusts that CA — including ones created long 94 accepted by every guest that trusts that CA — including ones created long
152 before the delegation. Remote exec without a live delegation is refused with the 95 before the delegation. Exec without a live delegation is refused with the
153 three-step recipe rather than a generic authentication failure. 96 three-step recipe rather than a generic authentication failure.
154 97
155 VMs are addressed by their namespaced connect name, not IP, so recycled IPs and 98 VMs are addressed by their namespaced connect name, not IP, so recycled IPs and
@@ -175,7 +118,7 @@ result or error.
175 be aimed at a third party. The tool descriptions say so, so the model treats 118 be aimed at a third party. The tool descriptions say so, so the model treats
176 publishing as a deliberate act. DNS, TLS certs and routing remain out of 119 publishing as a deliberate act. DNS, TLS certs and routing remain out of
177 scope—the tools hand back a host address and a port. 120 scope—the tools hand back a host address and a port.
178 - The remote endpoint authenticates with a bearer PAT. Browser connectors 121 - The endpoint authenticates with a bearer PAT. Browser connectors
179 (claude.ai) need OAuth, which the endpoint does not speak. 122 (claude.ai) need OAuth, which the endpoint does not speak.
180 123
181 > **IMPORTANT—"ready" is not "booted."** `vm_create`'s `lifecycle=ready` 124 > **IMPORTANT—"ready" is not "booted."** `vm_create`'s `lifecycle=ready`
docs/shape.html
Old New
@@ -79,15 +79,6 @@
79 ] 79 ]
80 }, 80 },
81 { 81 {
82 "importPath": "cmd/eitri-mcp",
83 "plane": "binaries",
84 "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.",
85 "imports": [
86 "internal/mcpserver",
87 "internal/version"
88 ]
89 },
90 {
91 "importPath": "cmd/eitri-oidc", 82 "importPath": "cmd/eitri-oidc",
92 "plane": "binaries", 83 "plane": "binaries",
93 "synopsis": "eitri-oidc: the bundled OIDC issuer.", 84 "synopsis": "eitri-oidc: the bundled OIDC issuer.",
@@ -352,9 +343,8 @@
352 { 343 {
353 "importPath": "internal/mcpserver", 344 "importPath": "internal/mcpserver",
354 "plane": "tooling", 345 "plane": "tooling",
355 "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", 346 "synopsis": "Package mcpserver implements eitri's MCP toolset: tools that let a model create, control (SSH exec/files), and destroy eitri VMs.",
356 "imports": [ 347 "imports": [
357 "internal/gateclient",
358 "internal/random", 348 "internal/random",
359 "internal/server/api/client", 349 "internal/server/api/client",
360 "internal/server/release" 350 "internal/server/release"
docs/shape.json
Old New
@@ -28,15 +28,6 @@
28 ] 28 ]
29 }, 29 },
30 { 30 {
31 "importPath": "cmd/eitri-mcp",
32 "plane": "binaries",
33 "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.",
34 "imports": [
35 "internal/mcpserver",
36 "internal/version"
37 ]
38 },
39 {
40 "importPath": "cmd/eitri-oidc", 31 "importPath": "cmd/eitri-oidc",
41 "plane": "binaries", 32 "plane": "binaries",
42 "synopsis": "eitri-oidc: the bundled OIDC issuer.", 33 "synopsis": "eitri-oidc: the bundled OIDC issuer.",
@@ -301,9 +292,8 @@
301 { 292 {
302 "importPath": "internal/mcpserver", 293 "importPath": "internal/mcpserver",
303 "plane": "tooling", 294 "plane": "tooling",
304 "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", 295 "synopsis": "Package mcpserver implements eitri's MCP toolset: tools that let a model create, control (SSH exec/files), and destroy eitri VMs.",
305 "imports": [ 296 "imports": [
306 "internal/gateclient",
307 "internal/random", 297 "internal/random",
308 "internal/server/api/client", 298 "internal/server/api/client",
309 "internal/server/release" 299 "internal/server/release"
internal/mcpserver/cli.go
Old New
@@ -1,140 +0,0 @@
1 // cli.go is the eitri-mcp command line: it loads the config, loads (or creates)
2 // a persistent per-client user CA, and serves the MCP tools over stdio. The user
3 // CA self-registers with the caller's tenant on first gate use (see
4 // gateDialer.ensure), so a fresh install needs only a PAT. It lives here rather
5 // than in cmd/eitri-mcp so it is testable and coverage-gated (arch R14: main
6 // packages are wiring only).
7
8 package mcpserver
9
10 import (
11 "context"
12 "crypto/ed25519"
13 "crypto/rand"
14 "encoding/pem"
15 "flag"
16 "fmt"
17 "os"
18 "os/signal"
19 "syscall"
20
21 "github.com/a73x/eitri/internal/server/api/client"
22 "github.com/modelcontextprotocol/go-sdk/mcp"
23 "golang.org/x/crypto/ssh"
24 )
25
26 // RunCLI dispatches the eitri-mcp command line (everything after the binary
27 // name, --version excluded — that stays in cmd/eitri-mcp). The config path
28 // defaults to EITRI_MCP_CONFIG, else ~/.config/eitri-mcp/config.json.
29 func RunCLI(args []string) error {
30 defaultCfg := "~/.config/eitri-mcp/config.json"
31 if env := os.Getenv("EITRI_MCP_CONFIG"); env != "" {
32 defaultCfg = env
33 }
34 fs := flag.NewFlagSet("eitri-mcp", flag.ExitOnError)
35 cfgPath := fs.String("config", defaultCfg, "path to eitri-mcp config.json")
36 if err := fs.Parse(args); err != nil {
37 return err
38 }
39 return run(*cfgPath)
40 }
41
42 func run(cfgPath string) error {
43 cfg, err := LoadConfig(cfgPath)
44 if err != nil {
45 return err
46 }
47 // This client owns its own persistent user CA (BYO model): it self-signs
48 // short-lived user certs locally rather than asking the server to mint them.
49 userCA, err := loadOrCreateCA(cfg.CAKeyPath)
50 if err != nil {
51 return fmt.Errorf("load mcp user CA: %w", err)
52 }
53 // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it
54 // authenticates with short-lived user certs it self-signs on demand with its
55 // own user CA, and verifies both hops' host certs against the eitri host CA.
56 // On first gate use it derives the caller's tenant (an empty cfg.Tenant) and
57 // registers this user CA's public key so VMs trust those certs — all backed by
58 // the same API client. The upload carries a per-install label so the console
59 // shows WHICH install holds signing power, and revocation stays per-install.
60 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token, UserCALabel: caLabel()}
61 tools := &Tools{
62 API: API{Client: api},
63 Runner: NewRunner(newGateDialer(gateDialerConfig{
64 Gate: cfg.Gate,
65 VMUser: cfg.VMUser,
66 API: api,
67 UserCA: userCA,
68 Tenant: cfg.Tenant,
69 })),
70 Gate: cfg.Gate,
71 VMUser: cfg.VMUser,
72 }
73
74 // No Registrar: this install signs with its own CA, so it has nothing to
75 // ask eitri to hold.
76 server := NewServer(tools, Options{})
77
78 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
79 defer stop()
80
81 // The user CA registers with the caller's tenant lazily, on the first gate
82 // connection (gateDialer.ensure), so a control-plane blip at startup can't stop the
83 // server from coming up and serving the non-SSH tools.
84 return server.Run(ctx, &mcp.StdioTransport{})
85 }
86
87 // caLabel names this install's user CA in the tenant's CA list. Hostname is
88 // the natural per-install discriminator; a host that can't name itself still
89 // gets the binary's name rather than an anonymous row.
90 func caLabel() string {
91 if hn, err := os.Hostname(); err == nil && hn != "" {
92 return "eitri-mcp@" + hn
93 }
94 return "eitri-mcp"
95 }
96
97 // loadOrCreateCA returns a stable ssh.Signer for the key at path. If the file
98 // is absent it generates a fresh ed25519 key, writes it 0600 with O_EXCL (so a
99 // concurrent creator can't clobber it and a symlink can't be followed), and
100 // returns its signer; if present it parses and returns the existing key. This
101 // is deliberately local to the MCP (a pure API client) and does NOT import the
102 // server's sshca package. Never logs or returns key material in errors.
103 func loadOrCreateCA(path string) (ssh.Signer, error) {
104 pemBytes, err := os.ReadFile(path)
105 if err == nil {
106 signer, perr := ssh.ParsePrivateKey(pemBytes)
107 if perr != nil {
108 return nil, fmt.Errorf("parse ssh key %q: %w", path, perr)
109 }
110 return signer, nil
111 }
112 if !os.IsNotExist(err) {
113 return nil, fmt.Errorf("read ssh key %q: %w", path, err)
114 }
115
116 _, priv, err := ed25519.GenerateKey(rand.Reader)
117 if err != nil {
118 return nil, fmt.Errorf("generate ssh key: %w", err)
119 }
120 block, err := ssh.MarshalPrivateKey(priv, "")
121 if err != nil {
122 return nil, fmt.Errorf("marshal ssh key: %w", err)
123 }
124 signer, err := ssh.NewSignerFromSigner(priv)
125 if err != nil {
126 return nil, fmt.Errorf("new signer: %w", err)
127 }
128 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
129 if err != nil {
130 return nil, fmt.Errorf("create ssh key %q: %w", path, err)
131 }
132 if _, werr := f.Write(pem.EncodeToMemory(block)); werr != nil {
133 f.Close()
134 return nil, fmt.Errorf("write ssh key %q: %w", path, werr)
135 }
136 if cerr := f.Close(); cerr != nil {
137 return nil, fmt.Errorf("close ssh key %q: %w", path, cerr)
138 }
139 return signer, nil
140 }
internal/mcpserver/cli_test.go
Old New
@@ -1,72 +0,0 @@
1 package mcpserver
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/modelcontextprotocol/go-sdk/mcp"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13 )
14
15 // TestLoadOrCreateCACreatesThenLoads covers the two steady-state paths: a fresh
16 // key is generated 0600 when absent, and the identical key loads back when
17 // present.
18 func TestLoadOrCreateCACreatesThenLoads(t *testing.T) {
19 path := filepath.Join(t.TempDir(), "user_ca")
20
21 signer, err := loadOrCreateCA(path)
22 require.NoError(t, err)
23 require.NotNil(t, signer)
24
25 info, err := os.Stat(path)
26 require.NoError(t, err)
27 assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "key material must be written 0600")
28
29 again, err := loadOrCreateCA(path)
30 require.NoError(t, err)
31 assert.Equal(t, signer.PublicKey().Marshal(), again.PublicKey().Marshal(),
32 "an existing key must load back unchanged, not be regenerated")
33 }
34
35 // TestCALabelNamesTheInstall pins the audit contract: the CA this client
36 // uploads is never an anonymous row — it carries the binary's name, and the
37 // hostname when one exists.
38 func TestCALabelNamesTheInstall(t *testing.T) {
39 label := caLabel()
40 assert.True(t, strings.HasPrefix(label, "eitri-mcp"), "label %q must name the client", label)
41 if hn, err := os.Hostname(); err == nil && hn != "" {
42 assert.Equal(t, "eitri-mcp@"+hn, label)
43 }
44 }
45
46 // TestLoadOrCreateCARejectsGarbage rejects an unparseable key file without
47 // leaking its bytes in the error.
48 func TestLoadOrCreateCARejectsGarbage(t *testing.T) {
49 path := filepath.Join(t.TempDir(), "user_ca")
50 require.NoError(t, os.WriteFile(path, []byte("this is not a pem key"), 0o600))
51
52 _, err := loadOrCreateCA(path)
53 require.Error(t, err)
54 assert.NotContains(t, err.Error(), "not a pem key", "key bytes must never appear in errors")
55 }
56
57 // TestLoadOrCreateCAReadError treats a non-not-exist read failure (a directory
58 // at the path) as a fatal error, not a signal to generate a key.
59 func TestLoadOrCreateCAReadError(t *testing.T) {
60 dir := t.TempDir()
61 _, err := loadOrCreateCA(dir)
62 require.Error(t, err)
63 }
64
65 // TestRegisterAddsTool pins the single SDK-generics adapter: it must wire a
66 // typed handler onto the server without panicking.
67 func TestRegisterAddsTool(t *testing.T) {
68 s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil)
69 register(s, "noop", "does nothing", func(context.Context, struct{}) (struct{}, error) {
70 return struct{}{}, nil
71 })
72 }
internal/mcpserver/config.go
Old New
@@ -1,84 +0,0 @@
1 // Package mcpserver implements the eitri-mcp server: MCP tools that let a
2 // model create, control (SSH exec/files), and destroy eitri VMs. It is an API
3 // CLIENT of the control plane — it speaks to it only through the shared API
4 // client (internal/server/api/client), never any other server internals, and
5 // the PAT it holds must never appear in tool results or errors.
6 package mcpserver
7
8 import (
9 "encoding/json"
10 "fmt"
11 "os"
12 "path/filepath"
13 "strings"
14 )
15
16 // Config is eitri-mcp's on-disk configuration.
17 type Config struct {
18 ServerURL string `json:"server_url"` // eitri API base, e.g. http://127.0.0.1:8080
19 TokenFile string `json:"token_file"` // file holding the PAT (mint one in console Settings)
20 Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>"
21 VMUser string `json:"vm_user"` // guest user (default "ubuntu")
22 CAKeyPath string `json:"ca_key_path"` // this client's own user CA (load-or-create; default next to config)
23 Tenant string `json:"tenant"` // optional; the credential names the tenant. Set only when the PAT's CA/tenant mapping is ambiguous (a human or user CA in more than one tenant).
24
25 Token string `json:"-"` // loaded from TokenFile; never serialized
26 }
27
28 // LoadConfig reads and validates the config file and loads the PAT.
29 func LoadConfig(path string) (*Config, error) {
30 path = expandTilde(path)
31 // Resolve to an absolute path for stable error messages regardless of the
32 // process's cwd — the MCP host launches this server with an unpredictable
33 // working directory.
34 abs, err := filepath.Abs(path)
35 if err != nil {
36 return nil, fmt.Errorf("resolve config path: %w", err)
37 }
38 path = abs
39 raw, err := os.ReadFile(path)
40 if err != nil {
41 return nil, fmt.Errorf("read config: %w", err)
42 }
43 cfg := &Config{}
44 if err := json.Unmarshal(raw, cfg); err != nil {
45 return nil, fmt.Errorf("parse config %s: %w", path, err)
46 }
47 if cfg.VMUser == "" {
48 cfg.VMUser = "ubuntu"
49 }
50 // An empty tenant is VALID: the credential names the tenant (Me() derives it
51 // for connect names, and the tenant-less CA routes register on it). No default.
52 if cfg.CAKeyPath == "" {
53 cfg.CAKeyPath = filepath.Join(filepath.Dir(path), "user_ca")
54 } else {
55 cfg.CAKeyPath = expandTilde(cfg.CAKeyPath)
56 }
57 if cfg.ServerURL == "" {
58 return nil, fmt.Errorf("config %s: server_url is required", path)
59 }
60 if cfg.TokenFile == "" {
61 return nil, fmt.Errorf("config %s: token_file is required", path)
62 }
63 tok, err := os.ReadFile(expandTilde(cfg.TokenFile))
64 if err != nil {
65 return nil, fmt.Errorf("read token: %w", err)
66 }
67 cfg.Token = strings.TrimSpace(string(tok))
68 if cfg.Token == "" {
69 return nil, fmt.Errorf("token file %s is empty", cfg.TokenFile)
70 }
71 return cfg, nil
72 }
73
74 // expandTilde expands a leading "~/" only. Bare "~" and "~user/x" forms pass
75 // through unchanged; those then fail loudly at file open rather than being
76 // silently mishandled here.
77 func expandTilde(p string) string {
78 if strings.HasPrefix(p, "~/") {
79 if home, err := os.UserHomeDir(); err == nil {
80 return filepath.Join(home, p[2:])
81 }
82 }
83 return p
84 }
internal/mcpserver/config_test.go
Old New
@@ -1,107 +0,0 @@
1 package mcpserver
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 func TestLoadConfigDefaultsAndExpansion(t *testing.T) {
13 dir := t.TempDir()
14 tok := filepath.Join(dir, "token")
15 require.NoError(t, os.WriteFile(tok, []byte("sekret\n"), 0o600))
16 cfgPath := filepath.Join(dir, "config.json")
17 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
18 "server_url": "http://127.0.0.1:9999",
19 "token_file": "`+tok+`",
20 "gate": "gate.example.com:2222"
21 }`), 0o600))
22
23 cfg, err := LoadConfig(cfgPath)
24 require.NoError(t, err)
25 assert.Equal(t, "http://127.0.0.1:9999", cfg.ServerURL)
26 assert.Equal(t, "gate.example.com:2222", cfg.Gate)
27 assert.Equal(t, "ubuntu", cfg.VMUser) // default
28 assert.Equal(t, "sekret", cfg.Token) // trimmed, loaded from file
29 }
30
31 func TestLoadConfigTenantOptionalNoDefault(t *testing.T) {
32 dir := t.TempDir()
33 tok := filepath.Join(dir, "token")
34 require.NoError(t, os.WriteFile(tok, []byte("sekret\n"), 0o600))
35 cfgPath := filepath.Join(dir, "config.json")
36
37 // Omitted tenant stays empty — the credential names it (no "default" fallback).
38 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
39 "server_url": "http://127.0.0.1:9999",
40 "token_file": "`+tok+`"
41 }`), 0o600))
42 cfg, err := LoadConfig(cfgPath)
43 require.NoError(t, err)
44 assert.Empty(t, cfg.Tenant, "an omitted tenant must NOT default to a seeded tenant")
45
46 // An explicit tenant is preserved for the ambiguous multi-tenant corner.
47 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
48 "server_url": "http://127.0.0.1:9999",
49 "token_file": "`+tok+`",
50 "tenant": "team"
51 }`), 0o600))
52 cfg, err = LoadConfig(cfgPath)
53 require.NoError(t, err)
54 assert.Equal(t, "team", cfg.Tenant)
55 }
56
57 func TestLoadConfigExplicitEmptyVMUserGetsDefault(t *testing.T) {
58 dir := t.TempDir()
59 tok := filepath.Join(dir, "token")
60 require.NoError(t, os.WriteFile(tok, []byte("sekret\n"), 0o600))
61 cfgPath := filepath.Join(dir, "config.json")
62 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
63 "server_url": "http://127.0.0.1:9999",
64 "token_file": "`+tok+`",
65 "vm_user": ""
66 }`), 0o600))
67
68 cfg, err := LoadConfig(cfgPath)
69 require.NoError(t, err)
70 assert.Equal(t, "ubuntu", cfg.VMUser, `explicit "vm_user": "" must not erase the default`)
71 }
72
73 func TestLoadConfigMissingRequired(t *testing.T) {
74 dir := t.TempDir()
75 cfgPath := filepath.Join(dir, "config.json")
76 require.NoError(t, os.WriteFile(cfgPath, []byte(`{"server_url": ""}`), 0o600))
77 _, err := LoadConfig(cfgPath)
78 assert.ErrorContains(t, err, "server_url")
79 }
80
81 func TestLoadConfigMissingTokenFile(t *testing.T) {
82 dir := t.TempDir()
83 cfgPath := filepath.Join(dir, "config.json")
84 require.NoError(t, os.WriteFile(cfgPath, []byte(`{"server_url": "http://127.0.0.1:9999"}`), 0o600))
85 _, err := LoadConfig(cfgPath)
86 assert.ErrorContains(t, err, "token_file")
87 }
88
89 func TestLoadConfigEmptyTokenFile(t *testing.T) {
90 dir := t.TempDir()
91 tok := filepath.Join(dir, "token")
92 require.NoError(t, os.WriteFile(tok, []byte(" \n"), 0o600))
93 cfgPath := filepath.Join(dir, "config.json")
94 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
95 "server_url": "http://127.0.0.1:9999",
96 "token_file": "`+tok+`"
97 }`), 0o600))
98 _, err := LoadConfig(cfgPath)
99 assert.ErrorContains(t, err, "empty")
100 }
101
102 func TestLoadConfigTildeExpansion(t *testing.T) {
103 home, err := os.UserHomeDir()
104 require.NoError(t, err)
105 assert.Equal(t, home+"/x", expandTilde("~/x"))
106 assert.Equal(t, "/abs/x", expandTilde("/abs/x"))
107 }
internal/mcpserver/gatedial.go
Old New
@@ -1,139 +0,0 @@
1 package mcpserver
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/a73x/eitri/internal/gateclient"
12 "github.com/a73x/eitri/internal/server/api/client"
13 "golang.org/x/crypto/ssh"
14 )
15
16 // gateAPI is what the gate dialer needs from the control-plane client to
17 // prepare credentials before the first connection: derive the caller's tenant
18 // (Me), and verify/register this client's user CA (ListUserCAs + the embedded
19 // CertAuthority's UploadUserCA). One *client.Client value satisfies it.
20 type gateAPI interface {
21 gateclient.CertAuthority
22 Me() (client.Me, error)
23 ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error)
24 }
25
26 var _ gateAPI = (*client.Client)(nil)
27
28 // gateDialerConfig configures SSH access to VMs through the eitri SSH-CA jump
29 // gate. Gate credentials are prepared lazily on first use from API + UserCA +
30 // Tenant (see gateDialer.ensure); the Gate/VMUser fields shape the dial itself.
31 type gateDialerConfig struct {
32 Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host)
33 VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal)
34 API gateAPI // control-plane client backing tenant derivation, CA registration, host-CA fetch
35 UserCA ssh.Signer // this client's persistent user CA; signs user certs locally
36 Tenant string // configured tenant; "" ⇒ derive from the credential via Me()
37 Now func() time.Time // test seam; nil ⇒ time.Now
38 }
39
40 // gateDialer reaches VMs the way a client outside the fleet does: through the
41 // SSH-CA jump gate, authenticating with short-lived user certs it self-signs
42 // with its own persistent user CA, verifying both hops' host certs against the
43 // eitri host CA. Registering that CA with the caller's tenant is a local
44 // install's concern and lives here, not in the shared tool layer — the control
45 // plane holds its own signing key and needs none of it.
46 type gateDialer struct {
47 cfg gateDialerConfig
48
49 mu sync.Mutex
50 auth gateclient.Credentials // gate credentials, built once ensure() succeeds; tests may inject
51 }
52
53 func newGateDialer(cfg gateDialerConfig) *gateDialer { return &gateDialer{cfg: cfg} }
54
55 var _ VMDialer = (*gateDialer)(nil)
56
57 // ensure prepares gate credentials on first use and caches them in g.auth. It
58 // resolves the caller's tenant (the configured one, else derived from the
59 // credential via Me()) and makes sure this client's user CA is registered with
60 // that tenant so VMs trust the certs it signs. Idempotent across restarts —
61 // registration lists the tenant's user CAs and uploads only when its own
62 // fingerprint is absent. A failed ensure caches nothing, so the next call
63 // retries; its error names the manual fallback (`eitri ca upload`). Never logs or
64 // returns key material.
65 func (g *gateDialer) ensure(ctx context.Context) error {
66 g.mu.Lock()
67 defer g.mu.Unlock()
68 if g.auth != nil {
69 return nil
70 }
71 tenant := g.cfg.Tenant
72 if tenant == "" {
73 // The credential names the tenant; derive it for the connect name, whose
74 // <tenant>.<vmName> form the VM's host cert principal must match.
75 me, err := g.cfg.API.Me()
76 if err != nil {
77 return fmt.Errorf("resolving tenant from credential: %w", err)
78 }
79 if me.Tenant == "" {
80 return errors.New("credential resolves to no tenant")
81 }
82 tenant = me.Tenant
83 }
84 if err := g.registerUserCA(ctx); err != nil {
85 return err
86 }
87 g.auth = gateclient.NewGateAuth(g.cfg.API, g.cfg.UserCA, tenant, g.cfg.Now)
88 return nil
89 }
90
91 // registerUserCA idempotently registers this client's user-CA public key with
92 // the caller's tenant: it lists the registered CAs and uploads the local pubkey
93 // only if its fingerprint is absent. Routing follows the configured tenant — an
94 // empty tenant hits the tenant-less endpoints, which operate on the caller's own
95 // tenant (the credential names it). Errors name the manual fallback and never
96 // carry key material.
97 func (g *gateDialer) registerUserCA(ctx context.Context) error {
98 pub := g.cfg.UserCA.PublicKey()
99 fp := ssh.FingerprintSHA256(pub)
100 cas, err := g.cfg.API.ListUserCAs(ctx, g.cfg.Tenant)
101 if err != nil {
102 return fmt.Errorf("checking registered user CAs (run `eitri ca upload` to register manually): %w", err)
103 }
104 for _, ca := range cas {
105 if ca.Fingerprint == fp {
106 return nil
107 }
108 }
109 line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
110 if err := g.cfg.API.UploadUserCA(ctx, g.cfg.Tenant, line); err != nil {
111 return fmt.Errorf("registering user CA (run `eitri ca upload` to register manually): %w", err)
112 }
113 return nil
114 }
115
116 // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>". It
117 // prepares gate credentials on first use, since the tenant half of the name may
118 // have to be derived from the credential.
119 func (g *gateDialer) ConnectName(ctx context.Context, vmName string) (string, error) {
120 if err := g.ensure(ctx); err != nil {
121 return "", err
122 }
123 g.mu.Lock()
124 auth := g.auth
125 g.mu.Unlock()
126 return auth.ConnectName(ctx, vmName)
127 }
128
129 // Dial reaches the VM named vmName through the eitri SSH-CA gate. See
130 // gateclient.Dial for the two-hop dial logic this delegates to.
131 func (g *gateDialer) Dial(ctx context.Context, vmName string) (*ssh.Client, error) {
132 if err := g.ensure(ctx); err != nil {
133 return nil, err
134 }
135 g.mu.Lock()
136 auth := g.auth
137 g.mu.Unlock()
138 return gateclient.Dial(ctx, gateclient.DialConfig{Gate: g.cfg.Gate, VMUser: g.cfg.VMUser, Auth: auth}, vmName)
139 }
internal/mcpserver/server.go
Old New
@@ -1,3 +1,9 @@
1 // Package mcpserver implements eitri's MCP toolset: tools that let a model
2 // create, control (SSH exec/files), and destroy eitri VMs. The control plane
3 // serves it at /mcp (internal/server/mcphttp). It is an API CLIENT of the
4 // control plane — it speaks to it only through the shared API client
5 // (internal/server/api/client), never any other server internals, and the PAT
6 // it holds must never appear in tool results or errors.
1 package mcpserver 7 package mcpserver
2 8
3 import ( 9 import (
@@ -6,9 +12,7 @@ import (
6 "github.com/modelcontextprotocol/go-sdk/mcp" 12 "github.com/modelcontextprotocol/go-sdk/mcp"
7 ) 13 )
8 14
9 // Options selects the tools a transport exposes. The VM tools and ca_upload are 15 // Options is what a server needs beyond the tools themselves.
10 // common; the two delegation tools are remote-only — a local install holds its
11 // own CA and signs locally, so it has nothing to delegate.
12 type Options struct { 16 type Options struct {
13 Delegator Delegator // non-nil ⇒ expose the delegate tools 17 Delegator Delegator // non-nil ⇒ expose the delegate tools
14 SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection 18 SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection
@@ -49,8 +53,8 @@ type DelegateCompleteIn struct {
49 Certificate string `json:"certificate" jsonschema:"the contents of the *-cert.pub file your CA produced"` 53 Certificate string `json:"certificate" jsonschema:"the contents of the *-cert.pub file your CA produced"`
50 } 54 }
51 55
52 // NewServer builds the MCP server for one identity. Both transports go through 56 // NewServer builds the MCP server for one identity. Every caller goes through
53 // here, so the tool list cannot drift between them. 57 // here, so the tool list is the same one for everyone.
54 func NewServer(t *Tools, opts Options) *mcp.Server { 58 func NewServer(t *Tools, opts Options) *mcp.Server {
55 s := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, 59 s := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"},
56 &mcp.ServerOptions{SchemaCache: opts.SchemaCache}) 60 &mcp.ServerOptions{SchemaCache: opts.SchemaCache})
internal/mcpserver/server_test.go
Old New
@@ -86,9 +86,9 @@ func describe(t *testing.T, cs *mcp.ClientSession, name string) string {
86 return "" 86 return ""
87 } 87 }
88 88
89 // TestNewServerExposesTheCommonTools: a local stdio install gets the VM tools 89 // TestNewServerExposesTheCommonTools: without a Delegator the server offers the
90 // and ca_upload, and nothing else — it holds its own CA and signs locally, so 90 // VM tools and ca_upload, and nothing else — there is nothing to delegate
91 // it has nothing to delegate. 91 // through.
92 func TestNewServerExposesTheCommonTools(t *testing.T) { 92 func TestNewServerExposesTheCommonTools(t *testing.T) {
93 names := toolNames(t, NewServer(&Tools{}, Options{})) 93 names := toolNames(t, NewServer(&Tools{}, Options{}))
94 assert.ElementsMatch(t, commonTools, names) 94 assert.ElementsMatch(t, commonTools, names)
@@ -163,7 +163,8 @@ func TestDelegateFailuresSurfaceAsToolErrors(t *testing.T) {
163 } 163 }
164 164
165 // TestReportProgressIsANoOpWithoutAListener: the tool layer calls it 165 // TestReportProgressIsANoOpWithoutAListener: the tool layer calls it
166 // unconditionally, so the stdio path must not depend on one being installed. 166 // unconditionally, so a caller that asked for no progress must not depend on
167 // one being installed.
167 func TestReportProgressIsANoOpWithoutAListener(t *testing.T) { 168 func TestReportProgressIsANoOpWithoutAListener(t *testing.T) {
168 assert.NotPanics(t, func() { reportProgress(t.Context(), "still working") }) 169 assert.NotPanics(t, func() { reportProgress(t.Context(), "still working") })
169 } 170 }
internal/mcpserver/sshrun.go
Old New
@@ -19,11 +19,10 @@ import (
19 const outputCap = 1 << 20 // 1 MiB 19 const outputCap = 1 << 20 // 1 MiB
20 20
21 // VMDialer reaches a VM by name and names it the way the caller's world spells 21 // VMDialer reaches a VM by name and names it the way the caller's world spells
22 // it. It is the one thing that differs between transports: the local stdio 22 // it. It is the seam between reaching a VM and doing anything to one: the
23 // install goes through the eitri SSH-CA jump gate with a certificate it 23 // control plane tunnels over the VM host's own sync connection with the
24 // self-signs, while the control plane tunnels over the VM host's own sync 24 // credential that tenant has delegated to it (internal/server/vmssh).
25 // connection with the credential that tenant has delegated to it. Everything 25 // Everything above this line — exec, SFTP, output capping — is transport-blind.
26 // above this line — exec, SFTP, output capping — is shared.
27 type VMDialer interface { 26 type VMDialer interface {
28 Dial(ctx context.Context, vmName string) (*ssh.Client, error) 27 Dial(ctx context.Context, vmName string) (*ssh.Client, error)
29 // ConnectName maps a bare VM name to the <tenant>.<name> form the gate 28 // ConnectName maps a bare VM name to the <tenant>.<name> form the gate
internal/mcpserver/sshrun_test.go
Old New
@@ -8,13 +8,9 @@ import (
8 "errors" 8 "errors"
9 "io" 9 "io"
10 "net" 10 "net"
11 "strings"
12 "sync"
13 "testing" 11 "testing"
14 "time" 12 "time"
15 13
16 "github.com/a73x/eitri/internal/gateclient"
17 "github.com/a73x/eitri/internal/server/api/client"
18 "github.com/stretchr/testify/assert" 14 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require" 15 "github.com/stretchr/testify/require"
20 "golang.org/x/crypto/ssh" 16 "golang.org/x/crypto/ssh"
@@ -49,8 +45,25 @@ func hostCertSigner(t *testing.T, ca ssh.Signer, principal string) ssh.Signer {
49 return cs 45 return cs
50 } 46 }
51 47
48 // userCertSigner builds a user-cert-backed signer for principal, signed by ca —
49 // the credential a VM's sshd accepts.
50 func userCertSigner(t *testing.T, ca ssh.Signer, principal string) ssh.Signer {
51 t.Helper()
52 userKey := newSigner(t)
53 cert := &ssh.Certificate{
54 Key: userKey.PublicKey(),
55 CertType: ssh.UserCert,
56 ValidPrincipals: []string{principal},
57 ValidBefore: ssh.CertTimeInfinity,
58 }
59 require.NoError(t, cert.SignCert(rand.Reader, ca))
60 cs, err := ssh.NewCertSigner(cert, userKey)
61 require.NoError(t, err)
62 return cs
63 }
64
52 // caUserAuth accepts a client only if it presents a user cert signed by ca — 65 // caUserAuth accepts a client only if it presents a user cert signed by ca —
53 // mirroring the gate/VM sshd's TrustedUserCAKeys policy. 66 // mirroring the VM sshd's TrustedUserCAKeys policy.
54 func caUserAuth(ca ssh.PublicKey) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { 67 func caUserAuth(ca ssh.PublicKey) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
55 checker := &ssh.CertChecker{ 68 checker := &ssh.CertChecker{
56 IsUserAuthority: func(auth ssh.PublicKey) bool { 69 IsUserAuthority: func(auth ssh.PublicKey) bool {
@@ -140,282 +153,90 @@ func handleExecSession(ch ssh.Channel, reqs <-chan *ssh.Request, out string, cod
140 } 153 }
141 } 154 }
142 155
143 // startGate runs a minimal SSH-CA gate on a random loopback port: it presents 156 // testDialer stands in for whatever transport reached the VM. Runner is
144 // hostSigner's host cert, accepts CA-signed user certs, and on a direct-tcpip 157 // transport-blind, so its tests supply the plainest dialer there is: connect to
145 // channel (the only kind it honors) dials vmAddr and pumps bytes both ways — 158 // a fixed address with a CA-signed user cert. Reaching the right VM and
146 // mirroring sshgate.handleDirectTCPIP. Returns its listen address. 159 // verifying its host certificate belong to the real dialer, and are pinned
147 func startGate(t *testing.T, hostSigner ssh.Signer, userCA ssh.PublicKey, vmAddr string) string { 160 // there (internal/server/vmssh).
148 t.Helper() 161 type testDialer struct {
149 conf := &ssh.ServerConfig{PublicKeyCallback: caUserAuth(userCA)} 162 addr string
150 conf.AddHostKey(hostSigner) 163 tenant string
151
152 ln, err := net.Listen("tcp", "127.0.0.1:0")
153 require.NoError(t, err)
154 t.Cleanup(func() { ln.Close() })
155
156 go func() {
157 for {
158 nc, err := ln.Accept()
159 if err != nil {
160 return
161 }
162 go serveGate(nc, conf, vmAddr)
163 }
164 }()
165 return ln.Addr().String()
166 }
167
168 func serveGate(nc net.Conn, conf *ssh.ServerConfig, vmAddr string) {
169 sc, chans, reqs, err := ssh.NewServerConn(nc, conf)
170 if err != nil {
171 nc.Close()
172 return
173 }
174 defer sc.Close()
175 go ssh.DiscardRequests(reqs)
176 for newCh := range chans {
177 if newCh.ChannelType() != "direct-tcpip" {
178 newCh.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted")
179 continue
180 }
181 go gatePipe(newCh, vmAddr)
182 }
183 }
184
185 func gatePipe(newCh ssh.NewChannel, vmAddr string) {
186 var p struct {
187 HostToConnect string
188 PortToConnect uint32
189 OriginatorIP string
190 OriginatorPort uint32
191 }
192 if err := ssh.Unmarshal(newCh.ExtraData(), &p); err != nil {
193 newCh.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request")
194 return
195 }
196 if p.PortToConnect != 22 {
197 newCh.Reject(ssh.Prohibited, "only port 22 is permitted")
198 return
199 }
200 target, err := net.Dial("tcp", vmAddr)
201 if err != nil {
202 newCh.Reject(ssh.ConnectionFailed, err.Error())
203 return
204 }
205 ch, chReqs, err := newCh.Accept()
206 if err != nil {
207 target.Close()
208 return
209 }
210 go ssh.DiscardRequests(chReqs)
211 go func() { io.Copy(ch, target); ch.Close() }()
212 go func() { io.Copy(target, ch); target.Close() }()
213 }
214
215 // fakeGateCreds is a minimal gateclient.Credentials with a caller-chosen
216 // client signer and host verifier, for exercising client-auth rejection paths.
217 type fakeGateCreds struct {
218 signer ssh.Signer 164 signer ssh.Signer
219 hostCB ssh.HostKeyCallback 165 hostCA ssh.PublicKey
220 } 166 }
221 167
222 func (f fakeGateCreds) Signer(context.Context) (ssh.Signer, error) { return f.signer, nil } 168 func (d testDialer) ConnectName(_ context.Context, vmName string) (string, error) {
223 func (f fakeGateCreds) HostKeyCallback() ssh.HostKeyCallback { return f.hostCB } 169 return d.connectName(vmName), nil
224 func (f fakeGateCreds) ConnectName(_ context.Context, vmName string) (string, error) {
225 return "default." + vmName, nil
226 } 170 }
227 171
228 // fakeCA is a minimal gateclient.CertAuthority whose FetchSSHCA serves a 172 func (d testDialer) connectName(vmName string) string { return d.tenant + "." + vmName }
229 // fixed host-CA signer's public key; UploadUserCA is unused by these dial
230 // tests. The same signer doubles as the user CA passed to
231 // gateclient.NewGateAuth, mirroring gateauth_test.go's fakeCertAuthority.
232 type fakeCA struct{ caSigner ssh.Signer }
233 173
234 func (f fakeCA) FetchSSHCA(context.Context) (ssh.PublicKey, error) { 174 func (d testDialer) Dial(_ context.Context, vmName string) (*ssh.Client, error) {
235 return f.caSigner.PublicKey(), nil 175 name := d.connectName(vmName)
176 checker := &ssh.CertChecker{
177 IsHostAuthority: func(auth ssh.PublicKey, _ string) bool {
178 return bytes.Equal(auth.Marshal(), d.hostCA.Marshal())
179 },
180 }
181 return ssh.Dial("tcp", d.addr, &ssh.ClientConfig{
182 User: "ubuntu",
183 Auth: []ssh.AuthMethod{ssh.PublicKeys(d.signer)},
184 HostKeyCallback: func(_ string, remote net.Addr, key ssh.PublicKey) error {
185 return checker.CheckHostKey(net.JoinHostPort(name, "22"), remote, key)
186 },
187 Timeout: 10 * time.Second,
188 })
236 } 189 }
237 func (f fakeCA) UploadUserCA(context.Context, string, string) error { return nil }
238
239 // ── tests ────────────────────────────────────────────────────────────────────
240 190
241 func TestExecThroughGate(t *testing.T) { 191 // newTestRunner wires a Runner to a backing VM that answers every exec with out
192 // and code.
193 func newTestRunner(t *testing.T, out string, code int) *Runner {
194 t.Helper()
242 ca := newSigner(t) 195 ca := newSigner(t)
243 ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil) 196 addr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), out, code)
197 return NewRunner(testDialer{
198 addr: addr,
199 tenant: "default",
200 signer: userCertSigner(t, ca, "ubuntu"),
201 hostCA: ca.PublicKey(),
202 })
203 }
244 204
245 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) 205 // ── tests ────────────────────────────────────────────────────────────────────
246 // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>,
247 // verifies it under that host.
248 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
249 206
250 r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga}) 207 func TestExecReturnsOutput(t *testing.T) {
251 res, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) 208 res, err := newTestRunner(t, "hi\n", 0).Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
252 require.NoError(t, err) 209 require.NoError(t, err)
253 assert.Equal(t, "hi\n", res.Stdout) 210 assert.Equal(t, "hi\n", res.Stdout)
254 assert.Equal(t, 0, res.ExitCode) 211 assert.Equal(t, 0, res.ExitCode)
212 assert.False(t, res.Truncated)
255 } 213 }
256 214
257 func TestExecVMForeignCAHostCertRejected(t *testing.T) { 215 func TestExecNonZeroExitIsNotAnError(t *testing.T) {
258 ca := newSigner(t) 216 res, err := newTestRunner(t, "nope\n", 3).Exec(t.Context(), "testvm", "false", 10*time.Second)
259 ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil) 217 require.NoError(t, err, "a command that fails is a result, not a transport failure")
260 218 assert.Equal(t, 3, res.ExitCode)
261 // The VM presents a host cert signed by a DIFFERENT CA. Its user-auth policy 219 assert.Equal(t, "nope\n", res.Stdout)
262 // still trusts the real CA, so the gate hop and client auth both succeed and
263 // this isolates the VM host-cert rejection.
264 foreignCA := newSigner(t)
265 vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "default.testvm"), ca.PublicKey(), "hi\n", 0)
266 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
267
268 r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga})
269 _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
270 require.Error(t, err)
271 assert.Contains(t, err.Error(), "vm testvm ssh handshake", "expected the VM hop to reject the foreign-CA host cert")
272 }
273
274 func TestExecGateRejectsNonCAUserKey(t *testing.T) {
275 ca := newSigner(t)
276 ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil)
277
278 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0)
279 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
280
281 // A plain (non-cert) client key: the gate only accepts CA-signed user certs,
282 // so its handshake must fail auth. Host verification still uses the real CA,
283 // isolating the client-auth rejection at the gate hop.
284 creds := fakeGateCreds{signer: newSigner(t), hostCB: ga.HostKeyCallback()}
285 r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: creds})
286 _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
287 require.Error(t, err)
288 assert.Contains(t, err.Error(), "gate "+gateAddr+" ssh handshake", "expected the gate hop to reject the non-CA user key")
289 }
290
291 // ── Runner.ensure: tenant derivation + idempotent CA self-registration ────────
292
293 // fakeGateAPI is a minimal gateAPI for exercising Runner.ensure: it serves a
294 // host CA, records user-CA uploads, and returns scripted Me()/ListUserCAs
295 // results so tenant derivation and idempotent registration can be pinned without
296 // an httptest server.
297 type fakeGateAPI struct {
298 caSigner ssh.Signer
299
300 meTenant string
301 meErr error
302 listCAs []client.UserCA
303 listErrs int // leading ListUserCAs calls that fail (control-plane blip)
304 uploadErr error
305
306 mu sync.Mutex
307 meCalls int
308 listCalls int
309 uploads []string // uploaded CA lines, in order
310 lastTenant string // tenant arg of the last CA (list/upload) call
311 }
312
313 func (f *fakeGateAPI) FetchSSHCA(context.Context) (ssh.PublicKey, error) {
314 return f.caSigner.PublicKey(), nil
315 } 220 }
316 221
317 func (f *fakeGateAPI) Me() (client.Me, error) { 222 func TestExecReportsTruncationPastTheCap(t *testing.T) {
318 f.mu.Lock() 223 res, err := newTestRunner(t, string(bytes.Repeat([]byte("x"), outputCap+512)), 0).
319 defer f.mu.Unlock() 224 Exec(t.Context(), "testvm", "cat big", 30*time.Second)
320 f.meCalls++
321 if f.meErr != nil {
322 return client.Me{}, f.meErr
323 }
324 return client.Me{Tenant: f.meTenant}, nil
325 }
326
327 func (f *fakeGateAPI) ListUserCAs(_ context.Context, tenant string) ([]client.UserCA, error) {
328 f.mu.Lock()
329 defer f.mu.Unlock()
330 f.listCalls++
331 f.lastTenant = tenant
332 if f.listErrs > 0 {
333 f.listErrs--
334 return nil, errors.New("control plane unavailable")
335 }
336 return f.listCAs, nil
337 }
338
339 func (f *fakeGateAPI) UploadUserCA(_ context.Context, tenant, line string) error {
340 f.mu.Lock()
341 defer f.mu.Unlock()
342 f.lastTenant = tenant
343 if f.uploadErr != nil {
344 return f.uploadErr
345 }
346 f.uploads = append(f.uploads, line)
347 return nil
348 }
349
350 func TestRunnerEnsureRegistersUserCAWhenAbsent(t *testing.T) {
351 userCA := newSigner(t)
352 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme"} // listCAs empty ⇒ absent
353 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA})
354
355 name, err := r.ConnectName(t.Context(), "web-1")
356 require.NoError(t, err)
357 assert.Equal(t, "acme.web-1", name, "connect name uses the tenant derived from the credential")
358
359 api.mu.Lock()
360 defer api.mu.Unlock()
361 require.Len(t, api.uploads, 1, "an absent CA must be uploaded exactly once")
362 assert.Equal(t, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(userCA.PublicKey()))), api.uploads[0])
363 assert.Equal(t, "", api.lastTenant, "an empty config tenant registers via the tenant-less route")
364 }
365
366 func TestRunnerEnsureSkipsUploadWhenFingerprintPresent(t *testing.T) {
367 userCA := newSigner(t)
368 fp := ssh.FingerprintSHA256(userCA.PublicKey())
369 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listCAs: []client.UserCA{{Fingerprint: fp, Label: "mcp"}}}
370 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA})
371
372 _, err := r.ConnectName(t.Context(), "web-1")
373 require.NoError(t, err) 225 require.NoError(t, err)
374 226 assert.Len(t, res.Stdout, outputCap, "captured output is capped")
375 api.mu.Lock() 227 assert.True(t, res.Truncated, "the model must be told the output was cut")
376 defer api.mu.Unlock()
377 assert.Empty(t, api.uploads, "a CA already registered (matching fingerprint) must NOT be re-uploaded")
378 } 228 }
379 229
380 func TestRunnerEnsureExplicitTenantSkipsMe(t *testing.T) { 230 func TestExecSurfacesADialFailure(t *testing.T) {
381 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "should-not-be-used"} 231 // A dialer that cannot reach the VM: the error is the transport's, and the
382 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t), Tenant: "team"}) 232 // Runner passes it through rather than reporting an empty success.
383 233 r := NewRunner(testDialer{addr: "127.0.0.1:1", tenant: "default", signer: newSigner(t), hostCA: newSigner(t).PublicKey()})
384 name, err := r.ConnectName(t.Context(), "web-1") 234 _, err := r.Exec(t.Context(), "testvm", "echo hi", 5*time.Second)
385 require.NoError(t, err)
386 assert.Equal(t, "team.web-1", name, "an explicit tenant is used verbatim")
387
388 api.mu.Lock()
389 defer api.mu.Unlock()
390 assert.Zero(t, api.meCalls, "an explicit tenant must not call Me()")
391 assert.Equal(t, "team", api.lastTenant, "an explicit tenant pins the /tenants/{tenant} CA route")
392 }
393
394 func TestRunnerEnsureRegistrationFailureNamesFallback(t *testing.T) {
395 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", uploadErr: errors.New("boom")}
396 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)})
397
398 _, err := r.ConnectName(t.Context(), "web-1")
399 require.Error(t, err) 235 require.Error(t, err)
400 assert.Contains(t, err.Error(), "eitri ca upload", "a registration failure must name the manual fallback")
401 } 236 }
402 237
403 func TestRunnerEnsureRetriesAfterFailureThenCaches(t *testing.T) { 238 func TestConnectNameComesFromTheDialer(t *testing.T) {
404 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listErrs: 1} // first list fails 239 name, err := newTestRunner(t, "", 0).ConnectName(t.Context(), "web-1")
405 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)})
406
407 _, err := r.ConnectName(t.Context(), "web-1")
408 require.Error(t, err, "a failed ensure surfaces the error")
409
410 // The next call retries and succeeds; a third rides the cache.
411 _, err = r.ConnectName(t.Context(), "web-1")
412 require.NoError(t, err)
413 _, err = r.ConnectName(t.Context(), "web-1")
414 require.NoError(t, err) 240 require.NoError(t, err)
415 241 assert.Equal(t, "default.web-1", name)
416 api.mu.Lock()
417 defer api.mu.Unlock()
418 assert.Equal(t, 2, api.listCalls, "one failed + one successful list; the cached third call lists nothing")
419 assert.Equal(t, 2, api.meCalls, "Me() re-runs on the retry, then not after caching")
420 assert.Len(t, api.uploads, 1, "the successful attempt uploads the absent CA once")
421 } 242 }
internal/mcpserver/tools.go
Old New
@@ -10,7 +10,6 @@ import (
10 "strings" 10 "strings"
11 "time" 11 "time"
12 12
13 "github.com/a73x/eitri/internal/gateclient"
14 "github.com/a73x/eitri/internal/random" 13 "github.com/a73x/eitri/internal/random"
15 "github.com/a73x/eitri/internal/server/api/client" 14 "github.com/a73x/eitri/internal/server/api/client"
16 "github.com/a73x/eitri/internal/server/release" 15 "github.com/a73x/eitri/internal/server/release"
@@ -34,9 +33,8 @@ type api interface {
34 } 33 }
35 34
36 // Exec is everything the tools do on a VM once something has reached it. It is 35 // Exec is everything the tools do on a VM once something has reached it. It is
37 // exported because the two transports supply it differently: the stdio binary 36 // exported because the transport supplies it: the control plane builds a Runner
38 // builds a Runner over the jump gate, the control plane one over the sync 37 // over the sync tunnel.
39 // tunnel.
40 type Exec interface { 38 type Exec interface {
41 Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) 39 Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error)
42 WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error 40 WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error
@@ -99,12 +97,10 @@ func (a API) FirstEligibleHost(ctx context.Context) (client.Host, error) {
99 return client.Host{}, errors.New("no online hosts") 97 return client.Host{}, errors.New("no online hosts")
100 } 98 }
101 99
102 // The real client and runner must satisfy the seams unchanged, and the shared 100 // The real client and runner must satisfy the seams unchanged.
103 // API client must keep satisfying the gate's cert-authority seam.
104 var ( 101 var (
105 _ api = API{} 102 _ api = API{}
106 _ Exec = (*Runner)(nil) 103 _ Exec = (*Runner)(nil)
107 _ gateclient.CertAuthority = (*client.Client)(nil)
108 ) 104 )
109 105
110 // Tools implements the ten eitri VM tools over the API and SSH seams. 106 // Tools implements the ten eitri VM tools over the API and SSH seams.
internal/server/boot/boot.go
Old New
@@ -232,8 +232,8 @@ func run(cfgPath string) error {
232 // Sign-in endpoints live OUTSIDE /api/ and its auth middleware: they are how 232 // Sign-in endpoints live OUTSIDE /api/ and its auth middleware: they are how
233 // a browser establishes a session in the first place (spec §2). 233 // a browser establishes a session in the first place (spec §2).
234 root.Handle("/auth/", a.AuthHandler()) 234 root.Handle("/auth/", a.AuthHandler())
235 // The MCP endpoint: the same toolset the stdio binary serves, for a client 235 // The MCP endpoint: eitri's whole toolset, for a client anywhere on the
236 // anywhere on the internet holding nothing but a PAT. It speaks JSON-RPC 236 // internet holding nothing but a PAT. It speaks JSON-RPC
237 // rather than the REST contract, so like /auth/* it lives outside the route 237 // rather than the REST contract, so like /auth/* it lives outside the route
238 // table — but wrapped in the API's own authentication, so a caller reaching 238 // table — but wrapped in the API's own authentication, so a caller reaching
239 // it is the same authenticated principal /api/v1 would see. Both patterns are 239 // it is the same authenticated principal /api/v1 would see. Both patterns are
internal/server/mcphttp/mcphttp.go
Old New
@@ -2,8 +2,7 @@
2 // is MCP streamable HTTP, stateless: one JSON-RPC message per POST, no session 2 // is MCP streamable HTTP, stateless: one JSON-RPC message per POST, no session
3 // state, nothing server-initiated except progress on a call in flight. Identity 3 // state, nothing server-initiated except progress on a call in flight. Identity
4 // is per request — the bearer PAT the auth middleware already resolved — so a 4 // is per request — the bearer PAT the auth middleware already resolved — so a
5 // server is built per request, bound to that caller, and the tools it exposes 5 // server is built per request, bound to that caller.
6 // are the same ones the stdio binary exposes.
7 // 6 //
8 // A PAT is the whole credential. The tools call the API in-process through the 7 // A PAT is the whole credential. The tools call the API in-process through the
9 // same client every other consumer uses, so tenant filtering and authorization 8 // same client every other consumer uses, so tenant filtering and authorization
internal/smoke/mcp_test.go
Old New
@@ -306,9 +306,9 @@ func TestProveMCPDestroysItsVMOnFailure(t *testing.T) {
306 assert.Contains(t, f.calls, "vm_destroy", "the leg must reap its own VM even when it fails") 306 assert.Contains(t, f.calls, "vm_destroy", "the leg must reap its own VM even when it fails")
307 } 307 }
308 308
309 // TestProveToolList covers the cheap leg every extra origin gets on its own: a 309 // TestProveToolList covers the cheap leg every extra origin gets on its own: an
310 // transport advertising fewer tools than the stdio binary means the two have 310 // origin advertising fewer tools than the toolset means that route reaches
311 // drifted apart, and a missing tool is named rather than counted. 311 // something else, and a missing tool is named rather than counted.
312 func TestProveToolList(t *testing.T) { 312 func TestProveToolList(t *testing.T) {
313 cases := []struct { 313 cases := []struct {
314 name string 314 name string