Add route declarations and automatic port allocation to MCP agent

Service definitions can now declare routes per component instead of
manual port mappings:

  [[components.routes]]
  name = "rest"
  port = 8443
  mode = "l4"

The agent allocates free host ports at deploy time and injects
$PORT/$PORT_<NAME> env vars into containers. Backward compatible:
components with old-style ports= work unchanged.

Changes:
- Proto: RouteSpec message, routes + env fields on ComponentSpec
- Servicedef: RouteDef parsing and validation from TOML
- Registry: component_routes table with host_port tracking
- Runtime: Env field on ContainerSpec, -e flag in BuildRunArgs
- Agent: PortAllocator (random 10000-60000, availability check),
  deploy wiring for route→port mapping and env injection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 01:04:47 -07:00
parent 503c52dc26
commit 777ba8a0e1
14 changed files with 1101 additions and 222 deletions

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
@@ -58,6 +59,25 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
a.Logger.Info("deploying component", "service", serviceName, "component", compName, "desired", desiredState)
// Convert proto routes to registry routes.
var regRoutes []registry.Route
for _, r := range cs.GetRoutes() {
mode := r.GetMode()
if mode == "" {
mode = "l4"
}
name := r.GetName()
if name == "" {
name = "default"
}
regRoutes = append(regRoutes, registry.Route{
Name: name,
Port: int(r.GetPort()),
Mode: mode,
Hostname: r.GetHostname(),
})
}
regComp := &registry.Component{
Name: compName,
Service: serviceName,
@@ -70,6 +90,7 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
Ports: cs.GetPorts(),
Volumes: cs.GetVolumes(),
Cmd: cs.GetCmd(),
Routes: regRoutes,
}
if err := ensureComponent(a.DB, regComp); err != nil {
@@ -89,16 +110,34 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
_ = a.Runtime.Stop(ctx, containerName) // may not exist yet
_ = a.Runtime.Remove(ctx, containerName) // may not exist yet
// Build the container spec. If the component has routes, use route-based
// port allocation and env injection. Otherwise, fall back to legacy ports.
runSpec := runtime.ContainerSpec{
Name: containerName,
Image: cs.GetImage(),
Network: cs.GetNetwork(),
User: cs.GetUser(),
Restart: cs.GetRestart(),
Ports: cs.GetPorts(),
Volumes: cs.GetVolumes(),
Cmd: cs.GetCmd(),
Env: cs.GetEnv(),
}
if len(regRoutes) > 0 && a.PortAlloc != nil {
ports, env, err := a.allocateRoutePorts(serviceName, compName, regRoutes)
if err != nil {
return &mcpv1.ComponentResult{
Name: compName,
Error: fmt.Sprintf("allocate route ports: %v", err),
}
}
runSpec.Ports = ports
runSpec.Env = append(runSpec.Env, env...)
} else {
// Legacy: use ports directly from the spec.
runSpec.Ports = cs.GetPorts()
}
if err := a.Runtime.Run(ctx, runSpec); err != nil {
_ = registry.UpdateComponentState(a.DB, serviceName, compName, "", "removed")
return &mcpv1.ComponentResult{
@@ -117,6 +156,36 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
}
}
// allocateRoutePorts allocates host ports for each route, stores them in
// the registry, and returns the port mappings and env vars for the container.
func (a *Agent) allocateRoutePorts(service, component string, routes []registry.Route) ([]string, []string, error) {
var ports []string
var env []string
for _, r := range routes {
hostPort, err := a.PortAlloc.Allocate()
if err != nil {
return nil, nil, fmt.Errorf("allocate port for route %q: %w", r.Name, err)
}
if err := registry.UpdateRouteHostPort(a.DB, service, component, r.Name, hostPort); err != nil {
a.PortAlloc.Release(hostPort)
return nil, nil, fmt.Errorf("store host port for route %q: %w", r.Name, err)
}
ports = append(ports, fmt.Sprintf("127.0.0.1:%d:%d", hostPort, r.Port))
if len(routes) == 1 {
env = append(env, fmt.Sprintf("PORT=%d", hostPort))
} else {
envName := "PORT_" + strings.ToUpper(r.Name)
env = append(env, fmt.Sprintf("%s=%d", envName, hostPort))
}
}
return ports, env, nil
}
// ensureService creates the service if it does not exist, or updates its
// active flag if it does.
func ensureService(db *sql.DB, name string, active bool) error {