Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
@@ -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
24
go.sum
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user