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
}