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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ mcp:
enabled: true
group: "default"
port: 4483
session_ttl: "12h" # idle eviction timeout for host MCP sessions

git:
forward_token: true
Expand Down
18 changes: 17 additions & 1 deletion cmd/bbox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func rootCmd() *cobra.Command {
mcpPort uint16
mcpConfig string
mcpAuthzProfile string
mcpSessionTTL time.Duration
noGitToken bool
noGitSSHAgent bool
noSaveCredentials bool
Expand Down Expand Up @@ -165,6 +166,7 @@ Example:
mcpPort: mcpPort,
mcpConfig: mcpConfig,
mcpAuthzProfile: mcpAuthzProfile,
mcpSessionTTL: mcpSessionTTL,
noGitToken: noGitToken,
noGitSSHAgent: noGitSSHAgent,
noSaveCredentials: noSaveCredentials,
Expand Down Expand Up @@ -204,6 +206,7 @@ Example:
cmd.Flags().Uint16Var(&mcpPort, "mcp-port", 4483, "Port for MCP proxy on VM gateway")
cmd.Flags().StringVar(&mcpConfig, "mcp-config", "", "Path to MCP config YAML (Cedar policies and aggregation settings)")
cmd.Flags().StringVar(&mcpAuthzProfile, "mcp-authz-profile", "", "MCP authorization profile: full-access, observe, safe-tools, custom (default: full-access)")
cmd.Flags().DurationVar(&mcpSessionTTL, "mcp-session-ttl", 0, "Idle timeout for host MCP sessions, e.g. 12h or 30m (0 = use config or default 12h)")
cmd.Flags().BoolVar(&noGitToken, "no-git-token", false, "Disable forwarding GITHUB_TOKEN/GH_TOKEN into the VM")
cmd.Flags().BoolVar(&noGitSSHAgent, "no-git-ssh-agent", false, "Disable SSH agent forwarding into the VM")
cmd.Flags().BoolVar(&noSaveCredentials, "no-save-credentials", false, "Disable saving agent credentials between sessions (enabled by default)")
Expand Down Expand Up @@ -363,6 +366,7 @@ type runFlags struct {
mcpPort uint16
mcpConfig string
mcpAuthzProfile string
mcpSessionTTL time.Duration
noGitToken bool
noGitSSHAgent bool
noSaveCredentials bool
Expand Down Expand Up @@ -782,6 +786,9 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
return fmt.Errorf("invalid --mcp-authz-profile %q: valid values are %v",
flags.mcpAuthzProfile, domainconfig.ValidMCPAuthzProfiles())
}
if flags.mcpSessionTTL < 0 {
return fmt.Errorf("--mcp-session-ttl must be non-negative, got %s", flags.mcpSessionTTL)
}

// Wire MCP proxy (enabled by default, --no-mcp to disable).
mcpEnabled := !flags.noMCP
Expand Down Expand Up @@ -835,7 +842,16 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
}
}

mcpProvider := inframcp.NewVMCPProvider(mcpGroup, mcpPort, mcpFileConfig, authzCfg, logger, logFile)
// Resolve session TTL: flag (non-zero) > config > default (12h).
sessionTTL := flags.mcpSessionTTL
if sessionTTL == 0 && cfg != nil {
sessionTTL = cfg.MCP.ResolvedSessionTTL()
}
if sessionTTL == 0 {
sessionTTL = domainconfig.DefaultMCPSessionTTL
}

