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

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")
}
}