e7136a4a20
New MCP server (mcp/) exposes kb operations as native MCP tools over
Streamable HTTP with Bearer token auth. Supports collections via tag
conventions, chunked file uploads, and agent-side search patterns.
Engine gains PATCH /api/v1/notes/{id} for in-place note updates with
transactional re-chunk/re-embed, and updated_at column on documents.
Go client adds updatenote command and Patch HTTP method.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
256 lines
6.4 KiB
Go
256 lines
6.4 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/kb-search/kb/internal/config"
|
|
)
|
|
|
|
// FileUpload describes a file to include in a multipart request.
|
|
type FileUpload struct {
|
|
FieldName string
|
|
FileName string
|
|
Reader io.Reader
|
|
}
|
|
|
|
// Package-level version info, set once by cmd.init via SetVersionInfo.
|
|
var (
|
|
clientVersion string
|
|
minEngineVersion string
|
|
)
|
|
|
|
// SetVersionInfo configures the client and minimum engine version for compatibility checking.
|
|
// Called once from cmd package initialization.
|
|
func SetVersionInfo(cv, minEV string) {
|
|
clientVersion = cv
|
|
minEngineVersion = minEV
|
|
}
|
|
|
|
// Client is an HTTP client for the kb-search engine API.
|
|
type Client struct {
|
|
baseURL string
|
|
apiKey string
|
|
httpClient *http.Client
|
|
versionChecked bool
|
|
}
|
|
|
|
// NewClient creates a Client from the current configuration.
|
|
func NewClient() *Client {
|
|
cfg := config.Get()
|
|
return &Client{
|
|
baseURL: cfg.EngineURL,
|
|
apiKey: cfg.APIKey,
|
|
httpClient: &http.Client{},
|
|
}
|
|
}
|
|
|
|
func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) {
|
|
url := c.baseURL + path
|
|
req, err := http.NewRequest(method, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request) (*http.Response, error) {
|
|
c.checkEngineVersion()
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Cannot reach engine at %s: %v", c.baseURL, err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (c *Client) checkEngineVersion() {
|
|
if c.versionChecked {
|
|
return
|
|
}
|
|
c.versionChecked = true
|
|
|
|
minVer := minEngineVersion
|
|
if minVer == "" || minVer == "dev" {
|
|
return
|
|
}
|
|
|
|
statusReq, err := c.newRequest(http.MethodGet, "/api/v1/status", nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
resp, err := c.httpClient.Do(statusReq)
|
|
if err != nil {
|
|
return // unreachable — let the actual request surface the error
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var status struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
|
|
return
|
|
}
|
|
|
|
if !semverAtLeast(status.Version, minVer) {
|
|
fmt.Fprintf(os.Stderr, "Error: kb client v%s requires engine v%s+ (connected engine is v%s)\nUpdate your engine image to engine-v%s or later.\n",
|
|
clientVersion, minVer, status.Version, minVer)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// semverAtLeast returns true if version >= minimum, comparing major.minor.patch.
|
|
func semverAtLeast(version, minimum string) bool {
|
|
parse := func(s string) (int, int, int) {
|
|
s = strings.TrimPrefix(s, "v")
|
|
parts := strings.SplitN(s, ".", 3)
|
|
var major, minor, patch int
|
|
if len(parts) >= 1 {
|
|
major, _ = strconv.Atoi(parts[0])
|
|
}
|
|
if len(parts) >= 2 {
|
|
minor, _ = strconv.Atoi(parts[1])
|
|
}
|
|
if len(parts) >= 3 {
|
|
patch, _ = strconv.Atoi(parts[2])
|
|
}
|
|
return major, minor, patch
|
|
}
|
|
|
|
vMaj, vMin, vPat := parse(version)
|
|
mMaj, mMin, mPat := parse(minimum)
|
|
|
|
if vMaj != mMaj {
|
|
return vMaj > mMaj
|
|
}
|
|
if vMin != mMin {
|
|
return vMin > mMin
|
|
}
|
|
return vPat >= mPat
|
|
}
|
|
|
|
// Get performs a GET request to the given path.
|
|
func (c *Client) Get(path string) (*http.Response, error) {
|
|
req, err := c.newRequest(http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.do(req)
|
|
}
|
|
|
|
// Post performs a POST request with a JSON body.
|
|
func (c *Client) Post(path string, body interface{}) (*http.Response, error) {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
req, err := c.newRequest(http.MethodPost, path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return c.do(req)
|
|
}
|
|
|
|
// PostMultipart performs a POST request with multipart/form-data encoding.
|
|
func (c *Client) PostMultipart(path string, fields map[string]string, file *FileUpload) (*http.Response, error) {
|
|
var buf bytes.Buffer
|
|
w := multipart.NewWriter(&buf)
|
|
|
|
for k, v := range fields {
|
|
if err := w.WriteField(k, v); err != nil {
|
|
return nil, fmt.Errorf("failed to write field %s: %w", k, err)
|
|
}
|
|
}
|
|
|
|
if file != nil {
|
|
part, err := w.CreateFormFile(file.FieldName, file.FileName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create form file: %w", err)
|
|
}
|
|
if _, err := io.Copy(part, file.Reader); err != nil {
|
|
return nil, fmt.Errorf("failed to copy file data: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := c.newRequest(http.MethodPost, path, &buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
return c.do(req)
|
|
}
|
|
|
|
// Delete performs a DELETE request to the given path.
|
|
func (c *Client) Delete(path string) (*http.Response, error) {
|
|
req, err := c.newRequest(http.MethodDelete, path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.do(req)
|
|
}
|
|
|
|
// Put performs a PUT request with a JSON body.
|
|
func (c *Client) Put(path string, body interface{}) (*http.Response, error) {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
req, err := c.newRequest(http.MethodPut, path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return c.do(req)
|
|
}
|
|
|
|
// Patch performs a PATCH request with a JSON body.
|
|
func (c *Client) Patch(path string, body interface{}) (*http.Response, error) {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
req, err := c.newRequest(http.MethodPatch, path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return c.do(req)
|
|
}
|
|
|
|
// DecodeJSON reads the response body and decodes it into target.
|
|
func DecodeJSON(resp *http.Response, target interface{}) error {
|
|
defer resp.Body.Close()
|
|
return json.NewDecoder(resp.Body).Decode(target)
|
|
}
|
|
|
|
// CheckError returns a formatted error for non-2xx responses, or nil on success.
|
|
func CheckError(resp *http.Response) error {
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
var errResp struct {
|
|
Detail string `json:"detail"`
|
|
}
|
|
if json.Unmarshal(body, &errResp) == nil && errResp.Detail != "" {
|
|
return fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Detail)
|
|
}
|
|
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
|
|
}
|