Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9f7580b
feat(mnemonik-server): co-located Mnemonic MCP server ansible role
mnemonik-dev Jul 8, 2026
51116b3
chore(secrets): add mnemonik MCP hosted-mode secrets (jwt + refresh s…
mnemonik-dev Jul 8, 2026
a9180fc
fix(mnemonik-server): make GHCR login non-fatal so public-package pul…
mnemonik-dev Jul 8, 2026
ca92eba
feat(deploy): enable mnemonik-server, pin image sha-61fdb3e
mnemonik-dev Jul 9, 2026
d183284
fix(mnemonik-server): non-blocking ollama pull + health-fail diagnostics
mnemonik-dev Jul 9, 2026
5d4874d
fix(mnemonik-server): loopback health probe + always-on diagnostics
mnemonik-dev Jul 9, 2026
d379d1a
fix(mnemonik-server): pre-create /data/model-cache so fastembed can s…
mnemonik-dev Jul 9, 2026
9ad78db
diag(mnemonik-server): debug container probe for fastembed env/DNS/eg…
mnemonik-dev Jul 9, 2026
d7b2d1d
fix(mnemonik-server): chown state dirs to image uid 10001 so fastembe…
mnemonik-dev Jul 9, 2026
8303b77
feat(mnemonik-server): CORS for webapp, fastembed cache dir, chain-st…
mnemonik-dev Jul 11, 2026
982f675
fix(mnemonik-server): use bash for pipefail in fastembed embed check
mnemonik-dev Jul 11, 2026
12f905c
fix(mnemonik-server): force-recreate on restart, longer embed log tail
mnemonik-dev Jul 11, 2026
cd7f497
fix(mnemonik-server): always force-recreate mcp containers on deploy
mnemonik-dev Jul 11, 2026
a931c64
fix(mnemonik-server): strip upstream CORS headers to avoid duplicates
mnemonik-dev Jul 11, 2026
0f0df8f
feat(mnemonik-server): support multiple CORS origins
mnemonik-dev Jul 12, 2026
6279665
fix(mnemonik-server): use Caddy expression matcher for multi-origin CORS
mnemonik-dev Jul 12, 2026
1fd840d
fix(deploy): remove stale mnemonik-mcp Caddy snippet before vaultwarden
mnemonik-dev Jul 12, 2026
98e2f6f
fix(mnemonik-server): point chat LLM at ollama service
mnemonik-dev Jul 12, 2026
9401e3c
fix(mnemonik-server): enable hosted anchoring config
mnemonik-dev Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions infrastructure/ansible/inventory/group_vars/all/features.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
# Feature enable flags, loaded by ansible from group_vars/all (adjacent to the
# CI-generated inventory hosts.yml). Higher precedence than role defaults,
# lower than the play's role vars in deploy.yml.

# mnemonik-server: co-located Mnemonic MCP server (Rust GHCR image + local
# Ollama), fronted by the shared Caddy. Enabled 2026-07-09 once the GHCR
# package went public and a build passed.
mnemonik_server_enabled: true

# Pin to the confirmed-good build rather than :latest for a reproducible
# deploy. Bump when a newer build is verified. Package is public, so the
# fail-soft GHCR login is skipped/ignored and the pull is anonymous.
mnemonik_mcp_image_tag: "sha-61fdb3e"

# Hosted MCP is the public participate-mode operator. Default writes should
# anchor to Irys/Arweave + Solana; local-only writes remain available via
# explicit mode=local.
mnemonik_mcp_storage_mode: "full"

# Wallets that have signed hosted anchored uploads. Include both the documented
# pre-Hetzner server identity and the accidental post-migration identity so the
# chain-backed public counters recover all indexed history.
mnemonik_chain_stats_wallets: >-
DYVu4Bry3BzGVsR3Hj2iGVT5fNdWFoHw2zRxsdTmrG25,
EdFyDHqqWTSPKs7c89U36Uy8QAnX2nX2A7qkYKD2Td21

mnemonik_arweave_url: "https://gateway.irys.xyz"
mnemonik_chain_stats_gateway_url: "https://gateway.irys.xyz"
mnemonik_chain_stats_graphql_url: "https://arweave.net/graphql"

# Browser webapp origins. Caddy reflects the request Origin if it matches
# one of these exact origins.
mnemonik_mcp_cors_origin:
- "https://www.mnemonik.xyz"
- "https://mnemonik-webapp.pages.dev"
51 changes: 43 additions & 8 deletions infrastructure/ansible/playbooks/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@
APT_LISTCHANGES_FRONTEND: none

pre_tasks:
# Recovery: a prior failed mnemonik-server deploy may have left a broken
# Caddy snippet in the shared conf.d. The vaultwarden role starts the
# shared Caddy before mnemonik-server has a chance to rewrite the snippet,
# so an invalid snippet blocks the entire deploy. Remove any stale
# mnemonik-mcp.conf; mnemonik-server will render a validated one later.
- name: Remove stale mnemonik-mcp Caddy snippet if present
ansible.builtin.file:
path: /opt/vaultwarden/caddy_conf_d/mnemonik-mcp.conf
state: absent
become: true
failed_when: false
changed_when: false

- name: Display deployment info
debug:
msg: |
Expand Down Expand Up @@ -114,6 +127,11 @@
vault_blogger_repo_ref: "{{ sops.blogger_repo_ref | default('') }}"
vault_claude_blog_repo_ref: "{{ sops.claude_blog_repo_ref | default('') }}"
vault_publish_callback_hmac_secrets: "{{ sops.publish_callback_hmac_secrets | default('') }}"
# Mnemonic MCP server (mnemonik-server role) hosted-mode secrets.
# Generate each once (openssl rand -base64 32) and keep stable;
# empty/safe default so a deploy with the role disabled never fails.
vault_mnemonik_mcp_jwt_secret: "{{ sops.mnemonik_mcp_jwt_secret | default('') }}"
vault_mnemonik_mcp_refresh_salt: "{{ sops.mnemonik_mcp_refresh_salt | default('') }}"
no_log: true

- name: Wait for system to stabilize post-boot
Expand Down Expand Up @@ -180,6 +198,21 @@
# at the VM's public IPv4. Empty = tailnet-only (default).
kaneo_public_domain: "{{ sops.kaneo_public_domain | default('kaneo.mnemonik.xyz') }}"

# Mnemonic protocol SERVER (Rust MCP image from GHCR + local Ollama),
# co-located and fronted by the shared Caddy. Distinct from the
# `mnemonic-mcp` role (that installs the CLIENT binary). Disabled by
# default: enable once DNS (mcp.mnemonik.xyz → VM IP) + sops secrets are
# in place — set mnemonik_server_enabled=true (group_vars or -e).
- role: mnemonik-server
tags: [mnemonik-server, mcp]
when: mnemonik_server_enabled | default(false) | bool
vars:
mnemonik_mcp_jwt_secret: "{{ vault_mnemonik_mcp_jwt_secret | default('') }}"
mnemonik_mcp_refresh_salt: "{{ vault_mnemonik_mcp_refresh_salt | default('') }}"
# GHCR pull auth — reuse the sops github PAT for private packages.
# Leave the token empty if the GHCR package is public.
mnemonik_ghcr_token: "{{ vault_github_deploy_pat | default('') }}"

- role: telegram-init
tags: [telegram-init, communication]
vars:
Expand Down Expand Up @@ -246,10 +279,12 @@
molyanov_skills_path: "/opt/molyanov/skills"
molyanov_pk_guard_enabled: true

# Phase 6: Attestation + MCP (DESCOPED 2026-05 — disabled by default).
# The role still runs every deploy but installs only no-op stub hooks when
# `mnemonic_mcp_enabled` is false. Flip to true once the local Mnemonic MCP
# server is production-ready (backlog: work/mnemonic-attestation-integration/).
# Phase 6: Mnemonik MCP client binary (per-spawn stdio) for
# content-publisher attestation. Enabled by default; the discovered binary
# path is exposed as fact `mnemonic_mcp_binary` for downstream roles.
# (The legacy daemon + hook design was retired 2026-06; this role now
# installs only the npm-shipped client binary and runs an MCP handshake
# smoke test.)
- role: mnemonic-mcp
tags: [mnemonic-mcp, attestation]
vars:
Expand Down Expand Up @@ -391,14 +426,14 @@
- name: Verify all services are running
block:
- name: Check systemd services
# mnemonic-mcp only added to the loop when the role is enabled
# (descoped 2026-05; backlog: work/mnemonic-attestation-integration/)
# mnemonic-mcp is a per-spawn client binary, not a daemon; no
# systemd unit to check here. workspace-manager + telegram-ai-agent
# are the long-running fabric services.
systemd:
name: "{{ item }}"
state: started
register: service_check
loop: "{{ ['workspace-manager', 'telegram-ai-agent']
+ (['mnemonic-mcp'] if (mnemonic_mcp_enabled | default(false) | bool) else []) }}"
loop: "{{ ['workspace-manager', 'telegram-ai-agent'] }}"
failed_when: false

