8 Commits

Author SHA1 Message Date
62df7ed6cd Add sticky ToC sidebar to document read view
Builds a table of contents client-side from rendered heading IDs.
Highlights the current section on scroll. Collapses to inline on
narrow screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:59:01 -07:00
051abae390 Add platform packaging and deployment guide
Synced from metacircular/docs with SSO login documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:50:10 -07:00
dd5142a48a Fix template error: pass CSRF func on SSO login page
Go templates require all referenced functions to be defined at parse
time, even in branches that won't execute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:35:21 -07:00
2c3db6ea25 Add SSO login support via mcdsl/sso
When [sso].redirect_uri is configured, the web UI shows a "Sign in
with MCIAS" button instead of the username/password form. Upgrades
mcdsl to v1.7.0 which includes the Firefox cookie fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:15:06 -07:00
063bdccf1b Second deployment test. 2026-03-29 18:13:15 -07:00
67cbcd85bd Tweaking to test deployment. 2026-03-29 18:10:51 -07:00
3d5f52729f Add CLI client subcommands and MCP server
Adds push, list, get, delete, and login subcommands backed by an HTTP
API client, plus an MCP server for tool-based access to the document
queue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:08:55 -07:00
ed3a547e54 Add unqueue (delete) button to web reading view
Adds a delete route and handler to the web UI so documents can be
removed directly from the reading page. Uses CSRF-protected POST with a
browser confirmation dialog. Styled with a danger accent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:08:45 -07:00
22 changed files with 2163 additions and 41 deletions

61
cmd/mcq/client.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcq/internal/client"
)
var clientFlags struct {
server string
token string
}
func addClientFlags(cmd *cobra.Command) {
cmd.Flags().StringVar(&clientFlags.server, "server", "", "MCQ server URL (env: MCQ_SERVER)")
cmd.Flags().StringVar(&clientFlags.token, "token", "", "auth token (env: MCQ_TOKEN)")
}
func newClient() (*client.Client, error) {
server := clientFlags.server
if server == "" {
server = os.Getenv("MCQ_SERVER")
}
if server == "" {
return nil, fmt.Errorf("server URL required: use --server or MCQ_SERVER")
}
token := clientFlags.token
if token == "" {
token = os.Getenv("MCQ_TOKEN")
}
if token == "" {
token = readCachedToken()
}
if token == "" {
return nil, fmt.Errorf("auth token required: use --token, MCQ_TOKEN, or run 'mcq login'")
}
return client.New(server, token), nil
}
func tokenPath() string {
if dir, err := os.UserConfigDir(); err == nil {
return filepath.Join(dir, "mcq", "token")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "mcq", "token")
}
func readCachedToken() string {
data, err := os.ReadFile(tokenPath())
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}

32
cmd/mcq/delete.go Normal file
View File

@@ -0,0 +1,32 @@
package main
import (
"context"
"fmt"
"github.com/spf13/cobra"
)
func deleteCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <slug>",
Short: "Delete a document from the queue",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if err := c.DeleteDocument(context.Background(), args[0]); err != nil {
return fmt.Errorf("delete document: %w", err)
}
fmt.Printf("deleted %s\n", args[0])
return nil
},
}
addClientFlags(cmd)
return cmd
}

34
cmd/mcq/get.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
)
func getCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "get <slug>",
Short: "Get a document's markdown body",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
doc, err := c.GetDocument(context.Background(), args[0])
if err != nil {
return fmt.Errorf("get document: %w", err)
}
_, _ = fmt.Fprint(os.Stdout, doc.Body)
return nil
},
}
addClientFlags(cmd)
return cmd
}

44
cmd/mcq/list.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"context"
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
)
func listCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List documents in the queue",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
c, err := newClient()
if err != nil {
return err
}
docs, err := c.ListDocuments(context.Background())
if err != nil {
return fmt.Errorf("list documents: %w", err)
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "SLUG\tTITLE\tPUSHED BY\tPUSHED AT\tREAD")
for _, d := range docs {
read := "no"
if d.Read {
read = "yes"
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Slug, d.Title, d.PushedBy, d.PushedAt, read)
}
_ = w.Flush()
return nil
},
}
addClientFlags(cmd)
return cmd
}

71
cmd/mcq/login.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/spf13/cobra"
"golang.org/x/term"
"git.wntrmute.dev/mc/mcq/internal/client"
)
func loginCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate with MCIAS and cache a token",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
server := clientFlags.server
if server == "" {
server = os.Getenv("MCQ_SERVER")
}
if server == "" {
return fmt.Errorf("server URL required: use --server or MCQ_SERVER")
}
reader := bufio.NewReader(os.Stdin)
fmt.Print("Username: ")
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
fmt.Print("Password: ")
passBytes, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("read password: %w", err)
}
fmt.Println()
password := string(passBytes)
fmt.Print("TOTP code (blank if none): ")
totpCode, _ := reader.ReadString('\n')
totpCode = strings.TrimSpace(totpCode)
c := client.New(server, "")
token, err := c.Login(context.Background(), username, password, totpCode)
if err != nil {
return fmt.Errorf("login: %w", err)
}
path := tokenPath()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, []byte(token+"\n"), 0600); err != nil {
return fmt.Errorf("cache token: %w", err)
}
fmt.Printf("token cached to %s\n", path)
return nil
},
}
cmd.Flags().StringVar(&clientFlags.server, "server", "", "MCQ server URL (env: MCQ_SERVER)")
return cmd
}

View File

@@ -16,6 +16,12 @@ func main() {
}
root.AddCommand(serverCmd())
root.AddCommand(pushCmd())
root.AddCommand(listCmd())
root.AddCommand(getCmd())
root.AddCommand(deleteCmd())
root.AddCommand(loginCmd())
root.AddCommand(mcpCmd())
if err := root.Execute(); err != nil {
os.Exit(1)

40
cmd/mcq/mcp.go Normal file
View File

@@ -0,0 +1,40 @@
package main
import (
"context"
"fmt"
"os"
"github.com/mark3labs/mcp-go/server"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcq/internal/client"
"git.wntrmute.dev/mc/mcq/internal/mcpserver"
)
func mcpCmd() *cobra.Command {
return &cobra.Command{
Use: "mcp",
Short: "Start MCP stdio server for Claude integration",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
return runMCP()
},
}
}
func runMCP() error {
serverURL := os.Getenv("MCQ_SERVER")
if serverURL == "" {
return fmt.Errorf("MCQ_SERVER environment variable required")
}
token := os.Getenv("MCQ_TOKEN")
if token == "" {
return fmt.Errorf("MCQ_TOKEN environment variable required")
}
c := client.New(serverURL, token)
mcpSrv := mcpserver.New(c, version)
stdioSrv := server.NewStdioServer(mcpSrv)
return stdioSrv.Listen(context.Background(), os.Stdin, os.Stdout)
}

58
cmd/mcq/push.go Normal file
View File

