This commit is contained in:
2026-03-27 03:58:57 +08:00
commit 86eba27a24
38 changed files with 4074 additions and 0 deletions

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1 @@
Use the instructions under the `instructions` dir wisely to guide your current task. These will greatly improve your context and help you to complete the task faster/with better quality.

View File

@@ -0,0 +1,46 @@
---
applyTo: '**'
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this instruction when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
---
This instruction guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use:
* generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts),
* cliched color schemes (particularly purple gradients on white backgrounds),
* predictable layouts and component patterns,
* unnecessary gradients out of context,
* too many emojis scattered throughout the UI (use proper icons/SVGs instead),
* and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: You are capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
*.egg-info/
dist/
build/
# Data
data/
# Logs
logs/
*.log
server.log
server_err.log
# IDE
.idea/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db

3
AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
## Notes on calling tools
* About `run_in_terminal` or some other similar tools that can make calls to external programs, be very careful about what programs/commands you call - some are blocking indefinitely by nature, like running servers, long-running monitors, etc. If you would like to execute such commands, make sure to run them in a non-blocking way, for example run/spawn them in another temp terminal session. Otherwise, your agent will be hung on the waiting indefinitely.
* About `AskUser`, `get_user_request` or other similar tools that can retrieve latest user input, if available, make sure to call them **AGGRESSIVELY** once you finished all tasks at hand, so as to AVOID ending the current task/work/session prematurely. NEVER end the current task/work/session on your own, the user will keep adding new instructions into this kind of tools in the background. You can be missing out CRITICAL instructions from user when you failed to call them aggressively. Also, sometimes these tools can fail to return any results due to technical issues. Just try to call again when that happens.

577
README.md Normal file
View File