- name: Report service status
Expand Down
8 changes: 8 additions & 0 deletions infrastructure/ansible/roles/mnemonik-server/.ansible-lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Role-local ansible-lint config. Mirrors the mnemonic-mcp role.
#
# role-name: every role in this repo uses kebab-case (mnemonic-mcp, kaneo,
# telegram-init, ssh-hardening, ...). Matching that convention trips the
# lint's snake_case rule; renaming is a repo-wide concern, out of scope here.
skip_list:
- role-name
81 changes: 81 additions & 0 deletions infrastructure/ansible/roles/mnemonik-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Role: mnemonik-server

Deploys the **server side** of the Mnemonic protocol, co-located on the fabric
VM: the Rust MCP HTTP server (GHCR image `ghcr.io/mnemonik-xyz/mnemonic-mcp`)
plus a local Ollama for `/chat`. TLS + public ingress are handled by the
**shared Caddy** (vaultwarden role), not the monorepo's own nginx/certbot.

> **Not** the `mnemonic-mcp` role. That one installs the *client* binary
> (`@mnemonik-xyz/mcp`, per-spawn stdio) that content-publisher spawns. This
> role hosts the *server* those clients connect to. Two sides of one protocol.

## What it does

1. **Swap** — idempotent `{{ mnemonik_server_swap_size_mb }}`MB swapfile
(ollama + fastembed headroom). Toggle with `mnemonik_server_swap_enabled`.
2. **State on the persistent volume** — creates `<volume>/mnemonik/{data,keypair,ollama}`
and bind-mounts them into the containers so server identity (`id.json` in
`/keypair`), the attestation DB (`/data`), and the pulled model survive a
VM rebuild.
3. **GHCR pull** — optional `docker login` (private package), then
`docker compose pull` — the box never compiles Rust.
4. **Ollama** — stock `ollama/ollama` image + a post-up `ollama pull` of
`{{ mnemonik_ollama_model }}` (replicates the monorepo's custom image
without needing its build context) + a non-fatal warm-up.
5. **Caddy vhost** — drops `mnemonik-mcp.conf` into the shared conf.d and
restarts Caddy; `{{ mnemonik_public_domain }}` gets a real Let's Encrypt
cert on first request.
6. **Health gate** — polls `https://{{ mnemonik_public_domain }}/health` the
same way an external client would.

