Compare commits

...

10 Commits

Author SHA1 Message Date
Brandon Zhang
9e2932fbc3 Use styled modal dialogs for confirmations 2026-03-27 18:51:34 +08:00
Brandon Zhang
920376449a Hide the settings panel for now 2026-03-27 18:38:36 +08:00
Brandon Zhang
b0c24e10c8 Restore global instruction numbering 2026-03-27 18:38:28 +08:00
Brandon Zhang
c690d0c483 Hardcode agent stale timeout 2026-03-27 18:32:25 +08:00
Brandon Zhang
07e31ce215 Fix consumed instruction ordering and clear history 2026-03-27 18:32:18 +08:00
Brandon Zhang
27592f2750 Stop updating removed header badges 2026-03-27 18:21:47 +08:00
Brandon Zhang
167633c7be Bump version to 1.0.1 2026-03-27 18:21:40 +08:00
Brandon Zhang
18352a99d5 Show server type in the web UI 2026-03-27 18:19:26 +08:00
Brandon Zhang
3360c2ad85 Remove duplicate Go README 2026-03-27 18:16:37 +08:00
Brandon Zhang
7a8dd14bd3 Show server version in logs and UI 2026-03-27 18:16:30 +08:00
24 changed files with 169 additions and 725 deletions

View File

@@ -27,14 +27,12 @@ def update_config(body: UpdateConfigRequest):
cfg = config_service.update_config(
default_wait_seconds=body.default_wait_seconds,
default_empty_response=body.default_empty_response,
agent_stale_after_seconds=body.agent_stale_after_seconds,
)
event_service.broadcast(
"config.updated",
{
"default_wait_seconds": cfg.default_wait_seconds,
"default_empty_response": cfg.default_empty_response,
"agent_stale_after_seconds": cfg.agent_stale_after_seconds,
},
)
return cfg

View File

@@ -11,6 +11,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from app.config import APP_VERSION
from app.models import (
AgentInfo,
HealthResponse,
@@ -49,6 +50,8 @@ def get_status():
server=ServerInfo(
status="up",
started_at=status_service.server_started_at(),
version=APP_VERSION,
type="python",
),
agent=agent_info,
queue=QueueCounts(**counts),

View File

@@ -9,7 +9,9 @@ import os
from dataclasses import dataclass, field
APP_VERSION = "1.0.1"
DEFAULT_EMPTY_RESPONSE = "call this tool `get_user_request` again to fetch latest user input..."
AGENT_STALE_AFTER_SECONDS = 30
@dataclass
@@ -27,7 +29,6 @@ class Settings:
# MCP / queue behaviour (runtime-editable values are stored in DB; these are defaults for first run)
default_wait_seconds: int = 10
default_empty_response: str = DEFAULT_EMPTY_RESPONSE
agent_stale_after_seconds: int = 30
# MCP server name
mcp_server_name: str = "local-mcp"
@@ -57,7 +58,6 @@ def load_settings() -> Settings:
log_level=os.getenv("LOG_LEVEL", "INFO"),
default_wait_seconds=int(os.getenv("DEFAULT_WAIT_SECONDS", "10")),
default_empty_response=os.getenv("DEFAULT_EMPTY_RESPONSE", DEFAULT_EMPTY_RESPONSE),
agent_stale_after_seconds=int(os.getenv("AGENT_STALE_AFTER_SECONDS", "30")),
mcp_server_name=os.getenv("MCP_SERVER_NAME", "local-mcp"),
mcp_stateless=_parse_bool(os.getenv("MCP_STATELESS", "true"), default=True),
api_token=os.getenv("API_TOKEN", ""),

View File

@@ -105,7 +105,6 @@ CREATE TABLE IF NOT EXISTS agent_activity (
_DEFAULT_SETTINGS = {
"default_wait_seconds": "10",
"default_empty_response": DEFAULT_EMPTY_RESPONSE,
"agent_stale_after_seconds": "30",
}
@@ -125,6 +124,7 @@ def _seed_defaults(conn: sqlite3.Connection) -> None:
"UPDATE settings SET value = ? WHERE key = 'default_empty_response' AND value = ''",
(_DEFAULT_SETTINGS["default_empty_response"],),
)
conn.execute("DELETE FROM settings WHERE key = 'agent_stale_after_seconds'")
conn.commit()
logger.debug("Default settings seeded")

View File

@@ -79,6 +79,8 @@ class UpdateInstructionRequest(BaseModel):
class ServerInfo(BaseModel):
status: str
started_at: datetime
version: str
type: str
class AgentInfo(BaseModel):
@@ -107,13 +109,11 @@ class StatusResponse(BaseModel):
class ConfigResponse(BaseModel):
default_wait_seconds: int
default_empty_response: str
agent_stale_after_seconds: int
class UpdateConfigRequest(BaseModel):
default_wait_seconds: Optional[int] = None
default_empty_response: Optional[str] = None
agent_stale_after_seconds: Optional[int] = None
# ---------------------------------------------------------------------------

View File

@@ -13,7 +13,6 @@ from app.models import ConfigResponse
logger = logging.getLogger(__name__)
_SETTING_KEYS = {"default_wait_seconds", "default_empty_response", "agent_stale_after_seconds"}
def get_config() -> ConfigResponse:
with get_conn() as conn:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
@@ -21,22 +20,18 @@ def get_config() -> ConfigResponse:
return ConfigResponse(
default_wait_seconds=int(data.get("default_wait_seconds", 10)),
default_empty_response=data.get("default_empty_response") or DEFAULT_EMPTY_RESPONSE,
agent_stale_after_seconds=int(data.get("agent_stale_after_seconds", 30)),
)
def update_config(
default_wait_seconds: int | None = None,
default_empty_response: str | None = None,
agent_stale_after_seconds: int | None = None,
) -> ConfigResponse:
updates: dict[str, str] = {}
if default_wait_seconds is not None:
updates["default_wait_seconds"] = str(default_wait_seconds)
if default_empty_response is not None:
updates["default_empty_response"] = default_empty_response
if agent_stale_after_seconds is not None:
updates["agent_stale_after_seconds"] = str(agent_stale_after_seconds)
if updates:
with get_write_conn() as conn:

View File

@@ -10,6 +10,7 @@ import sqlite3
from datetime import datetime, timezone
from typing import Optional
from app.config import AGENT_STALE_AFTER_SECONDS
from app.database import get_conn, get_write_conn
logger = logging.getLogger(__name__)
@@ -55,25 +56,15 @@ def get_latest_agent_activity() -> Optional[sqlite3.Row]:
).fetchone()
def get_agent_stale_seconds() -> int:
"""Read agent_stale_after_seconds from settings table."""
with get_conn() as conn:
row = conn.execute(
"SELECT value FROM settings WHERE key = 'agent_stale_after_seconds'"
).fetchone()
return int(row["value"]) if row else 30
def is_agent_connected() -> bool:
"""True if the most recent agent activity is within the stale threshold."""
row = get_latest_agent_activity()
if row is None:
return False
stale_seconds = get_agent_stale_seconds()
last_seen = datetime.fromisoformat(row["last_seen_at"])
now = datetime.now(timezone.utc)
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
delta = (now - last_seen).total_seconds()
return delta <= stale_seconds
return delta <= AGENT_STALE_AFTER_SECONDS