@@ -0,0 +1,58 @@
package main
import (
"context"
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcq/internal/client"
)
func pushCmd() *cobra.Command {
var title string
cmd := &cobra.Command{
Use: "push <slug> <file|->",
Short: "Push a document to the queue",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
slug := args[0]
source := args[1]
var body []byte
var err error
if source == "-" {
body, err = io.ReadAll(os.Stdin)
} else {
body, err = os.ReadFile(source)
}
if err != nil {
return fmt.Errorf("read input: %w", err)
}
if title == "" {
title = client.ExtractTitle(string(body), slug)
}
c, err := newClient()
if err != nil {
return err
}
doc, err := c.PutDocument(context.Background(), slug, title, string(body))
if err != nil {
return fmt.Errorf("push document: %w", err)
}
fmt.Printf("pushed %s (%s)\n", doc.Slug, doc.Title)
return nil
},
}
cmd.Flags().StringVar(&title, "title", "", "document title (default: first H1 heading, or slug)")
addClientFlags(cmd)
return cmd
}

View File

@@ -77,6 +77,9 @@ func runServer(configPath string) error {
wsCfg := webserver.Config{
ServiceName: cfg.MCIAS.ServiceName,
Tags: cfg.MCIAS.Tags,
MciasURL: cfg.MCIAS.ServerURL,
CACert: cfg.MCIAS.CACert,
RedirectURI: cfg.SSO.RedirectURI,
}
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
if err != nil {

View File

@@ -0,0 +1,755 @@
# Packaging and Deploying to the Metacircular Platform
This guide provides everything needed to build, package, and deploy a
service to the Metacircular platform. It assumes no prior knowledge of
the platform's internals.
---
## Platform Overview
Metacircular is a multi-service infrastructure platform. Services are
Go binaries running as containers on Linux nodes, managed by these core
components:
| Component | Role |
|-----------|------|
| **MCP** (Control Plane) | Deploys, monitors, and manages container lifecycle via rootless Podman |
| **MCR** (Container Registry) | OCI container registry at `mcr.svc.mcp.metacircular.net:8443` |
| **mc-proxy** (TLS Proxy) | Routes traffic to services via L4 (SNI passthrough) or L7 (TLS termination) |
| **MCIAS** (Identity Service) | Central SSO/IAM — all services authenticate through it |
| **MCNS** (DNS) | Authoritative DNS for `*.svc.mcp.metacircular.net` |
The operator workflow is: **build image → push to MCR → write service
definition → deploy via MCP**. MCP handles port assignment, route
registration, and container lifecycle.
---
## Prerequisites
| Requirement | Details |
|-------------|---------|
| Go | 1.25+ |
| Container engine | Docker or Podman (for building images) |
| `mcp` CLI | Installed on the operator workstation |
| MCR access | Credentials to push images to `mcr.svc.mcp.metacircular.net:8443` |
| MCP agent | Running on the target node (currently `rift`) |
| MCIAS account | For `mcp` CLI authentication to the agent |
---
## 1. Build the Container Image
### Dockerfile Pattern
All services use a two-stage Alpine build. This is the standard
template:
```dockerfile
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /<binary> ./cmd/<binary>
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /<binary> /usr/local/bin/<binary>
WORKDIR /srv/<service>
EXPOSE <ports>
ENTRYPOINT ["<binary>"]
CMD ["server", "--config", "/srv/<service>/<service>.toml"]
```
### Dockerfile Rules
- **`CGO_ENABLED=0`** — all builds are statically linked. No CGo in
production.
- **`ca-certificates` and `tzdata`** — required in the runtime image
for TLS verification and timezone-aware logging.
- **No `USER` directive** — containers run as `--user 0:0` under MCP's
rootless Podman. UID 0 inside the container maps to the unprivileged
`mcp` host user. A non-root `USER` directive creates a subordinate
UID that cannot access host-mounted volumes.
- **No `VOLUME` directive** — causes layer unpacking failures under
rootless Podman. The host volume mount is declared in the service
definition, not the image.
- **No `adduser`/`addgroup`** — unnecessary given the rootless Podman
model.
- **`WORKDIR /srv/<service>`** — so relative paths resolve correctly
against the mounted data directory.
- **Version injection** — pass the git tag via `--build-arg VERSION=...`
so the binary can report its version.
- **Stripped binaries** — `-trimpath -ldflags="-s -w"` removes debug
symbols and build paths.
### Split Binaries
If the service has separate API and web UI binaries, create separate
Dockerfiles:
- `Dockerfile.api` — builds the API/gRPC server
- `Dockerfile.web` — builds the web UI server
Both follow the same template. The web binary communicates with the API
server via gRPC (no direct database access).
### Makefile Target
Every service includes a `make docker` target:
```makefile
docker:
docker build --build-arg VERSION=$(shell git describe --tags --always --dirty) \
-t <service> -f Dockerfile.api .
```
---
## 2. Write a Service Definition
Service definitions are TOML files that tell MCP what to deploy. They
live at `~/.config/mcp/services/<service>.toml` on the operator
workstation.
### Minimal Example (Single Component, L7)
```toml
name = "myservice"
node = "rift"
[build.images]
myservice = "Dockerfile"
[[components]]
name = "web"
image = "mcr.svc.mcp.metacircular.net:8443/myservice:v1.0.0"
[[components.routes]]
port = 8443
mode = "l7"
```
### API Service Example (L4, Multiple Routes)
```toml
name = "myservice"
node = "rift"
[build.images]
myservice = "Dockerfile"
[[components]]
name = "api"
image = "mcr.svc.mcp.metacircular.net:8443/myservice:v1.0.0"
volumes = ["/srv/myservice:/srv/myservice"]
cmd = ["server", "--config", "/srv/myservice/myservice.toml"]
[[components.routes]]
name = "rest"
port = 8443
mode = "l4"
[[components.routes]]
name = "grpc"
port = 9443
mode = "l4"
```
### Full Example (API + Web)
```toml
name = "myservice"
node = "rift"
[build.images]
myservice = "Dockerfile.api"
myservice-web = "Dockerfile.web"
[[components]]
name = "api"
image = "mcr.svc.mcp.metacircular.net:8443/myservice:v1.0.0"
volumes = ["/srv/myservice:/srv/myservice"]
cmd = ["server", "--config", "/srv/myservice/myservice.toml"]
[[components.routes]]
name = "rest"
port = 8443
mode = "l4"
[[components.routes]]
name = "grpc"
port = 9443
mode = "l4"
[[components]]
name = "web"
image = "mcr.svc.mcp.metacircular.net:8443/myservice-web:v1.0.0"
volumes = ["/srv/myservice:/srv/myservice"]
cmd = ["server", "--config", "/srv/myservice/myservice.toml"]
[[components.routes]]
port = 443
mode = "l7"
```
### Conventions
A few fields are derived by the agent at deploy time:
| Field | Default | Override when... |
|-------|---------|------------------|
| Source path | `<service>` relative to workspace root | Directory name differs from service name (use `path`) |
| Hostname | `<service>.svc.mcp.metacircular.net` | Service needs a public hostname (use route `hostname`) |
All other fields must be explicit in the service definition.
### Service Definition Reference
**Top-level fields:**
| Field | Required | Purpose |
|-------|----------|---------|
| `name` | Yes | Service name (matches project name) |
| `node` | Yes | Target node to deploy to |
| `active` | No | Whether MCP keeps this running (default: `true`) |
| `path` | No | Source directory relative to workspace (default: `name`) |
**Build fields:**
| Field | Purpose |
|-------|---------|
| `build.images.<name>` | Maps build image name to Dockerfile path. The `<name>` must match the repository name in a component's `image` field (the part after the last `/`, before the `:` tag). |
**Component fields:**
| Field | Required | Purpose |
|-------|----------|---------|
| `name` | Yes | Component name (e.g. `api`, `web`) |
| `image` | Yes | Full image reference (e.g. `mcr.svc.mcp.metacircular.net:8443/myservice:v1.0.0`) |
| `volumes` | No | Volume mounts (list of `host:container` strings) |
| `cmd` | No | Command override (list of strings) |
| `env` | No | Extra environment variables (list of `KEY=VALUE` strings) |
| `network` | No | Container network (default: none) |
| `user` | No | Container user (e.g. `0:0`) |
| `restart` | No | Restart policy (e.g. `unless-stopped`) |
**Route fields (under `[[components.routes]]`):**
| Field | Purpose |
|-------|---------|
| `name` | Route name — determines `$PORT_<NAME>` env var |
| `port` | External port on mc-proxy (e.g. `8443`, `9443`, `443`) |
| `mode` | `l4` (TLS passthrough) or `l7` (TLS termination by mc-proxy) |
| `hostname` | Public hostname override |
### Routing Modes
| Mode | TLS handled by | Use when... |
|------|----------------|-------------|
| `l4` | The service itself | Service manages its own TLS (API servers, gRPC) |
| `l7` | mc-proxy | mc-proxy terminates TLS and proxies HTTP to the service (web UIs) |
### Version Pinning
Component `image` fields **must** pin an explicit semver tag (e.g.
`mcr.svc.mcp.metacircular.net:8443/myservice:v1.1.0`). Never use
`:latest`. This ensures deployments are reproducible and `mcp status`
shows the actual running version. The version is extracted from the
image tag.
---
## 3. Build, Push, and Deploy
### Tag the Release
```bash
git tag -a v1.0.0 -m "v1.0.0"
git push origin v1.0.0
```
### Build and Push Images
```bash
mcp build <service>
```
This reads the `[build.images]` section of the service definition,
builds each Dockerfile, tags the images with the version from the
definition, and pushes them to MCR.
The workspace root is configured in `~/.config/mcp/mcp.toml`:
```toml
[build]
workspace = "~/src/metacircular"
```
Each service's source is at `<workspace>/<path>` (where `path` defaults
to the service name).
### Sync and Deploy
```bash
# Push all service definitions to agents, auto-build missing images
mcp sync
# Deploy (or redeploy) a specific service
mcp deploy <service>
```
`mcp sync` checks whether each component's image tag exists in MCR. If
missing and the source tree is available, it builds and pushes
automatically.
`mcp deploy` pulls the image on the target node and creates or
recreates the containers.
### What Happens During Deploy
1. Agent assigns a free host port (1000060000) for each declared route.
2. Agent starts containers with `$PORT` / `$PORT_<NAME>` environment
variables set to the assigned ports.
3. Agent registers routes with mc-proxy (hostname → `127.0.0.1:<port>`,
mode, TLS cert paths).
4. Agent records the full state in its SQLite registry.
On stop (`mcp stop <service>`), the agent reverses the process: removes
mc-proxy routes, then stops containers.
---
## 4. Data Directory Convention
All runtime data lives in `/srv/<service>/` on the host. This directory
is bind-mounted into the container.
```
/srv/<service>/
├── <service>.toml # Configuration file
├── <service>.db # SQLite database (created on first run)
├── certs/ # TLS certificates
│ ├── cert.pem
│ └── key.pem
└── backups/ # Database snapshots
```
This directory must exist on the target node before the first deploy,
owned by the `mcp` user (which runs rootless Podman). Create it with:
```bash
sudo mkdir -p /srv/<service>/certs
sudo chown -R mcp:mcp /srv/<service>
```
Place the service's TOML configuration and TLS certificates here before
deploying.
---
## 5. Configuration
Services use TOML configuration with environment variable overrides.
### Standard Config Sections
```toml
[server]
listen_addr = ":8443"
grpc_addr = ":9443"
tls_cert = "/srv/<service>/certs/cert.pem"
tls_key = "/srv/<service>/certs/key.pem"
[database]
path = "/srv/<service>/<service>.db"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
ca_cert = ""
service_name = "<service>"
tags = []
[log]
level = "info"
```
For services with SSO-enabled web UIs, add:
```toml
[sso]
redirect_uri = "https://<service>.svc.mcp.metacircular.net/sso/callback"
```
For services with a separate web UI binary, add:
```toml
[web]
listen_addr = "127.0.0.1:8080"
vault_grpc = "127.0.0.1:9443"
vault_ca_cert = ""
```
### $PORT Convention
When deployed via MCP, the agent assigns host ports and passes them as
environment variables. **Applications should not hardcode listen
addresses** — they will be overridden at deploy time.
| Env var | When set |
|---------|----------|
| `$PORT` | Component has a single unnamed route |
| `$PORT_<NAME>` | Component has named routes |
Route names are uppercased: `name = "rest"``$PORT_REST`,
`name = "grpc"``$PORT_GRPC`.
**Container listen address:** Services must bind to `0.0.0.0:$PORT`
(or `:$PORT`), not `localhost:$PORT`. Podman port-forwards go through
the container's network namespace — binding to `localhost` inside the
container makes the port unreachable from outside.
Services built with **mcdsl v1.1.0+** handle this automatically —
`config.Load` checks `$PORT` → overrides `Server.ListenAddr`, and
`$PORT_GRPC` → overrides `Server.GRPCAddr`. These take precedence over
TOML values.
Services not using mcdsl must check these environment variables in their
own config loading.
### Environment Variable Overrides
Beyond `$PORT`, services support `$SERVICENAME_SECTION_KEY` overrides.
For example, `$MCR_SERVER_LISTEN_ADDR=:9999` overrides
`[server] listen_addr` in MCR's config. `$PORT` takes precedence over
these.
---
## 6. Authentication (MCIAS Integration)
Every service delegates authentication to MCIAS. No service maintains
its own user database. Services support two login modes: **SSO
redirect** (recommended for web UIs) and **direct credentials**
(fallback / API clients).
### SSO Login (Web UIs)
SSO is the preferred login method for web UIs. The flow is an OAuth
2.0-style authorization code exchange:
1. User visits the service and is redirected to `/login`.
2. Login page shows a "Sign in with MCIAS" button.
3. Click redirects to MCIAS (`/sso/authorize`), which authenticates the
user.
4. MCIAS redirects back to the service's `/sso/callback` with an
authorization code.
5. The service exchanges the code for a JWT via a server-to-server call
to MCIAS `POST /v1/sso/token`.
6. The JWT is stored in a session cookie.
SSO is enabled by adding an `[sso]` section to the service config and
registering the service as an SSO client in MCIAS.
**Service config:**
```toml
[sso]
redirect_uri = "https://<service>.svc.mcp.metacircular.net/sso/callback"
```
**MCIAS config** (add to the `[[sso_clients]]` list):
```toml
[[sso_clients]]
client_id = "<service>"
redirect_uri = "https://<service>.svc.mcp.metacircular.net/sso/callback"
service_name = "<service>"
```
The `redirect_uri` must match exactly between the service config and
the MCIAS client registration.
When `[sso].redirect_uri` is empty or absent, the service falls back to
the direct credentials form.
**Implementation:** Services use `mcdsl/sso` (v1.7.0+) which handles
state management, CSRF-safe cookies, and the code exchange. The web
server registers three routes:
| Route | Purpose |
|-------|---------|
| `GET /login` | Renders landing page with "Sign in with MCIAS" button |
| `GET /sso/redirect` | Sets state cookies, redirects to MCIAS |
| `GET /sso/callback` | Validates state, exchanges code for JWT, sets session |
### Direct Credentials (API / Fallback)
1. Client sends credentials to the service's `POST /v1/auth/login`.
2. Service forwards them to MCIAS via `mcdsl/auth.Authenticator.Login()`.
3. MCIAS validates and returns a bearer token.
4. Subsequent requests include `Authorization: Bearer <token>`.
5. Service validates tokens via `ValidateToken()`, cached for 30s
(keyed by SHA-256 of the token).
Web UIs use this mode when SSO is not configured, presenting a
username/password/TOTP form instead of the SSO button.
### Roles
| Role | Access |
|------|--------|
| `admin` | Full access, policy bypass |
| `user` | Access governed by policy rules, default deny |
| `guest` | Service-dependent restrictions, default deny |
Admin detection comes solely from the MCIAS `admin` role. Services
never promote users locally.
---
## 7. Networking
### Hostnames
Every service gets `<service>.svc.mcp.metacircular.net` automatically.
Public-facing services can declare additional hostnames:
```toml
[[components.routes]]
port = 443
mode = "l7"
hostname = "docs.metacircular.net"
```
### TLS
- **Minimum TLS 1.3.** No exceptions.
- L4 services manage their own TLS — certificates go in
`/srv/<service>/certs/`.
- L7 services have TLS terminated by mc-proxy — certs are stored at
`/srv/mc-proxy/certs/<service>.pem`.
- Certificate and key paths are required config — the service refuses
to start without them.
### Container Networking
Containers join the `mcpnet` Podman network by default. Services
communicate with each other over this network or via loopback (when
co-located on the same node).
---
## 8. Command Reference
| Command | Purpose |
|---------|---------|
| `mcp build <service>` | Build and push images to MCR |
| `mcp sync` | Push all service definitions to agents; auto-build missing images |
| `mcp deploy <service>` | Pull image, (re)create containers, register routes |
| `mcp undeploy <service>` | Full teardown: remove routes, DNS, certs, and containers |
| `mcp stop <service>` | Remove routes, stop containers |
| `mcp start <service>` | Start previously stopped containers |
| `mcp restart <service>` | Restart containers in place |
| `mcp ps` | List all managed containers and status |
| `mcp status [service]` | Detailed status for a specific service |
| `mcp logs <service>` | Stream container logs |
| `mcp edit <service>` | Edit service definition |
---
## 9. Complete Walkthrough
Deploying a new service called `myservice` from scratch:
```bash
# 1. Prepare the target node
ssh rift
sudo mkdir -p /srv/myservice/certs
sudo chown -R mcp:mcp /srv/myservice
# Place myservice.toml and TLS certs in /srv/myservice/
exit
# 2. Tag the release
cd ~/src/metacircular/myservice
git tag -a v1.0.0 -m "v1.0.0"
git push origin v1.0.0
# 3. Write the service definition
cat > ~/.config/mcp/services/myservice.toml << 'EOF'
name = "myservice"
node = "rift"
[build.images]
myservice = "Dockerfile.api"
[[components]]
name = "api"
image = "mcr.svc.mcp.metacircular.net:8443/myservice:v1.0.0"
volumes = ["/srv/myservice:/srv/myservice"]
[[components.routes]]
name = "rest"
port = 8443
mode = "l4"
[[components.routes]]
name = "grpc"
port = 9443
mode = "l4"
EOF
# 4. Build and push the image
mcp build myservice
# 5. Deploy
mcp deploy myservice
# 6. Verify
mcp status myservice
mcp ps
```
The service is now running, with mc-proxy routing
`myservice.svc.mcp.metacircular.net` traffic to the agent-assigned
ports.
---
## Appendix: Repository Layout
Services follow a standard directory structure:
```
.
├── cmd/<service>/ CLI entry point (server, subcommands)
├── cmd/<service>-web/ Web UI entry point (if separate)
├── internal/ All service logic (not importable externally)
│ ├── auth/ MCIAS integration
│ ├── config/ TOML config loading
│ ├── db/ Database setup, migrations
│ ├── server/ REST API server
│ ├── grpcserver/ gRPC server
│ └── webserver/ Web UI server (if applicable)
├── proto/<service>/v1/ Protobuf definitions
├── gen/<service>/v1/ Generated gRPC code
├── web/ Templates and static assets (embedded)
├── deploy/
│ ├── <service>-rift.toml Reference MCP service definition
│ ├── docker/ Docker Compose files
│ ├── examples/ Example config files
│ └── systemd/ systemd units
├── Dockerfile.api API server container
├── Dockerfile.web Web UI container (if applicable)
├── Makefile Standard build targets
└── <service>.toml.example Example configuration
```
### Standard Makefile Targets
| Target | Purpose |
|--------|---------|
| `make all` | vet → lint → test → build (the CI pipeline) |
| `make build` | `go build ./...` |
| `make test` | `go test ./...` |
| `make vet` | `go vet ./...` |
| `make lint` | `golangci-lint run ./...` |
| `make docker` | Build the container image |
| `make proto` | Regenerate gRPC code from .proto files |
| `make devserver` | Build and run locally against `srv/` config |
---
## 10. Agent Management
MCP manages a fleet of nodes with heterogeneous operating systems and
architectures. The agent binary lives at `/srv/mcp/mcp-agent` on every
node — this is a mutable path that MCP controls, regardless of whether
the node runs NixOS or Debian.
### Node Configuration
Each node in `~/.config/mcp/mcp.toml` includes SSH and architecture
info for agent management:
```toml
[[nodes]]
name = "rift"
address = "100.95.252.120:9444"
ssh = "rift"
arch = "amd64"
[[nodes]]
name = "hyperborea"
address = "100.x.x.x:9444"
ssh = "hyperborea"
arch = "arm64"
```
### Upgrading Agents
After tagging a new MCP release:
```bash
# Upgrade all nodes (recommended — prevents version skew)
mcp agent upgrade
# Upgrade a single node
mcp agent upgrade rift
# Check versions across the fleet
mcp agent status
```
`mcp agent upgrade` cross-compiles the agent binary for each target
architecture, SSHs to each node, atomically replaces the binary, and
restarts the systemd service. All nodes should be upgraded together
because new CLI versions often depend on new agent RPCs.
### Provisioning New Nodes
One-time setup for a new Debian node:
```bash
# 1. Provision the node (creates user, dirs, systemd unit, installs binary)
mcp node provision <name>
# 2. Register the node
mcp node add <name> <address>
# 3. Deploy services
mcp deploy <service>
```
For NixOS nodes, provisioning is handled by the NixOS configuration.
The NixOS config creates the `mcp` user, systemd unit, and directories.
The `ExecStart` path points to `/srv/mcp/mcp-agent` so that `mcp agent
upgrade` works the same as on Debian nodes.
---
## Appendix: Currently Deployed Services
For reference, these services are operational on the platform:
| Service | Version | Node | Purpose |
|---------|---------|------|---------|
| MCIAS | v1.9.0 | (separate) | Identity and access |
| Metacrypt | v1.4.1 | rift | Cryptographic service, PKI/CA |
| MC-Proxy | v1.2.1 | rift | TLS proxy and router |
| MCR | v1.2.1 | rift | Container registry |
| MCNS | v1.1.1 | rift | Authoritative DNS |
| MCDoc | v0.1.0 | rift | Documentation server |
| MCQ | v0.4.0 | rift | Document review queue |
| MCP | v0.7.6 | rift | Control plane agent |

7
go.mod
View File

@@ -3,13 +3,15 @@ module git.wntrmute.dev/mc/mcq
go 1.25.7
require (
git.wntrmute.dev/mc/mcdsl v1.2.0
git.wntrmute.dev/mc/mcdsl v1.7.0
github.com/alecthomas/chroma/v2 v2.18.0
github.com/go-chi/chi/v5 v5.2.5
github.com/mark3labs/mcp-go v0.46.0
github.com/pelletier/go-toml/v2 v2.3.0
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.7.12
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
golang.org/x/term v0.41.0
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
)
@@ -17,12 +19,15 @@ require (
require (
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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/spf13/pflag v1.0.9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.32.0 // indirect

28
go.sum
View File

@@ -1,5 +1,5 @@
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
git.wntrmute.dev/mc/mcdsl v1.7.0 h1:dAh2SGdzjhz0H66i3KAMDm1eRYYgMaxqQ0Pj5NzF7fc=
git.wntrmute.dev/mc/mcdsl v1.7.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
@@ -12,6 +12,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
@@ -19,6 +20,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -29,6 +32,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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=
@@ -39,22 +44,37 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
@@ -82,6 +102,8 @@ 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/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
@@ -96,6 +118,8 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=

223
internal/client/client.go Normal file
View File

@@ -0,0 +1,223 @@
// Package client provides an HTTP client for the MCQ REST API.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
)
// Document represents a document returned by the MCQ API.
type Document struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Body string `json:"body"`
PushedBy string `json:"pushed_by"`
PushedAt string `json:"pushed_at"`
Read bool `json:"read"`
}
// Client talks to a remote MCQ server's REST API.
type Client struct {
baseURL string
token string
httpClient *http.Client
}
// Option configures a Client.
type Option func(*Client)
// WithHTTPClient sets a custom HTTP client (useful for testing).
func WithHTTPClient(hc *http.Client) Option {
return func(c *Client) {
c.httpClient = hc
}
}
// New creates a Client. baseURL is the MCQ server URL (e.g. "https://mcq.example.com:8443").
// token is a Bearer token from MCIAS login.
func New(baseURL, token string, opts ...Option) *Client {
c := &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: http.DefaultClient,
}
for _, o := range opts {
o(c)
}
return c
}
func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
bodyReader = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
switch resp.StatusCode {
case http.StatusUnauthorized:
_ = resp.Body.Close()
return nil, ErrUnauthorized
case http.StatusForbidden:
_ = resp.Body.Close()
return nil, ErrForbidden
case http.StatusNotFound:
_ = resp.Body.Close()
return nil, ErrNotFound
}
return resp, nil
}
func decodeJSON[T any](resp *http.Response) (T, error) {
defer func() { _ = resp.Body.Close() }()
var v T
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return v, fmt.Errorf("decode response: %w", err)
}
return v, nil
}
// Login authenticates with MCIAS and returns a bearer token.
func (c *Client) Login(ctx context.Context, username, password, totpCode string) (string, error) {
resp, err := c.do(ctx, http.MethodPost, "/v1/auth/login", map[string]string{
"username": username,
"password": password,
"totp_code": totpCode,
})
if err != nil {
return "", err
}
result, err := decodeJSON[struct {
Token string `json:"token"`
}](resp)
if err != nil {
return "", err
}
return result.Token, nil
}
// ListDocuments returns all documents in the queue.
func (c *Client) ListDocuments(ctx context.Context) ([]Document, error) {
resp, err := c.do(ctx, http.MethodGet, "/v1/documents", nil)
if err != nil {
return nil, err
}
result, err := decodeJSON[struct {
Documents []Document `json:"documents"`
}](resp)
if err != nil {
return nil, err
}
return result.Documents, nil
}
// GetDocument fetches a single document by slug.
func (c *Client) GetDocument(ctx context.Context, slug string) (*Document, error) {
resp, err := c.do(ctx, http.MethodGet, "/v1/documents/"+slug, nil)
if err != nil {
return nil, err
}
doc, err := decodeJSON[Document](resp)
if err != nil {
return nil, err
}
return &doc, nil
}
// PutDocument creates or replaces a document.
func (c *Client) PutDocument(ctx context.Context, slug, title, body string) (*Document, error) {
resp, err := c.do(ctx, http.MethodPut, "/v1/documents/"+slug, map[string]string{
"title": title,
"body": body,
})
if err != nil {
return nil, err
}
doc, err := decodeJSON[Document](resp)
if err != nil {
return nil, err
}
return &doc, nil
}
// DeleteDocument removes a document by slug.
func (c *Client) DeleteDocument(ctx context.Context, slug string) error {
resp, err := c.do(ctx, http.MethodDelete, "/v1/documents/"+slug, nil)
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
// MarkRead marks a document as read.
func (c *Client) MarkRead(ctx context.Context, slug string) (*Document, error) {
resp, err := c.do(ctx, http.MethodPost, "/v1/documents/"+slug+"/read", nil)
if err != nil {
return nil, err
}
doc, err := decodeJSON[Document](resp)
if err != nil {
return nil, err
}
return &doc, nil
}
// MarkUnread marks a document as unread.
func (c *Client) MarkUnread(ctx context.Context, slug string) (*Document, error) {
resp, err := c.do(ctx, http.MethodPost, "/v1/documents/"+slug+"/unread", nil)
if err != nil {
return nil, err
}
doc, err := decodeJSON[Document](resp)
if err != nil {
return nil, err
}
return &doc, nil
}
var h1Re = regexp.MustCompile(`(?m)^#\s+(.+)$`)
// ExtractTitle returns the first H1 heading from markdown source.
// If no H1 is found, it returns the fallback string.
func ExtractTitle(markdown, fallback string) string {
m := h1Re.FindStringSubmatch(markdown)
if m == nil {
return fallback
}
return strings.TrimSpace(m[1])
}

View File

@@ -0,0 +1,239 @@
package client
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func testServer(t *testing.T) (*httptest.Server, *Client) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Username == "admin" && req.Password == "pass" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"token": "tok123"})
return
}
w.WriteHeader(http.StatusUnauthorized)
})
mux.HandleFunc("GET /v1/documents", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"documents": []Document{
{ID: 1, Slug: "test-doc", Title: "Test", Body: "# Test\nHello", PushedBy: "admin", PushedAt: "2026-01-01T00:00:00Z", Read: false},
},
})
})
mux.HandleFunc("GET /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "missing" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(Document{ID: 1, Slug: slug, Title: "Test", Body: "# Test\nHello"})
})
mux.HandleFunc("PUT /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
var req struct {
Title string `json:"title"`
Body string `json:"body"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(Document{ID: 1, Slug: slug, Title: req.Title, Body: req.Body, PushedBy: "admin"})
})
mux.HandleFunc("DELETE /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "missing" {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /v1/documents/{slug}/read", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(Document{ID: 1, Slug: slug, Title: "Test", Read: true})
})
mux.HandleFunc("POST /v1/documents/{slug}/unread", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(Document{ID: 1, Slug: slug, Title: "Test", Read: false})
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
c := New(ts.URL, "tok123", WithHTTPClient(ts.Client()))
return ts, c
}
func TestLogin(t *testing.T) {
_, c := testServer(t)
c.token = "" // login doesn't need a pre-existing token
token, err := c.Login(context.Background(), "admin", "pass", "")
if err != nil {
t.Fatalf("Login: %v", err)
}
if token != "tok123" {
t.Fatalf("got token %q, want %q", token, "tok123")
}
}
func TestLoginBadCredentials(t *testing.T) {
_, c := testServer(t)
c.token = ""
_, err := c.Login(context.Background(), "admin", "wrong", "")
if err != ErrUnauthorized {
t.Fatalf("got %v, want ErrUnauthorized", err)
}
}
func TestListDocuments(t *testing.T) {
_, c := testServer(t)
docs, err := c.ListDocuments(context.Background())
if err != nil {
t.Fatalf("ListDocuments: %v", err)
}
if len(docs) != 1 {
t.Fatalf("got %d docs, want 1", len(docs))
}
if docs[0].Slug != "test-doc" {
t.Fatalf("got slug %q, want %q", docs[0].Slug, "test-doc")
}
}
func TestGetDocument(t *testing.T) {
_, c := testServer(t)
doc, err := c.GetDocument(context.Background(), "test-doc")
if err != nil {
t.Fatalf("GetDocument: %v", err)
}
if doc.Slug != "test-doc" {
t.Fatalf("got slug %q, want %q", doc.Slug, "test-doc")
}
}
func TestGetDocumentNotFound(t *testing.T) {
_, c := testServer(t)
_, err := c.GetDocument(context.Background(), "missing")
if err != ErrNotFound {
t.Fatalf("got %v, want ErrNotFound", err)
}
}
func TestPutDocument(t *testing.T) {
_, c := testServer(t)
doc, err := c.PutDocument(context.Background(), "new-doc", "New Doc", "# New\nContent")
if err != nil {
t.Fatalf("PutDocument: %v", err)
}
if doc.Slug != "new-doc" {
t.Fatalf("got slug %q, want %q", doc.Slug, "new-doc")
}
if doc.Title != "New Doc" {
t.Fatalf("got title %q, want %q", doc.Title, "New Doc")
}
}
func TestDeleteDocument(t *testing.T) {
_, c := testServer(t)
if err := c.DeleteDocument(context.Background(), "test-doc"); err != nil {
t.Fatalf("DeleteDocument: %v", err)
}
}
func TestDeleteDocumentNotFound(t *testing.T) {
_, c := testServer(t)
err := c.DeleteDocument(context.Background(), "missing")
if err != ErrNotFound {
t.Fatalf("got %v, want ErrNotFound", err)
}
}
func TestMarkRead(t *testing.T) {
_, c := testServer(t)
doc, err := c.MarkRead(context.Background(), "test-doc")
if err != nil {
t.Fatalf("MarkRead: %v", err)
}
if !doc.Read {
t.Fatal("expected doc to be marked read")
}
}
func TestMarkUnread(t *testing.T) {
_, c := testServer(t)
doc, err := c.MarkUnread(context.Background(), "test-doc")
if err != nil {
t.Fatalf("MarkUnread: %v", err)
}
if doc.Read {
t.Fatal("expected doc to be marked unread")
}
}
func TestExtractTitle(t *testing.T) {
tests := []struct {
name string
markdown string
fallback string
want string
}{
{"h1 found", "# My Title\nSome content", "default", "My Title"},
{"no h1", "Some content without heading", "default", "default"},
{"h2 not matched", "## Subtitle\nContent", "default", "default"},
{"h1 with spaces", "# Spaced Title \nContent", "default", "Spaced Title"},
{"multiple h1s", "# First\n# Second", "default", "First"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractTitle(tt.markdown, tt.fallback)
if got != tt.want {
t.Errorf("ExtractTitle() = %q, want %q", got, tt.want)
}
})
}
}
func TestBearerTokenSent(t *testing.T) {
var gotAuth string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"documents": []Document{}})
}))
t.Cleanup(ts.Close)
c := New(ts.URL, "my-secret-token", WithHTTPClient(ts.Client()))
_, _ = c.ListDocuments(context.Background())
if gotAuth != "Bearer my-secret-token" {
t.Fatalf("got Authorization %q, want %q", gotAuth, "Bearer my-secret-token")
}
}

View File

@@ -14,10 +14,18 @@ import (
// Config is the MCQ configuration.
type Config struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
MCIAS mcdslauth.Config `toml:"mcias"`
Log LogConfig `toml:"log"`
SSO SSOConfig `toml:"sso"`
Log LogConfig `toml:"log"`
}
// SSOConfig holds SSO redirect settings for the web UI.
type SSOConfig struct {
// RedirectURI is the callback URL that MCIAS redirects to after login.
// Must exactly match the redirect_uri registered in MCIAS config.
RedirectURI string `toml:"redirect_uri"`
}
// ServerConfig holds HTTP/gRPC server settings. TLS fields are optional;

View File

@@ -0,0 +1,153 @@
// Package mcpserver implements an MCP stdio server for MCQ.
package mcpserver
import (
"context"
"fmt"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"git.wntrmute.dev/mc/mcq/internal/client"
)
// New creates an MCP server backed by the given MCQ client.
func New(c *client.Client, version string) *server.MCPServer {
s := server.NewMCPServer("mcq", version,
server.WithToolCapabilities(false),
)
s.AddTool(pushDocumentTool(), pushDocumentHandler(c))
s.AddTool(listDocumentsTool(), listDocumentsHandler(c))
s.AddTool(getDocumentTool(), getDocumentHandler(c))
s.AddTool(deleteDocumentTool(), deleteDocumentHandler(c))
return s
}
func pushDocumentTool() mcp.Tool {
return mcp.NewTool("push_document",
mcp.WithDescription("Push a markdown document to the MCQ reading queue. If a document with the same slug exists, it will be replaced and marked as unread."),
mcp.WithString("slug", mcp.Description("Unique identifier for the document (used in URLs)"), mcp.Required()),
mcp.WithString("title", mcp.Description("Document title"), mcp.Required()),
mcp.WithString("body", mcp.Description("Document body in markdown format"), mcp.Required()),
)
}
func pushDocumentHandler(c *client.Client) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
slug, err := request.RequireString("slug")
if err != nil {
return toolError("slug is required"), nil
}
title, err := request.RequireString("title")
if err != nil {
return toolError("title is required"), nil
}
body, err := request.RequireString("body")
if err != nil {
return toolError("body is required"), nil
}
doc, err := c.PutDocument(ctx, slug, title, body)
if err != nil {
return toolError(fmt.Sprintf("failed to push document: %v", err)), nil
}
return toolText(fmt.Sprintf("Pushed document %q (%s) to queue.", doc.Title, doc.Slug)), nil
}
}
func listDocumentsTool() mcp.Tool {
return mcp.NewTool("list_documents",
mcp.WithDescription("List all documents in the MCQ reading queue."),
)
}
func listDocumentsHandler(c *client.Client) server.ToolHandlerFunc {
return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
docs, err := c.ListDocuments(ctx)
if err != nil {
return toolError(fmt.Sprintf("failed to list documents: %v", err)), nil
}
if len(docs) == 0 {
return toolText("No documents in the queue."), nil
}
var b strings.Builder
fmt.Fprintf(&b, "%d document(s) in queue:\n\n", len(docs))
for _, d := range docs {
read := "unread"
if d.Read {
read = "read"
}
fmt.Fprintf(&b, "- **%s** (`%s`) — pushed by %s at %s [%s]\n", d.Title, d.Slug, d.PushedBy, d.PushedAt, read)
}
return toolText(b.String()), nil
}
}
func getDocumentTool() mcp.Tool {
return mcp.NewTool("get_document",
mcp.WithDescription("Get a document's markdown content from the MCQ reading queue."),
mcp.WithString("slug", mcp.Description("Document slug"), mcp.Required()),
)
}
func getDocumentHandler(c *client.Client) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
slug, err := request.RequireString("slug")
if err != nil {
return toolError("slug is required"), nil
}
doc, err := c.GetDocument(ctx, slug)
if err != nil {
return toolError(fmt.Sprintf("failed to get document: %v", err)), nil
}
return toolText(doc.Body), nil
}
}
func deleteDocumentTool() mcp.Tool {
return mcp.NewTool("delete_document",
mcp.WithDescription("Delete a document from the MCQ reading queue."),
mcp.WithString("slug", mcp.Description("Document slug"), mcp.Required()),
)
}
func deleteDocumentHandler(c *client.Client) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
slug, err := request.RequireString("slug")
if err != nil {
return toolError("slug is required"), nil
}
if err := c.DeleteDocument(ctx, slug); err != nil {
return toolError(fmt.Sprintf("failed to delete document: %v", err)), nil
}
return toolText(fmt.Sprintf("Deleted document %q from queue.", slug)), nil
}
}
func toolText(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: text},
},
}
}
func toolError(msg string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: msg},
},
IsError: true,
}
}

View File

@@ -0,0 +1,171 @@
package mcpserver
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"git.wntrmute.dev/mc/mcq/internal/client"
)
func testClient(t *testing.T) *client.Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/documents", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"documents": []client.Document{
{ID: 1, Slug: "doc-1", Title: "First Doc", Body: "# First\nContent", PushedBy: "admin", PushedAt: "2026-01-01T00:00:00Z"},
},
})
})
mux.HandleFunc("GET /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "missing" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(client.Document{ID: 1, Slug: slug, Title: "Test Doc", Body: "# Test\nContent"})
})
mux.HandleFunc("PUT /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
var req struct {
Title string `json:"title"`
Body string `json:"body"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(client.Document{ID: 1, Slug: slug, Title: req.Title, Body: req.Body, PushedBy: "admin"})
})
mux.HandleFunc("DELETE /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "missing" {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return client.New(ts.URL, "test-token", client.WithHTTPClient(ts.Client()))
}
func TestPushDocumentHandler(t *testing.T) {
c := testClient(t)
handler := pushDocumentHandler(c)
result, err := handler(context.Background(), mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "push_document",
Arguments: map[string]any{"slug": "test-doc", "title": "Test", "body": "# Test\nContent"},
},
})
if err != nil {
t.Fatalf("push handler: %v", err)
}
if result.IsError {
t.Fatalf("unexpected tool error: %v", result.Content)
}
text := result.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "test-doc") {
t.Fatalf("expected slug in response, got: %s", text)
}
}
func TestListDocumentsHandler(t *testing.T) {
c := testClient(t)
handler := listDocumentsHandler(c)
result, err := handler(context.Background(), mcp.CallToolRequest{})
if err != nil {
t.Fatalf("list handler: %v", err)
}
if result.IsError {
t.Fatalf("unexpected tool error: %v", result.Content)
}
text := result.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "doc-1") {
t.Fatalf("expected doc slug in response, got: %s", text)
}
}
func TestGetDocumentHandler(t *testing.T) {
c := testClient(t)
handler := getDocumentHandler(c)
result, err := handler(context.Background(), mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "get_document",
Arguments: map[string]any{"slug": "test-doc"},
},
})
if err != nil {
t.Fatalf("get handler: %v", err)
}
if result.IsError {
t.Fatalf("unexpected tool error: %v", result.Content)
}
text := result.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "# Test") {
t.Fatalf("expected markdown body, got: %s", text)
}
}
func TestDeleteDocumentHandler(t *testing.T) {
c := testClient(t)
handler := deleteDocumentHandler(c)
result, err := handler(context.Background(), mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "delete_document",
Arguments: map[string]any{"slug": "test-doc"},
},
})
if err != nil {
t.Fatalf("delete handler: %v", err)
}
if result.IsError {
t.Fatalf("unexpected tool error: %v", result.Content)
}
text := result.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "Deleted") {
t.Fatalf("expected deletion confirmation, got: %s", text)
}
}
func TestDeleteDocumentNotFound(t *testing.T) {
c := testClient(t)
handler := deleteDocumentHandler(c)
result, err := handler(context.Background(), mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "delete_document",
Arguments: map[string]any{"slug": "missing"},
},
})
if err != nil {
t.Fatalf("delete handler: %v", err)
}
if !result.IsError {
t.Fatal("expected tool error for missing document")
}
}
func TestNewCreatesServer(t *testing.T) {
c := testClient(t)
s := New(c, "test")
if s == nil {
t.Fatal("expected non-nil server")
}
}