## Boundary with the monorepo pipeline

- **Build stays upstream.** `mnemonik-xyz/monorepo` → `build-mcp-image.yml`
builds + pushes the image to GHCR. Untouched by this role.
- **Deploy is fabric's.** This role does what `deploy-mcp.yml`'s deploy half
does (pull + compose up + health gate), inside fabric's existing deploy that
already has authenticated access to this box — instead of maintaining the
monorepo's separate SSH path into a box whose port 22 is firewalled.
- **Webapp is elsewhere.** The frontend lives on Cloudflare Pages; only the
MCP host is served here.

## Enabling (first deploy)

1. **DNS** — A record `mcp.mnemonik.xyz` → VM public IPv4.
2. **sops** — `sops edit infrastructure/secrets/secrets.sops.yml`, set
`mnemonik_mcp_jwt_secret` + `mnemonik_mcp_refresh_salt`
(`openssl rand -base64 32` each, keep stable). Also confirm
`caddy_acme_email` is set so Let's Encrypt can issue.
3. **GHCR** — make the package public, or rely on the sops github PAT wired in
`deploy.yml` (`mnemonik_ghcr_token`).
4. **Enable** — set `mnemonik_server_enabled: true` (group_vars or `-e`) and
deploy.

## Key variables

| Variable | Default | Notes |
|----------|---------|-------|
| `mnemonik_server_enabled` | `false` | Master switch. |
| `mnemonik_mcp_image_tag` | `latest` | Pin a real tag for reproducible redeploys. |
| `mnemonik_ollama_model` | `qwen2.5:3b` | Pulled once, cached on the volume. |
| `mnemonik_public_domain` | `mcp.mnemonik.xyz` | Caddy public vhost. |
| `mnemonik_server_swap_size_mb` | `4096` | Swap cushion. |