View File

@@ -1,607 +0,0 @@
# local-mcp
`local-mcp` is a localhost-first MCP server whose primary responsibility is to deliver the latest user instruction to an agent through the `get_user_request` tool, while also providing a responsive web UI for managing the instruction queue and monitoring server/agent activity.
This document is the implementation plan for the project.
## 1. Goals
- Provide a single MCP tool, `get_user_request`, that returns at most one instruction per call.
- Give the user a polished local web UI to add, edit, remove, review, and monitor instructions.
- Preserve queue integrity so consumed instructions are clearly visible but no longer editable/deletable.
- Support configurable waiting/default-response behavior when no instruction is available.
- Show live server status and inferred agent connectivity in the UI.
- Keep the stack lightweight, maintainable, debuggable, and friendly to local development.
## 2. Recommended Tech Stack
### Backend
- **Language/runtime:** Python 3.11+
- **MCP integration:** official Python MCP SDK
- **HTTP server/API layer:** FastAPI
- **ASGI server:** Uvicorn
- **Persistence:** SQLite via Python standard library `sqlite3`
- **Concurrency/state coordination:** `asyncio` + standard library synchronization primitives where needed
- **Logging/error handling:** Python `logging`, structured request logs, centralized exception handling
- **Configuration:** environment variables + small local config file (`.json` or `.toml`)
### Why this backend stack
- The MCP SDK is the correct dependency for exposing the MCP tool cleanly.
- FastAPI + Uvicorn is a small, pragmatic backend stack that simplifies routing, validation, health endpoints, and server-sent updates without introducing a heavy framework.
- SQLite keeps the system local-first, dependency-light, and durable enough for instruction history and settings.
- Most supporting concerns remain in the Python standard library, which keeps third-party dependencies minimal.
### Frontend
- **UI technology:** plain HTML, CSS, and JavaScript only
- **Realtime updates:** Server-Sent Events (preferred) with polling fallback if necessary
- **Styling:** local CSS files with design tokens and component-specific stylesheets
- **Client architecture:** modular vanilla JS organized by feature (`api.js`, `state.js`, `events.js`, `instructions.js`, etc.)
- **Assets:** all fonts/icons/scripts/styles stored locally in the repository; no CDN usage
### Mandatory frontend implementation instruction
Any future frontend implementation work **must first read and follow**:
- `.github/instructions/frontend-design.instructions.md`
This instruction file is mandatory for the UI because it requires a distinctive, production-grade, non-generic frontend. The implementation should not default to generic dashboard aesthetics.
## 3. Product/Architecture Plan
### Core backend responsibilities
1. Expose the MCP tool `get_user_request`.
2. Maintain an instruction queue with durable storage.
3. Mark instructions as consumed atomically when delivered to an agent.
4. Expose local HTTP endpoints for the web UI.
5. Stream status/instruction updates to the browser in real time.
6. Infer agent connectivity from recent MCP tool activity.
7. Persist and serve server configuration such as wait timeout and default empty response.
### Core frontend responsibilities
1. Show queued and consumed instructions in separate, clearly labeled sections.
2. Allow add/edit/delete only for instructions that are still pending.
3. Cross out and grey out consumed instructions.
4. Show server status, inferred agent status, last fetch time, and configuration values.
5. Update live as instruction state changes.
6. Remain usable and visually polished on desktop and smaller screens.
### Suggested repository layout
```text
local-mcp/
├─ main.py
├─ README.md
├─ requirements.txt
├─ app/
│ ├─ __init__.py
│ ├─ config.py
│ ├─ database.py
│ ├─ logging_setup.py
│ ├─ models.py
│ ├─ services/
│ │ ├─ instruction_service.py
│ │ ├─ status_service.py
│ │ └─ event_service.py
│ ├─ api/
│ │ ├─ routes_instructions.py
│ │ ├─ routes_status.py
│ │ └─ routes_config.py
│ └─ mcp_server.py
├─ static/
│ ├─ index.html
│ ├─ css/
│ │ ├─ base.css
│ │ ├─ layout.css
│ │ └─ components.css
│ ├─ js/
│ │ ├─ api.js
│ │ ├─ app.js
│ │ ├─ events.js
│ │ ├─ instructions.js
│ │ └─ status.js
│ └─ assets/
└─ data/
└─ local_mcp.sqlite3
```
## 4. Data Model Plan
### `instructions`
- `id` - string/UUID primary key
- `content` - text, required
- `status` - enum: `pending`, `consumed`
- `created_at` - datetime
- `updated_at` - datetime
- `consumed_at` - nullable datetime
- `consumed_by_agent_id` - nullable string
- `position` - integer for stable queue order
### `settings`
- `default_wait_seconds` - integer — seconds the tool waits before returning an empty/default response; set exclusively by the user via the web UI
- `default_empty_response` - text, nullable
- `agent_stale_after_seconds` - integer
### `agent_activity`
- `agent_id` - string primary key
- `last_seen_at` - datetime
- `last_fetch_at` - datetime
- `last_result_type` - enum: `instruction`, `empty`, `default_response`
## 5. Detailed API Design
All routes are local-only and intended for `localhost` usage.
### 5.1 MCP tool contract
#### Tool: `get_user_request`
**Purpose**
- Return the next pending instruction, if one exists.
- If none exists, wait for a configurable duration, then return the server-controlled default response.
- Record agent activity so the UI can infer whether an agent is currently connected/recently active.
**Suggested input schema**
```json
{
"agent_id": "optional-string"
}
```
**Suggested output schema when an instruction is delivered**
```json
{
"status": "ok",
"result_type": "instruction",
"instruction": {
"id": "uuid",
"content": "user instruction text",
"consumed_at": "ISO-8601 timestamp"
},
"remaining_pending": 3,
"waited_seconds": 0
}
```
**Suggested output schema when queue is empty**
```json
{
"status": "ok",
"result_type": "empty",
"instruction": null,
"response": "",
"remaining_pending": 0,
"waited_seconds": 10
}
```
**Suggested output schema when a default response is returned**
```json
{
"status": "ok",
"result_type": "default_response",
"instruction": null,
"response": "call this tool `get_user_request` again to fetch latest user input...",
"remaining_pending": 0,
"waited_seconds": 10
}
```
**Behavior rules**
- Deliver the oldest pending instruction first.
- Mark the delivered instruction as consumed in the same transaction used to claim it.
- Never return more than one instruction per call.
- `default_wait_seconds` is fully server-controlled (set by the user via the web UI). Agents cannot override it.
- Clamp `actual_wait` to an absolute server maximum (86400 s).
- Update the agent activity record on every call, even when no instruction is returned.
- The UI should infer "agent connected" if the latest activity is within `agent_stale_after_seconds`.
- Agent implementations should continue calling this tool instead of ending their work session on their own, so they can pick up newly added instructions without missing critical follow-up requests.
### 5.2 HTTP API for the web UI
#### `GET /healthz`
Returns service health.
**Response**
```json
{
"status": "ok",
"server_time": "ISO-8601 timestamp"
}
```
#### `GET /api/status`
Returns current server and agent summary.
**Response**
```json
{
"server": {
"status": "up",
"started_at": "ISO-8601 timestamp"
},
"agent": {
"connected": true,
"last_seen_at": "ISO-8601 timestamp",
"last_fetch_at": "ISO-8601 timestamp",
"agent_id": "copilot-agent"
},
"queue": {
"pending_count": 2,
"consumed_count": 8
},
"settings": {
"default_wait_seconds": 10,
"default_empty_response": "call this tool `get_user_request` again to fetch latest user input...",
"agent_stale_after_seconds": 30
}
}
```
#### `GET /api/instructions`
Returns all instructions in queue order.
**Query params**
- `status=pending|consumed|all` (default `all`)
**Response**
```json
{
"items": [
{
"id": "uuid",
"content": "Implement logging",
"status": "pending",
"created_at": "ISO-8601 timestamp",
"updated_at": "ISO-8601 timestamp",
"consumed_at": null,
"consumed_by_agent_id": null,
"position": 1
}
]
}
```
#### `POST /api/instructions`
Creates a new pending instruction.
**Request**
```json
{
"content": "Add a new status indicator"
}
```
**Response**: `201 Created`
```json
{
"item": {
"id": "uuid",
"content": "Add a new status indicator",
"status": "pending",
"created_at": "ISO-8601 timestamp",
"updated_at": "ISO-8601 timestamp",
"consumed_at": null,
"consumed_by_agent_id": null,
"position": 3
}
}
```
#### `PATCH /api/instructions/{instruction_id}`
Edits a pending instruction only.
**Request**
```json
{
"content": "Reword an existing pending instruction"
}
```
**Rules**
- Return `409 Conflict` if the instruction has already been consumed.
- Return `404 Not Found` if the instruction does not exist.
#### `DELETE /api/instructions/{instruction_id}`
Deletes a pending instruction only.
**Rules**
- Return `409 Conflict` if the instruction has already been consumed.
- Return `204 No Content` on success.
#### `GET /api/config`
Returns editable runtime settings.
**Response**
```json
{
"default_wait_seconds": 10,
"default_empty_response": "call this tool `get_user_request` again to fetch latest user input...",
"agent_stale_after_seconds": 30
}
```
#### `PATCH /api/config`
Updates runtime settings.
**Request**
```json
{
"default_wait_seconds": 15,
"default_empty_response": "",
"agent_stale_after_seconds": 45
}
```
#### `GET /api/events`
Server-Sent Events endpoint for live UI updates.
**Event types**
- `instruction.created`
- `instruction.updated`
- `instruction.deleted`
- `instruction.consumed`
- `status.changed`
- `config.updated`
**SSE payload example**
```json
{
"type": "instruction.consumed",
"timestamp": "ISO-8601 timestamp",
"data": {
"id": "uuid",
"consumed_by_agent_id": "copilot-agent"
}
}
```
## 6. UI/UX Plan
### Layout priorities
- A strong local-control dashboard feel rather than a generic admin template
- Clear separation between pending work and already-consumed history
- High-visibility connection/status strip for server and agent state
- Fast creation flow for new instructions
- Mobile-friendly stacking without losing queue readability
### Required screens/sections
- Header with project identity and server status
- Agent activity panel with last seen/fetch information
- Composer form for new instructions
- Pending instructions list with edit/delete actions
- Consumed instructions list with crossed-out styling and metadata
- Settings panel for wait timeout/default response behavior
### Frontend quality bar
- Follow `.github/instructions/frontend-design.instructions.md` before implementing any UI.
- Use only local assets.
- Build a visually distinctive interface with careful typography, color, spacing, motion, and responsive behavior.
- Keep accessibility in scope: semantic HTML, keyboard support, visible focus states, sufficient contrast.
## 7. Logging, Reliability, and Error Handling Plan
- Log startup, shutdown, configuration load, database initialization, and MCP registration.
- Log each instruction lifecycle event: created, updated, deleted, consumed.
- Log each `get_user_request` call with agent id, wait time, and result type.
- Return structured JSON errors for API failures.
- Protect queue consumption with transactions/locking so two simultaneous fetches cannot consume the same instruction.
- Validate payloads and reject empty or whitespace-only instructions.
- Handle browser reconnects for SSE cleanly.
## 8. Todo List
- [x] **Project setup**
- [x] Create the backend package structure under `app/`.
- [x] Add `requirements.txt` with only the required dependencies.
- [x] Replace the placeholder contents of `main.py` with the application entrypoint.
- [x] Add a local configuration strategy for defaults and runtime overrides.
- [x] **Data layer**
- [x] Create SQLite schema for `instructions`, `settings`, and `agent_activity`.
- [x] Add startup migration/initialization logic.
- [x] Implement queue ordering and atomic consumption behavior.
- [x] Seed default settings on first run.
- [x] **MCP server**
- [x] Register the `get_user_request` tool using the official MCP Python SDK.
- [x] Implement one-at-a-time delivery semantics.
- [x] Implement wait-until-timeout behavior when the queue is empty.
- [x] Return empty/default responses based on configuration.
- [x] Record agent activity on every tool call.
- [x] **HTTP API**
- [x] Implement `GET /healthz`.
- [x] Implement `GET /api/status`.
- [x] Implement `GET /api/instructions`.
- [x] Implement `POST /api/instructions`.
- [x] Implement `PATCH /api/instructions/{instruction_id}`.
- [x] Implement `DELETE /api/instructions/{instruction_id}`.
- [x] Implement `GET /api/config`.
- [x] Implement `PATCH /api/config`.
- [x] Implement `GET /api/events` for SSE.
- [x] **Frontend**
- [x] Read and follow `.github/instructions/frontend-design.instructions.md` before starting UI work.
- [x] Create `static/index.html` and split CSS/JS into separate folders/files.
- [x] Build the instruction composer.
- [x] Build the pending instruction list with edit/delete controls.
- [x] Build the consumed instruction list with crossed-out/greyed-out styling.
- [x] Build the live server/agent status panel.
- [x] Build the settings editor for timeout/default-response behavior.
- [x] Wire SSE updates into the UI so changes appear in real time.
- [x] Make the interface responsive and keyboard accessible.
- [x] **Observability and robustness**
- [x] Add centralized logging configuration.
- [x] Add structured error responses and exception handling.
- [x] Add queue-consumption concurrency protection.
- [x] Add validation for invalid edits/deletes of consumed instructions.
- [ ] Add tests for empty-queue, timeout, and consume-once behavior.
- [x] **Improvements (post-launch)**
- [x] Replace 1-second polling wait loop with `asyncio.Event`-based immediate wakeup.
- [x] Min-wait is a floor only when the queue is empty — a new instruction immediately wakes any waiting tool call (verified with timing test in `tests/test_wakeup.py`).
- [x] Enrich SSE events with full item payloads (no extra re-fetch round-trips).
- [x] Auto-refresh relative timestamps in the UI every 20 s.
- [x] Document title badge showing pending instruction count.
- [x] SSE reconnecting indicator in the header.
- [x] Dark / light theme toggle defaulting to OS colour-scheme preference.
- [x] `default_wait_seconds` changed to fully server-controlled (agents can no longer override wait time).
- [x] Non-blocking `server.ps1` management script (start / stop / restart / status / logs).
- [x] Non-blocking `server.sh` bash management script — identical feature set for macOS / Linux.
- [x] MCP stateless/stateful mode configurable via `MCP_STATELESS` env var (default `true`).
- [x] Per-agent generation counter prevents abandoned (timed-out) coroutines from silently consuming instructions meant for newer calls.
- [x] `tests/test_wakeup.py` covers both immediate-wakeup timing and concurrent-call generation safety.
- [x] Optional Bearer-token authentication via `API_TOKEN` env var (disabled by default); web UI prompts for token on first load.
- [ ] **Documentation and developer experience**
- [x] Document local run instructions.
- [x] Document the MCP tool contract clearly.
- [x] Document the HTTP API with request/response examples.
- [x] Document how agent connectivity is inferred.
- [x] Document how the frontend design instruction must be used during UI implementation.
## 9. Running the Server
### Prerequisites
- Python 3.11+
- pip
### Install dependencies
```bash
pip install -r requirements.txt
```
### Start the server
```bash
python main.py
```
Or use the included management scripts (recommended — non-blocking):
**PowerShell (Windows)**
```powershell
.\server.ps1 start # start in background, logs to logs/
.\server.ps1 stop # graceful stop
.\server.ps1 restart # stop + start
.\server.ps1 status # PID, memory, tail logs
.\server.ps1 logs # show last 40 stdout lines
.\server.ps1 logs -f # follow logs live
.\server.ps1 logs 100 # show last 100 lines
```
**Bash (macOS / Linux)**
```bash
chmod +x server.sh # make executable once
./server.sh start # start in background, logs to logs/
./server.sh stop # graceful stop
./server.sh restart # stop + start
./server.sh status # PID, memory, tail logs
./server.sh logs # show last 40 stdout lines
./server.sh logs -f # follow logs live
./server.sh logs 100 # show last 100 lines
```
The server starts on `http://localhost:8000` by default.
| URL | Description |
|-----|-------------|
| `http://localhost:8000/` | Web UI |
| `http://localhost:8000/mcp` | MCP streamable-HTTP endpoint |
| `http://localhost:8000/docs` | FastAPI interactive API docs |
### Environment variable overrides
| Variable | Default | Description |
|----------|---------|-------------|
| `HOST` | `0.0.0.0` | Bind address |
| `HTTP_PORT` | `8000` | HTTP port |
| `DB_PATH` | `data/local_mcp.sqlite3` | SQLite database path |
| `LOG_LEVEL` | `INFO` | Logging level |
| `DEFAULT_WAIT_SECONDS` | `10` | Default tool wait timeout |
| `DEFAULT_EMPTY_RESPONSE` | `call this tool \`get_user_request\` again to fetch latest user input...` | Default response when queue is empty |
| `AGENT_STALE_AFTER_SECONDS` | `30` | Seconds of inactivity before agent shown as idle |
| `MCP_STATELESS` | `true` | `true` for stateless sessions (survives restarts, recommended); `false` for stateful |
| `API_TOKEN` | _(empty)_ | When set, all `/api/*` and `/mcp` requests require `Authorization: Bearer <token>`; web UI prompts for the token on first load |
### Configuring an MCP client (agent)
Point the agent's MCP client to the streamable-HTTP transport:
```json
{
"mcpServers": {
"local-mcp": {
"url": "http://localhost:8000/mcp",
"transport": "streamable-http"
}
}
}
```
If `API_TOKEN` is set, include the token as a request header:
```json
{
"mcpServers": {
"local-mcp": {
"url": "http://localhost:8000/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer <your-token>"
}
}
}
}
```
The agent should call `get_user_request` aggressively and continuously — **do not end the working session**. Every call returns the next pending instruction (if any). When the queue is empty the tool waits up to `wait_seconds` before returning an empty/default response, so the agent should loop and call again.
## 10. Implementation Notes for Future Work
- Prefer small, explicit modules over monolithic files.
- Keep the project localhost-first and avoid remote asset dependencies.
- Treat the MCP tool and the web UI as two views over the same instruction queue.
- Optimize for correctness of queue semantics first, then refine the visual and realtime experience.

