diff --git a/cmd/mcq/client.go b/cmd/mcq/client.go new file mode 100644 index 0000000..dcaf7f9 --- /dev/null +++ b/cmd/mcq/client.go @@ -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)) +} diff --git a/cmd/mcq/delete.go b/cmd/mcq/delete.go new file mode 100644 index 0000000..192ee67 --- /dev/null +++ b/cmd/mcq/delete.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +func deleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + 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 +} diff --git a/cmd/mcq/get.go b/cmd/mcq/get.go new file mode 100644 index 0000000..36c63be --- /dev/null +++ b/cmd/mcq/get.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func getCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + 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 +} diff --git a/cmd/mcq/list.go b/cmd/mcq/list.go new file mode 100644 index 0000000..0a4ee43 --- /dev/null +++ b/cmd/mcq/list.go @@ -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 +} diff --git a/cmd/mcq/login.go b/cmd/mcq/login.go new file mode 100644 index 0000000..9fd5895 --- /dev/null +++ b/cmd/mcq/login.go @@ -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 +} diff --git a/cmd/mcq/main.go b/cmd/mcq/main.go index 09f0158..0aaacc5 100644 --- a/cmd/mcq/main.go +++ b/cmd/mcq/main.go @@ -16,6 +16,12 @@ func main() { } root.AddCommand(serverCmd()) + root.AddCommand(pushCmd()) + root.AddCommand(listCmd()) + root.AddCommand(getCmd()) + root.AddCommand(deleteCmd()) + root.AddCommand(loginCmd()) + root.AddCommand(mcpCmd()) if err := root.Execute(); err != nil { os.Exit(1) diff --git a/cmd/mcq/mcp.go b/cmd/mcq/mcp.go new file mode 100644 index 0000000..0da18c1 --- /dev/null +++ b/cmd/mcq/mcp.go @@ -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) +} diff --git a/cmd/mcq/push.go b/cmd/mcq/push.go new file mode 100644 index 0000000..4f2f276 --- /dev/null +++ b/cmd/mcq/push.go @@ -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 ", + 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 +} diff --git a/go.mod b/go.mod index d7d1560..20167ec 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,12 @@ require ( git.wntrmute.dev/mc/mcdsl v1.2.0 github.com/alecthomas/chroma/v2 v2.18.0 github.com/go-chi/chi/v5 v5.2.5 + github.com/mark3labs/mcp-go v0.46.0 github.com/pelletier/go-toml/v2 v2.3.0 github.com/spf13/cobra v1.10.2 github.com/yuin/goldmark v1.7.12 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc + golang.org/x/term v0.41.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 ) @@ -17,12 +19,15 @@ require ( require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index 633165e..84507ed 100644 --- a/go.sum +++ b/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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= @@ -19,6 +20,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -29,6 +32,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -39,22 +44,37 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo= +github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= @@ -82,6 +102,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= @@ -96,6 +118,8 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..c25dba0 --- /dev/null +++ b/internal/client/client.go @@ -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]) +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..36ab4cc --- /dev/null +++ b/internal/client/client_test.go @@ -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") + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..aadfd3d --- /dev/null +++ b/internal/mcpserver/server.go @@ -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, + } +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..d00a878 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -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") + } +}