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

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

Expand Down Expand Up @@ -87,6 +88,19 @@ SSE Examples:
jc mcp serve --transport sse --addr 0.0.0.0:8080 --tls-cert cert.pem --tls-key key.pem
jc mcp serve --transport sse --cors-origin "https://app.example.com"

Streamable HTTP Examples (for Claude Desktop custom connectors and MCP Apps):
jc mcp serve --transport http
jc mcp serve --transport http --port 8090
jc mcp serve --transport http --require-auth (for tunnels)

Security: the http transport is stateless and permissive by default (wide-open
CORS, cross-origin checks disabled) so browser-based MCP clients like basic-host
and MCP Apps UIs can connect without friction. When exposing the server via a
tunnel (cloudflared, ngrok, etc.), add --require-auth to enforce that every
request carries the configured JumpCloud API key via x-api-key or
Authorization: Bearer. --require-auth reads the key from 'jc auth login',
JC_API_KEY, or the --api-key global flag, and refuses to start without one.

Comment thread
cursor[bot] marked this conversation as resolved.
Use JC_PROFILE environment variable to select which JumpCloud org to use.`,
RunE: func(cmd *cobra.Command, args []string) error {
// Use config values as defaults; CLI flags override.
Expand All @@ -99,7 +113,7 @@ Use JC_PROFILE environment variable to select which JumpCloud org to use.`,
if !cmd.Flags().Changed("port") {
port = config.MCPSSEPort()
}
return runMcpServe(rateLimit, readOnly, transport, addr, port, corsOrigin, tlsCert, tlsKey)
return runMcpServe(rateLimit, readOnly, transport, addr, port, corsOrigin, tlsCert, tlsKey, requireAuth)
},
}