`mnemonik_mcp_jwt_secret` / `mnemonik_mcp_refresh_salt` have no default — the
role fails loudly if enabled without them.

## Cross-role contract

Depends on the **vaultwarden** role for the shared Caddy container
(`{{ vaultwarden_caddy_container_name }}`) and its external docker network
(`{{ mnemonik_shared_caddy_network }}`). The mcp container joins that network
with alias `mnemonik-mcp`; Caddy reverse-proxies to it by name. Must run after
vaultwarden + kaneo in the `deploy.yml` roles list.

## Smoke (manual, post-deploy)

```bash
curl -fsS https://mcp.mnemonik.xyz/health
ssh <vm> 'docker compose -f /opt/mnemonik-server/docker-compose.yml ps'
ssh <vm> 'docker exec mnemonik-ollama-1 ollama list' # model present
```
110 changes: 110 additions & 0 deletions infrastructure/ansible/roles/mnemonik-server/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
# Role: mnemonik-server
#
# Deploys the SERVER side of the Mnemonic protocol — the Rust MCP HTTP server
# (GHCR image ghcr.io/mnemonik-xyz/mnemonic-mcp) plus a co-located Ollama for
# /chat — co-located on the fabric VM and fronted by the existing shared Caddy
# (vaultwarden role), NOT by the monorepo's own nginx+certbot.
#
# NOTE: distinct from the `mnemonic-mcp` role. That role installs the CLIENT
# binary (@mnemonik-xyz/mcp, per-spawn stdio) consumed by content-publisher.
# This role hosts the SERVER the protocol clients connect to.
#
# Upstream reference (adapted): mnemonik-xyz/monorepo docker-compose.yml +
# mcp/deploy/compose.env.example. We keep the `mcp` + `ollama` services and
# drop `nginx` + `certbot` (Caddy terminates TLS and reverse-proxies instead).

# Master switch. Disabled by default: enable per-environment (group_vars or
# CLI -e) once DNS + sops secrets are in place. Mirrors the mnemonic-mcp /
# restic gating pattern.
mnemonik_server_enabled: false

# --- Deploy dir + compose project ------------------------------------------
mnemonik_server_install_dir: /opt/mnemonik-server
mnemonik_server_compose_project: mnemonik

# --- MCP server image ------------------------------------------------------
# CI (monorepo build-mcp-image.yml) builds + pushes this to GHCR; the fabric
# box only PULLS — it never compiles Rust. Pin a real tag (e.g. v0.2.8) for
# reproducible redeploys instead of `latest`.
mnemonik_mcp_image: "ghcr.io/mnemonik-xyz/mnemonic-mcp"
mnemonik_mcp_image_tag: "latest"

# GHCR pull auth. If the package is private, the role does `docker login`
# using this token (reuse the sops github PAT). Leave empty if the GHCR
# package is public (no login attempted).
mnemonik_ghcr_username: "mnemonik-dev"
mnemonik_ghcr_token: ""

# --- Ollama (co-located, local inference) ----------------------------------
# Stock upstream image + a post-up `ollama pull` (the monorepo's custom
# `monorepo-ollama` image is just this base + a pre-pulled model + warm-up;
# we replicate that without needing the monorepo build context on the box).
mnemonik_ollama_image: "ollama/ollama:0.21.2"
mnemonik_ollama_model: "qwen2.5:3b"

# --- MCP runtime env (passed into the mcp container) -----------------------
mnemonik_mcp_storage_mode: "local"
mnemonik_mcp_embed_provider: "fastembed"
mnemonik_mcp_turbo_bits: 4
mnemonik_mcp_payment_mode: "none"
mnemonik_mcp_rust_log: "info"

# fastembed cache dir inside the container (bind-mounted from persistent
# volume via docker-compose.yml). Set explicitly so model downloads survive
# VM rebuilds and land in a writable, pre-created directory.
mnemonik_mcp_fastembed_cache_dir: "/data/model-cache"

# Chain-backed traction stats (required by server images >= #194).
# Defaults are safe/public; override in sops/group_vars for real tracking.
mnemonik_chain_stats_wallets: ""
mnemonik_solana_rpc_url: "https://api.mainnet-beta.solana.com"
mnemonik_arweave_url: "https://gateway.irys.xyz"
mnemonik_chain_stats_graphql_url: "https://arweave.net/graphql"
mnemonik_chain_stats_gateway_url: "https://gateway.irys.xyz"
# Origin advertised in OAuth metadata + challenge envelopes. MUST match the
# public hostname external clients (Cursor/Claude.ai) connect to — RFC 8707
# origin check is enforced. No trailing slash.
mnemonik_mcp_public_base_url: "https://mcp.mnemonik.xyz"

