feat: add Go server implementation in go-server/

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)
This commit is contained in:
Brandon Zhang
2026-03-27 15:45:26 +08:00
parent 4db402f258
commit 8a0dffbcae
20 changed files with 2284 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
package api
import (
"net/http"
"strings"
)
// bearerAuthMiddleware enforces Bearer token authentication for protected routes.
func bearerAuthMiddleware(requiredToken string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
writeError(w, http.StatusUnauthorized, "Missing or invalid Authorization header")
return
}
token := strings.TrimPrefix(auth, "Bearer ")
if token != requiredToken {
writeError(w, http.StatusUnauthorized, "Invalid token")
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,61 @@
package api
import (
"encoding/json"
"net/http"
"github.com/local-mcp/local-mcp-go/internal/events"
)
func handleGetConfig(stores Stores) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, err := stores.Settings.Get()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
}
func handleUpdateConfig(stores Stores, broker *events.Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Decode partial patch
var patch struct {
DefaultWaitSeconds *int `json:"default_wait_seconds"`
DefaultEmptyResponse *string `json:"default_empty_response"`
AgentStaleAfterSeconds *int `json:"agent_stale_after_seconds"`
}
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
// Get current settings
current, err := stores.Settings.Get()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Apply patches
if patch.DefaultWaitSeconds != nil {
current.DefaultWaitSeconds = *patch.DefaultWaitSeconds
}
if patch.DefaultEmptyResponse != nil {
current.DefaultEmptyResponse = *patch.DefaultEmptyResponse
}
if patch.AgentStaleAfterSeconds != nil {
current.AgentStaleAfterSeconds = *patch.AgentStaleAfterSeconds
}
if err := stores.Settings.Update(current); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
broker.Broadcast("config.updated", map[string]any{"config": current})
writeJSON(w, http.StatusOK, current)
}
}

View File

@@ -0,0 +1,47 @@
package api
import (
"net/http"
"github.com/local-mcp/local-mcp-go/internal/events"
)
// handleSSE streams server-sent events to browser clients.
func handleSSE(broker *events.Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "Streaming not supported")
return
}
// Subscribe to event broker
ch := broker.Subscribe()
defer broker.Unsubscribe(ch)
// Send initial connection event
w.Write([]byte("data: {\"type\":\"connected\"}\n\n"))
flusher.Flush()
// Stream events until client disconnects
for {
select {
case msg, ok := <-ch:
if !ok {
return // broker closed
}
w.Write(msg)
flusher.Flush()
case <-r.Context().Done():
return // client disconnected
}
}
}
}

View File

