Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62df7ed6cd | |||
| 051abae390 | |||
| dd5142a48a | |||
| 2c3db6ea25 | |||
| 063bdccf1b | |||
| 67cbcd85bd | |||
| 3d5f52729f | |||
| ed3a547e54 |
61
cmd/mcq/client.go
Normal file
61
cmd/mcq/client.go
Normal 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
32
cmd/mcq/delete.go
Normal 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
34
cmd/mcq/get.go
Normal 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
44
cmd/mcq/list.go
Normal 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
71
cmd/mcq/login.go
Normal 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
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
root.AddCommand(serverCmd())
|
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 {
|
if err := root.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
40
cmd/mcq/mcp.go
Normal file
40
cmd/mcq/mcp.go
Normal 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
58
cmd/mcq/push.go
Normal 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
|
||||||
|
}
|
||||||
@@ -77,6 +77,9 @@ func runServer(configPath string) error {
|
|||||||
wsCfg := webserver.Config{
|
wsCfg := webserver.Config{
|
||||||
ServiceName: cfg.MCIAS.ServiceName,
|
ServiceName: cfg.MCIAS.ServiceName,
|
||||||
Tags: cfg.MCIAS.Tags,
|
Tags: cfg.MCIAS.Tags,
|
||||||
|
MciasURL: cfg.MCIAS.ServerURL,
|
||||||
|
CACert: cfg.MCIAS.CACert,
|
||||||
|
RedirectURI: cfg.SSO.RedirectURI,
|
||||||
}
|
}
|
||||||
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
|
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
755
docs/packaging-and-deployment.md
Normal file
755
docs/packaging-and-deployment.md
Normal 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 (10000–60000) 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
7
go.mod
@@ -3,13 +3,15 @@ module git.wntrmute.dev/mc/mcq
|
|||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
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/alecthomas/chroma/v2 v2.18.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
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/pelletier/go-toml/v2 v2.3.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/yuin/goldmark v1.7.12
|
github.com/yuin/goldmark v1.7.12
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
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/grpc v1.79.3
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
@@ -17,12 +19,15 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/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/net v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -1,5 +1,5 @@
|
|||||||
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
|
git.wntrmute.dev/mc/mcdsl v1.7.0 h1:dAh2SGdzjhz0H66i3KAMDm1eRYYgMaxqQ0Pj5NzF7fc=
|
||||||
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
|
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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
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=
|
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/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/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.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/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.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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/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.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.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 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||||
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
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=
|
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/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.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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
|||||||
223
internal/client/client.go
Normal file
223
internal/client/client.go
Normal 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])
|
||||||
|
}
|
||||||
239
internal/client/client_test.go
Normal file
239
internal/client/client_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,18 @@ import (
|
|||||||
|
|
||||||
// Config is the MCQ configuration.
|
// Config is the MCQ configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
Database DatabaseConfig `toml:"database"`
|
Database DatabaseConfig `toml:"database"`
|
||||||
MCIAS mcdslauth.Config `toml:"mcias"`
|
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;
|
// ServerConfig holds HTTP/gRPC server settings. TLS fields are optional;
|
||||||
|
|||||||
153
internal/mcpserver/server.go
Normal file
153
internal/mcpserver/server.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
171
internal/mcpserver/server_test.go
Normal file
171
internal/mcpserver/server_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"git.wntrmute.dev/mc/mcdsl/auth"
|
"git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
"git.wntrmute.dev/mc/mcdsl/csrf"
|
"git.wntrmute.dev/mc/mcdsl/csrf"
|
||||||
|
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||||
"git.wntrmute.dev/mc/mcdsl/web"
|
"git.wntrmute.dev/mc/mcdsl/web"
|
||||||
|
|
||||||
mcqweb "git.wntrmute.dev/mc/mcq/web"
|
mcqweb "git.wntrmute.dev/mc/mcq/web"
|
||||||
@@ -25,16 +26,22 @@ const cookieName = "mcq_session"
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
Tags []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.
|
// Server is the MCQ web UI server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
auth *auth.Authenticator
|
auth *auth.Authenticator
|
||||||
csrf *csrf.Protect
|
csrf *csrf.Protect
|
||||||
render *render.Renderer
|
ssoClient *mcdsso.Client
|
||||||
logger *slog.Logger
|
render *render.Renderer
|
||||||
config Config
|
logger *slog.Logger
|
||||||
|
config Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a web UI server.
|
// 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")
|
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
|
||||||
|
|
||||||
return &Server{
|
s := &Server{
|
||||||
db: database,
|
db: database,
|
||||||
auth: authenticator,
|
auth: authenticator,
|
||||||
csrf: csrfProtect,
|
csrf: csrfProtect,
|
||||||
render: render.New(),
|
render: render.New(),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
config: cfg,
|
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.
|
// RegisterRoutes adds web UI routes to the given router.
|
||||||
func (s *Server) RegisterRoutes(r chi.Router) {
|
func (s *Server) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/login", s.handleLoginPage)
|
if s.ssoClient != nil {
|
||||||
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
|
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)
|
r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP)
|
||||||
|
|
||||||
// Authenticated routes.
|
// Authenticated routes.
|
||||||
@@ -70,6 +100,7 @@ func (s *Server) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/d/{slug}", s.handleRead)
|
r.Get("/d/{slug}", s.handleRead)
|
||||||
r.Post("/d/{slug}/read", s.handleMarkRead)
|
r.Post("/d/{slug}/read", s.handleMarkRead)
|
||||||
r.Post("/d/{slug}/unread", s.handleMarkUnread)
|
r.Post("/d/{slug}/unread", s.handleMarkUnread)
|
||||||
|
r.Post("/d/{slug}/delete", s.handleDelete)
|
||||||
r.Post("/logout", s.handleLogout)
|
r.Post("/logout", s.handleLogout)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -79,6 +110,7 @@ type pageData struct {
|
|||||||
Error string
|
Error string
|
||||||
Title string
|
Title string
|
||||||
Content any
|
Content any
|
||||||
|
SSO bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
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)
|
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) {
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
token := web.GetSessionToken(r, cookieName)
|
token := web.GetSessionToken(r, cookieName)
|
||||||
if token != "" {
|
if token != "" {
|
||||||
@@ -176,3 +234,11 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -252,6 +252,14 @@ button:hover, .btn:hover {
|
|||||||
padding: 0.1875rem 0.625rem;
|
padding: 0.1875rem 0.625rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================
|
/* ===========================
|
||||||
Forms
|
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 {
|
.read-header {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{define "title"}} — Queue{{end}}
|
{{define "title"}} — Queue{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h2>Reading Queue</h2>
|
<h2>Metacircular Reading Queue</h2>
|
||||||
{{if not .Documents}}
|
{{if not .Documents}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p>No documents in queue.</p>
|
<p>No documents in queue.</p>
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="auth-header">
|
<div class="auth-header">
|
||||||
<div class="brand">mcq</div>
|
<div class="brand">mcq</div>
|
||||||
<div class="tagline">Reading Queue</div>
|
<div class="tagline">Metacircular Reading Queue</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
{{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">
|
<form method="POST" action="/login">
|
||||||
{{csrfField}}
|
{{csrfField}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -25,5 +30,6 @@
|
|||||||
<button type="submit" class="btn">Login</button>
|
<button type="submit" class="btn">Login</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,27 +1,75 @@
|
|||||||
{{define "title"}} — {{.Doc.Title}}{{end}}
|
{{define "title"}} — {{.Doc.Title}}{{end}}
|
||||||
|
{{define "container-class"}}read-container{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="read-header">
|
<div class="read-layout">
|
||||||
<h2>{{.Doc.Title}}</h2>
|
<nav class="read-toc" id="toc" aria-label="Table of contents"></nav>
|
||||||
<div class="read-meta">
|
<div class="read-main">
|
||||||
<span>Pushed by {{.Doc.PushedBy}}</span>
|
<div class="read-header">
|
||||||
<span>{{.Doc.PushedAt}}</span>
|
<h2>{{.Doc.Title}}</h2>
|
||||||
</div>
|
<div class="read-meta">
|
||||||
<div class="read-actions">
|
<span>Pushed by {{.Doc.PushedBy}}</span>
|
||||||
{{if .Doc.Read}}
|
<span>{{.Doc.PushedAt}}</span>
|
||||||
<form method="POST" action="/d/{{.Doc.Slug}}/unread" style="display:inline">
|
</div>
|
||||||
{{csrfField}}
|
<div class="read-actions">
|
||||||
<button type="submit" class="btn-ghost btn btn-sm">Mark unread</button>
|
{{if .Doc.Read}}
|
||||||
</form>
|
<form method="POST" action="/d/{{.Doc.Slug}}/unread" style="display:inline">
|
||||||
{{else}}
|
{{csrfField}}
|
||||||
<form method="POST" action="/d/{{.Doc.Slug}}/read" style="display:inline">
|
<button type="submit" class="btn-ghost btn btn-sm">Mark unread</button>
|
||||||
{{csrfField}}
|
</form>
|
||||||
<button type="submit" class="btn-ghost btn btn-sm">Mark read</button>
|
{{else}}
|
||||||
</form>
|
<form method="POST" action="/d/{{.Doc.Slug}}/read" style="display:inline">
|
||||||
{{end}}
|
{{csrfField}}
|
||||||
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
|
<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>
|
</div>
|
||||||
<div class="card markdown-body">
|
<script>
|
||||||
{{.HTML}}
|
(function(){
|
||||||
</div>
|
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}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user