Skip to content

Commit 12ba927

Browse files
authored
Merge pull request #14 from aniongithub/refactor/strip-sse
Replace SSE with REST API for web UI
2 parents 8486a12 + 214acc5 commit 12ba927

7 files changed

Lines changed: 204 additions & 214 deletions

File tree

README.md

Lines changed: 35 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,25 @@
44

55
**A wiki for AI agents — and humans too.**
66

7-
`mind-map` is a wiki engine that stores pages as plain markdown files, indexes them with SQLite FTS5, and exposes everything via MCP over HTTP/SSE. AI agents and humans connect to the same server using the same protocol. One binary, zero runtime dependencies.
7+
`mind-map` is a wiki engine that stores pages as plain markdown files, indexes them with SQLite FTS5, and exposes them via MCP (for AI agents) and a REST API with web UI (for humans). One binary, zero runtime dependencies.
88

99
## The Problem
1010

1111
AI agents need persistent, structured memory. Today that means:
1212

13-
- 🔴 **Desktop apps** — tools like Tolaria require Node.js + Rust + WebKit + a display server just to give agents a knowledge base
14-
- 🔴 **Single-user** — stdio MCP is one agent, one pipe, that's it
15-
- 🔴 **No web access** — the knowledge is locked in a desktop app only the local user can see
16-
- 🔴 **Can't deploy headless** — needs a GUI environment even when no human is looking
13+
- **Desktop apps** -- tools like Tolaria require Node.js + Rust + WebKit + a display server just to give agents a knowledge base
14+
- **No web access** -- the knowledge is locked in a desktop app only the local user can see
15+
- **Can't deploy headless** -- needs a GUI environment even when no human is looking
1716

1817
## The Solution
1918

20-
`mind-map` is a **server**, not an app. It runs anywhere your laptop, a container or a cloud VM.
19+
`mind-map` is a **server**, not an app. It runs anywhere -- your laptop, a container or a cloud VM.
2120

22-
1. **One protocol** — MCP over HTTP/SSE. The web UI and AI agents are both MCP clients
23-
2. **One binary** — Go, statically compiled, `curl | bash` to install
24-
3. **Plain markdown** — pages are `.md` files with YAML frontmatter. Git-friendly, portable, yours
25-
4. **Multi-agent** — HTTP/SSE means any number of agents can connect simultaneously
26-
5. **Built-in web UI** — browse, search, and edit the wiki from any browser
21+
1. **Agents use stdio** -- `mind-map` with no args starts an MCP server on stdin/stdout
22+
2. **Humans use the web UI** -- `mind-map serve` starts an HTTP server with REST API and browser UI
23+
3. **One binary** -- Go, statically compiled, `curl | bash` to install
24+
4. **Plain markdown** -- pages are `.md` files with YAML frontmatter. Git-friendly, portable, yours
25+
5. **Multi-process safe** -- SQLite page locking lets multiple agents share the same wiki directory
2726

2827
```
2928
Agent: "What do we know about authentication?"
@@ -52,28 +51,31 @@ Binaries available for **linux-x64**, **linux-arm64**, **darwin-x64**, **darwin-
5251

5352
```mermaid
5453
graph TD
55-
A[Preact Web App] -->|MCP over HTTP/SSE| B
56-
C[AI Agent] -->|MCP over HTTP/SSE| B
57-
D[AI Agent] -->|MCP over stdio| B
54+
A[Preact Web App] -->|REST API| B
55+
C[AI Agent] -->|MCP stdio| D
5856
5957
subgraph "mind-map serve"
60-
B[MCP Server] --> E[Wiki Engine]
61-
E --> F[SQLite FTS5]
58+
B[HTTP Server] --> E[Wiki Engine]
6259
end
6360
61+
subgraph "mind-map (stdio)"
62+
D[MCP Server] --> E
63+
end
64+
65+
E --> F[SQLite FTS5]
6466
E -->|read/write| G[Markdown Files]
6567
```
6668

67-
The web UI is a static Preact app served from the same binary. It connects to the MCP SSE endpoint at `/mcp` — the same endpoint AI agents use. There is no separate REST API.
69+
The web UI is a static Preact app served by `mind-map serve`. It uses a REST API to read and write pages. AI agents use stdio MCP -- each agent launches its own `mind-map` process.
6870

69-
## Two Modes, One Server
71+
## Two Modes
7072

7173
| Mode | Command | Use case |
7274
|------|---------|----------|
73-
| **HTTP/SSE** (default) | `mind-map serve --dir ~/.mind-map/wiki` | Web UI + multiple agents |
74-
| **stdio** | `mind-map serve --stdio --dir ~/.mind-map/wiki` | Single agent (Copilot, Claude Desktop, Cursor) |
75+
| **stdio** (default) | `mind-map` | AI agents (Copilot, Claude, Cursor) |
76+
| **HTTP** | `mind-map serve` | Web UI for humans |
7577

76-
Both modes use the same wiki engine, same MCP tools, same code path. The only difference is the transport.
78+
Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wiki` by default). Multiple stdio processes can safely share the same wiki via SQLite page locking.
7779