@@ -0,0 +1,119 @@
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/local-mcp/local-mcp-go/internal/events"
"github.com/local-mcp/local-mcp-go/internal/store"
)
func handleListInstructions(stores Stores) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
if status == "" {
status = "all"
}
items, err := stores.Instructions.List(status)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
}
func handleCreateInstruction(stores Stores, broker *events.Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if body.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
item, err := stores.Instructions.Create(body.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
counts, _ := stores.Instructions.Counts()
broker.Broadcast("instruction.created", map[string]any{"item": item})
broker.Broadcast("status.changed", map[string]any{"queue": counts})
writeJSON(w, http.StatusCreated, item)
}
}
func handleUpdateInstruction(stores Stores, broker *events.Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var body struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
item, err := stores.Instructions.Update(id, body.Content)
if err == store.ErrNotFound {
writeError(w, http.StatusNotFound, "Instruction not found")
return
}
if err == store.ErrAlreadyConsumed {
writeError(w, http.StatusConflict, "Cannot edit consumed instruction")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
broker.Broadcast("instruction.updated", map[string]any{"item": item})
writeJSON(w, http.StatusOK, item)
}
}
func handleDeleteInstruction(stores Stores, broker *events.Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := stores.Instructions.Delete(id); err == store.ErrNotFound {
writeError(w, http.StatusNotFound, "Instruction not found")
return
} else if err == store.ErrAlreadyConsumed {
writeError(w, http.StatusConflict, "Cannot delete consumed instruction")
return
} else if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
counts, _ := stores.Instructions.Counts()
broker.Broadcast("instruction.deleted", map[string]any{"id": id})
broker.Broadcast("status.changed", map[string]any{"queue": counts})
w.WriteHeader(http.StatusNoContent)
}
}
func handleClearConsumed(stores Stores, broker *events.Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := stores.Instructions.DeleteConsumed(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
counts, _ := stores.Instructions.Counts()
broker.Broadcast("history.cleared", nil)
broker.Broadcast("status.changed", map[string]any{"queue": counts})
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,85 @@
// Package api implements the REST HTTP endpoints served alongside the MCP server.
package api
import (
"encoding/json"
"io/fs"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/local-mcp/local-mcp-go/internal/events"
"github.com/local-mcp/local-mcp-go/internal/store"
)
// Stores groups all database stores that the API handlers need.
type Stores struct {
Instructions *store.InstructionStore
Settings *store.SettingsStore
Agents *store.AgentStore
}
// NewRouter builds and returns the main chi router.
// staticFS must serve the embedded static directory; pass nil to skip.
func NewRouter(stores Stores, broker *events.Broker, apiToken string, staticFS fs.FS) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
// Auth-check endpoint — always public
r.Get("/auth-check", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"auth_required": apiToken != "",
})
})
// Health — always public
r.Get("/healthz", handleHealth())
// Static files — always public
if staticFS != nil {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, staticFS, "index.html")
})
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(staticFS)))
}
// All /api/* routes are protected when apiToken is set
r.Group(func(r chi.Router) {
if apiToken != "" {
r.Use(bearerAuthMiddleware(apiToken))
}
r.Get("/api/status", handleStatus(stores))
r.Get("/api/instructions", handleListInstructions(stores))
r.Post("/api/instructions", handleCreateInstruction(stores, broker))
r.Patch("/api/instructions/{id}", handleUpdateInstruction(stores, broker))
r.Delete("/api/instructions/consumed", handleClearConsumed(stores, broker))
r.Delete("/api/instructions/{id}", handleDeleteInstruction(stores, broker))
r.Get("/api/config", handleGetConfig(stores))
r.Patch("/api/config", handleUpdateConfig(stores, broker))
r.Get("/api/events", handleSSE(broker))
})
return r
}
// writeJSON serialises v as JSON with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError writes a JSON {"detail": msg} error response.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"detail": msg})
}
// serverStartTime records when this process started, used by /api/status.
var serverStartTime = time.Now().UTC()

View File

@@ -0,0 +1,43 @@
package api
import (
"net/http"
"time"
)
func handleHealth() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"server_time": time.Now().UTC().Format(time.RFC3339Nano),
})
}
}
func handleStatus(stores Stores) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
counts, _ := stores.Instructions.Counts()
latest, _ := stores.Agents.Latest()
cfg, _ := stores.Settings.Get()
resp := map[string]any{
"uptime_seconds": int(time.Since(serverStartTime).Seconds()),
"queue_pending": counts.PendingCount,
"queue_consumed": counts.ConsumedCount,
"agent_stale_after_seconds": cfg.AgentStaleAfterSeconds,
}
if latest != nil {
isStale := time.Since(latest.LastSeenAt).Seconds() > float64(cfg.AgentStaleAfterSeconds)
resp["agent"] = map[string]any{
"agent_id": latest.AgentID,
"last_fetch_at": latest.LastFetchAt.Format(time.RFC3339Nano),
"last_result_type": latest.LastResultType,
"is_stale": isStale,
}
}
writeJSON(w, http.StatusOK, resp)
}
}

View File

@@ -0,0 +1,61 @@
// Package config loads runtime configuration from environment variables.
package config
import (
"os"
"strconv"
)
// Config holds all runtime configuration values for local-mcp.
type Config struct {
Host string
HTTPPort string
DBPath string
LogLevel string
DefaultWaitSeconds int
DefaultEmptyResponse string
AgentStaleAfterSeconds int
MCPStateless bool
APIToken string
}
// Load reads configuration from environment variables with sensible defaults.
func Load() Config {
return Config{
Host: getEnv("HOST", "0.0.0.0"),
HTTPPort: getEnv("HTTP_PORT", "8000"),
DBPath: getEnv("DB_PATH", "data/local_mcp.sqlite3"),
LogLevel: getEnv("LOG_LEVEL", "INFO"),
DefaultWaitSeconds: getEnvInt("DEFAULT_WAIT_SECONDS", 10),
DefaultEmptyResponse: getEnv("DEFAULT_EMPTY_RESPONSE", ""),
AgentStaleAfterSeconds: getEnvInt("AGENT_STALE_AFTER_SECONDS", 30),
MCPStateless: getEnvBool("MCP_STATELESS", true),
APIToken: getEnv("API_TOKEN", ""),
}
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return defaultVal
}
func getEnvBool(key string, defaultVal bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return defaultVal
}