Expand All @@ -111,6 +125,7 @@ Use JC_PROFILE environment variable to select which JumpCloud org to use.`,
cmd.Flags().StringVar(&corsOrigin, "cors-origin", "", "Allowed CORS origin for SSE transport")
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.")

return cmd
}
Expand Down Expand Up @@ -163,7 +178,7 @@ func resolveSSEAddr(addr string, port int) string {
return addr
}

func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, corsOrigin, tlsCert, tlsKey string) error {
func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, corsOrigin, tlsCert, tlsKey string, requireAuth bool) error {
server := mcp.NewServer(mcp.Options{
RateLimit: rateLimit,
ReadOnly: readOnly,
Expand Down Expand Up @@ -220,15 +235,37 @@ func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int,
})

case "http":
// Streamable HTTP transport for Claude Desktop custom connectors and MCP Apps.
// No auth by default on loopback — designed for local use with cloudflared tunnels.
// Streamable HTTP transport for Claude Desktop custom connectors and
// MCP Apps. Auth is off by default so browser-based clients like
// basic-host can connect for local dev. Enable with --require-auth
// when exposing via a tunnel; the server then requires x-api-key or
// Authorization: Bearer matching the configured JumpCloud API key.
listenAddr := resolveSSEAddr(addr, port)

var httpAPIKey string
if requireAuth {
httpAPIKey = config.APIKey()
if httpAPIKey == "" {
return fmt.Errorf("--require-auth needs an API key to enforce. Run 'jc auth login' or set JC_API_KEY, or drop --require-auth for local dev")
}
}

fmt.Fprintf(os.Stderr, "jc: starting MCP server on Streamable HTTP transport at http://%s/mcp\n", listenAddr)
if httpAPIKey != "" {
fmt.Fprintln(os.Stderr, "jc: auth REQUIRED (x-api-key or Authorization: Bearer).")
} else {
// Warn on non-loopback binds without auth — effectively public.
if host, _, err := net.SplitHostPort(listenAddr); err == nil {
if host != "127.0.0.1" && host != "::1" && host != "localhost" {
fmt.Fprintln(os.Stderr, "jc: WARNING: bound to a non-loopback address without --require-auth. Anyone who reaches the server can call all tools.")
}
}
}

return server.RunStreamableHTTP(ctx, mcp.SSEConfig{
Addr: listenAddr,
CORSOrigin: "*",
APIKey: httpAPIKey,
})

default:
Expand Down
50 changes: 44 additions & 6 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ func NewServer(opts Options) *Server {
Level: slog.LevelInfo,
}))

// Declare support for the MCP Apps extension so clients (Claude, VS Code
// Copilot, etc.) know to fetch ui:// resources and render them in a
// sandboxed iframe. Without this, the _meta.ui.resourceUri on tool
// definitions is silently ignored by the client per the MCP extension
// handshake rules. See:
// https://modelcontextprotocol.io/extensions/client-matrix
caps := &mcp.ServerCapabilities{}
caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{})

mcpServer := mcp.NewServer(
&mcp.Implementation{
Name: "jc",
Expand All @@ -77,6 +86,7 @@ func NewServer(opts Options) *Server {
&mcp.ServerOptions{
Instructions: "JumpCloud CLI MCP server. Manage users, devices, groups, policies, commands, and more.",
Logger: logger,
Capabilities: caps,
},
)

Expand Down Expand Up @@ -228,13 +238,31 @@ func (s *Server) RunSSE(ctx context.Context, cfg SSEConfig) error {
func (s *Server) RunStreamableHTTP(ctx context.Context, cfg SSEConfig) error {
defer s.auditLog.close()

handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
return s.mcpServer
}, &mcp.StreamableHTTPOptions{
opts := &mcp.StreamableHTTPOptions{
// Disable DNS rebinding protection so tunneled requests (e.g. cloudflared)
// with non-localhost Host headers are accepted.
DisableLocalhostProtection: true,
})
// Stateless skips Mcp-Session-Id validation. Browser clients cannot
// send custom headers on EventSource (the GET SSE stream), so strict
// session validation breaks basic-host, MCP Apps UIs, etc. The trade-
// off is that server→client requests are rejected, but MCP Apps only
// use client-initiated tool calls and resource reads.
Stateless: true,
}
// When CORS is explicitly configured, the operator wants browser clients
// to connect (basic-host, MCP Apps UIs, web dashboards). The SDK's default
// CrossOriginProtection rejects such requests via Sec-Fetch-Site with 403
// "cross-origin request detected". Bypass the check on /mcp — that's the
// only path we mount, and CORS headers still enforce origin policy.
if cfg.CORSOrigin != "" {
cop := http.NewCrossOriginProtection()
cop.AddInsecureBypassPattern("/mcp")
opts.CrossOriginProtection = cop
}

handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
return s.mcpServer
}, opts)

var h http.Handler = handler
if cfg.APIKey != "" {
Expand Down Expand Up @@ -306,8 +334,18 @@ func (s *Server) authMiddleware(apiKey string, next http.Handler) http.Handler {
func corsMiddleware(origin string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, x-api-key, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
// Streamable HTTP clients send several custom headers: Mcp-Session-Id,
// Mcp-Protocol-Version, Last-Event-ID. The wildcard covers all of
// those plus x-api-key. Authorization must be listed explicitly — per
// the Fetch spec it's a special-cased header that wildcards don't
// cover (https://fetch.spec.whatwg.org/#cors-safelisted-request-header)
// and browser clients using Authorization: Bearer would otherwise fail
// the preflight even though authMiddleware accepts it.
w.Header().Set("Access-Control-Allow-Headers", "*, Authorization")
// Clients need to read Mcp-Session-Id from the initialize response
// to echo it back on subsequent requests.
w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
Expand Down
8 changes: 8 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ func TestMCP_Initialize(t *testing.T) {
if initResult.Capabilities.Resources == nil {
t.Fatal("expected resources capability")
}
// MCP Apps extension must be advertised for ui:// resources to render in
// supporting clients (Claude web/desktop, VS Code Copilot, etc.). Without
// this, clients skip the iframe render even though tool defs carry
// _meta.ui.resourceUri.
if _, ok := initResult.Capabilities.Extensions["io.modelcontextprotocol/ui"]; !ok {
t.Fatalf("expected 'io.modelcontextprotocol/ui' extension in capabilities; got %v",
initResult.Capabilities.Extensions)
}
}

func TestMCP_ListTools(t *testing.T) {
Expand Down
122 changes: 120 additions & 2 deletions internal/mcp/sse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,18 @@ func TestSSE_CORSHeaders(t *testing.T) {
t.Errorf("expected CORS methods to include GET and POST, got %q", methods)
}
headers := resp.Header.Get("Access-Control-Allow-Headers")
if !strings.Contains(headers, "x-api-key") {
t.Errorf("expected CORS headers to include x-api-key, got %q", headers)
// Server sends "*" so all custom MCP headers (Mcp-Session-Id,
// Mcp-Protocol-Version, Last-Event-ID, x-api-key, etc.) are allowed
// without having to enumerate them and chase SDK additions.
if !strings.Contains(headers, "*") && !strings.Contains(headers, "x-api-key") {
t.Errorf("expected CORS headers to be wildcard or include x-api-key, got %q", headers)
}
if !strings.Contains(headers, "*") && !strings.Contains(headers, "Mcp-Session-Id") {
t.Errorf("expected CORS headers to be wildcard or include Mcp-Session-Id, got %q", headers)
}
exposed := resp.Header.Get("Access-Control-Expose-Headers")
if !strings.Contains(exposed, "Mcp-Session-Id") {
t.Errorf("expected Mcp-Session-Id in Access-Control-Expose-Headers so clients can read it from the initialize response, got %q", exposed)
}
}

Expand Down Expand Up @@ -587,3 +597,111 @@ func generateTestCert(t *testing.T) (certFile, keyFile string) {

return certFile, keyFile
}

// startHTTPStreamServer mirrors startSSEServer but for the Streamable HTTP
// transport. Returns the base URL (including the /mcp path).
func startHTTPStreamServer(t *testing.T, cfg SSEConfig) (*Server, string) {
t.Helper()
setupTest(t)

if cfg.Addr == "" {
cfg.Addr = ":0"
}

server := NewServer(Options{
RateLimit: 60,
AuditLogPath: filepath.Join(t.TempDir(), "audit.log"),
})

ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

errCh := make(chan error, 1)
go func() { errCh <- server.RunStreamableHTTP(ctx, cfg) }()

var addr net.Addr
for i := 0; i < 50; i++ {
addr = server.Listener()
if addr != nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if addr == nil {
t.Fatal("HTTP stream server did not start listening")
}

return server, "http://" + addr.String() + "/mcp"
}

// TestHTTP_AuthRejectsUnauthenticated is the regression guard for the high-
// severity Bugbot finding: when APIKey is set on the http transport, requests
// without credentials must be rejected.
func TestHTTP_AuthRejectsUnauthenticated(t *testing.T) {
_, baseURL := startHTTPStreamServer(t, SSEConfig{
APIKey: "test-key",
CORSOrigin: "*",
})

body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`)
req, _ := http.NewRequest(http.MethodPost, baseURL, body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")

resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401 Unauthorized without x-api-key, got %d", resp.StatusCode)
}
}

// TestHTTP_AuthAcceptsCorrectKey confirms auth is actually checked, not bypassed.
func TestHTTP_AuthAcceptsCorrectKey(t *testing.T) {
_, baseURL := startHTTPStreamServer(t, SSEConfig{
APIKey: "test-key",
CORSOrigin: "*",
})

body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`)
req, _ := http.NewRequest(http.MethodPost, baseURL, body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("x-api-key", "test-key")

resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 with correct x-api-key, got %d", resp.StatusCode)
}
}

// TestHTTP_NoAuthWhenNoKey asserts the permissive default: no API key → any
// client can connect (needed for basic-host and local MCP Apps dev).
func TestHTTP_NoAuthWhenNoKey(t *testing.T) {
_, baseURL := startHTTPStreamServer(t, SSEConfig{
CORSOrigin: "*",
})

body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`)
req, _ := http.NewRequest(http.MethodPost, baseURL, body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")

resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 without auth when no API key configured, got %d", resp.StatusCode)
}
}
Loading