mcpProvider := inframcp.NewVMCPProvider(mcpGroup, mcpPort, mcpFileConfig, authzCfg, sessionTTL, logger, logFile)
deps.MCPProvider = mcpProvider
defer func() { _ = mcpProvider.Close() }()
// Ensure sandbox config reflects MCP enabled state for the application layer.
Expand Down
2 changes: 2 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ bbox <agent-name> [flags] [-- <agent-args...>]
| `--mcp-port` | `4483` | Port for MCP proxy on VM gateway |
| `--mcp-config` | (none) | Path to MCP config YAML (Cedar policies and aggregation settings) |
| `--mcp-authz-profile` | `full-access` | MCP authorization profile: `full-access`, `observe`, `safe-tools`, `custom` |
| `--mcp-session-ttl` | `12h` | Idle timeout for host MCP sessions (e.g. `12h`, `30m`) |
| `--no-git-token` | `false` | Disable forwarding GITHUB_TOKEN/GH_TOKEN into the VM |
| `--no-git-ssh-agent` | `false` | Disable SSH agent forwarding into the VM |
| `--no-settings` | `false` | Disable injecting host agent settings (rules, skills, etc.) into the VM |
Expand Down Expand Up @@ -160,6 +161,7 @@ mcp:
enabled: true
group: "default"
port: 4483
session_ttl: "12h" # Idle timeout for host MCP sessions (default: 12h)
# Optional inline MCP config (Cedar policies and aggregation)
# config:
# authz:
Expand Down
9 changes: 8 additions & 1 deletion internal/infra/mcp/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"log/slog"
"net/http"
"time"