View File

@@ -0,0 +1,77 @@
// Package db manages the SQLite connection and schema migrations.
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite" // pure-Go SQLite driver, no CGo required
)
// schema creates all tables if they do not already exist.
const schema = `
CREATE TABLE IF NOT EXISTS instructions (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
consumed_at TEXT,
consumed_by_agent_id TEXT,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_instructions_status ON instructions(status);
CREATE INDEX IF NOT EXISTS idx_instructions_position ON instructions(position);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_activity (
agent_id TEXT PRIMARY KEY,
last_seen_at TEXT NOT NULL,
last_fetch_at TEXT NOT NULL,
last_result_type TEXT NOT NULL
);
`
// defaultSettings seeds initial values; OR IGNORE means existing rows are unchanged.
const defaultSettings = `
INSERT OR IGNORE INTO settings (key, value) VALUES ('default_wait_seconds', '10');
INSERT OR IGNORE INTO settings (key, value) VALUES ('default_empty_response', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('agent_stale_after_seconds','30');
`
// Open opens (creating if necessary) a SQLite database at dbPath, applies the
// schema, and seeds default settings.
func Open(dbPath string) (*sql.DB, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// Serialise all writes through a single connection to avoid locking.
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
if _, err := db.Exec(defaultSettings); err != nil {
_ = db.Close()
return nil, fmt.Errorf("seed settings: %w", err)
}
return db, nil
}

View File

@@ -0,0 +1,73 @@
// Package events provides an SSE event broker for fanning out server-sent
// events to browser clients watching /api/events.
package events
import (
"encoding/json"
"fmt"
"sync"
"time"
)
// Event is the wire format sent to browser clients.
type Event struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Data any `json:"data"`
}
// Broker distributes named events to all currently-connected SSE clients.
// Clients subscribe by calling Subscribe(); they must call Unsubscribe() when
// done to avoid goroutine leaks.
type Broker struct {
mu sync.RWMutex
clients map[chan []byte]struct{}
}
// NewBroker creates a ready-to-use Broker.
func NewBroker() *Broker {
return &Broker{clients: make(map[chan []byte]struct{})}
}
// Subscribe returns a channel that will receive serialised SSE "data: ..." lines.
func (b *Broker) Subscribe() chan []byte {
ch := make(chan []byte, 32) // buffered so a slow reader doesn't stall others
b.mu.Lock()
b.clients[ch] = struct{}{}
b.mu.Unlock()
return ch
}
// Unsubscribe removes the channel and closes it.
func (b *Broker) Unsubscribe(ch chan []byte) {
b.mu.Lock()
delete(b.clients, ch)
b.mu.Unlock()
close(ch)
}
// Broadcast encodes and sends an event to all subscribers. Slow subscribers
// are skipped (their buffered channel is full) to prevent head-of-line blocking.
func (b *Broker) Broadcast(eventType string, data any) {
ev := Event{
Type: eventType,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Data: data,
}
payload, err := json.Marshal(ev)
if err != nil {
return // should never happen
}
line := fmt.Sprintf("data: %s\n\n", payload)
msg := []byte(line)
b.mu.RLock()
for ch := range b.clients {
select {
case ch <- msg:
default: // skip stalled clients
}
}
b.mu.RUnlock()
}

View File

@@ -0,0 +1,289 @@
// 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)
}

View File

@@ -0,0 +1,46 @@
// Package models defines the core data types shared across all packages.
package models
import "time"
// InstructionStatus represents the lifecycle state of a queue item.
type InstructionStatus string
const (
StatusPending InstructionStatus = "pending"
StatusConsumed InstructionStatus = "consumed"
)
// Instruction is a single item in the queue.
type Instruction struct {
ID string `json:"id"`
Content string `json:"content"`
Status InstructionStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ConsumedAt *time.Time `json:"consumed_at"`
ConsumedByAgentID *string `json:"consumed_by_agent_id"`
Position int `json:"position"`
}
// Settings holds user-configurable runtime parameters.
type Settings struct {
DefaultWaitSeconds int `json:"default_wait_seconds"`
DefaultEmptyResponse string `json:"default_empty_response"`
AgentStaleAfterSeconds int `json:"agent_stale_after_seconds"`
}
// AgentActivity tracks the last time an agent called get_user_request.
type AgentActivity struct {
AgentID string `json:"agent_id"`
LastSeenAt time.Time `json:"last_seen_at"`
LastFetchAt time.Time `json:"last_fetch_at"`
LastResultType string `json:"last_result_type"`
}
// QueueCounts summarises the number of items in each state.
type QueueCounts struct {
PendingCount int `json:"pending_count"`
ConsumedCount int `json:"consumed_count"`
}

