From efd36ffbb7c8fe9dca9d9edeeba076201f55dee8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?=
Date: Thu, 22 Jan 2026 02:27:14 +0000
Subject: [PATCH] feat: http logging middleware + mcp random port (#74)
---
apps/backend/cmd/kandev/main.go | 2 +
.../agentctl/server/api/control_server.go | 3 ++
.../internal/agentctl/server/api/server.go | 3 ++
apps/backend/internal/common/config/config.go | 2 +
.../backend/internal/common/httpmw/logging.go | 38 +++++++++++++++++++
apps/backend/internal/mcpserver/server.go | 4 +-
apps/cli/src/constants.ts | 1 +
apps/cli/src/dev.ts | 8 ++++
apps/web/app/layout.tsx | 8 ++++
.../[agentId]/profile-mcp-config-card.tsx | 27 +++++++++----
apps/web/global.d.ts | 1 +
apps/web/lib/config.ts | 9 ++++-
12 files changed, 95 insertions(+), 11 deletions(-)
create mode 100644 apps/backend/internal/common/httpmw/logging.go
diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go
index ae209da8f2..a606825fad 100644
--- a/apps/backend/cmd/kandev/main.go
+++ b/apps/backend/cmd/kandev/main.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/gin-gonic/gin"
+ "github.com/kandev/kandev/internal/common/httpmw"
"go.uber.org/zap"
// Common packages
@@ -648,6 +649,7 @@ func main() {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
+ router.Use(httpmw.RequestLogger(log, "kandev"))
router.Use(gin.Recovery())
router.Use(corsMiddleware())
diff --git a/apps/backend/internal/agentctl/server/api/control_server.go b/apps/backend/internal/agentctl/server/api/control_server.go
index c352be2be2..a5ffe3d41c 100644
--- a/apps/backend/internal/agentctl/server/api/control_server.go
+++ b/apps/backend/internal/agentctl/server/api/control_server.go
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/instance"
+ "github.com/kandev/kandev/internal/common/httpmw"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
@@ -33,6 +34,8 @@ func NewControlServer(cfg *config.Config, instMgr *instance.Manager, log *logger
router: gin.New(),
}
+ cs.router.Use(httpmw.RequestLogger(cs.logger, "agentctl-control"))
+
cs.setupRoutes()
return cs
}
diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go
index 4f44cf4214..ce15ba7825 100644
--- a/apps/backend/internal/agentctl/server/api/server.go
+++ b/apps/backend/internal/agentctl/server/api/server.go
@@ -9,6 +9,7 @@ import (
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/process"
+ "github.com/kandev/kandev/internal/common/httpmw"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
@@ -39,6 +40,8 @@ func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, log *logger
},
}
+ s.router.Use(httpmw.RequestLogger(s.logger, "agentctl-instance"))
+
s.setupRoutes()
return s
}
diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go
index 7077193c5e..c7b28c1aa6 100644
--- a/apps/backend/internal/common/config/config.go
+++ b/apps/backend/internal/common/config/config.go
@@ -212,6 +212,8 @@ func LoadWithPath(configPath string) (*Config, error) {
// so we explicitly bind keys where env var naming differs from config key naming.
_ = v.BindEnv("agent.standalonePort", "AGENTCTL_PORT", "KANDEV_AGENT_STANDALONE_PORT")
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
+ _ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT")
+ _ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL")
// Configure config file
v.SetConfigName("config")
diff --git a/apps/backend/internal/common/httpmw/logging.go b/apps/backend/internal/common/httpmw/logging.go
new file mode 100644
index 0000000000..2316fe7b76
--- /dev/null
+++ b/apps/backend/internal/common/httpmw/logging.go
@@ -0,0 +1,38 @@
+package httpmw
+
+import (
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/kandev/kandev/internal/common/logger"
+ "go.uber.org/zap"
+)
+
+// RequestLogger logs HTTP request details after the handler completes.
+func RequestLogger(log *logger.Logger, serverName string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+ path := c.FullPath()
+ if path == "" {
+ path = c.Request.URL.Path
+ }
+
+ c.Next()
+
+ latency := time.Since(start)
+ status := c.Writer.Status()
+ size := c.Writer.Size()
+ if size < 0 {
+ size = 0
+ }
+
+ log.Info("http",
+ zap.String("server", serverName),
+ zap.String("method", c.Request.Method),
+ zap.String("path", path),
+ zap.Int("status", status),
+ zap.Int64("duration_ms", latency.Milliseconds()),
+ zap.Int("bytes", size),
+ )
+ }
+}
diff --git a/apps/backend/internal/mcpserver/server.go b/apps/backend/internal/mcpserver/server.go
index 5126bd5dca..9b06ec2895 100644
--- a/apps/backend/internal/mcpserver/server.go
+++ b/apps/backend/internal/mcpserver/server.go
@@ -88,6 +88,9 @@ func (s *Server) Start(ctx context.Context) error {
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", addr, err)
}
+ if tcpAddr, ok := listener.Addr().(*net.TCPAddr); ok {
+ s.cfg.Port = tcpAddr.Port
+ }
// Create HTTP server with the mux
s.httpServer = &http.Server{
@@ -174,4 +177,3 @@ func (s *Server) SSEEndpoint() string {
func (s *Server) StreamableHTTPEndpoint() string {
return fmt.Sprintf("http://localhost:%d/mcp", s.cfg.Port)
}
-
diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts
index ad72f9af6f..cb26eed2c0 100644
--- a/apps/cli/src/constants.ts
+++ b/apps/cli/src/constants.ts
@@ -5,6 +5,7 @@ import path from "node:path";
export const DEFAULT_BACKEND_PORT = 8080;
export const DEFAULT_WEB_PORT = 3000;
export const DEFAULT_AGENTCTL_PORT = 9999;
+export const DEFAULT_MCP_PORT = 9090;
// Random fallback range for port selection.
export const RANDOM_PORT_MIN = 10000;
diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts
index 0fda42645c..6f943ba908 100644
--- a/apps/cli/src/dev.ts
+++ b/apps/cli/src/dev.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import {
DEFAULT_AGENTCTL_PORT,
DEFAULT_BACKEND_PORT,
+ DEFAULT_MCP_PORT,
DEFAULT_WEB_PORT,
HEALTH_TIMEOUT_MS,
} from "./constants";
@@ -22,17 +23,22 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT));
const actualWebPort = webPort ?? (await pickAvailablePort(DEFAULT_WEB_PORT));
const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT);
+ const mcpPort = await pickAvailablePort(DEFAULT_MCP_PORT);
const backendUrl = `http://localhost:${actualBackendPort}`;
+ const mcpUrl = `http://localhost:${mcpPort}/sse`;
const backendEnv = {
...process.env,
KANDEV_SERVER_PORT: String(actualBackendPort),
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
+ KANDEV_AGENT_MCP_SERVER_PORT: String(mcpPort),
};
const webEnv = {
...process.env,
KANDEV_API_BASE_URL: backendUrl,
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
+ KANDEV_MCP_SERVER_URL: mcpUrl,
+ NEXT_PUBLIC_KANDEV_MCP_SERVER_URL: mcpUrl,
PORT: String(actualWebPort),
NEXT_PUBLIC_KANDEV_DEBUG: "true",
};
@@ -41,6 +47,8 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
console.log("[kandev] backend port:", actualBackendPort);
console.log("[kandev] web port:", actualWebPort);
console.log("[kandev] agentctl port:", agentctlPort);
+ console.log("[kandev] mcp port:", mcpPort);
+ console.log("[kandev] mcp url:", mcpUrl);
const supervisor = createProcessSupervisor();
supervisor.attachSignalHandlers();
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 06f90fcc53..1b2f928adc 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -17,6 +17,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
const runtimeApiBaseUrl = process.env.KANDEV_API_BASE_URL ?? "";
+ const runtimeMcpServerUrl = process.env.KANDEV_MCP_SERVER_URL ?? "";
return (
@@ -28,6 +29,13 @@ export default function RootLayout({
}}
/>
) : null}
+ {runtimeMcpServerUrl ? (
+
+ ) : null}
diff --git a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx
index 8febbc0bba..0d4ae008ca 100644
--- a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx
+++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx
@@ -4,7 +4,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Label } from '@kandev/ui/label';
import { Switch } from '@kandev/ui/switch';
import { Textarea } from '@kandev/ui/textarea';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
+import { getBackendConfig } from '@/lib/config';
import { useProfileMcpConfig } from './use-profile-mcp-config';
import type { AgentProfileMcpConfig } from '@/lib/types/http';
@@ -62,11 +64,13 @@ export function ProfileMcpConfigCard({
const currentServers = isDraft ? draftState?.servers ?? '' : mcpServers;
const currentError = isDraft ? draftState?.error ?? null : mcpError;
const currentDirty = isDraft ? draftState?.dirty ?? false : mcpDirty;
+ const { mcpServerUrl } = getBackendConfig();
+ const kandevTools = 'Tools: list_workspaces, list_boards, list_columns, list_tasks, create_task, update_task';
const popularServers: Record> = {
kandev: {
type: 'sse',
- url: 'http://localhost:9090/sse',
+ url: mcpServerUrl,
},
playwright: {
type: 'stdio',
@@ -202,13 +206,20 @@ export function ProfileMcpConfigCard({
MCP definitions are stored in the database and resolved per executor at runtime. This does not override your local agent config.
Popular servers
-
+
+
+
+
+
+ {kandevTools}
+
+