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).