Skip to main content
Glama
README.md
# design-viewer

A small Hono service that takes Mermaid designs pushed from an MCP tool and serves
them as clean, shareable web pages.

```
Claude / agent ──serve_design──▶ design-viewer-mcp (stdio) ──POST /api/designs──▶ design-viewer :3100
                                                                                      │
                                                        ~/.design-viewer/designs.json │
```

- `GET /` — every design, newest first, with title and timestamp
- `GET /designs/:id` — title, description, and the Mermaid diagram rendered in the browser
- `GET /healthz`, `GET /api/designs`, `GET /api/designs/:id`, `POST /api/designs`

Designs live in memory while the service runs and are flushed to
`~/.design-viewer/designs.json` on every write (temp file + rename, so a crash cannot
leave a half-written file). An unparsable data file is moved aside with a
`.corrupt-<timestamp>` suffix rather than blocking startup.

## MCP tool: `serve_design`

| input | type | notes |
| --- | --- | --- |
| `title` | string | short name, shown in the index and the page heading |
| `description` | string | one or two sentences shown above the diagram (may be empty) |
| `mermaid` | string | Mermaid source, without the ```` ```mermaid ```` fence |

Returns `{ id, url }` — `url` is the page to open, e.g.
`http://hp-z420-mint-steve:3100/designs/7`. Links are built from `DESIGN_VIEWER_PUBLIC_URL`
at request time, so changing that one setting rewrites every link the service emits.

The tool runs as a stdio MCP server and posts to the running service, so the
service stays the single writer of the data file. If the service is down the tool
returns an error telling you to start it.

## Layout

```
src/config.ts   env parsing (PORT, DESIGN_VIEWER_HOST, *_DATA_DIR, *_PUBLIC_URL)
src/store.ts    in-memory store + atomic JSON flush
src/render.ts   HTML pages, HTML escaping, pinned Mermaid CDN import
src/app.ts      Hono routes (web pages + JSON API)
src/server.ts   entry point: loads the store, serves on PORT
src/mcp.ts      stdio MCP server exposing serve_design
scripts/smoke-test.mjs  end-to-end test (HTTP + MCP + restart)
systemd/design-viewer.service
```

## Build

```bash
npm install
npm run build      # tsc, strict mode
npm run smoke      # boots the service on :3199 and exercises everything above
```

## Run

```bash
npm start                                    # foreground, http://localhost:3100
node dist/mcp.js                             # the MCP stdio server
```

### systemd (user unit)

```bash
mkdir -p ~/.config/systemd/user
cp systemd/design-viewer.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now design-viewer
journalctl --user -u design-viewer -f
```

The unit expects `node` at `~/.local/bin/node`; adjust `ExecStart` if Node lives
elsewhere. User services need lingering to start at boot (`loginctl enable-linger
$USER`), which is already enabled on the Z420.

Overrides go in `~/.config/design-viewer/env` (`KEY=value` lines); see
`.env.example`.

## Registering the MCP tool

Claude Code reads the tool over stdio. On the machine the service runs on:

```bash
claude mcp add design-viewer --scope user \
  --env DESIGN_VIEWER_URL=http://localhost:3100 \
  -- node /home/pastry/projects/design-viewer/dist/mcp.js
```

From another machine on the mesh, point the tool at the Z420 by its MagicDNS name —
`node` runs locally, only the POST crosses the mesh:

```bash
claude mcp add design-viewer --env DESIGN_VIEWER_URL=http://hp-z420-mint-steve:3100 -- \
  node /path/to/design-viewer/dist/mcp.js
```

The MagicDNS name is also what the returned links use, so nothing needs a tunnel or
an IP lookup: open the URL the tool returns in a browser on any tailnet device.

Registered on the G14 at user scope with:

```bash
claude mcp add design-viewer --scope user \
  -e DESIGN_VIEWER_URL=http://hp-z420-mint-steve:3100 -- \
  /home/pastrycak3s/.nvm/versions/node/v24.14.0/bin/node \
  /home/pastrycak3s/projects/design-viewer/dist/mcp.js
```

The command path is absolute on purpose — Claude Code spawns the server with its own
environment, so a bare `node` only resolves if nvm's `PATH` was loaded when the CLI
started. The tradeoff is that it pins Node v24.14.0: after removing that version,
update the path (`claude mcp get design-viewer` to check, `claude mcp remove
design-viewer -s user` to start over). Newly added servers need a CLI restart before
their tools appear in a running session.

## Environment

| variable | default | meaning |
| --- | --- | --- |
| `PORT` | `3100` | listen port; the default link follows it |
| `DESIGN_VIEWER_HOST` | `0.0.0.0` | bind address; use `127.0.0.1` for loopback only |
| `DESIGN_VIEWER_DATA_DIR` | `~/.design-viewer` | directory holding `designs.json` |
| `DESIGN_VIEWER_DATA_FILE` | `$DESIGN_VIEWER_DATA_DIR/designs.json` | exact data file |
| `DESIGN_VIEWER_PUBLIC_URL` | `http://localhost:$PORT` | base URL used in returned links; the Z420 runs with `http://hp-z420-mint-steve:3100` |
| `DESIGN_VIEWER_URL` | `http://localhost:3100` | where the MCP server posts |
| `DESIGN_VIEWER_TIMEOUT_MS` | `15000` | MCP POST timeout |

## Notes

- Mermaid is loaded from jsDelivr, pinned to 11.17.2, with `securityLevel: "strict"`.
  If the CDN is unreachable the page falls back to showing the stored Mermaid source.
- Titles, descriptions and diagram source are HTML-escaped on output; the renderer
  never interpolates stored content raw.
- Deleting a design is not exposed on purpose — the index is append-only history.