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
64 changes: 48 additions & 16 deletions internal/cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,16 @@ Configure in Claude Desktop:

func newMcpServeCmd() *cobra.Command {
var (
rateLimit int
readOnly bool
transport string
addr string
port int
corsOrigin string
requireAuth bool
tlsCert string
tlsKey string
rateLimit int
readOnly bool
transport string
addr string
port int
corsOrigin string
requireAuth bool
requireStepUp bool
tlsCert string
tlsKey string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -113,6 +114,10 @@ Use JC_PROFILE environment variable to select which JumpCloud org to use.`,
if !cmd.Flags().Changed("port") {
port = config.MCPSSEPort()
}
if !cmd.Flags().Changed("require-step-up") {
requireStepUp = config.MCPRequireStepUp()
}

// Profile-role enforcement: a profile bound to a read-only OAuth
// client must not advertise mutation tools. Reject the start
// rather than silently coercing — operators who passed
Expand All @@ -130,7 +135,18 @@ Use JC_PROFILE environment variable to select which JumpCloud org to use.`,
if warning != "" {
fmt.Fprintln(cmd.ErrOrStderr(), warning)
}
return runMcpServe(rateLimit, readOnly, transport, addr, port, corsOrigin, tlsCert, tlsKey, requireAuth)

// Stdio transport hands stdin/stdout to the JSON-RPC stream;
// a TTY-based step-up prompt has nowhere to go. Warn the
// operator at startup so destructive calls don't silently
// fail closed with "no challenge channel available."
if requireStepUp && transport == "stdio" {
fmt.Fprintln(cmd.ErrOrStderr(),
"jc: --require-step-up is set but transport is stdio; TTY prompts cannot reach the operator. "+
"Destructive calls will be rejected as 'step-up unavailable'. Use --transport http with a terminal "+
"session, or wait for an out-of-band authenticator (KLA-408 Slice 3).")
}
return runMcpServe(rateLimit, readOnly, transport, addr, port, corsOrigin, tlsCert, tlsKey, requireAuth, requireStepUp)
Comment thread
cursor[bot] marked this conversation as resolved.
},
}