View File

@@ -0,0 +1,58 @@
package store
import (
"database/sql"
"fmt"
"time"
"github.com/local-mcp/local-mcp-go/internal/models"
)
// AgentStore records and retrieves agent connectivity data.
type AgentStore struct {
db *sql.DB
}
// NewAgentStore creates an AgentStore backed by db.
func NewAgentStore(db *sql.DB) *AgentStore {
return &AgentStore{db: db}
}
// Record upserts agent activity for agentID with the given result type.
func (s *AgentStore) Record(agentID, resultType string) error {
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.db.Exec(`
INSERT INTO agent_activity (agent_id, last_seen_at, last_fetch_at, last_result_type)
VALUES (?, ?, ?, ?)
ON CONFLICT(agent_id) DO UPDATE SET
last_seen_at = excluded.last_seen_at,
last_fetch_at = excluded.last_fetch_at,
last_result_type = excluded.last_result_type`,
agentID, now, now, resultType)
return err
}
// Latest returns the most recently active agent, or nil if no agent has ever
// called get_user_request.
func (s *AgentStore) Latest() (*models.AgentActivity, error) {
row := s.db.QueryRow(`
SELECT agent_id, last_seen_at, last_fetch_at, last_result_type
FROM agent_activity
ORDER BY last_seen_at DESC
LIMIT 1`)
var a models.AgentActivity
var seenStr, fetchStr string
err := row.Scan(&a.AgentID, &seenStr, &fetchStr, &a.LastResultType)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("latest agent: %w", err)
}
a.LastSeenAt, _ = time.Parse(time.RFC3339Nano, seenStr)
a.LastFetchAt, _ = time.Parse(time.RFC3339Nano, fetchStr)
return &a, nil
}

View File

