feat: add Go server implementation in go-server/
Full Go port of local-mcp with all core features. Copied from local-mcp-go worktree to consolidate into single-branch repo (easier maintenance). Architecture: - internal/config: Environment variable configuration - internal/models: Shared types (Instruction, Settings, AgentActivity, etc.) - internal/db: SQLite init with modernc.org/sqlite (pure Go, no CGo) - internal/store: Database operations + WakeupSignal + AgentTracker - internal/events: SSE broker for browser /api/events endpoint - internal/mcp: get_user_request MCP tool with 5s keepalive progress bars - internal/api: chi HTTP router with Bearer auth middleware - main.go: Entry point with auto port switching and Windows interactive banner Dependencies: - github.com/mark3labs/mcp-go@v0.46.0 - github.com/go-chi/chi/v5@v5.2.5 - modernc.org/sqlite@v1.47.0 (pure Go SQLite) - github.com/google/uuid@v1.6.0 Static assets embedded via //go:embed static Features matching Python: - Same wait strategy: 50s with 5s progress keepalives - Same hardcoded constants (DEFAULT_WAIT_SECONDS, DEFAULT_EMPTY_RESPONSE) - Auto port switching (tries 8000-8009) - Windows interactive mode (formatted banner on double-click launch) Build: cd go-server && go build -o local-mcp.exe . Run: ./local-mcp.exe Binary size: ~18 MB (vs Python ~60+ MB memory footprint) Startup: ~10 ms (vs Python ~1-2s)
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,6 +8,10 @@ venv/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
go-server/local-mcp
|
||||||
|
go-server/local-mcp.exe
|
||||||
|
|
||||||
# Data
|
# Data
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
|||||||
32
go-server/.gitignore.go
Normal file
32
go-server/.gitignore.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
local-mcp
|
||||||
|
local-mcp.exe
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
server.log
|
||||||
|
server_err.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
608
go-server/README-GO.md
Normal file
608
go-server/README-GO.md
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
# local-mcp
|
||||||
|
|
||||||
|
`local-mcp` is a localhost-first MCP server whose primary responsibility is to deliver the latest user instruction to an agent through the `get_user_request` tool, while also providing a responsive web UI for managing the instruction queue and monitoring server/agent activity.
|
||||||
|
|
||||||
|
This document is the implementation plan for the project.
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
- Provide a single MCP tool, `get_user_request`, that returns at most one instruction per call.
|
||||||
|
- Give the user a polished local web UI to add, edit, remove, review, and monitor instructions.
|
||||||
|
- Preserve queue integrity so consumed instructions are clearly visible but no longer editable/deletable.
|
||||||
|
- Support configurable waiting/default-response behavior when no instruction is available.
|
||||||
|
- Show live server status and inferred agent connectivity in the UI.
|
||||||
|
- Keep the stack lightweight, maintainable, debuggable, and friendly to local development.
|
||||||
|
|
||||||
|
## 2. Recommended Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **Language/runtime:** Python 3.11+
|
||||||
|
- **MCP integration:** official Python MCP SDK
|
||||||
|
- **HTTP server/API layer:** FastAPI
|
||||||
|
- **ASGI server:** Uvicorn
|
||||||
|
- **Persistence:** SQLite via Python standard library `sqlite3`
|
||||||
|
- **Concurrency/state coordination:** `asyncio` + standard library synchronization primitives where needed
|
||||||
|
- **Logging/error handling:** Python `logging`, structured request logs, centralized exception handling
|
||||||
|
- **Configuration:** environment variables + small local config file (`.json` or `.toml`)
|
||||||
|
|
||||||
|
### Why this backend stack
|
||||||
|
|
||||||
|
- The MCP SDK is the correct dependency for exposing the MCP tool cleanly.
|
||||||
|
- FastAPI + Uvicorn is a small, pragmatic backend stack that simplifies routing, validation, health endpoints, and server-sent updates without introducing a heavy framework.
|
||||||
|
- SQLite keeps the system local-first, dependency-light, and durable enough for instruction history and settings.
|
||||||
|
- Most supporting concerns remain in the Python standard library, which keeps third-party dependencies minimal.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **UI technology:** plain HTML, CSS, and JavaScript only
|
||||||
|
- **Realtime updates:** Server-Sent Events (preferred) with polling fallback if necessary
|
||||||
|
- **Styling:** local CSS files with design tokens and component-specific stylesheets
|
||||||
|
- **Client architecture:** modular vanilla JS organized by feature (`api.js`, `state.js`, `events.js`, `instructions.js`, etc.)
|
||||||
|
- **Assets:** all fonts/icons/scripts/styles stored locally in the repository; no CDN usage
|
||||||
|
|
||||||
|
### Mandatory frontend implementation instruction
|
||||||
|
|
||||||
|
Any future frontend implementation work **must first read and follow**:
|
||||||
|
|
||||||
|
- `.github/instructions/frontend-design.instructions.md`
|
||||||
|
|
||||||
|
This instruction file is mandatory for the UI because it requires a distinctive, production-grade, non-generic frontend. The implementation should not default to generic dashboard aesthetics.
|
||||||
|
|
||||||
|
## 3. Product/Architecture Plan
|
||||||
|
|
||||||
|
### Core backend responsibilities
|
||||||
|
|
||||||
|
1. Expose the MCP tool `get_user_request`.
|
||||||
|
2. Maintain an instruction queue with durable storage.
|
||||||
|
3. Mark instructions as consumed atomically when delivered to an agent.
|
||||||
|
4. Expose local HTTP endpoints for the web UI.
|
||||||
|
5. Stream status/instruction updates to the browser in real time.
|
||||||
|
6. Infer agent connectivity from recent MCP tool activity.
|
||||||
|
7. Persist and serve server configuration such as wait timeout and default empty response.
|
||||||
|
|
||||||
|
### Core frontend responsibilities
|
||||||
|
|
||||||
|
1. Show queued and consumed instructions in separate, clearly labeled sections.
|
||||||
|
2. Allow add/edit/delete only for instructions that are still pending.
|
||||||
|
3. Cross out and grey out consumed instructions.
|
||||||
|
4. Show server status, inferred agent status, last fetch time, and configuration values.
|
||||||
|
5. Update live as instruction state changes.
|
||||||
|
6. Remain usable and visually polished on desktop and smaller screens.
|
||||||
|
|
||||||
|
### Suggested repository layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
local-mcp/
|
||||||
|
├─ main.py
|
||||||
|
├─ README.md
|
||||||
|
├─ requirements.txt
|
||||||
|
├─ app/
|
||||||
|
│ ├─ __init__.py
|
||||||
|
│ ├─ config.py
|
||||||
|
│ ├─ database.py
|
||||||
|
│ ├─ logging_setup.py
|
||||||
|
│ ├─ models.py
|
||||||
|
│ ├─ services/
|
||||||
|
│ │ ├─ instruction_service.py
|
||||||
|
│ │ ├─ status_service.py
|
||||||
|
│ │ └─ event_service.py
|
||||||
|
│ ├─ api/
|
||||||
|
│ │ ├─ routes_instructions.py
|
||||||
|
│ │ ├─ routes_status.py
|
||||||
|
│ │ └─ routes_config.py
|
||||||
|
│ └─ mcp_server.py
|
||||||
|
├─ static/
|
||||||
|
│ ├─ index.html
|
||||||
|
│ ├─ css/
|
||||||
|
│ │ ├─ base.css
|
||||||
|
│ │ ├─ layout.css
|
||||||
|
│ │ └─ components.css
|
||||||
|
│ ├─ js/
|
||||||
|
│ │ ├─ api.js
|
||||||
|
│ │ ├─ app.js
|
||||||
|
│ │ ├─ events.js
|
||||||
|
│ │ ├─ instructions.js
|
||||||
|
│ │ └─ status.js
|
||||||
|
│ └─ assets/
|
||||||
|
└─ data/
|
||||||
|
└─ local_mcp.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Data Model Plan
|
||||||
|
|
||||||
|
### `instructions`
|
||||||
|
|
||||||
|
- `id` - string/UUID primary key
|
||||||
|
- `content` - text, required
|
||||||
|
- `status` - enum: `pending`, `consumed`
|
||||||
|
- `created_at` - datetime
|
||||||
|
- `updated_at` - datetime
|
||||||
|
- `consumed_at` - nullable datetime
|
||||||
|
- `consumed_by_agent_id` - nullable string
|
||||||
|
- `position` - integer for stable queue order
|
||||||
|
|
||||||
|
### `settings`
|
||||||
|
|
||||||
|
- `default_wait_seconds` - integer — seconds the tool waits before returning an empty/default response; set exclusively by the user via the web UI
|
||||||
|
- `default_empty_response` - text, nullable
|
||||||
|
- `agent_stale_after_seconds` - integer
|
||||||
|
|
||||||
|
### `agent_activity`
|
||||||
|
|
||||||
|
- `agent_id` - string primary key
|
||||||
|
- `last_seen_at` - datetime
|
||||||
|
- `last_fetch_at` - datetime
|
||||||
|
- `last_result_type` - enum: `instruction`, `empty`, `default_response`
|
||||||
|
|
||||||
|
## 5. Detailed API Design
|
||||||
|
|
||||||
|
All routes are local-only and intended for `localhost` usage.
|
||||||
|
|
||||||
|
### 5.1 MCP tool contract
|
||||||
|
|
||||||
|
#### Tool: `get_user_request`
|
||||||
|
|
||||||
|
**Purpose**
|
||||||
|
|
||||||
|
- Return the next pending instruction, if one exists.
|
||||||
|
- If none exists, wait for a configurable duration, then return 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",
|
||||||
|
"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 fully server-controlled (set by the user via the web UI). Agents cannot override it.
|
||||||
|
- Clamp `actual_wait` to an absolute server maximum (86400 s).
|
||||||
|
- Update the agent activity record on every call, even when no instruction is returned.
|
||||||
|
- The UI should infer "agent connected" if the latest activity is within `agent_stale_after_seconds`.
|
||||||
|
- Agent implementations should continue calling this tool instead of ending their work session on their own, so they can pick up newly added instructions without missing critical follow-up requests.
|
||||||
|
|
||||||
|
### 5.2 HTTP API for the web UI
|
||||||
|
|
||||||
|
#### `GET /healthz`
|
||||||
|
|
||||||
|
Returns service health.
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"server_time": "ISO-8601 timestamp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/status`
|
||||||
|
|
||||||
|
Returns current server and agent summary.
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"status": "up",
|
||||||
|
"started_at": "ISO-8601 timestamp"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"connected": true,
|
||||||
|
"last_seen_at": "ISO-8601 timestamp",
|
||||||
|
"last_fetch_at": "ISO-8601 timestamp",
|
||||||
|
"agent_id": "copilot-agent"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"pending_count": 2,
|
||||||
|
"consumed_count": 8
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"default_wait_seconds": 10,
|
||||||
|
"default_empty_response": "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 fully server-controlled (agents can no longer override wait time).
|
||||||
|
- [x] Non-blocking `server.ps1` management script (start / stop / restart / status / logs).
|
||||||
|
- [x] Non-blocking `server.sh` bash management script — identical feature set for macOS / Linux.
|
||||||
|
- [x] MCP stateless/stateful mode configurable via `MCP_STATELESS` env var (default `true`).
|
||||||
|
- [x] Per-agent generation counter prevents abandoned (timed-out) coroutines from silently consuming instructions meant for newer calls.
|
||||||
|
- [x] `tests/test_wakeup.py` covers both immediate-wakeup timing and concurrent-call generation safety.
|
||||||
|
- [x] Optional Bearer-token authentication via `API_TOKEN` env var (disabled by default); web UI prompts for token on first load.
|
||||||
|
|
||||||
|
- [ ] **Documentation and developer experience**
|
||||||
|
- [x] Document local run instructions.
|
||||||
|
- [x] Document the MCP tool contract clearly.
|
||||||
|
- [x] Document the HTTP API with request/response examples.
|
||||||
|
- [x] Document how agent connectivity is inferred.
|
||||||
|
- [x] Document how the frontend design instruction must be used during UI implementation.
|
||||||
|
|
||||||
|
## 9. Running the Server
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- pip
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the included management scripts (recommended — non-blocking):
|
||||||
|
|
||||||
|
**PowerShell (Windows)**
|
||||||
|
```powershell
|
||||||
|
.\server.ps1 start # start in background, logs to logs/
|
||||||
|
.\server.ps1 stop # graceful stop
|
||||||
|
.\server.ps1 restart # stop + start
|
||||||
|
.\server.ps1 status # PID, memory, tail logs
|
||||||
|
.\server.ps1 logs # show last 40 stdout lines
|
||||||
|
.\server.ps1 logs -f # follow logs live
|
||||||
|
.\server.ps1 logs 100 # show last 100 lines
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bash (macOS / Linux)**
|
||||||
|
```bash
|
||||||
|
chmod +x server.sh # make executable once
|
||||||
|
./server.sh start # start in background, logs to logs/
|
||||||
|
./server.sh stop # graceful stop
|
||||||
|
./server.sh restart # stop + start
|
||||||
|
./server.sh status # PID, memory, tail logs
|
||||||
|
./server.sh logs # show last 40 stdout lines
|
||||||
|
./server.sh logs -f # follow logs live
|
||||||
|
./server.sh logs 100 # show last 100 lines
|
||||||
|
```
|
||||||
|
|
||||||
|
The server starts on `http://localhost:8000` by default.
|
||||||
|
|
||||||
|
| URL | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `http://localhost:8000/` | Web UI |
|
||||||
|
| `http://localhost:8000/mcp` | MCP streamable-HTTP endpoint |
|
||||||
|
| `http://localhost:8000/docs` | FastAPI interactive API docs |
|
||||||
|
|
||||||
|
### Environment variable overrides
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `HOST` | `0.0.0.0` | Bind address |
|
||||||
|
| `HTTP_PORT` | `8000` | HTTP port |
|
||||||
|
| `DB_PATH` | `data/local_mcp.sqlite3` | SQLite database path |
|
||||||
|
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||||
|
| `DEFAULT_WAIT_SECONDS` | `10` | Default tool wait timeout |
|
||||||
|
| `DEFAULT_EMPTY_RESPONSE` | _(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 |
|
||||||
|
| `API_TOKEN` | _(empty)_ | When set, all `/api/*` and `/mcp` requests require `Authorization: Bearer <token>`; web UI prompts for the token on first load |
|
||||||
|
|
||||||
|
### Configuring an MCP client (agent)
|
||||||
|
|
||||||
|
Point the agent's MCP client to the streamable-HTTP transport:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"local-mcp": {
|
||||||
|
"url": "http://localhost:8000/mcp",
|
||||||
|
"transport": "streamable-http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `API_TOKEN` is set, include the token as a request header:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"local-mcp": {
|
||||||
|
"url": "http://localhost:8000/mcp",
|
||||||
|
"transport": "streamable-http",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer <your-token>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent should call `get_user_request` aggressively and continuously — **do not end the working session**. Every call returns the next pending instruction (if any). When the queue is empty the tool waits up to `wait_seconds` before returning an empty/default response, so the agent should loop and call again.
|
||||||
|
|
||||||
|
## 10. Implementation Notes for Future Work
|
||||||
|
|
||||||
|
- Prefer small, explicit modules over monolithic files.
|
||||||
|
- Keep the project localhost-first and avoid remote asset dependencies.
|
||||||
|
- Treat the MCP tool and the web UI as two views over the same instruction queue.
|
||||||
|
- Optimize for correctness of queue semantics first, then refine the visual and realtime experience.
|
||||||
24
go-server/go.mod
Normal file
24
go-server/go.mod
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module github.com/local-mcp/local-mcp-go
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/mark3labs/mcp-go v0.46.0
|
||||||
|
modernc.org/sqlite v1.47.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
modernc.org/libc v1.70.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
79
go-server/go.sum
Normal file
79
go-server/go.sum
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo=
|
||||||
|
github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||||
|
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
26
go-server/internal/api/auth.go
Normal file
26
go-server/internal/api/auth.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bearerAuthMiddleware enforces Bearer token authentication for protected routes.
|
||||||
|
func bearerAuthMiddleware(requiredToken string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Missing or invalid Authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
if token != requiredToken {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
61
go-server/internal/api/config.go
Normal file
61
go-server/internal/api/config.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleGetConfig(stores Stores) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := stores.Settings.Get()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateConfig(stores Stores, broker *events.Broker) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Decode partial patch
|
||||||
|
var patch struct {
|
||||||
|
DefaultWaitSeconds *int `json:"default_wait_seconds"`
|
||||||
|
DefaultEmptyResponse *string `json:"default_empty_response"`
|
||||||
|
AgentStaleAfterSeconds *int `json:"agent_stale_after_seconds"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current settings
|
||||||
|
current, err := stores.Settings.Get()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply patches
|
||||||
|
if patch.DefaultWaitSeconds != nil {
|
||||||
|
current.DefaultWaitSeconds = *patch.DefaultWaitSeconds
|
||||||
|
}
|
||||||
|
if patch.DefaultEmptyResponse != nil {
|
||||||
|
current.DefaultEmptyResponse = *patch.DefaultEmptyResponse
|
||||||
|
}
|
||||||
|
if patch.AgentStaleAfterSeconds != nil {
|
||||||
|
current.AgentStaleAfterSeconds = *patch.AgentStaleAfterSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stores.Settings.Update(current); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
broker.Broadcast("config.updated", map[string]any{"config": current})
|
||||||
|
writeJSON(w, http.StatusOK, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
47
go-server/internal/api/events.go
Normal file
47
go-server/internal/api/events.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleSSE streams server-sent events to browser clients.
|
||||||
|
func handleSSE(broker *events.Broker) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Set SSE headers
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Streaming not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to event broker
|
||||||
|
ch := broker.Subscribe()
|
||||||
|
defer broker.Unsubscribe(ch)
|
||||||
|
|
||||||
|
// Send initial connection event
|
||||||
|
w.Write([]byte("data: {\"type\":\"connected\"}\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
// Stream events until client disconnects
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return // broker closed
|
||||||
|
}
|
||||||
|
w.Write(msg)
|
||||||
|
flusher.Flush()
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return // client disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
119
go-server/internal/api/instructions.go
Normal file
119
go-server/internal/api/instructions.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/events"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleListInstructions(stores Stores) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
items, err := stores.Instructions.List(status)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateInstruction(stores Stores, broker *events.Broker) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Content == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "content is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := stores.Instructions.Create(body.Content)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, _ := stores.Instructions.Counts()
|
||||||
|
broker.Broadcast("instruction.created", map[string]any{"item": item})
|
||||||
|
broker.Broadcast("status.changed", map[string]any{"queue": counts})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateInstruction(stores Stores, broker *events.Broker) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := stores.Instructions.Update(id, body.Content)
|
||||||
|
if err == store.ErrNotFound {
|
||||||
|
writeError(w, http.StatusNotFound, "Instruction not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == store.ErrAlreadyConsumed {
|
||||||
|
writeError(w, http.StatusConflict, "Cannot edit consumed instruction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
broker.Broadcast("instruction.updated", map[string]any{"item": item})
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteInstruction(stores Stores, broker *events.Broker) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := stores.Instructions.Delete(id); err == store.ErrNotFound {
|
||||||
|
writeError(w, http.StatusNotFound, "Instruction not found")
|
||||||
|
return
|
||||||
|
} else if err == store.ErrAlreadyConsumed {
|
||||||
|
writeError(w, http.StatusConflict, "Cannot delete consumed instruction")
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, _ := stores.Instructions.Counts()
|
||||||
|
broker.Broadcast("instruction.deleted", map[string]any{"id": id})
|
||||||
|
broker.Broadcast("status.changed", map[string]any{"queue": counts})
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleClearConsumed(stores Stores, broker *events.Broker) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := stores.Instructions.DeleteConsumed(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counts, _ := stores.Instructions.Counts()
|
||||||
|
broker.Broadcast("history.cleared", nil)
|
||||||
|
broker.Broadcast("status.changed", map[string]any{"queue": counts})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
85
go-server/internal/api/router.go
Normal file
85
go-server/internal/api/router.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Package api implements the REST HTTP endpoints served alongside the MCP server.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/events"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stores groups all database stores that the API handlers need.
|
||||||
|
type Stores struct {
|
||||||
|
Instructions *store.InstructionStore
|
||||||
|
Settings *store.SettingsStore
|
||||||
|
Agents *store.AgentStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouter builds and returns the main chi router.
|
||||||
|
// staticFS must serve the embedded static directory; pass nil to skip.
|
||||||
|
func NewRouter(stores Stores, broker *events.Broker, apiToken string, staticFS fs.FS) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
// Auth-check endpoint — always public
|
||||||
|
r.Get("/auth-check", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"auth_required": apiToken != "",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Health — always public
|
||||||
|
r.Get("/healthz", handleHealth())
|
||||||
|
|
||||||
|
// Static files — always public
|
||||||
|
if staticFS != nil {
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFileFS(w, r, staticFS, "index.html")
|
||||||
|
})
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(staticFS)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// All /api/* routes are protected when apiToken is set
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
if apiToken != "" {
|
||||||
|
r.Use(bearerAuthMiddleware(apiToken))
|
||||||
|
}
|
||||||
|
r.Get("/api/status", handleStatus(stores))
|
||||||
|
r.Get("/api/instructions", handleListInstructions(stores))
|
||||||
|
r.Post("/api/instructions", handleCreateInstruction(stores, broker))
|
||||||
|
r.Patch("/api/instructions/{id}", handleUpdateInstruction(stores, broker))
|
||||||
|
r.Delete("/api/instructions/consumed", handleClearConsumed(stores, broker))
|
||||||
|
r.Delete("/api/instructions/{id}", handleDeleteInstruction(stores, broker))
|
||||||
|
r.Get("/api/config", handleGetConfig(stores))
|
||||||
|
r.Patch("/api/config", handleUpdateConfig(stores, broker))
|
||||||
|
r.Get("/api/events", handleSSE(broker))
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON serialises v as JSON with the given status code.
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError writes a JSON {"detail": msg} error response.
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"detail": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverStartTime records when this process started, used by /api/status.
|
||||||
|
var serverStartTime = time.Now().UTC()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
43
go-server/internal/api/status.go
Normal file
43
go-server/internal/api/status.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleHealth() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"server_time": time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStatus(stores Stores) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
counts, _ := stores.Instructions.Counts()
|
||||||
|
latest, _ := stores.Agents.Latest()
|
||||||
|
cfg, _ := stores.Settings.Get()
|
||||||
|
|
||||||
|
resp := map[string]any{
|
||||||
|
"uptime_seconds": int(time.Since(serverStartTime).Seconds()),
|
||||||
|
"queue_pending": counts.PendingCount,
|
||||||
|
"queue_consumed": counts.ConsumedCount,
|
||||||
|
"agent_stale_after_seconds": cfg.AgentStaleAfterSeconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest != nil {
|
||||||
|
isStale := time.Since(latest.LastSeenAt).Seconds() > float64(cfg.AgentStaleAfterSeconds)
|
||||||
|
resp["agent"] = map[string]any{
|
||||||
|
"agent_id": latest.AgentID,
|
||||||
|
"last_fetch_at": latest.LastFetchAt.Format(time.RFC3339Nano),
|
||||||
|
"last_result_type": latest.LastResultType,
|
||||||
|
"is_stale": isStale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
61
go-server/internal/config/config.go
Normal file
61
go-server/internal/config/config.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Package config loads runtime configuration from environment variables.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all runtime configuration values for local-mcp.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
HTTPPort string
|
||||||
|
DBPath string
|
||||||
|
LogLevel string
|
||||||
|
DefaultWaitSeconds int
|
||||||
|
DefaultEmptyResponse string
|
||||||
|
AgentStaleAfterSeconds int
|
||||||
|
MCPStateless bool
|
||||||
|
APIToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from environment variables with sensible defaults.
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
Host: getEnv("HOST", "0.0.0.0"),
|
||||||
|
HTTPPort: getEnv("HTTP_PORT", "8000"),
|
||||||
|
DBPath: getEnv("DB_PATH", "data/local_mcp.sqlite3"),
|
||||||
|
LogLevel: getEnv("LOG_LEVEL", "INFO"),
|
||||||
|
DefaultWaitSeconds: getEnvInt("DEFAULT_WAIT_SECONDS", 10),
|
||||||
|
DefaultEmptyResponse: getEnv("DEFAULT_EMPTY_RESPONSE", ""),
|
||||||
|
AgentStaleAfterSeconds: getEnvInt("AGENT_STALE_AFTER_SECONDS", 30),
|
||||||
|
MCPStateless: getEnvBool("MCP_STATELESS", true),
|
||||||
|
APIToken: getEnv("API_TOKEN", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultVal string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, defaultVal int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if i, err := strconv.Atoi(v); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvBool(key string, defaultVal bool) bool {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
77
go-server/internal/db/db.go
Normal file
77
go-server/internal/db/db.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Package db manages the SQLite connection and schema migrations.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // pure-Go SQLite driver, no CGo required
|
||||||
|
)
|
||||||
|
|
||||||
|
// schema creates all tables if they do not already exist.
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS instructions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
consumed_at TEXT,
|
||||||
|
consumed_by_agent_id TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instructions_status ON instructions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instructions_position ON instructions(position);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_activity (
|
||||||
|
agent_id TEXT PRIMARY KEY,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
last_fetch_at TEXT NOT NULL,
|
||||||
|
last_result_type TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
// defaultSettings seeds initial values; OR IGNORE means existing rows are unchanged.
|
||||||
|
const defaultSettings = `
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('default_wait_seconds', '10');
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('default_empty_response', '');
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('agent_stale_after_seconds','30');
|
||||||
|
`
|
||||||
|
|
||||||
|
// Open opens (creating if necessary) a SQLite database at dbPath, applies the
|
||||||
|
// schema, and seeds default settings.
|
||||||
|
func Open(dbPath string) (*sql.DB, error) {
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create db directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialise all writes through a single connection to avoid locking.
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(defaultSettings); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("seed settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
73
go-server/internal/events/broker.go
Normal file
73
go-server/internal/events/broker.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Package events provides an SSE event broker for fanning out server-sent
|
||||||
|
// events to browser clients watching /api/events.
|
||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is the wire format sent to browser clients.
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broker distributes named events to all currently-connected SSE clients.
|
||||||
|
// Clients subscribe by calling Subscribe(); they must call Unsubscribe() when
|
||||||
|
// done to avoid goroutine leaks.
|
||||||
|
type Broker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[chan []byte]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBroker creates a ready-to-use Broker.
|
||||||
|
func NewBroker() *Broker {
|
||||||
|
return &Broker{clients: make(map[chan []byte]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns a channel that will receive serialised SSE "data: ..." lines.
|
||||||
|
func (b *Broker) Subscribe() chan []byte {
|
||||||
|
ch := make(chan []byte, 32) // buffered so a slow reader doesn't stall others
|
||||||
|
b.mu.Lock()
|
||||||
|
b.clients[ch] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes the channel and closes it.
|
||||||
|
func (b *Broker) Unsubscribe(ch chan []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.clients, ch)
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast encodes and sends an event to all subscribers. Slow subscribers
|
||||||
|
// are skipped (their buffered channel is full) to prevent head-of-line blocking.
|
||||||
|
func (b *Broker) Broadcast(eventType string, data any) {
|
||||||
|
ev := Event{
|
||||||
|
Type: eventType,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
return // should never happen
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("data: %s\n\n", payload)
|
||||||
|
msg := []byte(line)
|
||||||
|
|
||||||
|
b.mu.RLock()
|
||||||
|
for ch := range b.clients {
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
default: // skip stalled clients
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
289
go-server/internal/mcp/handler.go
Normal file
289
go-server/internal/mcp/handler.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// Package mcp registers the MCP server and implements the get_user_request tool.
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/events"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/models"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxWaitSeconds is the absolute upper bound for a single tool call wait.
|
||||||
|
maxWaitSeconds = 86400
|
||||||
|
|
||||||
|
// defaultWaitSeconds is the hardcoded wait time when no instruction is available.
|
||||||
|
// Set to 50s to stay safely under the 60s client timeout while allowing
|
||||||
|
// multiple keepalive progress updates.
|
||||||
|
defaultWaitSeconds = 50
|
||||||
|
|
||||||
|
// defaultEmptyResponse is returned when the queue is empty after waiting.
|
||||||
|
defaultEmptyResponse = "call this tool `get_user_request` again to fetch latest user input..."
|
||||||
|
|
||||||
|
// keepaliveInterval controls how often a log notification is sent to the
|
||||||
|
// client while waiting. Reduced to 5s (from 20s) for more frequent progress updates.
|
||||||
|
// This keeps transport-level TCP/HTTP read timeouts from firing.
|
||||||
|
// Note: it does NOT reset application-level wall-clock timers
|
||||||
|
// (e.g. the Copilot 60 s limit), which are unaffected by SSE bytes.
|
||||||
|
keepaliveInterval = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler wraps the MCP server and holds references to the stores it needs.
|
||||||
|
type Handler struct {
|
||||||
|
MCP *server.MCPServer
|
||||||
|
instStore *store.InstructionStore
|
||||||
|
settStore *store.SettingsStore
|
||||||
|
agentStore *store.AgentStore
|
||||||
|
broker *events.Broker
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Handler and registers the get_user_request tool.
|
||||||
|
func New(
|
||||||
|
instStore *store.InstructionStore,
|
||||||
|
settStore *store.SettingsStore,
|
||||||
|
agentStore *store.AgentStore,
|
||||||
|
broker *events.Broker,
|
||||||
|
) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
MCP: server.NewMCPServer("local-mcp", "1.0.0"),
|
||||||
|
instStore: instStore,
|
||||||
|
settStore: settStore,
|
||||||
|
agentStore: agentStore,
|
||||||
|
broker: broker,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.MCP.AddTool(
|
||||||
|
mcp.NewTool("get_user_request",
|
||||||
|
mcp.WithDescription(`Fetch the next pending user instruction from the queue.
|
||||||
|
|
||||||
|
If no instruction is available the tool will wait up to wait_seconds
|
||||||
|
(or the server-configured default) before returning an empty / default response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: An identifier for this agent instance (used to track connectivity).
|
||||||
|
default_response_override: Override the server-default empty response text
|
||||||
|
for this single call.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with keys: status, result_type, instruction, response,
|
||||||
|
remaining_pending, waited_seconds.`),
|
||||||
|
mcp.WithString("agent_id",
|
||||||
|
mcp.Description("Identifier for this agent instance"),
|
||||||
|
mcp.DefaultString("unknown"),
|
||||||
|
),
|
||||||
|
mcp.WithString("default_response_override",
|
||||||
|
mcp.Description("Override the server-default empty response for this call"),
|
||||||
|
mcp.DefaultString(""),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
h.handleGetUserRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleGetUserRequest(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
agentID := req.GetString("agent_id", "unknown")
|
||||||
|
defaultOverride := req.GetString("default_response_override", "")
|
||||||
|
|
||||||
|
// Wait time is hardcoded to stay safely under the 60s client timeout
|
||||||
|
actualWait := defaultWaitSeconds
|
||||||
|
if actualWait > maxWaitSeconds {
|
||||||
|
actualWait = maxWaitSeconds
|
||||||
|
}
|
||||||
|
waitDur := time.Duration(actualWait) * time.Second
|
||||||
|
|
||||||
|
// Register this call as the newest for this agent.
|
||||||
|
myGen := h.instStore.Agents().NewGeneration(agentID)
|
||||||
|
|
||||||
|
// Immediate dequeue attempt.
|
||||||
|
item, err := h.instStore.ConsumeNext(agentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("consume: %w", err)
|
||||||
|
}
|
||||||
|
if item != nil {
|
||||||
|
return h.deliverInstruction(ctx, item, agentID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wait loop ---
|
||||||
|
deadline := time.Now().Add(waitDur)
|
||||||
|
wakeup := h.instStore.Wakeup()
|
||||||
|
lastKeepalive := time.Now()
|
||||||
|
|
||||||
|
for {
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step aside if a newer call arrived for this agent.
|
||||||
|
if !h.instStore.Agents().IsActive(agentID, myGen) {
|
||||||
|
slog.Debug("get_user_request: superseded", "agent", agentID, "gen", myGen)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the queue.
|
||||||
|
item, err = h.instStore.ConsumeNext(agentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if item != nil {
|
||||||
|
waited := int(time.Since(deadline.Add(-waitDur)).Seconds())
|
||||||
|
if waited < 0 {
|
||||||
|
waited = 0
|
||||||
|
}
|
||||||
|
return h.deliverInstruction(ctx, item, agentID, waited)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate next sleep: no longer than time-to-keepalive and no longer than remaining.
|
||||||
|
toKeepalive := keepaliveInterval - time.Since(lastKeepalive)
|
||||||
|
if toKeepalive < 0 {
|
||||||
|
toKeepalive = 0
|
||||||
|
}
|
||||||
|
sleep := remaining
|
||||||
|
if toKeepalive < sleep {
|
||||||
|
sleep = toKeepalive
|
||||||
|
}
|
||||||
|
if sleep > time.Second {
|
||||||
|
sleep = time.Second // check activity/cancellation at least every second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for wakeup, context cancel, or sleep expiry.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Client disconnected.
|
||||||
|
slog.Debug("get_user_request: context cancelled", "agent", agentID)
|
||||||
|
return emptyResult(defaultOverride, 0), nil
|
||||||
|
case <-wakeup.Chan():
|
||||||
|
// Instruction may have arrived — loop back to check.
|
||||||
|
case <-time.After(sleep):
|
||||||
|
// Timeout slice expired.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send SSE keepalive if interval has elapsed.
|
||||||
|
if time.Since(lastKeepalive) >= keepaliveInterval {
|
||||||
|
waited := int(time.Since(deadline.Add(-waitDur)).Seconds())
|
||||||
|
if waited < 0 {
|
||||||
|
waited = 0
|
||||||
|
}
|
||||||
|
remaining := actualWait - waited
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
// Progress bar: filled dots proportional to elapsed time
|
||||||
|
progressPct := (waited * 100) / actualWait
|
||||||
|
if progressPct > 100 {
|
||||||
|
progressPct = 100
|
||||||
|
}
|
||||||
|
filled := progressPct / 10
|
||||||
|
bar := ""
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if i < filled {
|
||||||
|
bar += "●"
|
||||||
|
} else {
|
||||||
|
bar += "○"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("⏳ Waiting for instructions... %s %ds / %ds (agent=%s, %ds remaining)",
|
||||||
|
bar, waited, actualWait, agentID, remaining)
|
||||||
|
if err := h.MCP.SendLogMessageToClient(ctx, mcp.LoggingMessageNotification{
|
||||||
|
Params: mcp.LoggingMessageNotificationParams{
|
||||||
|
Level: mcp.LoggingLevelInfo,
|
||||||
|
Data: msg,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
// Client gone — stop waiting.
|
||||||
|
slog.Debug("get_user_request: keepalive failed, stopping", "agent", agentID, "err", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Debug("get_user_request: keepalive sent", "agent", agentID, "waited", waited, "progress", progressPct)
|
||||||
|
lastKeepalive = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue still empty (or superseded / cancelled) after waiting.
|
||||||
|
waited := int(waitDur.Seconds() - time.Until(deadline).Seconds())
|
||||||
|
if waited < 0 {
|
||||||
|
waited = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.instStore.Agents().IsActive(agentID, myGen) {
|
||||||
|
_ = h.agentStore.Record(agentID, "empty")
|
||||||
|
h.broker.Broadcast("status.changed", map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("get_user_request: empty", "agent", agentID, "waited", waited, "gen", myGen)
|
||||||
|
return emptyResult(defaultOverride, waited), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deliverInstruction(ctx context.Context, item *models.Instruction, agentID string, waited int) (*mcp.CallToolResult, error) {
|
||||||
|
counts, _ := h.instStore.Counts()
|
||||||
|
_ = h.agentStore.Record(agentID, "instruction")
|
||||||
|
|
||||||
|
// Broadcast consumed event + status update.
|
||||||
|
h.broker.Broadcast("instruction.consumed", map[string]any{
|
||||||
|
"item": item,
|
||||||
|
"consumed_by_agent_id": agentID,
|
||||||
|
})
|
||||||
|
h.broker.Broadcast("status.changed", map[string]any{"queue": counts})
|
||||||
|
|
||||||
|
slog.Info("get_user_request: delivered", "id", item.ID, "agent", agentID, "waited", waited)
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"result_type": "instruction",
|
||||||
|
"instruction": map[string]any{
|
||||||
|
"id": item.ID,
|
||||||
|
"content": item.Content,
|
||||||
|
"consumed_at": item.ConsumedAt,
|
||||||
|
},
|
||||||
|
"response": nil,
|
||||||
|
"remaining_pending": counts.PendingCount,
|
||||||
|
"waited_seconds": waited,
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText(jsonMarshalStr(result)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyResult(override string, waited int) *mcp.CallToolResult {
|
||||||
|
resp := override
|
||||||
|
if resp == "" {
|
||||||
|
resp = defaultEmptyResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
resultType := "empty"
|
||||||
|
if resp != "" {
|
||||||
|
resultType = "default_response"
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"result_type": resultType,
|
||||||
|
"instruction": nil,
|
||||||
|
"response": resp,
|
||||||
|
"remaining_pending": 0,
|
||||||
|
"waited_seconds": waited,
|
||||||
|
}
|
||||||
|
return mcp.NewToolResultText(jsonMarshalStr(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonMarshalStr(v any) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
46
go-server/internal/models/models.go
Normal file
46
go-server/internal/models/models.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Package models defines the core data types shared across all packages.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// InstructionStatus represents the lifecycle state of a queue item.
|
||||||
|
type InstructionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending InstructionStatus = "pending"
|
||||||
|
StatusConsumed InstructionStatus = "consumed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Instruction is a single item in the queue.
|
||||||
|
type Instruction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status InstructionStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
ConsumedAt *time.Time `json:"consumed_at"`
|
||||||
|
ConsumedByAgentID *string `json:"consumed_by_agent_id"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings holds user-configurable runtime parameters.
|
||||||
|
type Settings struct {
|
||||||
|
DefaultWaitSeconds int `json:"default_wait_seconds"`
|
||||||
|
DefaultEmptyResponse string `json:"default_empty_response"`
|
||||||
|
AgentStaleAfterSeconds int `json:"agent_stale_after_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentActivity tracks the last time an agent called get_user_request.
|
||||||
|
type AgentActivity struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
LastFetchAt time.Time `json:"last_fetch_at"`
|
||||||
|
LastResultType string `json:"last_result_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueCounts summarises the number of items in each state.
|
||||||
|
type QueueCounts struct {
|
||||||
|
PendingCount int `json:"pending_count"`
|
||||||
|
ConsumedCount int `json:"consumed_count"`
|
||||||
|
}
|
||||||
|
|
||||||
58
go-server/internal/store/agent.go
Normal file
58
go-server/internal/store/agent.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentStore records and retrieves agent connectivity data.
|
||||||
|
type AgentStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentStore creates an AgentStore backed by db.
|
||||||
|
func NewAgentStore(db *sql.DB) *AgentStore {
|
||||||
|
return &AgentStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record upserts agent activity for agentID with the given result type.
|
||||||
|
func (s *AgentStore) Record(agentID, resultType string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO agent_activity (agent_id, last_seen_at, last_fetch_at, last_result_type)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(agent_id) DO UPDATE SET
|
||||||
|
last_seen_at = excluded.last_seen_at,
|
||||||
|
last_fetch_at = excluded.last_fetch_at,
|
||||||
|
last_result_type = excluded.last_result_type`,
|
||||||
|
agentID, now, now, resultType)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest returns the most recently active agent, or nil if no agent has ever
|
||||||
|
// called get_user_request.
|
||||||
|
func (s *AgentStore) Latest() (*models.AgentActivity, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT agent_id, last_seen_at, last_fetch_at, last_result_type
|
||||||
|
FROM agent_activity
|
||||||
|
ORDER BY last_seen_at DESC
|
||||||
|
LIMIT 1`)
|
||||||
|
|
||||||
|
var a models.AgentActivity
|
||||||
|
var seenStr, fetchStr string
|
||||||
|
err := row.Scan(&a.AgentID, &seenStr, &fetchStr, &a.LastResultType)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("latest agent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.LastSeenAt, _ = time.Parse(time.RFC3339Nano, seenStr)
|
||||||
|
a.LastFetchAt, _ = time.Parse(time.RFC3339Nano, fetchStr)
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
320
go-server/internal/store/instruction.go
Normal file
320
go-server/internal/store/instruction.go
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
// Package store contains all database access logic.
|
||||||
|
// This file handles instruction queue operations.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WakeupSignal is an edge-triggered broadcast mechanism: closing the internal
|
||||||
|
// channel wakes all goroutines currently blocked on Chan(), then a new channel
|
||||||
|
// is installed for the next round of waiters. This mirrors asyncio.Event in
|
||||||
|
// the Python implementation.
|
||||||
|
type WakeupSignal struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ch chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWakeupSignal creates a ready-to-use WakeupSignal.
|
||||||
|
func NewWakeupSignal() *WakeupSignal {
|
||||||
|
return &WakeupSignal{ch: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chan returns the current wait channel. Callers should capture the return
|
||||||
|
// value once and then select on it — do not call Chan() repeatedly.
|
||||||
|
func (w *WakeupSignal) Chan() <-chan struct{} {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify wakes all goroutines currently waiting on Chan() by closing the
|
||||||
|
// channel, then installs a fresh channel for future waiters.
|
||||||
|
func (w *WakeupSignal) Notify() {
|
||||||
|
w.mu.Lock()
|
||||||
|
old := w.ch
|
||||||
|
w.ch = make(chan struct{})
|
||||||
|
w.mu.Unlock()
|
||||||
|
close(old)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentTracker manages per-agent generation counters so that stale
|
||||||
|
// coroutines cannot silently consume instructions intended for newer calls.
|
||||||
|
type AgentTracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
generations map[string]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentTracker creates an AgentTracker ready for use.
|
||||||
|
func NewAgentTracker() *AgentTracker {
|
||||||
|
return &AgentTracker{generations: make(map[string]uint64)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeneration increments and returns the current generation for agentID.
|
||||||
|
func (t *AgentTracker) NewGeneration(agentID string) uint64 {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.generations[agentID]++
|
||||||
|
return t.generations[agentID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive returns true only if no newer call has arrived for agentID since
|
||||||
|
// this generation was issued.
|
||||||
|
func (t *AgentTracker) IsActive(agentID string, gen uint64) bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.generations[agentID] == gen
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstructionStore provides all instruction queue operations.
|
||||||
|
type InstructionStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
wakeup *WakeupSignal
|
||||||
|
agents *AgentTracker
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstructionStore creates a store backed by db.
|
||||||
|
func NewInstructionStore(db *sql.DB) *InstructionStore {
|
||||||
|
return &InstructionStore{
|
||||||
|
db: db,
|
||||||
|
wakeup: NewWakeupSignal(),
|
||||||
|
agents: NewAgentTracker(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wakeup returns the shared wakeup signal.
|
||||||
|
func (s *InstructionStore) Wakeup() *WakeupSignal { return s.wakeup }
|
||||||
|
|
||||||
|
// Agents returns the shared agent tracker.
|
||||||
|
func (s *InstructionStore) Agents() *AgentTracker { return s.agents }
|
||||||
|
|
||||||
|
// List returns instructions filtered by status ("pending", "consumed", or "all").
|
||||||
|
func (s *InstructionStore) List(status string) ([]models.Instruction, error) {
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "pending", "consumed":
|
||||||
|
rows, err = s.db.Query(`
|
||||||
|
SELECT id, content, status, created_at, updated_at,
|
||||||
|
consumed_at, consumed_by_agent_id, position
|
||||||
|
FROM instructions
|
||||||
|
WHERE status = ?
|
||||||
|
ORDER BY position ASC, created_at ASC`, status)
|
||||||
|
default: // "all"
|
||||||
|
rows, err = s.db.Query(`
|
||||||
|
SELECT id, content, status, created_at, updated_at,
|
||||||
|
consumed_at, consumed_by_agent_id, position
|
||||||
|
FROM instructions
|
||||||
|
ORDER BY position ASC, created_at ASC`)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list instructions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []models.Instruction
|
||||||
|
for rows.Next() {
|
||||||
|
it, err := scanInstruction(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new pending instruction at the end of the queue.
|
||||||
|
func (s *InstructionStore) Create(content string) (*models.Instruction, error) {
|
||||||
|
id := uuid.New().String()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Assign next position
|
||||||
|
var maxPos sql.NullInt64
|
||||||
|
_ = s.db.QueryRow(`SELECT MAX(position) FROM instructions WHERE status = 'pending'`).Scan(&maxPos)
|
||||||
|
position := int(maxPos.Int64) + 1
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO instructions (id, content, status, created_at, updated_at, position)
|
||||||
|
VALUES (?, ?, 'pending', ?, ?, ?)`,
|
||||||
|
id, content, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), position)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create instruction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake any waiting tool calls
|
||||||
|
s.wakeup.Notify()
|
||||||
|
|
||||||
|
return s.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update edits a pending instruction's content. Returns the updated item or an
|
||||||
|
// error if the instruction is already consumed.
|
||||||
|
func (s *InstructionStore) Update(id, content string) (*models.Instruction, error) {
|
||||||
|
it, err := s.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if it.Status == models.StatusConsumed {
|
||||||
|
return nil, ErrAlreadyConsumed
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err = s.db.Exec(`UPDATE instructions SET content = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
content, now.Format(time.RFC3339Nano), id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update instruction: %w", err)
|
||||||
|
}
|
||||||
|
return s.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a pending instruction. Returns ErrAlreadyConsumed if the
|
||||||
|
// instruction has been delivered.
|
||||||
|
func (s *InstructionStore) Delete(id string) error {
|
||||||
|
it, err := s.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if it.Status == models.StatusConsumed {
|
||||||
|
return ErrAlreadyConsumed
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(`DELETE FROM instructions WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConsumed removes all consumed instructions.
|
||||||
|
func (s *InstructionStore) DeleteConsumed() error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM instructions WHERE status = 'consumed'`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a single instruction or ErrNotFound.
|
||||||
|
func (s *InstructionStore) GetByID(id string) (*models.Instruction, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT id, content, status, created_at, updated_at,
|
||||||
|
consumed_at, consumed_by_agent_id, position
|
||||||
|
FROM instructions WHERE id = ?`, id)
|
||||||
|
|
||||||
|
it, err := scanInstruction(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &it, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeNext atomically claims the oldest pending instruction for agentID.
|
||||||
|
// Returns nil if the queue is empty.
|
||||||
|
func (s *InstructionStore) ConsumeNext(agentID string) (*models.Instruction, error) {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// Claim the oldest pending item with a row-level lock (SQLite uses file lock).
|
||||||
|
var id string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
SELECT id FROM instructions
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY position ASC, created_at ASC
|
||||||
|
LIMIT 1`).Scan(&id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil // queue empty
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("select next: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE instructions
|
||||||
|
SET status = 'consumed', consumed_at = ?, consumed_by_agent_id = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'pending'`,
|
||||||
|
now.Format(time.RFC3339Nano), agentID, now.Format(time.RFC3339Nano), id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mark consumed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts returns pending and consumed queue sizes.
|
||||||
|
func (s *InstructionStore) Counts() (models.QueueCounts, error) {
|
||||||
|
var c models.QueueCounts
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT status, COUNT(*) FROM instructions GROUP BY status`)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var status string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&status, &n); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case "pending":
|
||||||
|
c.PendingCount = n
|
||||||
|
case "consumed":
|
||||||
|
c.ConsumedCount = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel errors returned by InstructionStore.
|
||||||
|
var (
|
||||||
|
ErrNotFound = fmt.Errorf("instruction not found")
|
||||||
|
ErrAlreadyConsumed = fmt.Errorf("instruction already consumed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanInstruction(r scanner) (models.Instruction, error) {
|
||||||
|
var it models.Instruction
|
||||||
|
var createdAtStr, updatedAtStr string
|
||||||
|
var consumedAtStr sql.NullString
|
||||||
|
var consumedByAgentID sql.NullString
|
||||||
|
|
||||||
|
err := r.Scan(
|
||||||
|
&it.ID, &it.Content, &it.Status,
|
||||||
|
&createdAtStr, &updatedAtStr,
|
||||||
|
&consumedAtStr, &consumedByAgentID,
|
||||||
|
&it.Position,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return it, err
|
||||||
|
}
|
||||||
|
|
||||||
|
it.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr)
|
||||||
|
it.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtStr)
|
||||||
|
|
||||||
|
if consumedAtStr.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339Nano, consumedAtStr.String)
|
||||||
|
it.ConsumedAt = &t
|
||||||
|
}
|
||||||
|
if consumedByAgentID.Valid {
|
||||||
|
s := consumedByAgentID.String
|
||||||
|
it.ConsumedByAgentID = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
return it, nil
|
||||||
|
}
|
||||||
|
|
||||||
69
go-server/internal/store/settings.go
Normal file
69
go-server/internal/store/settings.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsStore reads and writes the settings table.
|
||||||
|
type SettingsStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingsStore creates a SettingsStore backed by db.
|
||||||
|
func NewSettingsStore(db *sql.DB) *SettingsStore {
|
||||||
|
return &SettingsStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current settings.
|
||||||
|
func (s *SettingsStore) Get() (models.Settings, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT key, value FROM settings`)
|
||||||
|
if err != nil {
|
||||||
|
return models.Settings{}, fmt.Errorf("get settings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cfg := models.Settings{
|
||||||
|
DefaultWaitSeconds: 10,
|
||||||
|
AgentStaleAfterSeconds: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var key, value string
|
||||||
|
if err := rows.Scan(&key, &value); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "default_wait_seconds":
|
||||||
|
if n, err := strconv.Atoi(value); err == nil {
|
||||||
|
cfg.DefaultWaitSeconds = n
|
||||||
|
}
|
||||||
|
case "default_empty_response":
|
||||||
|
cfg.DefaultEmptyResponse = value
|
||||||
|
case "agent_stale_after_seconds":
|
||||||
|
if n, err := strconv.Atoi(value); err == nil {
|
||||||
|
cfg.AgentStaleAfterSeconds = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update saves settings. Only non-nil fields are updated; pass a partial
|
||||||
|
// struct pointer using the Patch helper below.
|
||||||
|
func (s *SettingsStore) Update(patch models.Settings) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT OR REPLACE INTO settings (key, value) VALUES
|
||||||
|
('default_wait_seconds', ?),
|
||||||
|
('default_empty_response', ?),
|
||||||
|
('agent_stale_after_seconds', ?)`,
|
||||||
|
strconv.Itoa(patch.DefaultWaitSeconds),
|
||||||
|
patch.DefaultEmptyResponse,
|
||||||
|
strconv.Itoa(patch.AgentStaleAfterSeconds),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
163
go-server/main.go
Normal file
163
go-server/main.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// local-mcp-go — localhost MCP server delivering user instructions to agents.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/api"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/config"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/db"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/events"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/mcp"
|
||||||
|
"github.com/local-mcp/local-mcp-go/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// Logger
|
||||||
|
level := slog.LevelInfo
|
||||||
|
switch cfg.LogLevel {
|
||||||
|
case "DEBUG":
|
||||||
|
level = slog.LevelDebug
|
||||||
|
case "WARN", "WARNING":
|
||||||
|
level = slog.LevelWarn
|
||||||
|
case "ERROR":
|
||||||
|
level = slog.LevelError
|
||||||
|
}
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})))
|
||||||
|
|
||||||
|
// Database
|
||||||
|
database, err := db.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to open database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
slog.Info("Database initialised", "path", cfg.DBPath)
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
instStore := store.NewInstructionStore(database)
|
||||||
|
settStore := store.NewSettingsStore(database)
|
||||||
|
agentStore := store.NewAgentStore(database)
|
||||||
|
|
||||||
|
// Event broker
|
||||||
|
broker := events.NewBroker()
|
||||||
|
|
||||||
|
// MCP server
|
||||||
|
mcpHandler := mcp.New(instStore, settStore, agentStore, broker)
|
||||||
|
sseServer := server.NewStreamableHTTPServer(
|
||||||
|
mcpHandler.MCP,
|
||||||
|
server.WithEndpointPath("/mcp"),
|
||||||
|
server.WithStateLess(cfg.MCPStateless),
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP router
|
||||||
|
staticSubFS, _ := fs.Sub(staticFS, "static")
|
||||||
|
|
||||||
|
router := api.NewRouter(
|
||||||
|
api.Stores{
|
||||||
|
Instructions: instStore,
|
||||||
|
Settings: settStore,
|
||||||
|
Agents: agentStore,
|
||||||
|
},
|
||||||
|
broker,
|
||||||
|
cfg.APIToken,
|
||||||
|
staticSubFS,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Combined router: /mcp → MCP StreamableHTTP, /* → REST API
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/mcp", sseServer)
|
||||||
|
mux.Handle("/mcp/", http.StripPrefix("/mcp", sseServer))
|
||||||
|
mux.Handle("/", router)
|
||||||
|
|
||||||
|
// Server with auto port switching
|
||||||
|
port := cfg.HTTPPort
|
||||||
|
maxAttempts := 10
|
||||||
|
var srv *http.Server
|
||||||
|
var addr string
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||||
|
addr = fmt.Sprintf("%s:%s", cfg.Host, port)
|
||||||
|
srv = &http.Server{Addr: addr, Handler: mux}
|
||||||
|
|
||||||
|
// Try to listen
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err == nil {
|
||||||
|
ln.Close() // Close test listener
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port taken, try next
|
||||||
|
portNum := 8000 + attempt
|
||||||
|
port = fmt.Sprintf("%d", portNum+1)
|
||||||
|
if attempt == maxAttempts-1 {
|
||||||
|
slog.Error("Could not find available port", "tried", maxAttempts)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.APIToken != "" {
|
||||||
|
slog.Info("Token authentication enabled")
|
||||||
|
} else {
|
||||||
|
slog.Info("Token authentication disabled (set API_TOKEN to enable)")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpURL := fmt.Sprintf("http://%s", addr)
|
||||||
|
mcpURL := fmt.Sprintf("http://%s/mcp", addr)
|
||||||
|
|
||||||
|
slog.Info("local-mcp-go ready",
|
||||||
|
"http", httpURL,
|
||||||
|
"mcp", mcpURL,
|
||||||
|
"stateless", cfg.MCPStateless,
|
||||||
|
)
|
||||||
|
|
||||||
|
// On Windows, show interactive prompt
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("╔══════════════════════════════════════════════════════════╗")
|
||||||
|
fmt.Printf("║ local-mcp-go ready on port %s%-24s║\n", port, "")
|
||||||
|
fmt.Println("║ ║")
|
||||||
|
fmt.Printf("║ Web UI: %-46s║\n", httpURL)
|
||||||
|
fmt.Printf("║ MCP: %-46s║\n", mcpURL)
|
||||||
|
fmt.Println("║ ║")
|
||||||
|
fmt.Println("║ Press Ctrl+C to stop the server ║")
|
||||||
|
fmt.Println("╚══════════════════════════════════════════════════════════╝")
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown on SIGINT / SIGTERM
|
||||||
|
stop := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-stop
|
||||||
|
fmt.Println()
|
||||||
|
slog.Info("Shutting down gracefully...")
|
||||||
|
_ = srv.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("Server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user