4 Commits

Author SHA1 Message Date
063bdccf1b Second deployment test. 2026-03-29 18:13:15 -07:00
67cbcd85bd Tweaking to test deployment. 2026-03-29 18:10:51 -07:00
3d5f52729f Add CLI client subcommands and MCP server
Adds push, list, get, delete, and login subcommands backed by an HTTP
API client, plus an MCP server for tool-based access to the document
queue.

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -16,6 +16,12 @@ func main() {
} }
root.AddCommand(serverCmd()) root.AddCommand(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
View File

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

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

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

5
go.mod
View File

@@ -6,10 +6,12 @@ require (
git.wntrmute.dev/mc/mcdsl v1.2.0 git.wntrmute.dev/mc/mcdsl v1.2.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

24
go.sum
View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

@@ -70,6 +70,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)
}) })
} }
@@ -176,3 +177,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)
}

View File

@@ -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

View File

@@ -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>

View File

@@ -3,7 +3,7 @@
{{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}}

View File

@@ -18,6 +18,11 @@
<button type="submit" class="btn-ghost btn btn-sm">Mark read</button> <button type="submit" class="btn-ghost btn btn-sm">Mark read</button>
</form> </form>
{{end}} {{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> <a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
</div> </div>
</div> </div>