"go.uber.org/zap"
"go.uber.org/zap/zapcore"
Expand Down Expand Up @@ -39,6 +40,7 @@ type VMCPProvider struct {
port uint16
mcpConfig *domainconfig.MCPFileConfig
authzConfig *domainconfig.MCPAuthzConfig
sessionTTL time.Duration
server *vmcpserver.Server
logger *slog.Logger
logWriter io.Writer
Expand All @@ -48,14 +50,18 @@ type VMCPProvider struct {
// backends discovered in the given ToolHive group.
// mcpConfig provides Cedar policies and aggregation settings (nil = no custom config).
// authzConfig controls MCP authorization (nil = full-access, no restrictions).
// sessionTTL is the idle-eviction timeout for host vMCP sessions; zero defers
// to toolhive's built-in default (30m), which is typically too short — callers
// should pass domainconfig.DefaultMCPSessionTTL or a configured value.
// logWriter receives toolhive's zap logs (typically the bbox log file).
// If nil, toolhive logs are discarded.
func NewVMCPProvider(group string, port uint16, mcpConfig *domainconfig.MCPFileConfig, authzConfig *domainconfig.MCPAuthzConfig, logger *slog.Logger, logWriter io.Writer) *VMCPProvider {
func NewVMCPProvider(group string, port uint16, mcpConfig *domainconfig.MCPFileConfig, authzConfig *domainconfig.MCPAuthzConfig, sessionTTL time.Duration, logger *slog.Logger, logWriter io.Writer) *VMCPProvider {
return &VMCPProvider{
group: group,
port: port,
mcpConfig: mcpConfig,
authzConfig: authzConfig,
sessionTTL: sessionTTL,
logger: logger,
logWriter: logWriter,
}
Expand Down Expand Up @@ -163,6 +169,7 @@ func (p *VMCPProvider) Services(ctx context.Context) ([]hostservice.Service, err
GroupRef: p.group,
Port: int(p.port),
EndpointPath: "/mcp",
SessionTTL: p.sessionTTL,
AuthMiddleware: authMiddleware,
AuthzMiddleware: authzMiddleware,
AuthInfoHandler: authInfoHandler,
Expand Down
14 changes: 8 additions & 6 deletions internal/infra/mcp/provider_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ func TestVMCPProvider_Services_Integration(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))

provider := mcp.NewVMCPProvider(
"default", // group
4483, // port
nil, // no custom MCP config
nil, // full-access (no authz restrictions)
logger, // logger
io.Discard, // log writer
"default", // group
4483, // port
nil, // no custom MCP config
nil, // full-access (no authz restrictions)
config.DefaultMCPSessionTTL, // session TTL
logger, // logger
io.Discard, // log writer
)

ctx := context.Background()
Expand Down Expand Up @@ -93,6 +94,7 @@ func TestVMCPProvider_Services_WithAuthzProfiles_Integration(t *testing.T) {
4483,
nil,
&config.MCPAuthzConfig{Profile: profile},
config.DefaultMCPSessionTTL,
logger,
io.Discard,
)
Expand Down
46 changes: 46 additions & 0 deletions pkg/domain/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package config
import (
"fmt"
"strings"
"time"

"github.com/stacklok/brood-box/pkg/domain/agent"
"github.com/stacklok/brood-box/pkg/domain/bytesize"
Expand Down Expand Up @@ -70,6 +71,15 @@ func (c *Config) Validate() error {
return fmt.Errorf("workspace.mode %q: valid values are %v",
c.Workspace.Mode, ValidWorkspaceModes())
}
if c.MCP.SessionTTL != "" {
d, err := time.ParseDuration(c.MCP.SessionTTL)
if err != nil {
return fmt.Errorf("mcp.session_ttl %q: %w", c.MCP.SessionTTL, err)
}
if d < 0 {
return fmt.Errorf("mcp.session_ttl must be non-negative, got %s", d)
}
}
for name, override := range c.Agents {
if err := agent.ValidateEnvForwardPatterns(override.EnvForward); err != nil {
return fmt.Errorf("agents.%s.%w", name, err)
Expand Down Expand Up @@ -277,6 +287,36 @@ type MCPConfig struct {

// Authz configures authorization for the MCP proxy.
Authz *MCPAuthzConfig `yaml:"authz,omitempty"`

// SessionTTL is the idle-eviction timeout for MCP sessions on the host
// vMCP server, parsed by time.ParseDuration ("12h", "30m", "1h30m"). Empty
// uses DefaultMCPSessionTTL. Stored as a string (not time.Duration) so the
// YAML wire format is human-readable rather than nanosecond integers.
//
// NOTE: MCP.SessionTTL is intentionally NOT merged from workspace-local
// config — it is operational rather than a security/policy knob, so the
// global value (or --mcp-session-ttl) is authoritative.
SessionTTL string `yaml:"session_ttl,omitempty"`
}

// DefaultMCPSessionTTL is the default idle-eviction timeout for host vMCP
// sessions when neither config nor flag overrides it. 12h covers a typical
// workday including long breaks; the toolhive default of 30m is too short
// for interactive agent use where the user steps away mid-session.
const DefaultMCPSessionTTL = 12 * time.Hour

// ResolvedSessionTTL parses MCP.SessionTTL and returns the effective duration,
// falling back to DefaultMCPSessionTTL when empty. Validation runs at config
// load time, so this assumes the value already parses cleanly.
func (m MCPConfig) ResolvedSessionTTL() time.Duration {
if m.SessionTTL == "" {
return DefaultMCPSessionTTL
}
d, err := time.ParseDuration(m.SessionTTL)
if err != nil || d <= 0 {
return DefaultMCPSessionTTL
}
return d
}

// MCPFileConfig is the user-facing MCP configuration format.
Expand Down Expand Up @@ -657,6 +697,7 @@ func clampTmpSize(s bytesize.ByteSize) bytesize.ByteSize {
// - Review.ExcludePatterns: additive (global + local).
// - Defaults.EgressProfile: local can only tighten (not widen).
// - Network.AllowHosts: additive (global + local).
// - MCP.SessionTTL: local value is IGNORED (operational, not policy).
// - Agents map: local extends/overrides global per key.
//
// Returns global unchanged when local is nil.
Expand Down Expand Up @@ -754,6 +795,11 @@ func MergeConfigs(global, local *Config) *Config {
// as egress profiles — a workspace config cannot escalate permissions.
result.MCP.Authz = MergeMCPAuthzConfig(global.MCP.Authz, local.MCP.Authz)

// MCP.SessionTTL: workspace-local value is IGNORED. Same handling as
// review.enabled — operational, not security/policy, so global is
// authoritative.
// (result.MCP.SessionTTL already carries the global value via *global copy.)

// Agents: local extends/overrides global per key.
// Security fields (EgressProfile, MCP.Authz) use tighten-only merge.
// Resource/identity fields use local-overrides-when-non-zero.
Expand Down