Add CLI client subcommands and MCP server

Adds push, list, get, delete, and login subcommands backed by an HTTP
API client, plus an MCP server for tool-based access to the document
queue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 00:08:55 -07:00
parent ed3a547e54
commit 3d5f52729f
14 changed files with 1161 additions and 0 deletions

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

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

View File

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

View File

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

View File

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