View File

@@ -11,6 +11,7 @@ import (
"git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/csrf"
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
"git.wntrmute.dev/mc/mcdsl/web"
mcqweb "git.wntrmute.dev/mc/mcq/web"
@@ -25,16 +26,22 @@ const cookieName = "mcq_session"
type Config struct {
ServiceName string
Tags []string
// SSO fields — when RedirectURI is non-empty, the web UI uses SSO instead
// of the direct username/password login form.
MciasURL string
CACert string
RedirectURI string
}
// Server is the MCQ web UI server.
type Server struct {
db *db.DB
auth *auth.Authenticator
csrf *csrf.Protect
render *render.Renderer
logger *slog.Logger
config Config
db *db.DB
auth *auth.Authenticator
csrf *csrf.Protect
ssoClient *mcdsso.Client
render *render.Renderer
logger *slog.Logger
config Config
}
// New creates a web UI server.
@@ -45,20 +52,43 @@ func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger
}
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
return &Server{
s := &Server{
db: database,
auth: authenticator,
csrf: csrfProtect,
render: render.New(),
logger: logger,
config: cfg,
}, nil
}
// Create SSO client if the service has an SSO redirect_uri configured.
if cfg.RedirectURI != "" {
ssoClient, err := mcdsso.New(mcdsso.Config{
MciasURL: cfg.MciasURL,
ClientID: "mcq",
RedirectURI: cfg.RedirectURI,
CACert: cfg.CACert,
})
if err != nil {
return nil, fmt.Errorf("create SSO client: %w", err)
}
s.ssoClient = ssoClient
logger.Info("SSO enabled: redirecting to MCIAS for login", "mcias_url", cfg.MciasURL)
}
return s, nil
}
// RegisterRoutes adds web UI routes to the given router.
func (s *Server) RegisterRoutes(r chi.Router) {
r.Get("/login", s.handleLoginPage)
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
if s.ssoClient != nil {
r.Get("/login", s.handleSSOLogin)
r.Get("/sso/redirect", s.handleSSORedirect)
r.Get("/sso/callback", s.handleSSOCallback)
} else {
r.Get("/login", s.handleLoginPage)
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
}
r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP)
// Authenticated routes.
@@ -70,6 +100,7 @@ func (s *Server) RegisterRoutes(r chi.Router) {
r.Get("/d/{slug}", s.handleRead)
r.Post("/d/{slug}/read", s.handleMarkRead)
r.Post("/d/{slug}/unread", s.handleMarkUnread)
r.Post("/d/{slug}/delete", s.handleDelete)
r.Post("/logout", s.handleLogout)
})
}
@@ -79,6 +110,7 @@ type pageData struct {
Error string
Title string
Content any
SSO bool
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
@@ -100,6 +132,32 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button.
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{SSO: true}, s.csrf.TemplateFunc(w))
}
// handleSSORedirect initiates the SSO redirect to MCIAS.
func (s *Server) handleSSORedirect(w http.ResponseWriter, r *http.Request) {
if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcq"); err != nil {
s.logger.Error("sso: redirect to login", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
// handleSSOCallback exchanges the authorization code for a JWT and sets the session.
func (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
token, returnTo, err := mcdsso.HandleCallback(w, r, s.ssoClient, "mcq")
if err != nil {
s.logger.Error("sso: callback", "error", err)
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
return
}
web.SetSessionCookie(w, cookieName, token)
http.Redirect(w, r, returnTo, http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, cookieName)
if token != "" {
@@ -176,3 +234,11 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if err := s.db.DeleteDocument(slug); err != nil {
s.logger.Error("failed to delete document", "slug", slug, "error", err)
}
http.Redirect(w, r, "/", http.StatusFound)
}

View File

@@ -252,6 +252,14 @@ button:hover, .btn:hover {
padding: 0.1875rem 0.625rem;
font-size: 0.75rem;
}
.btn-danger {
color: var(--accent);
border-color: var(--accent);
}
.btn-danger:hover {
background: var(--accent);
color: var(--bg);
}
/* ===========================
Forms
@@ -345,8 +353,75 @@ button:hover, .btn:hover {
}
/* ===========================
Read view
Read view — two-column layout
=========================== */
.read-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 2rem 2rem 0;
}
.read-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 2rem;
align-items: start;
}
.read-main {
max-width: 900px;
min-width: 0;
}
.read-toc {
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
padding: 0.5rem 0;
font-size: 0.75rem;
line-height: 1.5;
border-right: 1px solid var(--border-lt);
padding-right: 1rem;
}
.read-toc ul {
list-style: none;
padding: 0;
margin: 0;
}
.read-toc li {
margin-bottom: 0.25rem;
}
.read-toc a {
color: var(--text-lt);
text-decoration: none;
display: block;
padding: 0.125rem 0;
transition: color 0.1s;
}
.read-toc a:hover {
color: var(--text);
text-decoration: none;
}
.read-toc a.toc-active {
color: var(--accent);
}
@media (max-width: 900px) {
.read-container {
padding: 1.5rem 1rem;
}
.read-layout {
grid-template-columns: 1fr;
}
.read-toc {
position: static;
max-height: none;
border-right: none;
border-bottom: 1px solid var(--border-lt);
padding-right: 0;
padding-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
}
.read-header {
margin-bottom: 1.5rem;
}

View File

@@ -1,6 +1,6 @@
{{define "title"}} — Queue{{end}}
{{define "content"}}
<h2>Reading Queue</h2>
<h2>Metacircular Reading Queue</h2>
{{if not .Documents}}
<div class="card">
<p>No documents in queue.</p>

View File

@@ -3,10 +3,15 @@
{{define "content"}}
<div class="auth-header">
<div class="brand">mcq</div>
<div class="tagline">Reading Queue</div>
<div class="tagline">Metacircular Reading Queue</div>
</div>
<div class="card">
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .SSO}}
<div class="form-actions">
<a href="/sso/redirect" style="display:block;text-align:center;text-decoration:none;"><button type="button" style="width:100%" class="btn">Sign in with MCIAS</button></a>
</div>
{{else}}
<form method="POST" action="/login">
{{csrfField}}
<div class="form-group">
@@ -25,5 +30,6 @@
<button type="submit" class="btn">Login</button>
</div>
</form>
{{end}}
</div>
{{end}}

