Full Go port of local-mcp with all core features. Copied from local-mcp-go worktree to consolidate into single-branch repo (easier maintenance). Architecture: - internal/config: Environment variable configuration - internal/models: Shared types (Instruction, Settings, AgentActivity, etc.) - internal/db: SQLite init with modernc.org/sqlite (pure Go, no CGo) - internal/store: Database operations + WakeupSignal + AgentTracker - internal/events: SSE broker for browser /api/events endpoint - internal/mcp: get_user_request MCP tool with 5s keepalive progress bars - internal/api: chi HTTP router with Bearer auth middleware - main.go: Entry point with auto port switching and Windows interactive banner Dependencies: - github.com/mark3labs/mcp-go@v0.46.0 - github.com/go-chi/chi/v5@v5.2.5 - modernc.org/sqlite@v1.47.0 (pure Go SQLite) - github.com/google/uuid@v1.6.0 Static assets embedded via //go:embed static Features matching Python: - Same wait strategy: 50s with 5s progress keepalives - Same hardcoded constants (DEFAULT_WAIT_SECONDS, DEFAULT_EMPTY_RESPONSE) - Auto port switching (tries 8000-8009) - Windows interactive mode (formatted banner on double-click launch) Build: cd go-server && go build -o local-mcp.exe . Run: ./local-mcp.exe Binary size: ~18 MB (vs Python ~60+ MB memory footprint) Startup: ~10 ms (vs Python ~1-2s)
290 lines
8.2 KiB
Go
290 lines
8.2 KiB
Go
// Package mcp registers the MCP server and implements the get_user_request tool.
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
|
|
"github.com/local-mcp/local-mcp-go/internal/events"
|
|
"github.com/local-mcp/local-mcp-go/internal/models"
|
|
"github.com/local-mcp/local-mcp-go/internal/store"
|
|
)
|
|
|
|
const (
|
|
// maxWaitSeconds is the absolute upper bound for a single tool call wait.
|
|
maxWaitSeconds = 86400
|
|
|
|
// defaultWaitSeconds is the hardcoded wait time when no instruction is available.
|
|
// Set to 50s to stay safely under the 60s client timeout while allowing
|
|
// multiple keepalive progress updates.
|
|
defaultWaitSeconds = 50
|
|
|
|
// defaultEmptyResponse is returned when the queue is empty after waiting.
|
|
defaultEmptyResponse = "call this tool `get_user_request` again to fetch latest user input..."
|
|
|
|
// keepaliveInterval controls how often a log notification is sent to the
|
|
// client while waiting. Reduced to 5s (from 20s) for more frequent progress updates.
|
|
// This keeps transport-level TCP/HTTP read timeouts from firing.
|
|
// Note: it does NOT reset application-level wall-clock timers
|
|
// (e.g. the Copilot 60 s limit), which are unaffected by SSE bytes.
|
|
keepaliveInterval = 5 * time.Second
|
|
)
|
|
|
|
// Handler wraps the MCP server and holds references to the stores it needs.
|
|
type Handler struct {
|
|
MCP *server.MCPServer
|
|
instStore *store.InstructionStore
|
|
settStore *store.SettingsStore
|
|
agentStore *store.AgentStore
|
|
broker *events.Broker
|
|
}
|
|
|
|
// New creates a Handler and registers the get_user_request tool.
|
|
func New(
|
|
instStore *store.InstructionStore,
|
|
settStore *store.SettingsStore,
|
|
agentStore *store.AgentStore,
|
|
broker *events.Broker,
|
|
) *Handler {
|
|
h := &Handler{
|
|
MCP: server.NewMCPServer("local-mcp", "1.0.0"),
|
|
instStore: instStore,
|
|
settStore: settStore,
|
|
agentStore: agentStore,
|
|
broker: broker,
|
|
}
|
|
|
|
h.MCP.AddTool(
|
|
mcp.NewTool("get_user_request",
|
|
mcp.WithDescription(`Fetch the next pending user instruction from the queue.
|
|
|
|
If no instruction is available the tool will wait up to wait_seconds
|
|
(or the server-configured default) before returning an empty / default response.
|
|
|
|
Args:
|
|
agent_id: An identifier for this agent instance (used to track connectivity).
|
|
default_response_override: Override the server-default empty response text
|
|
for this single call.
|
|
|
|
Returns:
|
|
A dict with keys: status, result_type, instruction, response,
|
|
remaining_pending, waited_seconds.`),
|
|
mcp.WithString("agent_id",
|
|
mcp.Description("Identifier for this agent instance"),
|
|
mcp.DefaultString("unknown"),
|
|
),
|
|
mcp.WithString("default_response_override",
|
|
mcp.Description("Override the server-default empty response for this call"),
|
|
mcp.DefaultString(""),
|
|
),
|
|
),
|
|
h.handleGetUserRequest,
|
|
)
|
|
|
|
return h
|
|
}
|
|
|
|
func (h *Handler) handleGetUserRequest(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
agentID := req.GetString("agent_id", "unknown")
|
|
defaultOverride := req.GetString("default_response_override", "")
|
|
|
|
// Wait time is hardcoded to stay safely under the 60s client timeout
|
|
actualWait := defaultWaitSeconds
|
|
if actualWait > maxWaitSeconds {
|
|
actualWait = maxWaitSeconds
|
|
}
|
|
waitDur := time.Duration(actualWait) * time.Second
|
|
|
|
// Register this call as the newest for this agent.
|
|
myGen := h.instStore.Agents().NewGeneration(agentID)
|
|
|
|
// Immediate dequeue attempt.
|
|
item, err := h.instStore.ConsumeNext(agentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("consume: %w", err)
|
|
}
|
|
if item != nil {
|
|
return h.deliverInstruction(ctx, item, agentID, 0)
|
|
}
|
|
|
|
// --- Wait loop ---
|
|
deadline := time.Now().Add(waitDur)
|
|
wakeup := h.instStore.Wakeup()
|
|
lastKeepalive := time.Now()
|
|
|
|
for {
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
|
|
// Step aside if a newer call arrived for this agent.
|
|
if !h.instStore.Agents().IsActive(agentID, myGen) {
|
|
slog.Debug("get_user_request: superseded", "agent", agentID, "gen", myGen)
|
|
break
|
|
}
|
|
|
|
// Check the queue.
|
|
item, err = h.instStore.ConsumeNext(agentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if item != nil {
|
|
waited := int(time.Since(deadline.Add(-waitDur)).Seconds())
|
|
if waited < 0 {
|
|
waited = 0
|
|
}
|
|
return h.deliverInstruction(ctx, item, agentID, waited)
|
|
}
|
|
|
|
// Calculate next sleep: no longer than time-to-keepalive and no longer than remaining.
|
|
toKeepalive := keepaliveInterval - time.Since(lastKeepalive)
|
|
if toKeepalive < 0 {
|
|
toKeepalive = 0
|
|
}
|
|
sleep := remaining
|
|
if toKeepalive < sleep {
|
|
sleep = toKeepalive
|
|
}
|
|
if sleep > time.Second {
|
|
sleep = time.Second // check activity/cancellation at least every second
|
|
}
|
|
|
|
// Wait for wakeup, context cancel, or sleep expiry.
|
|
select {
|
|
case <-ctx.Done():
|
|
// Client disconnected.
|
|
slog.Debug("get_user_request: context cancelled", "agent", agentID)
|
|
return emptyResult(defaultOverride, 0), nil
|
|
case <-wakeup.Chan():
|
|
// Instruction may have arrived — loop back to check.
|
|
case <-time.After(sleep):
|
|
// Timeout slice expired.
|
|
}
|
|
|
|
// Send SSE keepalive if interval has elapsed.
|
|
if time.Since(lastKeepalive) >= keepaliveInterval {
|
|
waited := int(time.Since(deadline.Add(-waitDur)).Seconds())
|
|
if waited < 0 {
|
|
waited = 0
|
|
}
|
|
remaining := actualWait - waited
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
// Progress bar: filled dots proportional to elapsed time
|
|
progressPct := (waited * 100) / actualWait
|
|
if progressPct > 100 {
|
|
progressPct = 100
|
|
}
|
|
filled := progressPct / 10
|
|
bar := ""
|
|
for i := 0; i < 10; i++ {
|
|
if i < filled {
|
|
bar += "●"
|
|
} else {
|
|
bar += "○"
|
|
}
|
|
}
|
|
msg := fmt.Sprintf("⏳ Waiting for instructions... %s %ds / %ds (agent=%s, %ds remaining)",
|
|
bar, waited, actualWait, agentID, remaining)
|
|
if err := h.MCP.SendLogMessageToClient(ctx, mcp.LoggingMessageNotification{
|
|
Params: mcp.LoggingMessageNotificationParams{
|
|
Level: mcp.LoggingLevelInfo,
|
|
Data: msg,
|
|
},
|
|
}); err != nil {
|
|
// Client gone — stop waiting.
|
|
slog.Debug("get_user_request: keepalive failed, stopping", "agent", agentID, "err", err)
|
|
break
|
|
}
|
|
slog.Debug("get_user_request: keepalive sent", "agent", agentID, "waited", waited, "progress", progressPct)
|
|
lastKeepalive = time.Now()
|
|
}
|
|
}
|
|
|
|
// Queue still empty (or superseded / cancelled) after waiting.
|
|
waited := int(waitDur.Seconds() - time.Until(deadline).Seconds())
|
|
if waited < 0 {
|
|
waited = 0
|
|
}
|
|
|
|
if h.instStore.Agents().IsActive(agentID, myGen) {
|
|
_ = h.agentStore.Record(agentID, "empty")
|
|
h.broker.Broadcast("status.changed", map[string]any{})
|
|
}
|
|
|
|
slog.Info("get_user_request: empty", "agent", agentID, "waited", waited, "gen", myGen)
|
|
return emptyResult(defaultOverride, waited), nil
|
|
}
|
|
|
|
func (h *Handler) deliverInstruction(ctx context.Context, item *models.Instruction, agentID string, waited int) (*mcp.CallToolResult, error) {
|
|
counts, _ := h.instStore.Counts()
|
|
_ = h.agentStore.Record(agentID, "instruction")
|
|
|
|
// Broadcast consumed event + status update.
|
|
h.broker.Broadcast("instruction.consumed", map[string]any{
|
|
"item": item,
|
|
"consumed_by_agent_id": agentID,
|
|
})
|
|
h.broker.Broadcast("status.changed", map[string]any{"queue": counts})
|
|
|
|
slog.Info("get_user_request: delivered", "id", item.ID, "agent", agentID, "waited", waited)
|
|
|
|
result := map[string]any{
|
|
"status": "ok",
|
|
"result_type": "instruction",
|
|
"instruction": map[string]any{
|
|
"id": item.ID,
|
|
"content": item.Content,
|
|
"consumed_at": item.ConsumedAt,
|
|
},
|
|
"response": nil,
|
|
"remaining_pending": counts.PendingCount,
|
|
"waited_seconds": waited,
|
|
}
|
|
return mcp.NewToolResultText(jsonMarshalStr(result)), nil
|
|
}
|
|
|
|
func emptyResult(override string, waited int) *mcp.CallToolResult {
|
|
resp := override
|
|
if resp == "" {
|
|
resp = defaultEmptyResponse
|
|
}
|
|
|
|
resultType := "empty"
|
|
if resp != "" {
|
|
resultType = "default_response"
|
|
}
|
|
|
|
result := map[string]any{
|
|
"status": "ok",
|
|
"result_type": resultType,
|
|
"instruction": nil,
|
|
"response": resp,
|
|
"remaining_pending": 0,
|
|
"waited_seconds": waited,
|
|
}
|
|
return mcp.NewToolResultText(jsonMarshalStr(result))
|
|
}
|
|
|
|
func jsonMarshalStr(v any) string {
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|