feat: allow remote external mcp access (#1307)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user