feat: allow remote external mcp access (#1307)

This commit is contained in:
Carlos Florêncio
2026-06-08 17:41:29 +01:00
committed by GitHub
parent c68323c9e0
commit 637123f84f
7 changed files with 54 additions and 36 deletions
+8 -22
View File
@@ -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()
}
}
+33
View File
@@ -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
}
@@ -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()
+5 -6
View File
@@ -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.
@@ -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"))
}
@@ -67,7 +67,7 @@ export function ExternalMcpSettings() {
<SettingsSection
icon={<IconPlugConnected className="h-5 w-5" />}
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."
>
<Card>
<CardHeader>
+3 -4
View File
@@ -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).