Files
ombi-mcp/internal/ombi/client.go
gronod eab16991ac Implement the runnable MCP server: 31 tools, auth, projections (Phase 07)
Lands the executable half of the Phase 01-06 contracts: stdio MCP
wiring with bundle policy enforced at call time, JWT/api_key upstream
auth with single-flight renewal and 401 retry, strict argument
decoding, allowlisted result projections with enum label twins, TV
season expansion, settings read-modify-write under a revision lock,
and a sanitized ToolError envelope that never forwards raw upstream
bodies.
2026-09-18 19:14:10 +01:00

92 lines
2.5 KiB
Go

package ombi
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
)
// ErrUnauthorized is returned by Do after a post-renewal retry still
// yields 401, so the adapter can decide its own retry policy.
var ErrUnauthorized = errors.New("upstream unauthorized after renewal retry")
// Client performs one authenticated upstream call. It applies auth,
// encodes segments, sends, and returns the raw response for the
// adapter to interpret. It never decides business outcomes.
type Client struct {
base string
auth *AuthManager
http *http.Client
}
// NewClient builds a Client against the configured base URL. The base
// may carry a reverse-proxy path prefix; it is preserved when joining
// API paths. hc is the shared upstream HTTP client.
func NewClient(base string, auth *AuthManager, hc *http.Client) *Client {
return &Client{base: base, auth: auth, http: hc}
}
// Do issues a single authenticated request. Callers pass method, the
// API path already joined onto the configured base (prefix preserved),
// optional query values, and an optional JSON body. Do returns
// ErrUnauthorized after one failed post-renewal retry so the adapter
// can decide its own retry policy.
func (c *Client) Do(ctx context.Context, method, path string,
query map[string]string, body any) (*http.Response, error) {
resp, err := c.send(ctx, method, path, query, body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
resp.Body.Close()
c.auth.Invalidate()
resp, err = c.send(ctx, method, path, query, body)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close()
return nil, ErrUnauthorized
}
return resp, nil
}
func (c *Client) send(ctx context.Context, method, path string,
query map[string]string, body any) (*http.Response, error) {
u := strings.TrimSuffix(c.base, "/") + "/" + strings.TrimPrefix(path, "/")
if len(query) > 0 {
q := url.Values{}
for k, v := range query {
q.Set(k, v)
}
u += "?" + q.Encode()
}
var rdr *bytes.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req, err := http.NewRequestWithContext(ctx, method, u, rdr)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if err := c.auth.Apply(ctx, req); err != nil {
return nil, err
}
return c.http.Do(req)
}