7880
## MCP Tools (8 total)
7981

@@ -88,13 +90,16 @@ Both modes use the same wiki engine, same MCP tools, same code path. The only di
8890
| `list_pages` | List pages, optionally filtered by path prefix |
8991
| `get_backlinks` | Get all pages that link to a given page |
9092

93+
| `register_sync` | Register a wiki path prefix to sync with a git remote |
94+
9195
## Wiki Features
9296

93-
- **YAML frontmatter** — structured metadata on every page (`title`, `type`, `status`, custom fields)
94-
- **Wikilinks**`[[target]]` and `[[display|target]]` syntax, resolved to clickable links
95-
- **Backlink index** — every page knows what links to it
96-
- **Full-text search** — SQLite FTS5 with ranked results and snippets
97-
- **Concurrent access**`sync.RWMutex` for safe multi-agent reads and writes
97+
- **YAML frontmatter** -- structured metadata on every page (`title`, `type`, `status`, custom fields)
98+
- **Wikilinks** -- `[[target]]` and `[[display|target]]` syntax, resolved to clickable links
99+
- **Backlink index** -- every page knows what links to it
100+
- **Full-text search** -- SQLite FTS5 with ranked results and snippets
101+
- **Multi-process safe** -- SQLite page locking for concurrent agent access
102+
- **Git sync** -- sync wiki pages to GitHub repo wikis via configurable mappings
98103

99104
## Web UI
100105

@@ -106,35 +111,21 @@ The built-in web UI is a metro-inspired, chromeless Preact app:
106111
- Edit mode with raw markdown editor
107112
- Dark / light theme toggle
108113

109-
The web UI speaks MCP — it's an MCP client, not a separate interface. If an agent creates a page, it appears in the browser. If you edit in the browser, the agent sees the change.
110-
111-
## MCP Server Configuration
114+
The web UI speaks the same language as the wiki engine. If an agent creates a page via stdio, it appears in the browser. If you edit in the browser, the agent sees the change on its next read.
112115

113-
### Linux / macOS (stdio)
116+
## MCP Client Configuration
114117

115118
```json
116119
{
117120
"mcpServers": {
118121
"mind-map": {
119-
"command": "mind-map",
120-
"args": ["serve", "--stdio", "--dir", "~/.mind-map/wiki"]
122+
"command": "mind-map"
121123
}
122124
}
123125
}
124126
```
125127

126-
### Windows
127-
128-
```json
129-
{
130-
"mcpServers": {
131-
"mind-map": {
132-
"command": "mind-map",
133-
"args": ["serve", "--stdio"]
134-
}
135-
}
136-
}
137-
```
128+
That's it. No args needed -- stdio mode and `~/.mind-map/wiki` are the defaults. Override the directory with `--dir` if needed.
138129

139130
## Page Format
140131

cmd/mind-map/main.go

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,28 @@ import (
2020
mindsync "github.com/aniongithub/mind-map/internal/sync"
2121
"github.com/aniongithub/mind-map/webui"
2222
"github.com/kardianos/service"
23-
"github.com/modelcontextprotocol/go-sdk/mcp"
23+
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
2424
"github.com/spf13/cobra"
2525
)
2626

2727
var rootCmd = &cobra.Command{
2828
Use: "mind-map",
2929
Short: "A wiki engine with MCP interface for AI agents",
30-
Long: "mind-map is a wiki that stores pages as markdown files, indexes them with SQLite FTS5, and exposes everything via MCP over HTTP/SSE. AI agents and humans use the same protocol.\n\nRunning without a subcommand starts the MCP server in stdio mode.",
30+
Long: "mind-map is a wiki that stores pages as markdown files, indexes them with SQLite FTS5, and exposes everything via MCP (stdio) or a REST API (serve). Agents use stdio, humans use the web UI.\n\nRunning without a subcommand starts the MCP server in stdio mode.",
3131
RunE: runStdio,
3232
}
3333