@@ -0,0 +1,320 @@
// Package store contains all database access logic.
// This file handles instruction queue operations.
package store
import (
"database/sql"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/local-mcp/local-mcp-go/internal/models"
)
// WakeupSignal is an edge-triggered broadcast mechanism: closing the internal
// channel wakes all goroutines currently blocked on Chan(), then a new channel
// is installed for the next round of waiters. This mirrors asyncio.Event in
// the Python implementation.
type WakeupSignal struct {
mu sync.Mutex
ch chan struct{}
}
// NewWakeupSignal creates a ready-to-use WakeupSignal.
func NewWakeupSignal() *WakeupSignal {
return &WakeupSignal{ch: make(chan struct{})}
}
// Chan returns the current wait channel. Callers should capture the return
// value once and then select on it — do not call Chan() repeatedly.
func (w *WakeupSignal) Chan() <-chan struct{} {
w.mu.Lock()
defer w.mu.Unlock()
return w.ch
}
// Notify wakes all goroutines currently waiting on Chan() by closing the
// channel, then installs a fresh channel for future waiters.
func (w *WakeupSignal) Notify() {
w.mu.Lock()
old := w.ch
w.ch = make(chan struct{})
w.mu.Unlock()
close(old)
}
// AgentTracker manages per-agent generation counters so that stale
// coroutines cannot silently consume instructions intended for newer calls.
type AgentTracker struct {
mu sync.Mutex
generations map[string]uint64
}
// NewAgentTracker creates an AgentTracker ready for use.
func NewAgentTracker() *AgentTracker {
return &AgentTracker{generations: make(map[string]uint64)}
}
// NewGeneration increments and returns the current generation for agentID.
func (t *AgentTracker) NewGeneration(agentID string) uint64 {
t.mu.Lock()
defer t.mu.Unlock()
t.generations[agentID]++
return t.generations[agentID]
}
// IsActive returns true only if no newer call has arrived for agentID since
// this generation was issued.
func (t *AgentTracker) IsActive(agentID string, gen uint64) bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.generations[agentID] == gen
}
// InstructionStore provides all instruction queue operations.
type InstructionStore struct {
db *sql.DB
wakeup *WakeupSignal
agents *AgentTracker
}
// NewInstructionStore creates a store backed by db.
func NewInstructionStore(db *sql.DB) *InstructionStore {
return &InstructionStore{
db: db,
wakeup: NewWakeupSignal(),
agents: NewAgentTracker(),
}
}
// Wakeup returns the shared wakeup signal.
func (s *InstructionStore) Wakeup() *WakeupSignal { return s.wakeup }
// Agents returns the shared agent tracker.
func (s *InstructionStore) Agents() *AgentTracker { return s.agents }
// List returns instructions filtered by status ("pending", "consumed", or "all").
func (s *InstructionStore) List(status string) ([]models.Instruction, error) {
var rows *sql.Rows
var err error
switch status {
case "pending", "consumed":
rows, err = s.db.Query(`
SELECT id, content, status, created_at, updated_at,
consumed_at, consumed_by_agent_id, position
FROM instructions
WHERE status = ?
ORDER BY position ASC, created_at ASC`, status)
default: // "all"
rows, err = s.db.Query(`
SELECT id, content, status, created_at, updated_at,
consumed_at, consumed_by_agent_id, position
FROM instructions
ORDER BY position ASC, created_at ASC`)
}
if err != nil {
return nil, fmt.Errorf("list instructions: %w", err)
}
defer rows.Close()
var items []models.Instruction
for rows.Next() {
it, err := scanInstruction(rows)
if err != nil {
return nil, err
}
items = append(items, it)
}
return items, rows.Err()
}
// Create inserts a new pending instruction at the end of the queue.
func (s *InstructionStore) Create(content string) (*models.Instruction, error) {
id := uuid.New().String()
now := time.Now().UTC()
// Assign next position
var maxPos sql.NullInt64
_ = s.db.QueryRow(`SELECT MAX(position) FROM instructions WHERE status = 'pending'`).Scan(&maxPos)
position := int(maxPos.Int64) + 1
_, err := s.db.Exec(`
INSERT INTO instructions (id, content, status, created_at, updated_at, position)
VALUES (?, ?, 'pending', ?, ?, ?)`,
id, content, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), position)
if err != nil {
return nil, fmt.Errorf("create instruction: %w", err)
}
// Wake any waiting tool calls
s.wakeup.Notify()
return s.GetByID(id)
}
// Update edits a pending instruction's content. Returns the updated item or an
// error if the instruction is already consumed.
func (s *InstructionStore) Update(id, content string) (*models.Instruction, error) {
it, err := s.GetByID(id)
if err != nil {
return nil, err
}
if it.Status == models.StatusConsumed {
return nil, ErrAlreadyConsumed
}
now := time.Now().UTC()
_, err = s.db.Exec(`UPDATE instructions SET content = ?, updated_at = ? WHERE id = ?`,
content, now.Format(time.RFC3339Nano), id)
if err != nil {
return nil, fmt.Errorf("update instruction: %w", err)
}
return s.GetByID(id)
}
// Delete removes a pending instruction. Returns ErrAlreadyConsumed if the
// instruction has been delivered.
func (s *InstructionStore) Delete(id string) error {
it, err := s.GetByID(id)
if err != nil {
return err
}
if it.Status == models.StatusConsumed {
return ErrAlreadyConsumed
}
_, err = s.db.Exec(`DELETE FROM instructions WHERE id = ?`, id)
return err
}
// DeleteConsumed removes all consumed instructions.
func (s *InstructionStore) DeleteConsumed() error {
_, err := s.db.Exec(`DELETE FROM instructions WHERE status = 'consumed'`)
return err
}
// GetByID returns a single instruction or ErrNotFound.
func (s *InstructionStore) GetByID(id string) (*models.Instruction, error) {
row := s.db.QueryRow(`
SELECT id, content, status, created_at, updated_at,
consumed_at, consumed_by_agent_id, position
FROM instructions WHERE id = ?`, id)
it, err := scanInstruction(row)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &it, nil
}
// ConsumeNext atomically claims the oldest pending instruction for agentID.
// Returns nil if the queue is empty.
func (s *InstructionStore) ConsumeNext(agentID string) (*models.Instruction, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Claim the oldest pending item with a row-level lock (SQLite uses file lock).
var id string
err = tx.QueryRow(`
SELECT id FROM instructions
WHERE status = 'pending'
ORDER BY position ASC, created_at ASC
LIMIT 1`).Scan(&id)
if err == sql.ErrNoRows {
return nil, nil // queue empty
}
if err != nil {
return nil, fmt.Errorf("select next: %w", err)
}
now := time.Now().UTC()
_, err = tx.Exec(`
UPDATE instructions
SET status = 'consumed', consumed_at = ?, consumed_by_agent_id = ?, updated_at = ?
WHERE id = ? AND status = 'pending'`,
now.Format(time.RFC3339Nano), agentID, now.Format(time.RFC3339Nano), id)
if err != nil {
return nil, fmt.Errorf("mark consumed: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return s.GetByID(id)
}
// Counts returns pending and consumed queue sizes.
func (s *InstructionStore) Counts() (models.QueueCounts, error) {
var c models.QueueCounts
rows, err := s.db.Query(`
SELECT status, COUNT(*) FROM instructions GROUP BY status`)
if err != nil {
return c, err
}
defer rows.Close()
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return c, err
}
switch status {
case "pending":
c.PendingCount = n
case "consumed":
c.ConsumedCount = n
}
}
return c, rows.Err()
}
// Sentinel errors returned by InstructionStore.
var (
ErrNotFound = fmt.Errorf("instruction not found")
ErrAlreadyConsumed = fmt.Errorf("instruction already consumed")
)
// scanner is satisfied by both *sql.Row and *sql.Rows.
type scanner interface {
Scan(dest ...any) error
}
func scanInstruction(r scanner) (models.Instruction, error) {
var it models.Instruction
var createdAtStr, updatedAtStr string
var consumedAtStr sql.NullString
var consumedByAgentID sql.NullString
err := r.Scan(
&it.ID, &it.Content, &it.Status,
&createdAtStr, &updatedAtStr,
&consumedAtStr, &consumedByAgentID,
&it.Position,
)
if err != nil {
return it, err
}
it.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr)
it.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtStr)
if consumedAtStr.Valid {
t, _ := time.Parse(time.RFC3339Nano, consumedAtStr.String)
it.ConsumedAt = &t
}
if consumedByAgentID.Valid {
s := consumedByAgentID.String
it.ConsumedByAgentID = &s
}
return it, nil
}