View File

@@ -1,27 +1,75 @@
{{define "title"}} — {{.Doc.Title}}{{end}}
{{define "container-class"}}read-container{{end}}
{{define "content"}}
<div class="read-header">
<h2>{{.Doc.Title}}</h2>
<div class="read-meta">
<span>Pushed by {{.Doc.PushedBy}}</span>
<span>{{.Doc.PushedAt}}</span>
</div>
<div class="read-actions">
{{if .Doc.Read}}
<form method="POST" action="/d/{{.Doc.Slug}}/unread" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark unread</button>
</form>
{{else}}
<form method="POST" action="/d/{{.Doc.Slug}}/read" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark read</button>
</form>
{{end}}
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
<div class="read-layout">
<nav class="read-toc" id="toc" aria-label="Table of contents"></nav>
<div class="read-main">
<div class="read-header">
<h2>{{.Doc.Title}}</h2>
<div class="read-meta">
<span>Pushed by {{.Doc.PushedBy}}</span>
<span>{{.Doc.PushedAt}}</span>
</div>
<div class="read-actions">
{{if .Doc.Read}}
<form method="POST" action="/d/{{.Doc.Slug}}/unread" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark unread</button>
</form>
{{else}}
<form method="POST" action="/d/{{.Doc.Slug}}/read" style="display:inline">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm">Mark read</button>
</form>
{{end}}
<form method="POST" action="/d/{{.Doc.Slug}}/delete" style="display:inline"
onsubmit="return confirm('Delete this document?')">
{{csrfField}}
<button type="submit" class="btn-ghost btn btn-sm btn-danger">Unqueue</button>
</form>
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
</div>
</div>
<div class="card markdown-body" id="article">
{{.HTML}}
</div>
</div>
</div>
<div class="card markdown-body">
{{.HTML}}
</div>
<script>
(function(){
var toc=document.getElementById("toc");
var headings=document.querySelectorAll("#article h1, #article h2, #article h3");
if(headings.length<2){toc.style.display="none";return;}
var ul=document.createElement("ul");
var minLevel=6;
headings.forEach(function(h){var l=parseInt(h.tagName[1]);if(l<minLevel)minLevel=l;});
headings.forEach(function(h){
if(!h.id)return;
var li=document.createElement("li");
var a=document.createElement("a");
a.href="#"+h.id;
a.textContent=h.textContent;
var depth=parseInt(h.tagName[1])-minLevel;
li.style.paddingLeft=(depth*0.75)+"rem";
li.appendChild(a);
ul.appendChild(li);
});
toc.appendChild(ul);
/* highlight current section on scroll */
var links=toc.querySelectorAll("a");
var ids=[];links.forEach(function(a){ids.push(a.getAttribute("href").slice(1));});
function onScroll(){
var current="";
for(var i=0;i<ids.length;i++){
var el=document.getElementById(ids[i]);
if(el&&el.getBoundingClientRect().top<=80)current=ids[i];
}
links.forEach(function(a){
a.classList.toggle("toc-active",a.getAttribute("href")==="#"+current);
});
}
window.addEventListener("scroll",onScroll,{passive:true});
onScroll();
})();
</script>
{{end}}