3434
var serveCmd = &cobra.Command{
3535
Use: "serve",
36-
Short: "Start the HTTP/SSE server with web UI",
37-
Long: "Starts the mind-map server in HTTP/SSE mode with the web UI. Use this for browser access and multi-agent setups.",
36+
Short: "Start the HTTP server with web UI",
37+
Long: "Starts the mind-map HTTP server with REST API and web UI.",
3838
RunE: runServe,
3939
}
4040

4141
func init() {
4242
rootCmd.PersistentFlags().StringP("dir", "d", defaultWikiDir(), "Path to the wiki directory")
4343

44-
serveCmd.Flags().StringP("addr", "a", ":51849", "Address to listen on (HTTP/SSE mode)")
44+
serveCmd.Flags().StringP("addr", "a", ":51849", "Address to listen on")
4545
serveCmd.Flags().String("webui", "", "Path to webui dist directory (overrides embedded webui)")
4646
serveCmd.Flags().String("log-file", "", "Path to log file (logs to stderr and file)")
4747
serveCmd.Flags().Duration("idle-timeout", 60*time.Second, "Idle timeout for HTTP connections (e.g. 30s, 1m)")
@@ -62,7 +62,7 @@ func runStdio(cmd *cobra.Command, args []string) error {
6262

6363
s := mindmcp.NewServer(w, nil)
6464
slog.Info("mind-map MCP server starting", slog.String("mode", "stdio"), slog.String("wiki", w.Root()))
65-
return s.MCPServer().Run(cmd.Context(), &mcp.StdioTransport{})
65+
return s.MCPServer().Run(cmd.Context(), &mcpsdk.StdioTransport{})
6666
}
6767

6868
func runServe(cmd *cobra.Command, args []string) error {
@@ -88,7 +88,7 @@ func runServe(cmd *cobra.Command, args []string) error {
8888
defer f.Close()
8989
}
9090

91-
// HTTP/SSE mode
91+
// HTTP mode
9292
addr, _ := cmd.Flags().GetString("addr")
9393
webuiDir, _ := cmd.Flags().GetString("webui")
9494

@@ -108,7 +108,7 @@ func runServe(cmd *cobra.Command, args []string) error {
108108
return runHTTPServer(addr, dir, webuiDir, idleTimeout, stopCh)
109109
}
110110

111-
// runHTTPServer starts the HTTP/SSE server and blocks until stopCh is closed.
111+
// runHTTPServer starts the HTTP server and blocks until stopCh is closed.
112112
// Shared by both the interactive `serve` command and the system service.
113113
func runHTTPServer(addr, dir, webuiDir string, idleTimeout time.Duration, stopCh chan struct{}) error {
114114
w, err := wiki.Open(dir)
@@ -139,12 +139,107 @@ func runHTTPServer(addr, dir, webuiDir string, idleTimeout time.Duration, stopCh
139139
}
140140
}
141141

142-
sseHandler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server {
143-
return mindmcp.NewServer(w, gs).MCPServer()
144-
}, nil)
145-
142+
// REST API for wiki operations (used by web UI)
146143
mux := http.NewServeMux()
147-
mux.Handle("/mcp", sseHandler)
144+
145+
mux.HandleFunc("GET /api/context", func(rw http.ResponseWriter, r *http.Request) {
146+
wctx, err := w.Context(r.Context())
147+
if err != nil {
148+
http.Error(rw, err.Error(), http.StatusInternalServerError)
149+
return
150+
}
151+
jsonResponse(rw, wctx)
152+
})
153+
154+
mux.HandleFunc("GET /api/pages", func(rw http.ResponseWriter, r *http.Request) {
155+
prefix := r.URL.Query().Get("prefix")
156+
pages, err := w.ListPages(r.Context(), prefix)
157+
if err != nil {
158+
http.Error(rw, err.Error(), http.StatusInternalServerError)
159+
return
160+
}
161+
jsonResponse(rw, pages)
162+
})
163+
164+
mux.HandleFunc("GET /api/pages/{path...}", func(rw http.ResponseWriter, r *http.Request) {
165+
pagePath := r.PathValue("path")
166+
page, err := w.GetPage(r.Context(), pagePath)
167+
if err != nil {
168+
http.Error(rw, err.Error(), http.StatusNotFound)
169+
return
170+
}
171+
jsonResponse(rw, page)
172+
})
173+
174+
mux.HandleFunc("POST /api/pages", func(rw http.ResponseWriter, r *http.Request) {
175+
var req struct {
176+
Path string `json:"path"`
177+
Content string `json:"content"`
178+
}
179+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
180+
http.Error(rw, "invalid JSON: "+err.Error(), http.StatusBadRequest)
181+
return
182+
}
183+
if req.Path == "" || req.Content == "" {
184+
http.Error(rw, "path and content are required", http.StatusBadRequest)
185+
return
186+
}
187+
if err := w.CreatePage(r.Context(), req.Path, req.Content); err != nil {
188+
http.Error(rw, err.Error(), http.StatusConflict)
189+
return
190+
}
191+
rw.WriteHeader(http.StatusCreated)
192+
jsonResponse(rw, map[string]string{"status": "created", "path": req.Path})
193+
})
194+
195+
mux.HandleFunc("PUT /api/pages/{path...}", func(rw http.ResponseWriter, r *http.Request) {
196+
pagePath := r.PathValue("path")
197+
var req struct {
198+
Content string `json:"content"`
199+
}
200+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
201+
http.Error(rw, "invalid JSON: "+err.Error(), http.StatusBadRequest)
202+
return
203+
}
204+
if err := w.UpdatePage(r.Context(), pagePath, req.Content); err != nil {
205+
http.Error(rw, err.Error(), http.StatusNotFound)
206+
return
207+
}
208+
jsonResponse(rw, map[string]string{"status": "updated", "path": pagePath})
209+
})
210+
211+
mux.HandleFunc("DELETE /api/pages/{path...}", func(rw http.ResponseWriter, r *http.Request) {
212+
pagePath := r.PathValue("path")
213+
if err := w.DeletePage(r.Context(), pagePath); err != nil {
214+
http.Error(rw, err.Error(), http.StatusNotFound)
215+
return
216+
}
217+
jsonResponse(rw, map[string]string{"status": "deleted", "path": pagePath})
218+
})
219+
220+
mux.HandleFunc("GET /api/search", func(rw http.ResponseWriter, r *http.Request) {
221+
q := r.URL.Query().Get("q")
222+
if q == "" {
223+
http.Error(rw, "q parameter is required", http.StatusBadRequest)
224+
return
225+
}
226+
results, err := w.Search(r.Context(), q, 20)
227+
if err != nil {
228+
http.Error(rw, err.Error(), http.StatusInternalServerError)
229+
return
230+
}
231+
jsonResponse(rw, results)
232+
})
233+
234+
mux.HandleFunc("GET /api/backlinks/{path...}", func(rw http.ResponseWriter, r *http.Request) {
235+
pagePath := r.PathValue("path")
236+
backlinks, err := w.GetBacklinks(r.Context(), pagePath)
237+
if err != nil {
238+
http.Error(rw, err.Error(), http.StatusInternalServerError)
239+
return
240+
}
241+
jsonResponse(rw, backlinks)
242+
})
148243