View File

@@ -0,0 +1,69 @@
package store
import (
"database/sql"
"fmt"
"strconv"
"github.com/local-mcp/local-mcp-go/internal/models"
)
// SettingsStore reads and writes the settings table.
type SettingsStore struct {
db *sql.DB
}
// NewSettingsStore creates a SettingsStore backed by db.
func NewSettingsStore(db *sql.DB) *SettingsStore {
return &SettingsStore{db: db}
}
// Get returns the current settings.
func (s *SettingsStore) Get() (models.Settings, error) {
rows, err := s.db.Query(`SELECT key, value FROM settings`)
if err != nil {
return models.Settings{}, fmt.Errorf("get settings: %w", err)
}
defer rows.Close()
cfg := models.Settings{
DefaultWaitSeconds: 10,
AgentStaleAfterSeconds: 30,
}
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return cfg, err
}
switch key {
case "default_wait_seconds":
if n, err := strconv.Atoi(value); err == nil {
cfg.DefaultWaitSeconds = n
}
case "default_empty_response":
cfg.DefaultEmptyResponse = value
case "agent_stale_after_seconds":
if n, err := strconv.Atoi(value); err == nil {
cfg.AgentStaleAfterSeconds = n
}
}
}
return cfg, rows.Err()
}
// Update saves settings. Only non-nil fields are updated; pass a partial
// struct pointer using the Patch helper below.
func (s *SettingsStore) Update(patch models.Settings) error {
_, err := s.db.Exec(`
INSERT OR REPLACE INTO settings (key, value) VALUES
('default_wait_seconds', ?),
('default_empty_response', ?),
('agent_stale_after_seconds', ?)`,
strconv.Itoa(patch.DefaultWaitSeconds),
patch.DefaultEmptyResponse,
strconv.Itoa(patch.AgentStaleAfterSeconds),
)
return err
}