# CORS origins for the webapp frontend. Set to the exact origin(s) the browser
# sends (e.g. "https://mnemonik.xyz" or ["https://www.mnemonik.xyz",
# "https://mnemonik-webapp.pages.dev"]). Empty string or empty list disables
# CORS headers at the Caddy layer. Do NOT use "*" with credentials.
mnemonik_mcp_cors_origin: ""

# Port the mcp server listens on inside the container. Caddy reverse-proxies
# to this over the shared docker network; NOT published to a host port.
mnemonik_mcp_container_port: 3000

# --- Caddy vhost (public, real Let's Encrypt) ------------------------------
# Public hostname served on 443. Requires: DNS A record → VM public IPv4,
# firewall 80+443 open (already the case), and caddy_acme_email set so LE
# can issue. The webapp lives on Cloudflare Pages — this is the MCP host only.
mnemonik_public_domain: "mcp.mnemonik.xyz"

# Shared Caddy — cross-role contract with the vaultwarden role. Snippet is
# dropped into its conf.d; the container is restarted to pick it up.
vaultwarden_caddy_container_name: "vaultwarden-caddy-1"
mnemonik_caddy_conf_d: "/opt/vaultwarden/caddy_conf_d"

# External docker network created by the vaultwarden role's compose stack.
# The mcp container joins it so Caddy can reverse_proxy by name.
mnemonik_shared_caddy_network: "vaultwarden_vaultwarden"

# --- Swap (ollama + fastembed headroom) ------------------------------------
# Safety cushion recommended by the monorepo bootstrap. Idempotent.
mnemonik_server_swap_enabled: true
mnemonik_server_swap_size_mb: 4096
mnemonik_server_swap_path: "/swapfile"

# Health probe budget (post-up). MCP start_period is 180s (fastembed model
# load), so give it generous retries.
mnemonik_mcp_health_retries: 120
mnemonik_mcp_health_delay: 3

# CRITICAL: provided by the playbook via sops decryption (deploy.yml).
# Not defined here so the role fails loudly if enabled without them.
# mnemonik_mcp_jwt_secret — openssl rand -base64 32, stable across deploys
# mnemonik_mcp_refresh_salt — openssl rand -base64 32, rotating invalidates
# every live refresh token (deliberate event)
25 changes: 25 additions & 0 deletions infrastructure/ansible/roles/mnemonik-server/handlers/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
- name: Restart mnemonik-server
# community.docker.docker_compose_v2 lacks `restarted:` on the runner's
# collection (see kaneo handler note). Shell out; handler only fires when
# a rendered template actually changed, so idempotency isn't needed here.
# `--force-recreate` ensures env_file/compose changes (e.g. FASTEMBED_CACHE_DIR)
# are picked up; `docker compose restart` alone keeps the old env in the
# container.
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ mnemonik_server_install_dir }}"
environment:
COMPOSE_PROJECT_NAME: "{{ mnemonik_server_compose_project }}"
become: true
become_user: op
changed_when: true

- name: Reload caddy for mnemonik vhost
# `docker restart` of the shared Caddy — the same reliable hammer the kaneo
# role uses (admin-API reload has flaked on this stack). Caddy re-reads
# Caddyfile + conf.d/*.conf on boot and issues LE certs for new vhosts.
become: true
ansible.builtin.command:
cmd: "docker restart {{ vaultwarden_caddy_container_name }}"
changed_when: true
17 changes: 17 additions & 0 deletions infrastructure/ansible/roles/mnemonik-server/meta/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
galaxy_info:
author: "Mnemonic Protocol"
description: "Deploy the Mnemonic MCP server (Rust GHCR image) + co-located Ollama, fronted by the shared Caddy"
company: "Mnemonic Protocol"
license: "MIT"
min_ansible_version: "2.15"
tags:
- docker
- mcp
- mnemonik
- ollama
- caddy

dependencies: []
# Ordering enforced by the playbook (deploy.yml roles list): must run after
# vaultwarden (owns the shared Caddy + external network) and kaneo.
Loading