149244
// Settings API endpoints (UI only, not MCP)
150245
mux.HandleFunc("GET /api/settings", func(rw http.ResponseWriter, r *http.Request) {
@@ -261,7 +356,7 @@ func runHTTPServer(addr, dir, webuiDir string, idleTimeout time.Duration, stopCh
261356
slog.Info("mind-map server starting",
262357
slog.String("addr", addr),
263358
slog.String("wiki", w.Root()),
264-
slog.String("mcp_endpoint", "http://localhost"+addr+"/mcp"),
359+
slog.String("url", "http://localhost"+addr),
265360
)
266361

267362
go func() {
@@ -278,6 +373,11 @@ func runHTTPServer(addr, dir, webuiDir string, idleTimeout time.Duration, stopCh
278373
return nil
279374
}
280375

376+
func jsonResponse(rw http.ResponseWriter, v any) {
377+
rw.Header().Set("Content-Type", "application/json")
378+
json.NewEncoder(rw).Encode(v)
379+
}
380+
281381
func main() {
282382
if err := rootCmd.Execute(); err != nil {
283383
fmt.Fprintln(os.Stderr, err)

cmd/mind-map/service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func newServiceConfig(addr, dir, webui string, idleTimeout time.Duration) *servi
8181
cfg := &service.Config{
8282
Name: "mind-map",
8383
DisplayName: "mind-map",
84-
Description: "mind-map wiki server — MCP over HTTP/SSE",
84+
Description: "mind-map wiki server",
8585
Arguments: args,
8686
Executable: execPath,
8787
}

0 commit comments

Comments
 (0)