Expand All @@ -143,6 +159,7 @@ Use JC_PROFILE environment variable to select which JumpCloud org to use.`,
cmd.Flags().StringVar(&tlsCert, "tls-cert", "", "TLS certificate file for SSE transport")
cmd.Flags().StringVar(&tlsKey, "tls-key", "", "TLS private key file for SSE transport")
cmd.Flags().BoolVar(&requireAuth, "require-auth", false, "Require x-api-key / Authorization Bearer on every request (http transport). Off by default so local browser clients like basic-host can connect. Turn on when exposing via a tunnel.")
cmd.Flags().BoolVar(&requireStepUp, "require-step-up", false, "Require step-up auth (last-6 of API key prompt) before any destructive tool with execute=true fires. Only effective when stdin is a TTY; not usable in stdio transport mode. Defaults to mcp.require_step_up_for_destructive in config.")

return cmd
}
Expand Down Expand Up @@ -220,13 +237,28 @@ func applyProfileRole(activeProfile string, profileReadOnly, flagChanged, readOn
return true, fmt.Sprintf("Profile %q is read-only — forcing --read-only and rejecting destructive tools.", activeProfile), nil
}

func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, corsOrigin, tlsCert, tlsKey string, requireAuth bool) error {
func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, corsOrigin, tlsCert, tlsKey string, requireAuth, requireStepUp bool) error {
// Step-up auth needs an API key to derive the challenge answer. Gate
// the read on the explicit opt-in flag and fail-fast at startup if
// it's missing — matches the --require-auth pattern below so an
// operator who turns step-up on without a credential gets a clear
// startup error instead of silent runtime denials.
var stepUpAPIKey string
if requireStepUp {
stepUpAPIKey = config.APIKey()
if stepUpAPIKey == "" {
return fmt.Errorf("--require-step-up needs an API key to derive the challenge answer. Run 'jc auth login' or set JC_API_KEY, or drop --require-step-up")
}
}

server := mcp.NewServer(mcp.Options{
RateLimit: rateLimit,
ReadOnly: readOnly,
AuditEnabled: config.MCPAuditLog(),
AllowedTools: config.MCPAllowedTools(),
BlockedTools: config.MCPBlockedTools(),
RateLimit: rateLimit,
ReadOnly: readOnly,
AuditEnabled: config.MCPAuditLog(),
AllowedTools: config.MCPAllowedTools(),
BlockedTools: config.MCPBlockedTools(),
RequireStepUp: requireStepUp,
StepUpAPIKey: stepUpAPIKey,
})

// Handle graceful shutdown on Ctrl+C / SIGTERM.
Expand Down
8 changes: 8 additions & 0 deletions internal/cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ func TestMcpServeCmd_FlagDefaults(t *testing.T) {
if cors.DefValue != "" {
t.Errorf("expected cors-origin default empty, got %s", cors.DefValue)
}

stepUp := mcpCmd.Flags().Lookup("require-step-up")
if stepUp == nil {
t.Fatal("expected require-step-up flag")
}
if stepUp.DefValue != "false" {
t.Errorf("expected require-step-up default false, got %s", stepUp.DefValue)
}
}

func TestRootCmd_IncludesMcp(t *testing.T) {
Expand Down
15 changes: 14 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mcp:
read_only: false
audit_log: true
plan_first: true
require_step_up_for_destructive: false

profiles:
default:
Expand Down Expand Up @@ -98,6 +99,7 @@ func setDefaults() {
viper.SetDefault("mcp.read_only", false)
viper.SetDefault("mcp.audit_log", true)
viper.SetDefault("mcp.plan_first", true)
viper.SetDefault("mcp.require_step_up_for_destructive", false)
viper.SetDefault("ask.provider", "disabled")
viper.SetDefault("ask.api_key", "")
viper.SetDefault("ask.model", "")
Expand Down Expand Up @@ -393,6 +395,7 @@ var ValidConfigKeys = []string{
"mcp.read_only",
"mcp.audit_log",
"mcp.plan_first",
"mcp.require_step_up_for_destructive",
"mcp.sse_port",
"mcp.allowed_tools",
"mcp.blocked_tools",
Expand Down Expand Up @@ -425,7 +428,8 @@ func coerceValue(key, value string) interface{} {
return n
}
case "defaults.confirm_destructive", "defaults.color", "cache.enabled",
"mcp.read_only", "mcp.audit_log", "mcp.plan_first", "ask.confirm_before_execute":
"mcp.read_only", "mcp.audit_log", "mcp.plan_first",
"mcp.require_step_up_for_destructive", "ask.confirm_before_execute":
// Attempt bool conversion.
switch strings.ToLower(value) {
case "true", "1", "yes":
Expand Down Expand Up @@ -457,6 +461,15 @@ func MCPPlanFirst() bool {
return viper.GetBool("mcp.plan_first")
}

// MCPRequireStepUp returns true if destructive MCP tool invocations
// (any tool argument with Execute: true) must clear a step-up
// authentication challenge before firing the underlying API call.
// When enabled, the chokepoint in addTypedTool blocks the call until
// the configured authenticator approves it.
func MCPRequireStepUp() bool {
return viper.GetBool("mcp.require_step_up_for_destructive")
}

// MCPSSEPort returns the configured SSE transport port (default 8080).
func MCPSSEPort() int {
port := viper.GetInt("mcp.sse_port")
Expand Down
21 changes: 21 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Server struct {
auditLog *auditLogger
readOnly bool
toolFilter *toolFilter
stepUp stepUpAuthenticator
toolNames []string // registered tool names, in registration order

mu sync.Mutex
Expand All @@ -53,6 +54,20 @@ type Options struct {
// BlockedTools is a list of glob patterns for tools that are blocked.
// Block list takes precedence over allow list.
BlockedTools []string
// RequireStepUp gates every destructive tool invocation (any tool
// argument carrying Execute: true) behind a fresh proof of operator
// presence. When true, the server prompts on its controlling TTY for
// the last 6 chars of StepUpAPIKey before each destructive call.
RequireStepUp bool
// StepUpAPIKey is the credential the TTY authenticator uses to derive
// the prompt's expected answer. Typically the same key the server is
// authenticated with (config.APIKey()). Required when RequireStepUp
// is true; ignored otherwise.
StepUpAPIKey string
// stepUp injects a custom authenticator. Reserved for tests within
// the mcp package; production callers configure step-up via
// RequireStepUp + StepUpAPIKey.
stepUp stepUpAuthenticator
}

// nowFunc is overridable for tests.
Expand Down Expand Up @@ -101,12 +116,18 @@ func NewServer(opts Options) *Server {
al = &auditLogger{} // no-op: enc is nil, log() returns early
}

stepUp := opts.stepUp
if stepUp == nil {
stepUp = newStepUp(opts.RequireStepUp, opts.StepUpAPIKey)
}

s := &Server{
mcpServer: mcpServer,
limiter: newRateLimiter(opts.RateLimit),
auditLog: al,
readOnly: opts.ReadOnly,
toolFilter: newToolFilter(opts.AllowedTools, opts.BlockedTools),
stepUp: stepUp,
}

s.registerTools()
Expand Down
Loading
Loading