@@ -0,0 +1,577 @@
# 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 — **minimum** seconds the tool waits before returning empty/default response; agents may request longer but not shorter
- `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 either an empty response or a configured 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",
"wait_seconds": "optional-integer",
"default_response_override": "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": "No new instructions available.",
"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 a **minimum** enforced by the server. The actual wait is `max(wait_seconds or 0, server_minimum)`. Agents may request longer waits but cannot go below the floor — this prevents busy-polling.
- Clamp `actual_wait` to an absolute server maximum (300 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": "No new instructions available.",
"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": "No new instructions available.",
"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 a server-enforced minimum (agents may request longer).
- [x] Non-blocking `server.ps1` management script (start / stop / restart / status / logs).
- [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.
- [ ] **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 script (recommended — non-blocking):
```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
```
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` | _(empty)_ | 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 |
### 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"
}
}
}
```
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.

2
app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# app package

2
app/api/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# api package

41
app/api/routes_config.py Normal file
View File

@@ -0,0 +1,41 @@
"""
app/api/routes_config.py
HTTP endpoints for reading and updating runtime configuration.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from app.models import ConfigResponse, UpdateConfigRequest
from app.services import config_service, event_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/config", tags=["config"])
@router.get("", response_model=ConfigResponse)
def get_config():
return config_service.get_config()
@router.patch("", response_model=ConfigResponse)
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

@@ -0,0 +1,72 @@
"""
app/api/routes_instructions.py
HTTP endpoints for instruction CRUD.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from app.models import (
CreateInstructionRequest,
InstructionCreateResponse,
InstructionListResponse,
UpdateInstructionRequest,
)
from app.services import event_service, instruction_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/instructions", tags=["instructions"])
@router.get("", response_model=InstructionListResponse)
def list_instructions(
status: str = Query(default="all", pattern="^(pending|consumed|all)$")
):
items = instruction_service.list_instructions(status_filter=status)
return InstructionListResponse(items=items)
@router.post("", response_model=InstructionCreateResponse, status_code=201)
def create_instruction(body: CreateInstructionRequest):
item = instruction_service.create_instruction(body.content)
event_service.broadcast(
"instruction.created",
{"item": item.model_dump(mode="json")},
)
return InstructionCreateResponse(item=item)
@router.patch("/{instruction_id}", response_model=InstructionCreateResponse)
def update_instruction(instruction_id: str, body: UpdateInstructionRequest):
try:
item = instruction_service.update_instruction(instruction_id, body.content)
except KeyError:
raise HTTPException(status_code=404, detail="Instruction not found")
except PermissionError as exc:
raise HTTPException(status_code=409, detail=str(exc))
event_service.broadcast(
"instruction.updated",
{"item": item.model_dump(mode="json")},
)
return InstructionCreateResponse(item=item)
@router.delete("/{instruction_id}", status_code=204)
def delete_instruction(instruction_id: str):
try:
instruction_service.delete_instruction(instruction_id)
except KeyError:
raise HTTPException(status_code=404, detail="Instruction not found")
except PermissionError as exc:
raise HTTPException(status_code=409, detail=str(exc))
event_service.broadcast("instruction.deleted", {"id": instruction_id})
return Response(status_code=204)

77
app/api/routes_status.py Normal file
View File

@@ -0,0 +1,77 @@
"""
app/api/routes_status.py
HTTP endpoints for server/agent status and SSE events.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from app.models import (
AgentInfo,
HealthResponse,
QueueCounts,
ServerInfo,
StatusResponse,
)
from app.services import config_service, event_service, instruction_service, status_service
logger = logging.getLogger(__name__)
router = APIRouter(tags=["status"])
@router.get("/healthz", response_model=HealthResponse)
def health():
return HealthResponse(status="ok", server_time=datetime.now(timezone.utc))
@router.get("/api/status", response_model=StatusResponse)
def get_status():
agent_row = status_service.get_latest_agent_activity()
connected = status_service.is_agent_connected()
agent_info = AgentInfo(
connected=connected,
last_seen_at=datetime.fromisoformat(agent_row["last_seen_at"]) if agent_row else None,
last_fetch_at=datetime.fromisoformat(agent_row["last_fetch_at"]) if agent_row else None,
agent_id=agent_row["agent_id"] if agent_row else None,
)
counts = instruction_service.get_queue_counts()
cfg = config_service.get_config()
return StatusResponse(
server=ServerInfo(
status="up",
started_at=status_service.server_started_at(),
),
agent=agent_info,
queue=QueueCounts(**counts),
settings=cfg,
)
@router.get("/api/events")
async def sse_events():
q = event_service.subscribe()
async def stream():
async for chunk in event_service.event_generator(q):
yield chunk
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)

62
app/config.py Normal file
View File

@@ -0,0 +1,62 @@
"""
app/config.py
Runtime configuration loaded from environment variables with sensible defaults.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass
class Settings:
# Server
host: str = "0.0.0.0"
http_port: int = 8000
# Database
db_path: str = "data/local_mcp.sqlite3"
# Logging
log_level: str = "INFO"
# 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 = ""
agent_stale_after_seconds: int = 30
# MCP server name
mcp_server_name: str = "local-mcp"
# MCP transport — stateless=True means no session IDs, survives server restarts.
# Set MCP_STATELESS=false to use stateful sessions (needed for multi-turn MCP flows).
mcp_stateless: bool = True
def _parse_bool(value: str, default: bool) -> bool:
if value.lower() in ("1", "true", "yes", "on"):
return True
if value.lower() in ("0", "false", "no", "off"):
return False
return default
def load_settings() -> Settings:
"""Load settings from environment variables, falling back to defaults."""
return Settings(
host=os.getenv("HOST", "0.0.0.0"),
http_port=int(os.getenv("HTTP_PORT", "8000")),
db_path=os.getenv("DB_PATH", "data/local_mcp.sqlite3"),
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", ""),
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),
)
# Singleton imported and used throughout the app
settings: Settings = load_settings()

124
app/database.py Normal file
View File

@@ -0,0 +1,124 @@
"""
app/database.py
SQLite database initialisation, schema management, and low-level connection helpers.
"""
from __future__ import annotations
import logging
import os
import sqlite3
import threading
from contextlib import contextmanager
from typing import Generator
logger = logging.getLogger(__name__)
# Module-level lock for write serialisation (consume atomicity, settings writes, etc.)
_write_lock = threading.Lock()
_db_path: str = ""
def init_db(db_path: str) -> None:
"""Initialise the database: create directory, schema, and seed defaults."""
global _db_path
_db_path = db_path
os.makedirs(os.path.dirname(db_path) if os.path.dirname(db_path) else ".", exist_ok=True)
with _connect() as conn:
_create_schema(conn)
_seed_defaults(conn)
logger.info("Database initialised at %s", db_path)
# ---------------------------------------------------------------------------
# Connection helpers
# ---------------------------------------------------------------------------
@contextmanager
def get_conn() -> Generator[sqlite3.Connection, None, None]:
"""Yield a short-lived read-write connection. Commits on success, rolls back on error."""
conn = _connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@contextmanager
def get_write_conn() -> Generator[sqlite3.Connection, None, None]:
"""Yield a connection protected by the module write lock (for atomic operations)."""
with _write_lock:
with get_conn() as conn:
yield conn
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(_db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
_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
);
CREATE INDEX IF NOT EXISTS idx_instructions_status_position
ON instructions (status, 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
);
"""
_DEFAULT_SETTINGS = {
"default_wait_seconds": "10",
"default_empty_response": "",
"agent_stale_after_seconds": "30",
}
def _create_schema(conn: sqlite3.Connection) -> None:
conn.executescript(_SCHEMA)
conn.commit()
logger.debug("Schema ensured")
def _seed_defaults(conn: sqlite3.Connection) -> None:
for key, value in _DEFAULT_SETTINGS.items():
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
conn.commit()
logger.debug("Default settings seeded")

41
app/logging_setup.py Normal file
View File

@@ -0,0 +1,41 @@
"""
app/logging_setup.py
Centralised logging configuration for the application.
"""
from __future__ import annotations
import logging
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.config import Settings
def configure_logging(settings: "Settings") -> None:
"""Configure root logger and suppress noisy third-party loggers."""
level = getattr(logging, settings.log_level.upper(), logging.INFO)
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)-8s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root = logging.getLogger()
root.setLevel(level)
# Avoid duplicate handlers if called multiple times
if not root.handlers:
root.addHandler(handler)
else:
root.handlers[0] = handler
# Quieten noisy third-party loggers
for noisy in ("uvicorn.access", "uvicorn.error", "httpx", "httpcore"):
logging.getLogger(noisy).setLevel(logging.WARNING)
logging.getLogger("uvicorn").setLevel(logging.INFO)

201
app/mcp_server.py Normal file
View File

@@ -0,0 +1,201 @@
"""
app/mcp_server.py
FastMCP server definition with the get_user_request tool.
The Starlette app returned by mcp.streamable_http_app() is mounted into
the main FastAPI application at /mcp.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Optional
from mcp.server.fastmcp import FastMCP
from app.config import settings
from app.services import config_service, event_service, instruction_service, status_service
logger = logging.getLogger(__name__)
mcp = FastMCP(
settings.mcp_server_name,
streamable_http_path="/",
stateless_http=settings.mcp_stateless,
)
# Build the ASGI app eagerly so that session_manager is created and can be
# started explicitly inside the FastAPI lifespan (see main.py).
mcp_asgi_app = mcp.streamable_http_app()
# Maximum wait the client is allowed to request (guards against runaway holds)
# Set very high — the wait is always interruptible by new instructions via the
# asyncio.Event wakeup, so there is no practical danger in long waits.
_MAX_WAIT_SECONDS = 86400 # 24 hours
# Per-agent generation counter — incremented on every new call.
# The wait loop only consumes an instruction when it holds the latest generation,
# preventing abandoned (timed-out) coroutines from silently consuming queue items.
_agent_generations: dict[str, int] = {}
@mcp.tool()
async def get_user_request(
agent_id: str = "unknown",
wait_seconds: Optional[int] = None,
default_response_override: Optional[str] = None,
) -> dict:
"""
Fetch the next pending user instruction from the queue.
The server enforces a minimum wait time (configurable via the web UI).
The agent may request a longer wait via `wait_seconds`, but cannot go
below that minimum — this prevents busy-polling when the queue is empty.
actual_wait = max(wait_seconds or 0, server_min_wait_seconds).
Args:
agent_id: An identifier for this agent instance (used to track connectivity).
wait_seconds: Desired wait when queue is empty. Actual wait is
max(this, server minimum). Omit to use server minimum only.
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.
"""
cfg = config_service.get_config()
# default_wait_seconds is the server-enforced MINIMUM wait time.
client_requested = wait_seconds if wait_seconds is not None else 0
actual_wait = min(
max(client_requested, cfg.default_wait_seconds), # enforce floor
_MAX_WAIT_SECONDS,
)
# Register this call as the newest for this agent. Any older coroutines
# still lingering (e.g. client timed-out and retried) will see a stale
# generation and skip the consume step, leaving the instruction for us.
my_gen = _agent_generations.get(agent_id, 0) + 1
_agent_generations[agent_id] = my_gen
def _i_am_active() -> bool:
"""True if no newer call has arrived for this agent since we started."""
return _agent_generations.get(agent_id) == my_gen
# --- Attempt immediate dequeue ---
item = instruction_service.consume_next(agent_id=agent_id)
if item is not None:
counts = instruction_service.get_queue_counts()
status_service.record_agent_activity(agent_id, "instruction")
event_service.broadcast(
"instruction.consumed",
{"item": item.model_dump(mode="json"), "consumed_by_agent_id": agent_id},
)
event_service.broadcast("status.changed", {"queue": counts})
logger.info(
"get_user_request: instruction delivered id=%s agent=%s", item.id, agent_id
)
return {
"status": "ok",
"result_type": "instruction",
"instruction": {
"id": item.id,
"content": item.content,
"consumed_at": item.consumed_at.isoformat() if item.consumed_at else None,
},
"response": None,
"remaining_pending": counts["pending_count"],
"waited_seconds": 0,
}
# --- Wait loop (event-driven, not polling) ---
wakeup = instruction_service.get_wakeup_event()
loop = asyncio.get_event_loop()
start = loop.time()
while True:
elapsed = loop.time() - start
remaining = actual_wait - elapsed
if remaining <= 0:
break
# If a newer call for this agent arrived, step aside without consuming.
if not _i_am_active():
logger.debug(
"get_user_request: superseded by newer call agent=%s gen=%d", agent_id, my_gen
)
break
# Clear the event BEFORE checking the queue so we never miss a
# wake-up that arrives between the DB check and event.wait().
if wakeup is not None:
wakeup.clear()
item = instruction_service.consume_next(agent_id=agent_id)
if item is not None:
counts = instruction_service.get_queue_counts()
status_service.record_agent_activity(agent_id, "instruction")
event_service.broadcast(
"instruction.consumed",
{"item": item.model_dump(mode="json"), "consumed_by_agent_id": agent_id},
)
event_service.broadcast("status.changed", {"queue": counts})
waited = int(loop.time() - start)
logger.info(
"get_user_request: instruction delivered (after %ds wait) id=%s agent=%s gen=%d",
waited, item.id, agent_id, my_gen,
)
return {
"status": "ok",
"result_type": "instruction",
"instruction": {
"id": item.id,
"content": item.content,
"consumed_at": item.consumed_at.isoformat() if item.consumed_at else None,
},
"response": None,
"remaining_pending": counts["pending_count"],
"waited_seconds": waited,
}
# Sleep until woken by a new instruction or 1 s elapses (safety net)
wait_for = min(remaining, 1.0)
if wakeup is not None:
try:
await asyncio.wait_for(wakeup.wait(), timeout=wait_for)
except asyncio.TimeoutError:
pass
else:
await asyncio.sleep(wait_for)
waited = int(loop.time() - start)
# --- Nothing available after waiting (or superseded) ---
if _i_am_active():
# Only record/broadcast when we're the active caller
status_service.record_agent_activity(agent_id, "empty")
event_service.broadcast("status.changed", {})
empty_response = (
default_response_override
if default_response_override is not None
else cfg.default_empty_response
)
result_type = "default_response" if empty_response else "empty"
if _i_am_active():
logger.info(
"get_user_request: empty result_type=%s waited=%ds agent=%s gen=%d",
result_type, waited, agent_id, my_gen,
)
return {
"status": "ok",
"result_type": result_type,
"instruction": None,
"response": empty_response,
"remaining_pending": 0,
"waited_seconds": waited,
}

146
app/models.py Normal file
View File

@@ -0,0 +1,146 @@
"""
app/models.py
Pydantic request/response models used by the HTTP API.
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, field_validator
# ---------------------------------------------------------------------------
# Enumerations
# ---------------------------------------------------------------------------
class InstructionStatus(str, Enum):
pending = "pending"
consumed = "consumed"
class ResultType(str, Enum):
instruction = "instruction"
empty = "empty"
default_response = "default_response"
# ---------------------------------------------------------------------------
# Instruction models
# ---------------------------------------------------------------------------
class InstructionItem(BaseModel):
id: str
content: str
status: InstructionStatus
created_at: datetime
updated_at: datetime
consumed_at: Optional[datetime] = None
consumed_by_agent_id: Optional[str] = None
position: int
class InstructionListResponse(BaseModel):
items: list[InstructionItem]
class InstructionCreateResponse(BaseModel):
item: InstructionItem
class CreateInstructionRequest(BaseModel):
content: str
@field_validator("content")
@classmethod
def content_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("content must not be blank")
return v.strip()
class UpdateInstructionRequest(BaseModel):
content: str
@field_validator("content")
@classmethod
def content_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("content must not be blank")
return v.strip()
# ---------------------------------------------------------------------------
# Status models
# ---------------------------------------------------------------------------
class ServerInfo(BaseModel):
status: str
started_at: datetime
class AgentInfo(BaseModel):
connected: bool
last_seen_at: Optional[datetime] = None
last_fetch_at: Optional[datetime] = None
agent_id: Optional[str] = None
class QueueCounts(BaseModel):
pending_count: int
consumed_count: int
class StatusResponse(BaseModel):
server: ServerInfo
agent: AgentInfo
queue: QueueCounts
settings: "ConfigResponse"
# ---------------------------------------------------------------------------
# Config models
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# Health model
# ---------------------------------------------------------------------------
class HealthResponse(BaseModel):
status: str
server_time: datetime
# ---------------------------------------------------------------------------
# MCP tool response models
# ---------------------------------------------------------------------------
class InstructionPayload(BaseModel):
id: str
content: str
consumed_at: datetime
class GetUserRequestResponse(BaseModel):
status: str
result_type: ResultType
instruction: Optional[InstructionPayload] = None
response: Optional[str] = None
remaining_pending: int
waited_seconds: int

2
app/services/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# services package

View File

@@ -0,0 +1,52 @@
"""
app/services/config_service.py
Read and write runtime settings stored in the SQLite settings table.
"""
from __future__ import annotations
import logging
from app.database import get_conn, get_write_conn
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()
data = {r["key"]: r["value"] for r in rows}
return ConfigResponse(
default_wait_seconds=int(data.get("default_wait_seconds", 10)),
default_empty_response=data.get("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:
for key, value in updates.items():
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
logger.info("Config updated: %s", list(updates.keys()))
return get_config()

View File

@@ -0,0 +1,75 @@
"""
app/services/event_service.py
Server-Sent Events (SSE) broadcaster.
Maintains a set of subscriber asyncio queues and fans out events to all of them.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import AsyncGenerator
logger = logging.getLogger(__name__)
# All active SSE subscriber queues
_subscribers: set[asyncio.Queue] = set()
def subscribe() -> asyncio.Queue:
"""Register a new SSE subscriber and return its queue."""
q: asyncio.Queue = asyncio.Queue(maxsize=64)
_subscribers.add(q)
logger.debug("SSE subscriber added, total=%d", len(_subscribers))
return q
def unsubscribe(q: asyncio.Queue) -> None:
"""Remove a subscriber queue."""
_subscribers.discard(q)
logger.debug("SSE subscriber removed, total=%d", len(_subscribers))
def broadcast(event_type: str, data: dict) -> None:
"""
Fan out an event to all current subscribers.
Safe to call from synchronous code uses put_nowait and discards slow consumers.
"""
payload = json.dumps(
{
"type": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": data,
}
)
dead: list[asyncio.Queue] = []
for q in list(_subscribers):
try:
q.put_nowait(payload)
except asyncio.QueueFull:
logger.warning("SSE subscriber queue full, dropping event type=%s", event_type)
dead.append(q)
for q in dead:
_subscribers.discard(q)
async def event_generator(q: asyncio.Queue) -> AsyncGenerator[str, None]:
"""
Async generator that yields SSE-formatted strings from a subscriber queue.
Yields a keep-alive comment every 15 seconds when idle.
"""
try:
while True:
try:
payload = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"data: {payload}\n\n"
except asyncio.TimeoutError:
# Keep-alive ping so browsers don't close the connection
yield ": ping\n\n"
except asyncio.CancelledError:
pass
finally:
unsubscribe(q)

View File

@@ -0,0 +1,203 @@
"""
app/services/instruction_service.py
Business logic for managing the instruction queue.
All write operations that affect queue integrity use the write lock via get_write_conn().
"""
from __future__ import annotations
import asyncio
import logging
import sqlite3
import uuid
from datetime import datetime, timezone
from typing import Optional
from app.database import get_conn, get_write_conn
from app.models import InstructionItem, InstructionStatus
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Wakeup event lets get_user_request react instantly when a new instruction
# is enqueued instead of sleeping for a full second.
# ---------------------------------------------------------------------------
_wakeup_event: asyncio.Event | None = None
_event_loop: asyncio.AbstractEventLoop | None = None
async def init_wakeup() -> None:
"""Create the wakeup event. Must be called from async context (lifespan)."""
global _wakeup_event, _event_loop
_wakeup_event = asyncio.Event()
_event_loop = asyncio.get_running_loop()
logger.debug("Instruction wakeup event initialised")
def get_wakeup_event() -> asyncio.Event | None:
return _wakeup_event
def _trigger_wakeup() -> None:
"""Thread-safe: schedule event.set() on the running event loop."""
if _wakeup_event is None or _event_loop is None:
return
_event_loop.call_soon_threadsafe(_wakeup_event.set)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _row_to_item(row: sqlite3.Row) -> InstructionItem:
return InstructionItem(
id=row["id"],
content=row["content"],
status=InstructionStatus(row["status"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
consumed_at=row["consumed_at"],
consumed_by_agent_id=row["consumed_by_agent_id"],
position=row["position"],
)
# ---------------------------------------------------------------------------
# Reads
# ---------------------------------------------------------------------------
def list_instructions(status_filter: Optional[str] = None) -> list[InstructionItem]:
query = "SELECT * FROM instructions"
params: tuple = ()
if status_filter and status_filter != "all":
query += " WHERE status = ?"
params = (status_filter,)
query += " ORDER BY position ASC"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
return [_row_to_item(r) for r in rows]
def get_instruction(instruction_id: str) -> Optional[InstructionItem]:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM instructions WHERE id = ?", (instruction_id,)
).fetchone()
return _row_to_item(row) if row else None
def get_queue_counts() -> dict[str, int]:
with get_conn() as conn:
pending = conn.execute(
"SELECT COUNT(*) FROM instructions WHERE status = 'pending'"
).fetchone()[0]
consumed = conn.execute(
"SELECT COUNT(*) FROM instructions WHERE status = 'consumed'"
).fetchone()[0]
return {"pending_count": pending, "consumed_count": consumed}
def _next_position(conn: sqlite3.Connection) -> int:
row = conn.execute("SELECT MAX(position) FROM instructions").fetchone()
return (row[0] or 0) + 1
# ---------------------------------------------------------------------------
# Writes
# ---------------------------------------------------------------------------
def create_instruction(content: str) -> InstructionItem:
instruction_id = str(uuid.uuid4())
now = _now_iso()
with get_write_conn() as conn:
pos = _next_position(conn)
conn.execute(
"""
INSERT INTO instructions (id, content, status, created_at, updated_at, position)
VALUES (?, ?, 'pending', ?, ?, ?)
""",
(instruction_id, content, now, now, pos),
)
logger.info("Instruction created id=%s pos=%d", instruction_id, pos)
item = get_instruction(instruction_id)
assert item is not None
_trigger_wakeup() # wake up any waiting get_user_request calls immediately
return item
def update_instruction(instruction_id: str, content: str) -> InstructionItem:
"""Update content of a pending instruction. Raises ValueError if consumed or not found."""
with get_write_conn() as conn:
row = conn.execute(
"SELECT status FROM instructions WHERE id = ?", (instruction_id,)
).fetchone()
if row is None:
raise KeyError(instruction_id)
if row["status"] != "pending":
raise PermissionError(f"Instruction {instruction_id} is already consumed")
now = _now_iso()
conn.execute(
"UPDATE instructions SET content = ?, updated_at = ? WHERE id = ?",
(content, now, instruction_id),
)
logger.info("Instruction updated id=%s", instruction_id)
item = get_instruction(instruction_id)
assert item is not None
return item
def delete_instruction(instruction_id: str) -> None:
"""Delete a pending instruction. Raises ValueError if consumed or not found."""
with get_write_conn() as conn:
row = conn.execute(
"SELECT status FROM instructions WHERE id = ?", (instruction_id,)
).fetchone()
if row is None:
raise KeyError(instruction_id)
if row["status"] != "pending":
raise PermissionError(f"Instruction {instruction_id} is already consumed")
conn.execute("DELETE FROM instructions WHERE id = ?", (instruction_id,))
logger.info("Instruction deleted id=%s", instruction_id)
def consume_next(agent_id: str = "unknown") -> Optional[InstructionItem]:
"""
Atomically claim and return the next pending instruction.
Uses the write lock to prevent two concurrent callers from consuming the same item.
Returns None if the queue is empty.
"""
with get_write_conn() as conn:
row = conn.execute(
"""
SELECT id FROM instructions
WHERE status = 'pending'
ORDER BY position ASC
LIMIT 1
"""
).fetchone()
if row is None:
return None
instruction_id = row["id"]
now = _now_iso()
conn.execute(
"""
UPDATE instructions
SET status = 'consumed', consumed_at = ?, consumed_by_agent_id = ?, updated_at = ?
WHERE id = ?
""",
(now, agent_id, now, instruction_id),
)
logger.info("Instruction consumed id=%s agent=%s", instruction_id, agent_id)
return get_instruction(instruction_id)

View File

@@ -0,0 +1,79 @@
"""
app/services/status_service.py
Tracks server startup time and agent activity.
"""
from __future__ import annotations
import logging
import sqlite3
from datetime import datetime, timezone
from typing import Optional
from app.database import get_conn, get_write_conn
logger = logging.getLogger(__name__)
_server_started_at: datetime = datetime.now(timezone.utc)
def server_started_at() -> datetime:
return _server_started_at
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# Agent activity
# ---------------------------------------------------------------------------
def record_agent_activity(agent_id: str, result_type: str) -> None:
"""Upsert agent activity record on every tool call."""
now = _now_iso()
with get_write_conn() as conn:
conn.execute(
"""
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
""",
(agent_id, now, now, result_type),
)
logger.debug("Agent activity recorded agent=%s result=%s", agent_id, result_type)
def get_latest_agent_activity() -> Optional[sqlite3.Row]:
"""Return the most recently active agent row, or None."""
with get_conn() as conn:
return conn.execute(
"SELECT * FROM agent_activity ORDER BY last_seen_at DESC LIMIT 1"
).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

102
main.py Normal file
View File

@@ -0,0 +1,102 @@
"""
main.py
Application entrypoint.
Starts the FastAPI HTTP server (web UI + REST API) and mounts the FastMCP
streamable-HTTP endpoint under /mcp.
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app.api import routes_config, routes_instructions, routes_status
from app.config import settings
from app.database import init_db
from app.logging_setup import configure_logging
from app.mcp_server import mcp, mcp_asgi_app
from app.services import instruction_service
# ---------------------------------------------------------------------------
# Logging must be configured before any module emits log messages
# ---------------------------------------------------------------------------
configure_logging(settings)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Build the FastAPI application
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info("local-mcp starting up initialising database …")
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)",
settings.host, settings.http_port,
settings.host, settings.http_port,
settings.mcp_stateless,
)
# Run the MCP session manager for the duration of the app lifetime
async with mcp.session_manager.run():
yield
# Shutdown
logger.info("local-mcp shutting down")
def create_app() -> FastAPI:
app = FastAPI(
title="local-mcp",
description="Localhost MCP server with instruction queue management UI",
version="1.0.0",
lifespan=lifespan,
)
# --- Global exception handler ---
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.exception("Unhandled exception for %s %s", request.method, request.url)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "error": str(exc)},
)
# --- API routers ---
app.include_router(routes_status.router)
app.include_router(routes_instructions.router)
app.include_router(routes_config.router)
# --- MCP streamable-HTTP transport mounted at /mcp ---
# mcp_asgi_app was pre-built in mcp_server.py; session manager is
# started explicitly in the lifespan above.
app.mount("/mcp", mcp_asgi_app)
# --- Static files for the web UI ---
app.mount("/static", StaticFiles(directory="static", html=False), name="static")
@app.get("/", include_in_schema=False)
def serve_index():
return FileResponse("static/index.html")
return app
app = create_app()
if __name__ == "__main__":
uvicorn.run(
"main:app",
host=settings.host,
port=settings.http_port,
log_level=settings.log_level.lower(),
reload=False,
)

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
mcp[cli]>=1.26.0
fastapi>=0.135.0
uvicorn>=0.42.0
sse-starlette>=3.3.0

229
server.ps1 Normal file
View File

@@ -0,0 +1,229 @@
<#
.SYNOPSIS
Non-blocking server management script for local-mcp.
.DESCRIPTION
Manages the local-mcp server process without blocking the terminal.
The server runs as a detached background process; stdout/stderr are
written to logs/server.log.
.USAGE
.\server.ps1 start # Start (no-op if already running)
.\server.ps1 stop # Kill the running server
.\server.ps1 restart # Stop then start
.\server.ps1 status # Show PID, port state, and tail 20 log lines
.\server.ps1 logs [N] # Tail last N lines of the log (default 40)
.\server.ps1 logs -f # Follow log live (Ctrl-C to quit)
#>
param(
[Parameter(Position = 0)]
[ValidateSet("start", "stop", "restart", "status", "logs")]
[string]$Command = "status",
[Parameter(Position = 1)]
[string]$Arg = ""
)
# ── Paths ─────────────────────────────────────────────────────────────────
$Root = $PSScriptRoot
$Python = Join-Path $Root ".venv\Scripts\python.exe"
$Entry = Join-Path $Root "main.py"
$LogDir = Join-Path $Root "logs"
$LogOut = Join-Path $LogDir "server.log"
$LogErr = Join-Path $LogDir "server.err.log"
$PidFile = Join-Path $LogDir "server.pid"
$Port = 8000
# ── Helpers ───────────────────────────────────────────────────────────────
function EnsureLogDir {
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Path $LogDir | Out-Null
}
}
function GetServerPid {
# Trust the PID file first; verify the process is actually alive
if (Test-Path $PidFile) {
$stored = Get-Content $PidFile -ErrorAction SilentlyContinue
if ($stored -match '^\d+$') {
$proc = Get-Process -Id ([int]$stored) -ErrorAction SilentlyContinue
if ($proc) { return [int]$stored }
}
Remove-Item $PidFile -ErrorAction SilentlyContinue
}
# Fallback: find python process listening on the port
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($conn) { return $conn.OwningProcess }
return $null
}
function IsRunning {
return $null -ne (GetServerPid)
}
function WriteLog([string]$msg, [string]$color = "White") {
Write-Host $msg -ForegroundColor $color
}
function MergedLogTail([int]$n) {
# Merge stdout + stderr logs sorted by content order and tail n lines
$lines = @()
if (Test-Path $LogOut) { $lines += Get-Content $LogOut -Tail ($n * 2) }
if (Test-Path $LogErr) { $lines += Get-Content $LogErr -Tail ($n * 2) }
# Return last $n lines (simple approach — interleaving is approximate)
$lines | Select-Object -Last $n
}
# ── Commands ──────────────────────────────────────────────────────────────
function Start-Server {
if (IsRunning) {
$pid_ = GetServerPid
WriteLog "Server already running (PID $pid_ http://localhost:$Port)" "Green"
return
}
if (-not (Test-Path $Python)) {
WriteLog "ERROR: Python venv not found at $Python" "Red"
WriteLog "Run: python -m venv .venv && .venv\Scripts\pip install -r requirements.txt" "Yellow"
exit 1
}
EnsureLogDir
# Stamp both log files so they exist and have a separator
$stamp = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [server.ps1] ---- starting ----"
Add-Content $LogOut $stamp
Add-Content $LogErr $stamp
# Start python with stdout -> LogOut, stderr -> LogErr (separate files required on Windows)
$proc = Start-Process `
-FilePath $Python `
-ArgumentList "-u `"$Entry`"" `
-WorkingDirectory $Root `
-RedirectStandardOutput $LogOut `
-RedirectStandardError $LogErr `
-WindowStyle Hidden `
-PassThru
$proc.Id | Set-Content $PidFile
# Wait up to 6 s for the port to open
$deadline = (Get-Date).AddSeconds(6)
$ready = $false
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 400
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
if ($conn) { $ready = $true; break }
}
if ($ready) {
WriteLog "Server started (PID $($proc.Id) http://localhost:$Port)" "Green"
} else {
WriteLog "Server process launched (PID $($proc.Id)) but port $Port not yet open." "Yellow"
WriteLog "Check logs: .\server.ps1 logs" "Yellow"
}
}
function Stop-Server {
$pid_ = GetServerPid
if (-not $pid_) {
WriteLog "Server is not running." "Yellow"
return
}
Stop-Process -Id $pid_ -Force -ErrorAction SilentlyContinue
Remove-Item $PidFile -ErrorAction SilentlyContinue
# Wait up to 4 s for the port to free
$deadline = (Get-Date).AddSeconds(4)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 300
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
if (-not $conn) { break }
}
WriteLog "Server stopped (was PID $pid_)" "Yellow"
}
function Restart-Server {
Stop-Server
Start-Sleep -Milliseconds 500
Start-Server
}
function Show-Status {
$pid_ = GetServerPid
if ($pid_) {
$proc = Get-Process -Id $pid_ -ErrorAction SilentlyContinue
$mem = if ($proc) { "{0:N0} MB" -f ($proc.WorkingSet64 / 1MB) } else { "?" }
WriteLog ""
WriteLog " Status : RUNNING" "Green"
WriteLog " PID : $pid_"
WriteLog " Memory : $mem"
WriteLog " URL : http://localhost:$Port"
WriteLog " Logs : $LogOut"
WriteLog " $LogErr"
} else {
WriteLog ""
WriteLog " Status : STOPPED" "Red"
}
WriteLog ""
WriteLog "--- Last 20 log lines (stdout) ---" "DarkGray"
if (Test-Path $LogOut) {
Get-Content $LogOut -Tail 20 | ForEach-Object { WriteLog $_ "DarkGray" }
} else {
WriteLog " (no log file yet)" "DarkGray"
}
if (Test-Path $LogErr) {
$errLines = Get-Content $LogErr -Tail 5 | Where-Object { $_ -match "ERROR|Exception|Traceback" }
if ($errLines) {
WriteLog ""
WriteLog "--- Recent errors (stderr) ---" "Red"
$errLines | ForEach-Object { WriteLog $_ "Red" }
}
}
WriteLog ""
}
function Tail-Logs {
EnsureLogDir
if ($Arg -eq "-f") {
if (-not (Test-Path $LogOut)) {
WriteLog "No log file yet. Start the server first." "Yellow"
return
}
WriteLog "Following $LogOut (Ctrl-C to stop)" "Cyan"
Get-Content $LogOut -Wait -Tail 30
} else {
$n = if ($Arg -match '^\d+$') { [int]$Arg } else { 40 }
WriteLog "--- stdout (last $n lines) ---" "Cyan"
if (Test-Path $LogOut) { Get-Content $LogOut -Tail $n } else { WriteLog " (empty)" "DarkGray" }
WriteLog ""
WriteLog "--- stderr (last 20 lines) ---" "Yellow"
if (Test-Path $LogErr) { Get-Content $LogErr -Tail 20 } else { WriteLog " (empty)" "DarkGray" }
}
}
# ── Dispatch ──────────────────────────────────────────────────────────────
switch ($Command) {
"start" { Start-Server }
"stop" { Stop-Server }
"restart" { Restart-Server }
"status" { Show-Status }
"logs" { Tail-Logs }
}

