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:
26
go-server/internal/api/auth.go
Normal file
26
go-server/internal/api/auth.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
61
go-server/internal/api/config.go
Normal file
61
go-server/internal/api/config.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
47
go-server/internal/api/events.go
Normal file
47
go-server/internal/api/events.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
go-server/internal/api/instructions.go
Normal file
119
go-server/internal/api/instructions.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
85
go-server/internal/api/router.go
Normal file
85
go-server/internal/api/router.go
Normal 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()
|
||||
|
||||
|
||||
|
||||
43
go-server/internal/api/status.go
Normal file
43
go-server/internal/api/status.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
61
go-server/internal/config/config.go
Normal file
61
go-server/internal/config/config.go
Normal 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
|
||||
}
|
||||
|
||||
77
go-server/internal/db/db.go
Normal file
77
go-server/internal/db/db.go
Normal 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
|
||||
}
|
||||
|
||||
73
go-server/internal/events/broker.go
Normal file
73
go-server/internal/events/broker.go
Normal 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()
|
||||
}
|
||||
|
||||
289
go-server/internal/mcp/handler.go
Normal file
289
go-server/internal/mcp/handler.go
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
46
go-server/internal/models/models.go
Normal file
46
go-server/internal/models/models.go
Normal 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"`
|
||||
}
|
||||
|
||||
58
go-server/internal/store/agent.go
Normal file
58
go-server/internal/store/agent.go
Normal 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
|
||||
}
|
||||
|
||||
320
go-server/internal/store/instruction.go
Normal file
320
go-server/internal/store/instruction.go
Normal 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
|
||||
}
|
||||
|
||||
69
go-server/internal/store/settings.go
Normal file
69
go-server/internal/store/settings.go
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user