From 637123f84f8755169e8cb055ffbeb2e31a2330bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 8 Jun 2026 17:41:29 +0100 Subject: [PATCH] feat: allow remote external mcp access (#1307) --- apps/backend/cmd/kandev/helpers.go | 30 +++++------------ apps/backend/cmd/kandev/helpers_test.go | 33 +++++++++++++++++++ .../mcp/server/external_integration_test.go | 4 +-- apps/backend/internal/mcp/server/server.go | 11 +++---- .../internal/mcp/server/server_test.go | 3 +- .../settings/external-mcp-settings.tsx | 2 +- docs/specs/integrations/external-mcp.md | 7 ++-- 7 files changed, 54 insertions(+), 36 deletions(-) diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index f298653098..326f5eda6c 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "net" "net/http" "net/http/httputil" "net/url" @@ -993,9 +992,7 @@ func registerMCPAndDebugRoutes( } // registerExternalMCP mounts an MCP server on the backend HTTP router so external -// coding agents can connect to Kandev at /mcp, /mcp/sse, /mcp/message. The MCP -// routes are gated by a loopback-only middleware because the endpoint is -// unauthenticated in v1 — see docs/specs/external-mcp/spec.md. +// coding agents can connect to Kandev at /mcp, /mcp/sse, /mcp/message. func registerExternalMCP(p routeParams) { port := p.httpPort if port == 0 { @@ -1004,8 +1001,8 @@ func registerExternalMCP(p routeParams) { baseURL := fmt.Sprintf("http://localhost:%d", port) backendClient := mcpserver.NewDispatcherBackendClient(p.gateway.Dispatcher, p.log) - srv := mcpserver.NewExternal(backendClient, baseURL, p.log, "") - mcpGroup := p.router.Group("", loopbackOnlyMiddleware(p.log)) + srv := mcpserver.NewExternal(backendClient, p.log, "") + mcpGroup := p.router.Group("", externalMCPOpenMiddleware()) srv.RegisterBackendRoutes(mcpGroup) if p.addCleanup != nil { p.addCleanup(func() error { @@ -1021,23 +1018,12 @@ func registerExternalMCP(p routeParams) { zap.String("sse_message", baseURL+"/mcp/message")) } -// loopbackOnlyMiddleware rejects requests that did not originate from the -// loopback interface. The external MCP endpoint has no authentication in v1, -// so it must only accept connections from the same machine. -func loopbackOnlyMiddleware(log *logger.Logger) gin.HandlerFunc { +// externalMCPOpenMiddleware documents the external MCP access policy: the +// endpoint is open on every interface the backend listens on. Kandev does not +// yet have a user auth boundary, so protecting MCP separately would create a +// false sense of security while the rest of the app remains reachable. +func externalMCPOpenMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - host, _, err := net.SplitHostPort(c.Request.RemoteAddr) - if err != nil { - host = c.Request.RemoteAddr - } - ip := net.ParseIP(host) - if ip == nil || !ip.IsLoopback() { - log.Warn("rejected non-loopback MCP request", - zap.String("remote_addr", c.Request.RemoteAddr), - zap.String("path", c.Request.URL.Path)) - c.AbortWithStatus(http.StatusForbidden) - return - } c.Next() } } diff --git a/apps/backend/cmd/kandev/helpers_test.go b/apps/backend/cmd/kandev/helpers_test.go index 2f556d318b..d529e5899d 100644 --- a/apps/backend/cmd/kandev/helpers_test.go +++ b/apps/backend/cmd/kandev/helpers_test.go @@ -2,9 +2,12 @@ package main import ( "encoding/json" + "net/http" + "net/http/httptest" "testing" "time" + "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/task/models" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -116,3 +119,33 @@ func TestAppendSessionStateMessage_OmitsEmptyTaskEnvironmentID(t *testing.T) { t.Fatalf("payload should not include task_environment_id when session has none") } } + +func TestExternalMCPOpenMiddleware_AllowsLoopbackAndRemote(t *testing.T) { + r := setupExternalMCPAccessRouter() + + for _, tc := range []struct{ name, remoteAddr string }{ + {"loopback", "127.0.0.1:4321"}, + {"remote", "203.0.113.10:4321"}, + } { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/mcp", nil) + req.RemoteAddr = tc.remoteAddr + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("request = %d, want %d", w.Code, http.StatusOK) + } + }) + } +} + +func setupExternalMCPAccessRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(externalMCPOpenMiddleware()) + r.GET("/mcp", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + return r +} diff --git a/apps/backend/internal/mcp/server/external_integration_test.go b/apps/backend/internal/mcp/server/external_integration_test.go index 0cf90815da..fac2945730 100644 --- a/apps/backend/internal/mcp/server/external_integration_test.go +++ b/apps/backend/internal/mcp/server/external_integration_test.go @@ -23,7 +23,7 @@ func TestExternalMCP_ToolsListOverHTTP(t *testing.T) { dispatcher := ws.NewDispatcher() backendClient := NewDispatcherBackendClient(dispatcher, log) - srv := NewExternal(backendClient, "http://localhost:38429", log, "") + srv := NewExternal(backendClient, log, "") gin.SetMode(gin.TestMode) router := gin.New() @@ -82,7 +82,7 @@ func TestExternalMCP_ToolsCallDispatchesToBackend(t *testing.T) { }) backendClient := NewDispatcherBackendClient(dispatcher, log) - srv := NewExternal(backendClient, "http://localhost:38429", log, "") + srv := NewExternal(backendClient, log, "") gin.SetMode(gin.TestMode) router := gin.New() diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 49d570efdd..93997b89c3 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -119,18 +119,17 @@ func New(backend BackendClient, sessionID, taskID string, port int, log *logger. // NewExternal creates an MCP server for the Kandev backend's external endpoint. // External coding agents (Claude Code, Cursor, etc.) connect here to manage Kandev // configuration and create tasks. Routes are mounted under /mcp on the backend. -// -// baseURL is the publicly reachable backend URL (e.g. "http://localhost:38429"). -// It is used to build the message endpoint URL emitted in SSE events. -func NewExternal(backend BackendClient, baseURL string, log *logger.Logger, mcpLogFile string) *Server { +func NewExternal(backend BackendClient, log *logger.Logger, mcpLogFile string) *Server { // External mode has no live session, so disable ask-question and use empty IDs. s := newServer(backend, "", "", log, mcpLogFile, true, ModeExternal) // SSE handlers are mounted at /mcp/sse and /mcp/message — the static base path - // makes the SSE endpoint event emit the correct full message URL. + // makes the SSE endpoint event emit /mcp/message. Keeping the message endpoint + // path-only lets remote clients resolve it against the URL they used to reach + // Kandev instead of a server-guessed localhost URL. s.sseServer = server.NewSSEServer(s.mcpServer, - server.WithBaseURL(baseURL), server.WithStaticBasePath("/mcp"), + server.WithUseFullURLForMessageEndpoint(false), ) // Streamable HTTP transport handler — mounted at /mcp on the backend. diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 25141329f7..807a35f22b 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -382,7 +382,7 @@ func TestNewExternal_Constructs(t *testing.T) { backend := NewChannelBackendClient(log) defer backend.Close() - s := NewExternal(backend, "http://localhost:38429", log, "") + s := NewExternal(backend, log, "") require.NotNil(t, s) assert.Equal(t, ModeExternal, s.mode) assert.True(t, s.disableAskQuestion) @@ -390,4 +390,5 @@ func TestNewExternal_Constructs(t *testing.T) { assert.Empty(t, s.taskID) assert.NotNil(t, s.sseServer) assert.NotNil(t, s.httpServer) + assert.Equal(t, "/mcp/message?sessionId=session-1", s.sseServer.GetMessageEndpointForClient(nil, "session-1")) } diff --git a/apps/web/components/settings/external-mcp-settings.tsx b/apps/web/components/settings/external-mcp-settings.tsx index 7c85dd11e2..20d30d6996 100644 --- a/apps/web/components/settings/external-mcp-settings.tsx +++ b/apps/web/components/settings/external-mcp-settings.tsx @@ -67,7 +67,7 @@ export function ExternalMcpSettings() { } title="Endpoints" - description="Loopback-only: requests from anything other than 127.0.0.1 / ::1 are rejected. No authentication is required because the endpoint is only reachable from this machine." + description="Available on the same host and port as Kandev. Use localhost for same-machine agents, or the reachable Kandev URL for LAN, VPN, and reverse-proxy clients." > diff --git a/docs/specs/integrations/external-mcp.md b/docs/specs/integrations/external-mcp.md index 19413e1be3..acc6bab78f 100644 --- a/docs/specs/integrations/external-mcp.md +++ b/docs/specs/integrations/external-mcp.md @@ -12,12 +12,12 @@ Users want to operate on Kandev workspaces, workflows, agents, and tasks from co ## What -- The Kandev backend exposes an MCP server on its existing HTTP port (default `38429`). The MCP routes are protected by a loopback-only middleware that rejects any request whose source IP is not `127.0.0.1` / `::1`, regardless of the listen address the backend is bound to. +- The Kandev backend exposes an MCP server on its existing HTTP port (default `38429`). The MCP routes are reachable from any network that can reach the Kandev backend. - Users register Kandev as an MCP server in their external coding agent using one of: - Streamable HTTP: `http://localhost:38429/mcp` - SSE: `http://localhost:38429/mcp/sse` - The external endpoint exposes the **config-mode tool surface** plus `create_task_kandev` (no plan tools, no `ask_user_question_kandev`). -- The endpoint requires no authentication in v1. +- The endpoint requires no authentication, matching the current Kandev app surface. - The Settings UI shows the URL and ready-to-paste config snippets for popular agents. - The existing per-`agentctl` MCP behavior is unchanged — the same tool definitions back both endpoints. @@ -30,8 +30,7 @@ Users want to operate on Kandev workspaces, workflows, agents, and tasks from co ## Out of scope -- Authentication / bearer tokens / OAuth (v1 relies on `127.0.0.1`-only binding). -- Binding to non-loopback addresses for remote access. +- Authentication / bearer tokens / OAuth. - Session-scoped tools (`create_task_plan_kandev`, `ask_user_question_kandev`, plan get/update/delete). - Per-workspace scoping of the endpoint. - Exposing `agentctl`'s per-session MCP externally — architectural mismatch (per-session, ephemeral, no auth boundary).