12
static/assets/favicon.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<!-- Dark background -->
<rect width="32" height="32" rx="6" fill="#0d1117"/>
<!-- Monitor icon (scaled from 24×24 to ~20×20, centered) -->
<g transform="translate(6, 6) scale(0.833)" stroke="#00d4ff" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" fill="none">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

Binary file not shown.

192
static/css/base.css Normal file
View File

@@ -0,0 +1,192 @@
/*
* static/css/base.css
* Design tokens, reset, and typographic foundation.
*
* Aesthetic: Industrial Ops Terminal
* Dark graphite workspace with electric cyan as the primary action colour
* and amber for warnings/consumed state. Syne for UI chrome, JetBrains Mono
* for instruction content. Strong structure, decisive whitespace.
*/
/* ── Fonts ─────────────────────────────────────────────────────────────── */
@font-face {
font-family: 'Syne';
src: url('/static/assets/fonts/syne.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/static/assets/fonts/jetbrains-mono.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
/* ── Design tokens ──────────────────────────────────────────────────────── */
:root {
/* Surfaces */
--bg-void: #080b0d;
--bg-base: #0d1117;
--bg-raised: #131920;
--bg-overlay: #1a2230;
--bg-hover: #1f2a3a;
--bg-active: #243040;
/* Borders */
--border-subtle: #1e2c3a;
--border-muted: #253345;
--border-strong: #2e4058;
/* Text */
--text-primary: #e8edf2;
--text-secondary:#8da0b3;
--text-muted: #4a6175;
--text-disabled: #2e4a5e;
/* Brand / accent */
--cyan: #00d4ff;
--cyan-dim: #007fa8;
--cyan-glow: rgba(0, 212, 255, 0.15);
/* Status */
--green: #00e676;
--green-dim: #00703a;
--green-glow: rgba(0, 230, 118, 0.15);
--amber: #ffbe0b;
--amber-dim: #8a6500;
--amber-glow: rgba(255, 190, 11, 0.12);
--red: #ff4f4f;
--red-dim: #8a0000;
--red-glow: rgba(255, 79, 79, 0.15);
/* Typography */
--font-ui: 'Syne', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
--text-xs: 0.6875rem; /* 11px */
--text-sm: 0.75rem; /* 12px */
--text-base: 0.875rem; /* 14px */
--text-md: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.375rem; /* 22px */
--text-2xl: 1.75rem; /* 28px */
--text-3xl: 2.25rem; /* 36px */
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
--shadow-md: 0 4px 16px rgba(0,0,0,0.5);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
--shadow-cyan: 0 0 20px var(--cyan-glow), 0 0 40px rgba(0,212,255,0.06);
/* Animation */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 120ms;
--duration-normal: 240ms;
--duration-slow: 400ms;
}
/* ── Light theme overrides ───────────────────────────────────────────────── */
/* Applied when <html data-theme="light"> is set by theme.js */
[data-theme="light"] {
--bg-void: #e8e8e3;
--bg-base: #f2f2ed;
--bg-raised: #ffffff;
--bg-overlay: #ebebe6;
--bg-hover: #e2e2dc;
--bg-active: #d8d8d2;
--border-subtle: #d0d0ca;
--border-muted: #c0c0b8;
--border-strong: #a8a8a0;
--text-primary: #1a1f2e;
--text-secondary:#4a5568;
--text-muted: #718096;
--text-disabled: #a0aec0;
/* Slightly desaturated accents so they work on a light surface */
--cyan: #007fa8;
--cyan-dim: #005c7a;
--cyan-glow: rgba(0, 127, 168, 0.14);
--green: #166534;
--green-dim: #14532d;
--green-glow: rgba(22, 101, 52, 0.12);
--amber: #92400e;
--amber-dim: #78350f;
--amber-glow: rgba(146, 64, 14, 0.1);
--red: #b91c1c;
--red-dim: #7f1d1d;
--red-glow: rgba(185, 28, 28, 0.1);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 16px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
--shadow-cyan: 0 0 20px rgba(0,127,168,0.1);
}
/* ── Reset ──────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-ui);
font-size: var(--text-base);
color: var(--text-primary);
background-color: var(--bg-base);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-base); }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* Focus ring */
:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Typography helpers ──────────────────────────────────────────────────── */
.mono { font-family: var(--font-mono); }
.label { font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.muted { color: var(--text-muted); }
.subtle { color: var(--text-secondary); }

482
static/css/components.css Normal file
View File

@@ -0,0 +1,482 @@
/*
* static/css/components.css
* Reusable UI components: buttons, inputs, badges, status indicators,
* instruction cards, and animations.
*/
/* ── Status indicator (LED dot) ─────────────────────────────────────────── */
.led {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.led__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.led--green .led__dot { background: var(--green); box-shadow: 0 0 6px var(--green), 0 0 12px var(--green-glow); }
.led--amber .led__dot { background: var(--amber); box-shadow: 0 0 6px var(--amber), 0 0 12px var(--amber-glow); }
.led--red .led__dot { background: var(--red); box-shadow: 0 0 6px var(--red), 0 0 12px var(--red-glow); }
.led--muted .led__dot { background: var(--text-muted); box-shadow: none; }
.led--cyan .led__dot { background: var(--cyan); box-shadow: 0 0 6px var(--cyan), 0 0 12px var(--cyan-glow); }
.led--green .led__label { color: var(--green); }
.led--amber .led__label { color: var(--amber); }
.led--red .led__label { color: var(--red); }
.led--muted .led__label { color: var(--text-muted); }
.led--cyan .led__label { color: var(--cyan); }
/* Pulse for "connected" / "active" */
.led--pulse .led__dot {
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
/* ── Queue count badge ───────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 var(--space-2);
border-radius: 10px;
font-size: var(--text-xs);
font-weight: 700;
line-height: 1;
}
.badge--cyan { background: var(--cyan-glow); color: var(--cyan); border: 1px solid rgba(0,212,255,0.3); }
.badge--amber { background: var(--amber-glow); color: var(--amber); border: 1px solid rgba(255,190,11,0.3); }
.badge--muted { background: var(--bg-overlay); color: var(--text-muted); border: 1px solid var(--border-subtle); }
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
text-decoration: none;
transition:
background var(--duration-fast) var(--ease-in-out),
border-color var(--duration-fast) var(--ease-in-out),
box-shadow var(--duration-fast) var(--ease-in-out),
transform var(--duration-fast) var(--ease-in-out);
user-select: none;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.btn:active:not(:disabled) {
transform: scale(0.97);
}
/* Primary cyan */
.btn--primary {
background: var(--cyan);
border-color: var(--cyan);
color: #000;
}
.btn--primary:hover {
background: #1de9ff;
box-shadow: 0 0 16px var(--cyan-glow);
}
/* Ghost */
.btn--ghost {
background: transparent;
border-color: var(--border-muted);
color: var(--text-secondary);
}
.btn--ghost:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
color: var(--text-primary);
}
/* Danger */
.btn--danger {
background: transparent;
border-color: var(--red-dim);
color: var(--red);
}
.btn--danger:hover {
background: var(--red-glow);
border-color: var(--red);
}
/* Icon-only */
.btn--icon {
padding: var(--space-2);
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
}
/* Small */
.btn--sm {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
height: 28px;
}
/* ── Form elements ──────────────────────────────────────────────────────── */
.input, .textarea {
width: 100%;
background: var(--bg-overlay);
border: 1px solid var(--border-muted);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-base);
padding: var(--space-3) var(--space-4);
transition:
border-color var(--duration-fast) var(--ease-in-out),
box-shadow var(--duration-fast) var(--ease-in-out);
outline: none;
resize: vertical;
}
.input::placeholder, .textarea::placeholder {
color: var(--text-muted);
}
.input:focus, .textarea:focus {
border-color: var(--cyan-dim);
box-shadow: 0 0 0 3px rgba(0,212,255,0.08);
}
.textarea {
min-height: 88px;
line-height: 1.6;
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.form-row {
display: flex;
gap: var(--space-3);
align-items: flex-end;
}
.form-row .textarea { flex: 1; }
/* ── Instruction card ─────────────────────────────────────────────────────── */
.instruction-card {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-subtle);
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-2) var(--space-4);
align-items: start;
transition: background var(--duration-fast) var(--ease-in-out);
animation: card-in var(--duration-slow) var(--ease-out-expo) both;
}
@keyframes card-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.instruction-card:last-child {
border-bottom: none;
}
.instruction-card:hover {
background: var(--bg-hover);
}
.instruction-card__meta {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.instruction-card__pos {
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
color: var(--cyan);
background: var(--cyan-glow);
border: 1px solid rgba(0,212,255,0.25);
border-radius: var(--radius-sm);
padding: 1px 6px;
letter-spacing: 0.08em;
}
.instruction-card__content {
font-family: var(--font-mono);
font-size: var(--text-base);
line-height: 1.65;
color: var(--text-primary);
word-break: break-word;
white-space: pre-wrap;
}
.instruction-card__time {
font-size: var(--text-xs);
color: var(--text-muted);
font-family: var(--font-mono);
}
.instruction-card__actions {
display: flex;
gap: var(--space-2);
align-items: center;
flex-shrink: 0;
}
/* Edit mode */
.instruction-card__edit-area {
grid-column: 1 / -1;
display: flex;
gap: var(--space-3);
align-items: flex-end;
margin-top: var(--space-2);
}
.instruction-card__edit-area .textarea {
flex: 1;
min-height: 72px;
}
/* Consumed card */
.instruction-card--consumed {
opacity: 0.45;
}
.instruction-card--consumed .instruction-card__content {
text-decoration: line-through;
text-decoration-color: var(--amber-dim);
color: var(--text-secondary);
}
.instruction-card--consumed .instruction-card__pos {
background: var(--amber-glow);
border-color: rgba(255,190,11,0.2);
color: var(--amber);
}
.instruction-card--consumed:hover {
opacity: 0.65;
}
.instruction-card__consumed-by {
font-size: var(--text-xs);
color: var(--amber);
font-family: var(--font-mono);
}
/* ── Empty state ─────────────────────────────────────────────────────────── */
.empty-state {
padding: var(--space-10) var(--space-6);
text-align: center;
color: var(--text-muted);
font-size: var(--text-sm);
letter-spacing: 0.04em;
}
.empty-state__icon {
font-size: 1.5rem;
margin-bottom: var(--space-3);
opacity: 0.4;
}
/* ── Status panel items ──────────────────────────────────────────────────── */
.stat-row {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border-subtle);
}
.stat-row:last-child { border-bottom: none; }
.stat-label {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.stat-value {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
}
.stat-value--cyan { color: var(--cyan); }
.stat-value--amber { color: var(--amber); }
/* ── Config form ─────────────────────────────────────────────────────────── */
.config-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-4);
}
.config-field:last-child { margin-bottom: 0; }
.config-label {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
}
.config-hint {
font-size: var(--text-xs);
color: var(--text-muted);
margin-top: var(--space-1);
}
.input--sm {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
/* ── Toast notification ──────────────────────────────────────────────────── */
#toast-container {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--space-2);
pointer-events: none;
}
.toast {
background: var(--bg-overlay);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
color: var(--text-primary);
box-shadow: var(--shadow-lg);
animation: toast-in var(--duration-normal) var(--ease-out-expo) both;
pointer-events: auto;
max-width: 320px;
}
.toast--success { border-color: var(--green-dim); color: var(--green); }
.toast--error { border-color: var(--red-dim); color: var(--red); }
.toast--info { border-color: var(--cyan-dim); color: var(--cyan); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(12px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(4px) scale(0.97); }
}
/* ── Theme toggle (header) ────────────────────────────────────────────────── */
#theme-toggle {
color: var(--text-secondary);
border-color: transparent;
transition: color var(--duration-fast) var(--ease-in-out),
background var(--duration-fast) var(--ease-in-out);
}
#theme-toggle:hover {
color: var(--text-primary);
background: var(--bg-hover);
border-color: var(--border-subtle);
}
/* ── SVG icons ───────────────────────────────────────────────────────────── */
.icon {
width: 16px;
height: 16px;
flex-shrink: 0;
stroke-width: 2;
stroke: currentColor;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon--sm { width: 12px; height: 12px; }
/* ── Page load animation ─────────────────────────────────────────────────── */
.fade-in {
animation: fade-in var(--duration-slow) var(--ease-out-expo) both;
}
.fade-in--delay-1 { animation-delay: 60ms; }
.fade-in--delay-2 { animation-delay: 120ms; }
.fade-in--delay-3 { animation-delay: 200ms; }
.fade-in--delay-4 { animation-delay: 300ms; }
@keyframes fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Loading spinner ─────────────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border-muted);
border-top-color: var(--cyan);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

