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:
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])
|
||||
}
|
||||
Reference in New Issue
Block a user