View File

@@ -23,7 +23,6 @@ func handleUpdateConfig(stores Stores, broker *events.Broker) http.HandlerFunc {
// Decode partial patch
var patch struct {
DefaultWaitSeconds *int `json:"default_wait_seconds"`
AgentStaleAfterSeconds *int `json:"agent_stale_after_seconds"`
}
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
@@ -41,9 +40,6 @@ func handleUpdateConfig(stores Stores, broker *events.Broker) http.HandlerFunc {
if patch.DefaultWaitSeconds != nil {
current.DefaultWaitSeconds = *patch.DefaultWaitSeconds
}
if patch.AgentStaleAfterSeconds != nil {
current.AgentStaleAfterSeconds = *patch.AgentStaleAfterSeconds
}
if err := stores.Settings.Update(current); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())

View File

@@ -106,14 +106,21 @@ func handleDeleteInstruction(stores Stores, broker *events.Broker) http.HandlerF
func handleClearConsumed(stores Stores, broker *events.Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
items, err := stores.Instructions.List("consumed")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := stores.Instructions.DeleteConsumed(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
counts, _ := stores.Instructions.Counts()
broker.Broadcast("history.cleared", nil)
cleared := len(items)
broker.Broadcast("history.cleared", map[string]any{"count": cleared})
broker.Broadcast("status.changed", map[string]any{"queue": counts})
w.WriteHeader(http.StatusNoContent)
writeJSON(w, http.StatusOK, map[string]any{"cleared": cleared})
}
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"time"
"github.com/local-mcp/local-mcp-go/internal/config"
"github.com/local-mcp/local-mcp-go/internal/models"
)
@@ -44,7 +45,7 @@ func handleStatus(stores Stores) http.HandlerFunc {
}
if latest != nil {
connected := time.Since(latest.LastSeenAt).Seconds() <= float64(cfg.AgentStaleAfterSeconds)
connected := time.Since(latest.LastSeenAt).Seconds() <= float64(config.AgentStaleAfterSeconds)
agent = map[string]any{
"connected": connected,
"last_seen_at": latest.LastSeenAt.Format(time.RFC3339Nano),
@@ -57,6 +58,8 @@ func handleStatus(stores Stores) http.HandlerFunc {
"server": map[string]any{
"status": "up",
"started_at": serverStartTime.Format(time.RFC3339Nano),
"type": "go",
"version": config.AppVersion,
},
"agent": agent,
"queue": map[string]any{
@@ -66,7 +69,6 @@ func handleStatus(stores Stores) http.HandlerFunc {
"settings": models.Settings{
DefaultWaitSeconds: cfg.DefaultWaitSeconds,
DefaultEmptyResponse: cfg.DefaultEmptyResponse,
AgentStaleAfterSeconds: cfg.AgentStaleAfterSeconds,
},
}

View File

@@ -6,7 +6,11 @@ import (
"strconv"
)
const DefaultEmptyResponse = "call this tool `get_user_request` again to fetch latest user input..."
const (
AppVersion = "1.0.1"
AgentStaleAfterSeconds = 30
DefaultEmptyResponse = "call this tool `get_user_request` again to fetch latest user input..."
)
// Config holds all runtime configuration values for local-mcp.
type Config struct {
@@ -15,7 +19,6 @@ type Config struct {
DBPath string
LogLevel string
DefaultWaitSeconds int
AgentStaleAfterSeconds int
MCPStateless bool
APIToken string
}
@@ -28,7 +31,6 @@ func Load() Config {
DBPath: getEnv("DB_PATH", "data/local_mcp.sqlite3"),
LogLevel: getEnv("LOG_LEVEL", "INFO"),
DefaultWaitSeconds: getEnvInt("DEFAULT_WAIT_SECONDS", 10),
AgentStaleAfterSeconds: getEnvInt("AGENT_STALE_AFTER_SECONDS", 30),
MCPStateless: getEnvBool("MCP_STATELESS", true),
APIToken: getEnv("API_TOKEN", ""),
}

View File

@@ -42,8 +42,8 @@ CREATE TABLE IF NOT EXISTS agent_activity (
// 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');\n" +
"INSERT OR IGNORE INTO settings (key, value) VALUES ('agent_stale_after_seconds', '30');\n" +
"DELETE FROM settings WHERE key = 'default_empty_response';\n"
"DELETE FROM settings WHERE key = 'default_empty_response';\n" +
"DELETE FROM settings WHERE key = 'agent_stale_after_seconds';\n"
// Open opens (creating if necessary) a SQLite database at dbPath, applies the
// schema, and seeds default settings.

View File

@@ -53,7 +53,7 @@ func New(
broker *events.Broker,
) *Handler {
h := &Handler{
MCP: server.NewMCPServer("local-mcp", "1.0.0"),
MCP: server.NewMCPServer("local-mcp", config.AppVersion),
instStore: instStore,
settStore: settStore,
agentStore: agentStore,

View File

@@ -27,7 +27,6 @@ type Instruction struct {
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.

View File

@@ -135,9 +135,10 @@ func (s *InstructionStore) Create(content string) (*models.Instruction, error) {
id := uuid.New().String()
now := time.Now().UTC()
// Assign next position
// Assign next position from the full instruction history so positions remain
// globally stable even after the pending queue empties.
var maxPos sql.NullInt64
_ = s.db.QueryRow(`SELECT MAX(position) FROM instructions WHERE status = 'pending'`).Scan(&maxPos)
_ = s.db.QueryRow(`SELECT MAX(position) FROM instructions`).Scan(&maxPos)
position := int(maxPos.Int64) + 1
_, err := s.db.Exec(`

View File

@@ -30,7 +30,6 @@ func (s *SettingsStore) Get() (models.Settings, error) {
cfg := models.Settings{
DefaultWaitSeconds: 10,
DefaultEmptyResponse: config.DefaultEmptyResponse,
AgentStaleAfterSeconds: 30,
}
for rows.Next() {
@@ -43,10 +42,6 @@ func (s *SettingsStore) Get() (models.Settings, error) {
if n, err := strconv.Atoi(value); err == nil {
cfg.DefaultWaitSeconds = n
}
case "agent_stale_after_seconds":
if n, err := strconv.Atoi(value); err == nil {
cfg.AgentStaleAfterSeconds = n
}
}
}
return cfg, rows.Err()
@@ -57,10 +52,8 @@ func (s *SettingsStore) Get() (models.Settings, error) {
func (s *SettingsStore) Update(patch models.Settings) error {
_, err := s.db.Exec(`
INSERT OR REPLACE INTO settings (key, value) VALUES
('default_wait_seconds', ?),
('agent_stale_after_seconds', ?)`,
('default_wait_seconds', ?)` ,
strconv.Itoa(patch.DefaultWaitSeconds),
strconv.Itoa(patch.AgentStaleAfterSeconds),
)
return err
}

View File

@@ -122,6 +122,7 @@ func main() {
mcpURL := fmt.Sprintf("http://%s/mcp", addr)
slog.Info("local-mcp-go ready",
"version", config.AppVersion,
"http", httpURL,
"mcp", mcpURL,
"stateless", cfg.MCPStateless,
@@ -131,7 +132,7 @@ func main() {
if runtime.GOOS == "windows" {
fmt.Println()
fmt.Println("╔══════════════════════════════════════════════════════════╗")
fmt.Printf("║ local-mcp-go ready on port %s%-24s║\n", port, "")
fmt.Printf("║ local-mcp-go v%-6s ready on port %s%-14s║\n", config.AppVersion, port, "")
fmt.Println("║ ║")
fmt.Printf("║ Web UI: %-46s║\n", httpURL)
fmt.Printf("║ MCP: %-46s║\n", mcpURL)

View File

@@ -17,7 +17,7 @@ from fastapi.staticfiles import StaticFiles
from app.api import routes_config, routes_instructions, routes_status
from app.api.auth import TokenAuthMiddleware
from app.config import settings
from app.config import APP_VERSION, settings
from app.database import init_db
from app.logging_setup import configure_logging
from app.mcp_server import mcp, mcp_asgi_app
@@ -37,11 +37,12 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info("local-mcp starting up initialising database …")
logger.info("local-mcp v%s starting up initialising database …", APP_VERSION)
init_db(settings.db_path)
await instruction_service.init_wakeup()
logger.info(
"local-mcp ready http://%s:%d | MCP http://%s:%d/mcp (stateless=%s)",
"local-mcp v%s ready http://%s:%d | MCP http://%s:%d/mcp (stateless=%s)",
APP_VERSION,
settings.host, settings.http_port,
settings.host, settings.http_port,
settings.mcp_stateless,
@@ -57,7 +58,7 @@ def create_app() -> FastAPI:
app = FastAPI(
title="local-mcp",
description="Localhost MCP server with instruction queue management UI",
version="1.0.0",
version=APP_VERSION,
lifespan=lifespan,
)

View File

@@ -489,9 +489,9 @@
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background: color-mix(in srgb, var(--bg-void) 44%, transparent);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.auth-card {
@@ -559,6 +559,27 @@
margin: 0;
}
.confirm-card {
max-width: 420px;
}
.confirm-card__icon--danger {
color: var(--red);
background: color-mix(in srgb, var(--red) 12%, transparent);
border-color: color-mix(in srgb, var(--red) 30%, transparent);
}
.confirm-card__actions {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.confirm-card__actions .btn {
width: 100%;
}
/* ── Quick shortcuts ─────────────────────────────────────────────────────── */
.shortcuts-container {

View File

@@ -148,28 +148,6 @@
</div>
</div>
<!-- Config -->
<div class="panel fade-in fade-in--delay-2">
<div class="panel-header">
<span class="panel-title">
<svg class="icon icon--sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</span>
</div>
<div class="panel-body">
<form id="config-form">
<div class="config-field">
<label class="config-label" for="cfg-stale">Agent Stale After (sec)</label>
<input id="cfg-stale" class="input input--sm" type="number" min="5" max="600" value="30" />
<span class="config-hint">Inactivity before agent shown as idle</span>
</div>
<button id="cfg-save" type="submit" class="btn btn--ghost btn--sm" style="width:100%">
<svg class="icon icon--sm" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save Settings
</button>
</form>
</div>
</div>
</aside>

View File

@@ -27,6 +27,64 @@ export function toast(message, type = 'info') {
}, 3000);
}
export function confirmDialog({
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmClass = 'btn--danger',
}) {
document.getElementById('confirm-modal')?.remove();
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'confirm-modal';
overlay.className = 'auth-overlay';
overlay.tabIndex = -1;
overlay.innerHTML = `
<div class="auth-card confirm-card fade-in" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="auth-card__icon confirm-card__icon confirm-card__icon--danger">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 9v4"/>
<path d="M12 17h.01"/>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
</svg>
</div>
<h2 id="confirm-title" class="auth-card__title">${title}</h2>
<p class="auth-card__desc">${message}</p>
<div class="confirm-card__actions">
<button type="button" class="btn btn--ghost" data-action="cancel">${cancelText}</button>
<button type="button" class="btn ${confirmClass}" data-action="confirm">${confirmText}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const cancelBtn = overlay.querySelector('[data-action="cancel"]');
const confirmBtn = overlay.querySelector('[data-action="confirm"]');
const close = (result) => {
overlay.remove();
resolve(result);
};
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(false);
});
cancelBtn.addEventListener('click', () => close(false));
confirmBtn.addEventListener('click', () => close(true));
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close(false);
});
overlay.focus();
confirmBtn.focus();
});
}
// ── Token auth modal ──────────────────────────────────────────────────────
function showTokenModal(onSuccess) {

View File

@@ -6,7 +6,7 @@
import { state } from './state.js';
import { api } from './api.js';
import { toast } from './app.js';
import { confirmDialog, toast } from './app.js';
// ── SVG icon helpers ──────────────────────────────────────────────────────
@@ -45,6 +45,16 @@ function fmtAbsTime(isoStr) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function comparePending(a, b) {
return (a.position - b.position) || String(a.created_at).localeCompare(String(b.created_at));
}
function compareConsumed(a, b) {
const consumedDelta = new Date(b.consumed_at || 0).getTime() - new Date(a.consumed_at || 0).getTime();
if (consumedDelta !== 0) return consumedDelta;
return b.position - a.position;
}
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
export function refreshTimestamps() {
document.querySelectorAll('[data-ts]').forEach(el => {
@@ -127,7 +137,14 @@ function renderPendingCard(item, index) {
});
deleteBtn.addEventListener('click', async () => {
if (!confirm('Delete this instruction?')) return;
const confirmed = await confirmDialog({
title: 'Delete instruction?',
message: 'This removes the pending instruction from the queue before any agent can consume it.',
confirmText: 'Delete Instruction',
cancelText: 'Keep Instruction',
});
if (!confirmed) return;
deleteBtn.disabled = true;
try {
await api.deleteInstruction(item.id);
@@ -141,7 +158,7 @@ function renderPendingCard(item, index) {
return card;
}
function renderConsumedCard(item) {
function renderConsumedCard(item, index) {
const card = document.createElement('div');
card.className = 'instruction-card instruction-card--consumed';
card.dataset.id = item.id;
@@ -171,8 +188,8 @@ export function initInstructions() {
function render(instructions) {
if (!instructions) return;
const pending = instructions.filter(i => i.status === 'pending');
const consumed = instructions.filter(i => i.status === 'consumed').reverse();
const pending = instructions.filter(i => i.status === 'pending').sort(comparePending);
const consumed = instructions.filter(i => i.status === 'consumed').sort(compareConsumed);
// Pending
pendingList.innerHTML = '';
@@ -194,7 +211,7 @@ export function initInstructions() {
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
clearHistoryBtn.style.display = 'none';
} else {
consumed.forEach(item => consumedList.appendChild(renderConsumedCard(item)));
consumed.forEach((item, i) => consumedList.appendChild(renderConsumedCard(item, i)));
clearHistoryBtn.style.display = '';
}
consumedBadge.textContent = consumed.length;
@@ -205,11 +222,19 @@ export function initInstructions() {
// Clear history button
clearHistoryBtn.addEventListener('click', async () => {
if (!confirm('Clear all consumed instruction history? This cannot be undone.')) return;
const confirmed = await confirmDialog({
title: 'Clear consumed history?',
message: 'This removes all consumed instructions from the history panel. This action cannot be undone.',
confirmText: 'Clear History',
cancelText: 'Keep History',
});
if (!confirmed) return;
clearHistoryBtn.disabled = true;
try {
const res = await api.clearConsumed();
toast(`Cleared ${res.cleared} consumed instruction${res.cleared !== 1 ? 's' : ''}`, 'info');
const cleared = res?.cleared ?? 0;
toast(`Cleared ${cleared} consumed instruction${cleared !== 1 ? 's' : ''}`, 'info');
} catch (e) {
toast(e.message, 'error');
} finally {

View File

@@ -5,8 +5,6 @@
*/
import { state } from './state.js';
import { api } from './api.js';
import { toast } from './app.js';
// ── Time helpers ──────────────────────────────────────────────────────────
@@ -65,12 +63,22 @@ function renderStatusPanel(status) {
pending_count: status.queue?.pending_count ?? 0,
consumed_count: status.queue?.consumed_count ?? 0,
};
const serverType = status.server?.type || 'unknown';
const version = status.server?.version || '';
el.innerHTML = `
<div class="stat-row">
<span class="stat-label">Server Up</span>
<span class="stat-value">${fmtTime(status.server?.started_at)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Version</span>
<span class="stat-value">v${escapeHtml(version)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Server Type</span>
<span class="stat-value">${escapeHtml(serverType)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Pending</span>
<span class="stat-value stat-value--cyan">${queue.pending_count}</span>
@@ -116,35 +124,7 @@ export function initStatus() {
// ── Config panel ──────────────────────────────────────────────────────────
export function initConfig() {
const form = document.getElementById('config-form');
const staleInput = document.getElementById('cfg-stale');
const saveBtn = document.getElementById('cfg-save');
// Populate from state
state.subscribe('config', (cfg) => {
if (!cfg) return;
staleInput.value = cfg.agent_stale_after_seconds;
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
saveBtn.disabled = true;
const original = saveBtn.innerHTML;
saveBtn.innerHTML = '<span class="spinner"></span>';
try {
const cfg = await api.updateConfig({
agent_stale_after_seconds: parseInt(staleInput.value, 10) || 30,
});
state.set('config', cfg);
toast('Settings saved', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = original;
}
});
// Intentionally empty: the web UI no longer exposes editable settings.
}
function escapeHtml(str) {