131
static/css/layout.css Normal file
View File

@@ -0,0 +1,131 @@
/*
* static/css/layout.css
* Page-level layout: header, main grid, panels.
*/
/* ── Page structure ─────────────────────────────────────────────────────── */
.page-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--bg-void);
border-bottom: 1px solid var(--border-subtle);
padding: 0 var(--space-6);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
backdrop-filter: blur(12px);
}
.header-brand {
display: flex;
align-items: center;
gap: var(--space-3);
}
.header-brand-name {
font-size: var(--text-lg);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.header-brand-name span {
color: var(--cyan);
}
.header-indicators {
display: flex;
align-items: center;
gap: var(--space-5);
}
/* ── Main layout ────────────────────────────────────────────────────────── */
.page-main {
max-width: 1280px;
margin: 0 auto;
padding: var(--space-8) var(--space-6) var(--space-12);
display: grid;
grid-template-columns: 1fr 320px;
grid-template-rows: auto;
gap: var(--space-6);
grid-template-areas:
"composer sidebar"
"queue sidebar"
"history sidebar";
}
.area-composer { grid-area: composer; }
.area-queue { grid-area: queue; }
.area-history { grid-area: history; }
.area-sidebar { grid-area: sidebar; display: flex; flex-direction: column; gap: var(--space-4); }
/* ── Panel container ─────────────────────────────────────────────────────── */
.panel {
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
}
.panel-header {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.panel-title {
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.panel-body {
padding: var(--space-5);
}
.panel-body--flush {
padding: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.page-main {
grid-template-columns: 1fr;
grid-template-areas:
"sidebar"
"composer"
"queue"
"history";
}
.area-sidebar {
flex-direction: row;
flex-wrap: wrap;
gap: var(--space-4);
}
.area-sidebar > .panel {
flex: 1 1 260px;
}
}
@media (max-width: 560px) {
.page-header { padding: 0 var(--space-4); }
.page-main { padding: var(--space-5) var(--space-4) var(--space-10); gap: var(--space-4); }
}

186
static/index.html Normal file
View File

@@ -0,0 +1,186 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>local-mcp — Instruction Queue</title>
<meta name="description" content="Localhost MCP instruction queue management" />
<link rel="icon" type="image/svg+xml" href="/static/assets/favicon.svg" />
<link rel="shortcut icon" href="/static/assets/favicon.svg" />
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/layout.css" />
<link rel="stylesheet" href="/static/css/components.css" />
<!-- Apply theme before paint to avoid flash of wrong theme -->
<script>
(function(){
var t = localStorage.getItem('local-mcp-theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = t;
})();
</script>
</head>
<body>
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<header class="page-header">
<div class="header-brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span class="header-brand-name">local<span>-mcp</span></span>
</div>
<div class="header-indicators">
<div id="led-server" class="led led--muted">
<span class="led__dot"></span>
<span class="led__label">Connecting…</span>
</div>
<div id="led-agent" class="led led--muted">
<span class="led__dot"></span>
<span class="led__label">Agent Idle</span>
</div>
<button id="theme-toggle" class="btn btn--ghost btn--icon" title="Toggle theme" aria-label="Toggle theme">
<!-- icon injected by theme.js -->
<svg class="icon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
</header>
<!-- ── Main ────────────────────────────────────────────────────────────── -->
<main class="page-main">
<!-- Composer -->
<section class="area-composer fade-in">
<div class="panel">
<div class="panel-header">
<span class="panel-title">
<svg class="icon icon--sm" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Instruction
</span>
<span class="subtle" style="font-size:var(--text-xs)">Enter to send · Shift+Enter for newline</span>
</div>
<div class="panel-body">
<form id="composer-form">
<div class="form-row">
<textarea
id="composer-input"
class="textarea"
placeholder="Type an instruction… (Enter to send, Shift+Enter for newline)"
rows="3"
autocomplete="off"
spellcheck="false"
></textarea>
<button id="composer-submit" type="submit" class="btn btn--primary" style="height:fit-content;align-self:flex-end">
<svg class="icon" viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
Send
</button>
</div>
</form>
</div>
</div>
</section>
<!-- Pending queue -->
<section class="area-queue fade-in fade-in--delay-1">
<div class="panel">
<div class="panel-header">
<span class="panel-title">
<svg class="icon icon--sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Pending Queue
</span>
<span id="pending-badge" class="badge badge--muted">0</span>
</div>
<div class="panel-body panel-body--flush">
<div id="pending-list">
<div class="empty-state">
<div class="empty-state__icon"></div>
Loading…
</div>
</div>
</div>
</div>
</section>
<!-- Consumed history -->
<section class="area-history fade-in fade-in--delay-2">
<div class="panel">
<div class="panel-header">
<span class="panel-title">
<svg class="icon icon--sm" viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Consumed
</span>
<span id="consumed-badge" class="badge badge--muted">0</span>
</div>
<div class="panel-body panel-body--flush">
<div id="consumed-list">
<div class="empty-state">
<div class="empty-state__icon"></div>
Loading…
</div>
</div>
</div>
</div>
</section>
<!-- Sidebar -->
<aside class="area-sidebar">
<!-- Server / Agent Status -->
<div class="panel fade-in fade-in--delay-1">
<div class="panel-header">
<span class="panel-title">
<svg class="icon icon--sm" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
Status
</span>
</div>
<div class="panel-body" id="status-panel-body">
<div class="stat-row"><span class="stat-label">Loading…</span></div>
</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-wait">Min Wait (sec)</label>
<input id="cfg-wait" class="input input--sm" type="number" min="0" max="300" value="10" />
<span class="config-hint">Server enforces this as the minimum wait before returning an empty response. Agent may request longer but never shorter.</span>
</div>
<div class="config-field">
<label class="config-label" for="cfg-empty">Empty Response</label>
<input id="cfg-empty" class="input input--sm" type="text" placeholder="Leave blank for no response" />
<span class="config-hint">Default text returned when queue is empty</span>
</div>
<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>
</main>
<!-- Toast container -->
<div id="toast-container"></div>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>

43
static/js/api.js Normal file
View File

@@ -0,0 +1,43 @@
/**
* static/js/api.js
* Thin fetch wrappers for all HTTP API endpoints.
*/
const BASE = '';
async function request(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(BASE + path, opts);
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) {
const msg = data?.detail || `HTTP ${res.status}`;
throw new Error(msg);
}
return data;
}
export const api = {
// Health
health: () => request('GET', '/healthz'),
// Status
status: () => request('GET', '/api/status'),
// Instructions
listInstructions: (status='all') => request('GET', `/api/instructions?status=${status}`),
createInstruction: (content) => request('POST', '/api/instructions', { content }),
updateInstruction: (id, content) => request('PATCH', `/api/instructions/${id}`, { content }),
deleteInstruction: (id) => request('DELETE', `/api/instructions/${id}`),
// Config
getConfig: () => request('GET', '/api/config'),
updateConfig: (patch) => request('PATCH', '/api/config', patch),
};

108
static/js/app.js Normal file
View File

@@ -0,0 +1,108 @@
/**
* static/js/app.js
* Application bootstrap initialises all modules, kicks off data fetching,
* and exports shared utilities like `toast`.
*/
import { api } from './api.js';
import { state } from './state.js';
import { connectSSE } from './events.js';
import { initInstructions, initComposer, refreshTimestamps } from './instructions.js';
import { initStatus, initConfig, refreshStatusTimestamps } from './status.js';
import { initTheme } from './theme.js';
// ── Toast notification ────────────────────────────────────────────────────
const _toastContainer = document.getElementById('toast-container');
export function toast(message, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.textContent = message;
_toastContainer.appendChild(el);
setTimeout(() => {
el.style.animation = 'toast-out 240ms cubic-bezier(0.4,0,1,1) forwards';
el.addEventListener('animationend', () => el.remove());
}, 3000);
}
// ── Document title badge ──────────────────────────────────────────────────
function updateTitle(instructions) {
const pending = (instructions || []).filter(i => i.status === 'pending').length;
document.title = pending > 0 ? `(${pending}) local-mcp` : 'local-mcp';
}
// ── Reconnecting indicator ────────────────────────────────────────────────
function initReconnectingIndicator() {
const serverLed = document.getElementById('led-server');
state.subscribe('sseReconnecting', (reconnecting) => {
if (!serverLed) return;
if (reconnecting) {
serverLed.className = 'led led--amber led--pulse';
serverLed.querySelector('.led__label').textContent = 'Reconnecting…';
}
// The full status update (onopen) will re-set the correct class
});
}
// ── Startup data load ─────────────────────────────────────────────────────
async function loadInitialData() {
try {
const [instructions, status, config] = await Promise.all([
api.listInstructions('all'),
api.status(),
api.getConfig(),
]);
state.set('instructions', instructions.items);
state.set('status', status);
state.set('config', config);
state.set('serverOnline', true);
state.set('sseReconnecting', false);
} catch (err) {
console.error('Failed to load initial data:', err);
state.set('serverOnline', false);
}
}
// ── Periodic refresh (fallback for SSE gaps) ──────────────────────────────
function startPolling() {
setInterval(async () => {
try {
const s = await api.status();
state.set('status', s);
state.set('serverOnline', true);
} catch {
state.set('serverOnline', false);
}
}, 15_000);
// Refresh relative timestamps every 20 seconds
setInterval(() => { refreshTimestamps(); refreshStatusTimestamps(); }, 20_000);
}
// ── Subscribe to state changes ────────────────────────────────────────────
function initGlobalSubscriptions() {
state.subscribe('instructions', updateTitle);
}
// ── Init ──────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
initTheme(); // must run first sets button icon
initReconnectingIndicator();
initStatus();
initConfig();
initInstructions();
initComposer();
initGlobalSubscriptions();
await loadInitialData();
connectSSE();
startPolling();
});

119
static/js/events.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* static/js/events.js
* Server-Sent Events client connects to /api/events and dispatches
* updates into the central state store.
* Uses full item payloads embedded in events to avoid extra re-fetch round-trips.
*/
import { state } from './state.js';
import { api } from './api.js';
let _es = null;
let _reconnectTimer = null;
let _reconnecting = false;
const RECONNECT_DELAY_MS = 3000;
export function connectSSE() {
if (_es) return;
_connect();
}
function _connect() {
_es = new EventSource('/api/events');
_es.onopen = () => {
console.debug('[SSE] connected');
if (_reconnecting) {
_reconnecting = false;
state.set('sseReconnecting', false);
// Full refresh after reconnect to catch anything we missed
_fullRefresh();
}
state.set('serverOnline', true);
};
_es.onmessage = (e) => {
try {
const event = JSON.parse(e.data);
_handleEvent(event);
} catch (err) {
console.warn('[SSE] parse error', err);
}
};
_es.onerror = () => {
console.warn('[SSE] connection lost reconnecting in', RECONNECT_DELAY_MS, 'ms');
state.set('serverOnline', false);
_reconnecting = true;
state.set('sseReconnecting', true);
_es.close();
_es = null;
clearTimeout(_reconnectTimer);
_reconnectTimer = setTimeout(_connect, RECONNECT_DELAY_MS);
};
}
async function _fullRefresh() {
try {
const [instructions, status, config] = await Promise.all([
api.listInstructions('all'),
api.status(),
api.getConfig(),
]);
state.set('instructions', instructions.items);
state.set('status', status);
state.set('config', config);
} catch (e) {
console.error('[SSE] full refresh failed', e);
}
}
function _applyInstructionPatch(item) {
const current = state.get('instructions') || [];
const idx = current.findIndex(i => i.id === item.id);
if (idx === -1) {
// New item append and re-sort by position
const next = [...current, item].sort((a, b) => a.position - b.position);
state.set('instructions', next);
} else {
// Replace in-place, preserving order
const next = [...current];
next[idx] = item;
state.set('instructions', next.sort((a, b) => a.position - b.position));
}
}
function _handleEvent(event) {
switch (event.type) {
case 'instruction.created':
case 'instruction.updated':
case 'instruction.consumed': {
if (event.data?.item) {
_applyInstructionPatch(event.data.item);
} else {
// Fallback: full refresh
api.listInstructions('all').then(d => state.set('instructions', d.items)).catch(console.error);
}
break;
}
case 'instruction.deleted': {
const id = event.data?.id;
if (id) {
const next = (state.get('instructions') || []).filter(i => i.id !== id);
state.set('instructions', next);
}
break;
}
case 'status.changed': {
api.status().then(s => state.set('status', s)).catch(console.error);
break;
}
case 'config.updated': {
api.getConfig().then(c => state.set('config', c)).catch(console.error);
break;
}
default:
console.debug('[SSE] unknown event type', event.type);
}
}

258
static/js/instructions.js Normal file
View File

@@ -0,0 +1,258 @@
/**
* static/js/instructions.js
* Renders the pending and consumed instruction panels.
* Handles add, edit, delete interactions.
*/
import { state } from './state.js';
import { api } from './api.js';
import { toast } from './app.js';
// ── SVG icon helpers ──────────────────────────────────────────────────────
function iconEdit() {
return `<svg class="icon" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
}
function iconDelete() {
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
}
function iconCheck() {
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`;
}
function iconX() {
return `<svg class="icon" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
}
// ── Time formatters ───────────────────────────────────────────────────────
function fmtRelativeTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return d.toLocaleDateString();
}
function fmtAbsTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
export function refreshTimestamps() {
document.querySelectorAll('[data-ts]').forEach(el => {
el.textContent = fmtRelativeTime(el.dataset.ts);
});
}
// ── Card renderer ─────────────────────────────────────────────────────────
function renderPendingCard(item, index) {
const card = document.createElement('div');
card.className = 'instruction-card';
card.dataset.id = item.id;
card.style.animationDelay = `${index * 30}ms`;
card.innerHTML = `
<div class="instruction-card__meta">
<span class="instruction-card__pos">#${item.position}</span>
<span class="instruction-card__time" data-ts="${item.created_at}">${fmtRelativeTime(item.created_at)}</span>
</div>
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
<div class="instruction-card__actions">
<button class="btn btn--ghost btn--icon btn--edit" title="Edit instruction" aria-label="Edit">${iconEdit()}</button>
<button class="btn btn--danger btn--icon btn--delete" title="Delete instruction" aria-label="Delete">${iconDelete()}</button>
</div>
<div class="instruction-card__edit-area" style="display:none; grid-column:1/-1;">
<textarea class="textarea edit-textarea" rows="3">${escapeHtml(item.content)}</textarea>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<button class="btn btn--primary btn--sm btn--save" title="Save">${iconCheck()}</button>
<button class="btn btn--ghost btn--sm btn--cancel" title="Cancel">${iconX()}</button>
</div>
</div>
`;
const editBtn = card.querySelector('.btn--edit');
const deleteBtn = card.querySelector('.btn--delete');
const cancelBtn = card.querySelector('.btn--cancel');
const saveBtn = card.querySelector('.btn--save');
const editArea = card.querySelector('.instruction-card__edit-area');
const content = card.querySelector('.instruction-card__content');
const actions = card.querySelector('.instruction-card__actions');
const editTA = card.querySelector('.edit-textarea');
function showEdit() {
editTA.value = item.content;
editArea.style.display = 'flex';
content.style.display = 'none';
actions.style.display = 'none';
editTA.focus();
editTA.setSelectionRange(editTA.value.length, editTA.value.length);
}
function hideEdit() {
editArea.style.display = 'none';
content.style.display = '';
actions.style.display = '';
}
editBtn.addEventListener('click', showEdit);
cancelBtn.addEventListener('click', hideEdit);
editTA.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); hideEdit(); }
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); saveBtn.click(); }
});
saveBtn.addEventListener('click', async () => {
const newContent = editTA.value.trim();
if (!newContent) { toast('Content cannot be empty', 'error'); return; }
saveBtn.disabled = true;
try {
await api.updateInstruction(item.id, newContent);
toast('Instruction updated', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
saveBtn.disabled = false;
}
});
deleteBtn.addEventListener('click', async () => {
if (!confirm('Delete this instruction?')) return;
deleteBtn.disabled = true;
try {
await api.deleteInstruction(item.id);
toast('Instruction deleted', 'info');
} catch (e) {
toast(e.message, 'error');
deleteBtn.disabled = false;
}
});
return card;
}
function renderConsumedCard(item) {
const card = document.createElement('div');
card.className = 'instruction-card instruction-card--consumed';
card.dataset.id = item.id;
card.innerHTML = `
<div class="instruction-card__meta">
<span class="instruction-card__pos">#${item.position}</span>
<span class="instruction-card__time">${fmtAbsTime(item.consumed_at)}</span>
${item.consumed_by_agent_id
? `<span class="instruction-card__consumed-by">→ ${escapeHtml(item.consumed_by_agent_id)}</span>`
: ''}
</div>
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
<div></div>
`;
return card;
}
// ── List renderers ────────────────────────────────────────────────────────
export function initInstructions() {
const pendingList = document.getElementById('pending-list');
const pendingBadge = document.getElementById('pending-badge');
const consumedList = document.getElementById('consumed-list');
const consumedBadge = document.getElementById('consumed-badge');
function render(instructions) {
if (!instructions) return;
const pending = instructions.filter(i => i.status === 'pending');
const consumed = instructions.filter(i => i.status === 'consumed').reverse();
// Pending
pendingList.innerHTML = '';
if (pending.length === 0) {
pendingList.innerHTML = `
<div class="empty-state">
<div class="empty-state__icon">◈</div>
Queue is empty add an instruction above
</div>`;
} else {
pending.forEach((item, i) => pendingList.appendChild(renderPendingCard(item, i)));
}
pendingBadge.textContent = pending.length;
pendingBadge.className = `badge ${pending.length > 0 ? 'badge--cyan' : 'badge--muted'}`;
// Consumed
consumedList.innerHTML = '';
if (consumed.length === 0) {
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
} else {
consumed.forEach(item => consumedList.appendChild(renderConsumedCard(item)));
}
consumedBadge.textContent = consumed.length;
consumedBadge.className = `badge ${consumed.length > 0 ? 'badge--amber' : 'badge--muted'}`;
}
state.subscribe('instructions', render);
}
// ── Composer ──────────────────────────────────────────────────────────────
export function initComposer() {
const form = document.getElementById('composer-form');
const textarea = document.getElementById('composer-input');
const btn = document.getElementById('composer-submit');
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
// Plain Enter → submit (like a chat box)
e.preventDefault();
form.requestSubmit();
}
// Shift+Enter → default browser behaviour (newline)
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const content = textarea.value.trim();
if (!content) return;
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner"></span>';
try {
await api.createInstruction(content);
textarea.value = '';
textarea.style.height = '';
toast('Instruction queued', 'success');
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
textarea.focus();
}
});
// Auto-resize textarea
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
});
}
// ── Utility ───────────────────────────────────────────────────────────────
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

34
static/js/state.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* static/js/state.js
* Centralised reactive state store.
* Components subscribe to state slices and are notified on change.
*/
const _state = {
instructions: [], // InstructionItem[]
status: null, // StatusResponse | null
config: null, // ConfigResponse | null
serverOnline: false,
};
const _listeners = {}; // key -> Set<fn>
export const state = {
get(key) {
return _state[key];
},
set(key, value) {
_state[key] = value;
(_listeners[key] || new Set()).forEach(fn => fn(value));
},
subscribe(key, fn) {
if (!_listeners[key]) _listeners[key] = new Set();
_listeners[key].add(fn);
// Immediately call with current value
fn(_state[key]);
return () => _listeners[key].delete(fn); // unsubscribe
},
};

157
static/js/status.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* static/js/status.js
* Renders the server status and agent activity panels,
* and the config settings panel.
*/
import { state } from './state.js';
import { api } from './api.js';
import { toast } from './app.js';
// ── Time helpers ──────────────────────────────────────────────────────────
function fmtTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function fmtRelative(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
// ── Server online indicator (header) ─────────────────────────────────────
function updateHeaderLeds(serverOnline, status) {
const serverLed = document.getElementById('led-server');
const agentLed = document.getElementById('led-agent');
if (!serverLed || !agentLed) return;
// Don't overwrite reconnecting state events.js sets that
if (serverOnline && !state.get('sseReconnecting')) {
serverLed.className = 'led led--green led--pulse';
serverLed.querySelector('.led__label').textContent = 'Server Online';
} else if (!serverOnline) {
serverLed.className = 'led led--red';
serverLed.querySelector('.led__label').textContent = 'Server Offline';
}
if (status?.agent?.connected) {
agentLed.className = 'led led--cyan led--pulse';
agentLed.querySelector('.led__label').textContent = 'Agent Connected';
} else {
agentLed.className = 'led led--muted';
agentLed.querySelector('.led__label').textContent = 'Agent Idle';
}
}
// ── Status sidebar panel ──────────────────────────────────────────────────
function renderStatusPanel(status) {
const el = document.getElementById('status-panel-body');
if (!el || !status) return;
const agent = status.agent;
const queue = status.queue;
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">Pending</span>
<span class="stat-value stat-value--cyan">${queue.pending_count}</span>
</div>
<div class="stat-row">
<span class="stat-label">Consumed</span>
<span class="stat-value stat-value--amber">${queue.consumed_count}</span>
</div>
<div class="stat-row">
<span class="stat-label">Agent</span>
<span class="stat-value">${agent.agent_id ? escapeHtml(agent.agent_id) : ''}</span>
</div>
<div class="stat-row">
<span class="stat-label">Last Seen</span>
<span class="stat-value" data-ts-rel="${agent.last_seen_at || ''}">${fmtRelative(agent.last_seen_at)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Last Fetch</span>
<span class="stat-value" data-ts-rel="${agent.last_fetch_at || ''}">${fmtRelative(agent.last_fetch_at)}</span>
</div>
`;
}
/** Called by app.js on a timer to keep relative times fresh. */
export function refreshStatusTimestamps() {
document.querySelectorAll('[data-ts-rel]').forEach(el => {
const iso = el.dataset.tsRel;
if (iso) el.textContent = fmtRelative(iso);
});
}
export function initStatus() {
state.subscribe('serverOnline', (online) => {
updateHeaderLeds(online, state.get('status'));
});
state.subscribe('status', (status) => {
updateHeaderLeds(state.get('serverOnline'), status);
renderStatusPanel(status);
});
}
// ── Config panel ──────────────────────────────────────────────────────────
export function initConfig() {
const form = document.getElementById('config-form');
const waitInput = document.getElementById('cfg-wait');
const emptyInput = document.getElementById('cfg-empty');
const staleInput = document.getElementById('cfg-stale');
const saveBtn = document.getElementById('cfg-save');
// Populate from state
state.subscribe('config', (cfg) => {
if (!cfg) return;
waitInput.value = cfg.default_wait_seconds;
emptyInput.value = cfg.default_empty_response;
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({
default_wait_seconds: parseInt(waitInput.value, 10) || 10,
default_empty_response: emptyInput.value,
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;
}
});
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

67
static/js/theme.js Normal file
View File

@@ -0,0 +1,67 @@
/**
* static/js/theme.js
* Dark / light theme toggle.
* - Defaults to the OS/browser colour-scheme preference.
* - User override is persisted in localStorage.
* - Applies via <html data-theme="dark|light">.
*/
const STORAGE_KEY = 'local-mcp-theme';
function systemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function storedTheme() {
return localStorage.getItem(STORAGE_KEY); // 'dark' | 'light' | null
}
function iconSun() {
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>`;
}
function iconMoon() {
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>`;
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
const btn = document.getElementById('theme-toggle');
if (!btn) return;
const isDark = theme === 'dark';
btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
btn.setAttribute('title', isDark ? 'Switch to light mode' : 'Switch to dark mode');
btn.innerHTML = isDark ? iconMoon() : iconSun();
}
function toggle() {
const current = document.documentElement.dataset.theme || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem(STORAGE_KEY, next);
applyTheme(next);
}
export function initTheme() {
// Apply immediately (before paint) based on stored or system preference
const theme = storedTheme() || systemTheme();
applyTheme(theme);
// Follow system changes only when the user hasn't manually overridden
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!storedTheme()) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
document.getElementById('theme-toggle')?.addEventListener('click', toggle);
}

115
tests/test_wakeup.py Normal file
View File

@@ -0,0 +1,115 @@
"""
tests/test_wakeup.py
Timing and correctness tests for the get_user_request wait loop.
Test 1 Immediate wakeup:
Verifies the asyncio.Event fires within ~10 ms of a new instruction being
enqueued, even when min_wait_seconds has not elapsed yet.
Test 2 Generation safety (concurrent calls):
Simulates two overlapping calls for the same agent_id. The OLDER call must
NOT consume the instruction; only the NEWER (active) call should receive it.
"""
import asyncio
import sys
import threading
import time
sys.path.insert(0, ".")
from app.database import init_db
from app.services import instruction_service
from app.services.config_service import update_config, get_config
WAKEUP_DELAY = 1.5
MIN_WAIT = 8
PASS_THRESH = 4.0
def run():
init_db("data/local_mcp.sqlite3")
update_config(default_wait_seconds=MIN_WAIT)
cfg = get_config()
print(f"min_wait_seconds = {cfg.default_wait_seconds} (wakeup in {WAKEUP_DELAY}s)")
print()
# ── Test 1: Immediate wakeup ───────────────────────────────────────────
async def _test1():
await instruction_service.init_wakeup()
t0 = time.monotonic()
def _add():
time.sleep(WAKEUP_DELAY)
item = instruction_service.create_instruction("Wakeup-timing-test")
print(f"[T1 thread] instruction added t={time.monotonic()-t0:.2f}s")
threading.Thread(target=_add, daemon=True).start()
from app.mcp_server import get_user_request
result = await get_user_request(agent_id="timing-test", wait_seconds=0)
elapsed = time.monotonic() - t0
print(f"[T1] Tool returned t={elapsed:.2f}s result_type={result['result_type']}")
if elapsed < PASS_THRESH:
print(f"[T1] PASS woke up at {elapsed:.2f}s (min_wait={MIN_WAIT}s)")
else:
print(f"[T1] FAIL took {elapsed:.2f}s — wakeup did not fire in time")
sys.exit(1)
asyncio.run(_test1())
print()
# ── Test 2: Generation safety ──────────────────────────────────────────
# Call 1 (old) starts waiting. Before any instruction arrives, Call 2
# (new) also starts. Then an instruction is added. Only Call 2 should
# receive it; Call 1 should step aside and return empty.
async def _test2():
await instruction_service.init_wakeup()
t0 = time.monotonic()
from app.mcp_server import get_user_request, _agent_generations
results = {}
async def _call1():
r = await get_user_request(agent_id="gen-test", wait_seconds=0)
results["call1"] = r
async def _call2():
# Slight delay so Call 1 starts first and registers gen=1
await asyncio.sleep(0.2)
r = await get_user_request(agent_id="gen-test", wait_seconds=0)
results["call2"] = r
def _add():
time.sleep(1.5)
instruction_service.create_instruction("Generation-safety-test")
print(f"[T2 thread] instruction added t={time.monotonic()-t0:.2f}s")
threading.Thread(target=_add, daemon=True).start()
await asyncio.gather(_call1(), _call2())
r1 = results.get("call1", {})
r2 = results.get("call2", {})
print(f"[T2] call1 result_type={r1.get('result_type')} waited={r1.get('waited_seconds')}s")
print(f"[T2] call2 result_type={r2.get('result_type')} waited={r2.get('waited_seconds')}s")
if r2.get("result_type") == "instruction" and r1.get("result_type") != "instruction":
print("[T2] PASS only the newest call received the instruction")
else:
print("[T2] FAIL unexpected result distribution")
sys.exit(1)
asyncio.run(_test2())
# Reset config
update_config(default_wait_seconds=10)
print("\nAll tests passed.")
if __name__ == "__main__":
run()