a73x

internal/server/mcphttp/mcphttp.go

Ref:   Size: 5.1 KiB   History

// Package mcphttp serves the eitri MCP toolset over HTTP at /mcp. The transport
// is MCP streamable HTTP, stateless: one JSON-RPC message per POST, no session
// state, nothing server-initiated except progress on a call in flight. Identity
// is per request — the bearer PAT the auth middleware already resolved — so a
// server is built per request, bound to that caller.
//
// A PAT is the whole credential. The tools call the API in-process through the
// same client every other consumer uses, so tenant filtering and authorization
// are the API's, unduplicated; SSH reaches VMs over the host's sync tunnel with
// the credential that tenant has delegated to eitri.
package mcphttp

import (
	"context"
	"fmt"
	"net/http"
	"strings"

	"github.com/a73x/eitri/internal/mcpserver"
	"github.com/a73x/eitri/internal/server/api"
	"github.com/a73x/eitri/internal/server/api/client"
	"github.com/a73x/eitri/internal/server/vmssh"
	"github.com/modelcontextprotocol/go-sdk/mcp"
	"golang.org/x/crypto/ssh"
)

// inprocBaseURL is the host the in-process client addresses. Nothing resolves
// it: the request never leaves the process, and the API's own mux answers.
const inprocBaseURL = "http://eitri.internal"

// Deps is everything /mcp needs from the rest of the control plane.
type Deps struct {
	Handler http.Handler      // the API's own mux, for the in-process client
	Creds   vmssh.Credentials // what a tenant has delegated, and whether it has any CA at all
	TCP     vmssh.TCPDialer
	Lookup  vmssh.VMLookup
	HostCA  ssh.PublicKey // nil ⇒ no jump gate ⇒ remote exec refused with a clear reason
	Gate    string        // gate address, for the ssh_command hint only
	VMUser  string        // guest login user, and the certificate principal
	// DelegationsURL is the full URL a caller POSTs to start a delegation. The
	// REST API and /mcp can be served on different hostnames, so the refusal
	// that carries this must not leave the caller to guess which.
	DelegationsURL string
}

// New returns the /mcp handler. The API mux and the schema cache are built once
// and shared by every request; everything identity-bearing is built per request.
func New(d Deps) http.Handler {
	transport := &http.Client{Transport: inproc{h: d.Handler}}
	cache := mcp.NewSchemaCache()
	return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
		return serverFor(d, transport, cache, r)
	}, &mcp.StreamableHTTPOptions{Stateless: true})
}

// serverFor builds the MCP server for one authenticated caller. The tenant comes
// from the principal the auth middleware resolved; the PAT is passed straight
// back down so the in-process API calls re-authenticate as that same caller.
func serverFor(d Deps, transport *http.Client, cache *mcp.SchemaCache, r *http.Request) *mcp.Server {
	tenant := api.TenantFromContext(r.Context())
	pat := bearer(r)
	c := &client.Client{BaseURL: inprocBaseURL, Token: pat, HTTP: transport}
	tools := &mcpserver.Tools{
		API: mcpserver.API{Client: c},
		Runner: mcpserver.NewRunner(&vmssh.Dialer{
			Tenant:         tenant,
			VMUser:         d.VMUser,
			TCP:            d.TCP,
			Lookup:         d.Lookup,
			Creds:          d.Creds,
			HostCA:         d.HostCA,
			DelegationsURL: d.DelegationsURL,
		}),
		Gate:   d.Gate,
		VMUser: d.VMUser,
	}
	return mcpserver.NewServer(tools, mcpserver.Options{
		Delegator:   delegator{c: c},
		SchemaCache: cache,
	})
}

// bearer returns the request's bearer token. A request that reached here passed
// the auth middleware, so a non-empty Authorization header is the PAT; a console
// session cookie instead leaves this empty, and the in-process API calls then
// fail as unauthenticated rather than silently borrowing someone's identity.
func bearer(r *http.Request) string {
	tok, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
	return tok
}

// delegator backs the two delegate tools with the delegation endpoints, and
// words what the model should do next at each step. Authorization stays the
// API's: these go through the same in-process client every other tool uses.
type delegator struct{ c *client.Client }

func (dg delegator) Begin(ctx context.Context) (mcpserver.BeginResult, error) {
	ch, err := dg.c.BeginDelegation(ctx)
	if err != nil {
		return mcpserver.BeginResult{}, fmt.Errorf("starting a delegation: %w", err)
	}
	return mcpserver.BeginResult{
		PublicKey:    ch.PublicKey,
		Principal:    ch.Principal,
		Instructions: ch.Instructions,
		Note: "eitri cannot sign this itself — it holds no CA. Show the human the public key and the command, " +
			"wait for them to run it, and pass the resulting *-cert.pub to delegate_complete.",
	}, nil
}

func (dg delegator) Complete(ctx context.Context, certificate string) (mcpserver.DelegationResult, error) {
	d, err := dg.c.CompleteDelegation(ctx, certificate)
	if err != nil {
		return mcpserver.DelegationResult{}, fmt.Errorf("completing the delegation: %w", err)
	}
	return mcpserver.DelegationResult{
		ExpiresAt:     d.ExpiresAt,
		CAFingerprint: d.CAFingerprint,
		Principals:    d.Principals,
		Note: "eitri can now reach your VMs until " + d.ExpiresAt + ". It holds no signing key — only this " +
			"certificate, in memory. If the control plane restarts, delegate again.",
	}, nil
}