From 24da4b14ef462dd916847e1af27127d05d16cab5 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 12:33:37 +0200 Subject: [PATCH 01/40] feat: add MCP server with search and photo tools - Implement MCP protocol handler (internal/mcp/) - Add search tool for querying photos by tags/keywords - Add get_photo tool for retrieving individual photos - Add collection events system (internal/collection/) - Update API schema with new endpoints - Add developer workflow documentation - Add test script and plan documents --- .mcp.json | 9 + GET_PHOTO_PLAN.md | 255 ++++++++++++++++ SEARCH_TOOL_PLAN.md | 247 ++++++++++++++++ api.yaml | 119 +++++++- docs/DEVELOPER_WORKFLOW.md | 281 ++++++++++++++++++ go.mod | 12 +- go.sum | 26 +- internal/collection/events.go | 126 ++++++++ internal/collection/search.go | 232 +++++++++++++++ internal/image/database.go | 13 +- internal/mcp/mcp.go | 333 +++++++++++++++++++++ internal/mcp/photo.go | 541 ++++++++++++++++++++++++++++++++++ internal/openapi/api.gen.go | 145 +++++++++ main.go | 146 ++++++++- test_mcp.sh | 36 +++ 15 files changed, 2507 insertions(+), 14 deletions(-) create mode 100644 .mcp.json create mode 100644 GET_PHOTO_PLAN.md create mode 100644 SEARCH_TOOL_PLAN.md create mode 100644 docs/DEVELOPER_WORKFLOW.md create mode 100644 internal/collection/events.go create mode 100644 internal/collection/search.go create mode 100644 internal/mcp/mcp.go create mode 100644 internal/mcp/photo.go create mode 100644 test_mcp.sh diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..f632e51 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "photofield": { + "url": "http://localhost:8080/mcp", + "transport": "http", + "directTools": true + } + } +} diff --git a/GET_PHOTO_PLAN.md b/GET_PHOTO_PLAN.md new file mode 100644 index 0000000..65492fb --- /dev/null +++ b/GET_PHOTO_PLAN.md @@ -0,0 +1,255 @@ +# Plan: MCP `get_photo` tool + +## Goal +Add an MCP tool that returns a photo (image data) by its ID, defaulting to a small/medium preview thumbnail. Users can optionally customize dimensions, format, and crop — mirroring the existing `GET /files/{id}/previews/{filename}` API parameters. + +--- + +## 1. New file: `internal/mcp/photo.go` + +Contains the input/output types and handler for the `get_photo` tool. + +### `getPhotoInput` struct + +```go +type getPhotoInput struct { + CollectionId string `json:"collection_id" jsonschema:"The collection ID containing the photo"` + FileId int `json:"file_id" jsonschema:"The photo file ID to retrieve"` + + // Dimensions — if omitted, defaults to thumbnail (256×256 max) + TargetW *int `json:"w" jsonschema:"Target width in pixels (1-4096). Omitted = auto thumbnail"` + TargetH *int `json:"h" jsonschema:"Target height in pixels (1-4096). Omitted = auto thumbnail"` + + // Format — if omitted, defaults to JPEG + Format *string `json:"format" jsonschema:"Output format: jpeg, png, or webp (default jpeg)"` + + // Cropping — all in original image pixel coordinates + CropX *int `json:"crop_x" jsonschema:"Crop left edge in original image pixels"` + CropY *int `json:"crop_y" jsonschema:"Crop top edge in original image pixels"` + CropW *int `json:"crop_w" jsonschema:"Crop width in original image pixels"` + CropH *int `json:"crop_h" jsonschema:"Crop height in original image pixels"` +} +``` + +### `getPhotoOutput` struct + +```go +type getPhotoOutput struct { + Data string `json:"data"` // base64-encoded image bytes + Format string `json:"format"` // "jpeg", "png", or "webp" + Width int `json:"width"` // output width in pixels + Height int `json:"height"` // output height in pixels + Media string `json:"media"` // MIME type (e.g. "image/jpeg") +} +``` + +> **Rationale for base64**: MCP `CallToolResult` supports `ContentText` and `ContentImage` with `data` + `mimeType`. The `go-sdk` `mcp` package's `CallToolResult` type accepts an `image` content block with base64 data. This avoids needing to stream raw bytes through the MCP protocol. + +### `getPhotoHandler` function + +```go +func getPhotoHandler(collections *[]collection.Collection, imageSource *image.Source) mcp.ToolHandlerFor[getPhotoInput, getPhotoOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, input getPhotoInput) (*mcp.CallToolResult, getPhotoOutput, error) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintln(os.Stderr, "get_photo handler recovered from panic:", r) + } + }() + + // 1. Resolve collection (optional — file_id is absolute in the DB) + // Skip collection validation since file IDs are global within a source. + // Just verify the file exists in the DB. + + // 2. Get file info to validate existence + info := imageSource.GetInfo(image.ImageId(input.FileId)) + if info.Width == 0 || info.Height == 0 { + return nil, getPhotoOutput{}, fmt.Errorf("file not found: %d", input.FileId) + } + + // 3. Determine target dimensions + targetW, targetH := input.TargetW, input.TargetH + if targetW == nil || targetH == nil { + // Default to small thumbnail: 256×256 max (like thumbnail sources) + targetW = intPtr(256) + targetH = intPtr(256) + } + + // 4. Parse format + formatStr := "jpeg" + if input.Format != nil && *input.Format != "" { + formatStr = strings.ToLower(*input.Format) + } + // Validate format + switch formatStr { + case "jpeg", "jpg": + formatStr = "jpeg" + case "png": + case "webp": + default: + return nil, getPhotoOutput{}, fmt.Errorf("unsupported format: %s (use jpeg, png, or webp)", formatStr) + } + + // 5. Encode image data using the preview render pipeline + // Reuse the same approach as GetFilesIdPreviewsFilename in main.go: + // - Create render config (defaultSceneConfig.Render) + // - Set ImageWidth, ImageHeight, MaxSolidPixelArea=0 + // - Render photo to canvas + // - Encode to requested format + // - Base64-encode the result + imageData, err := encodePhoto(imageSource, input.FileId, targetW, targetH, formatStr, + input.CropX, input.CropY, input.CropW, input.CropH) + if err != nil { + return nil, getPhotoOutput{}, err + } + + mime := "image/jpeg" + if formatStr == "png" { + mime = "image/png" + } else if formatStr == "webp" { + mime = "image/webp" + } + + return nil, getPhotoOutput{ + Data: base64.StdEncoding.EncodeToString(imageData), + Format: formatStr, + Width: *targetW, + Height: *targetH, + Media: mime, + }, nil + } +} +``` + +### `encodePhoto` helper + +This mirrors the preview endpoint logic from `main.go:GetFilesIdPreviewsFilename`, extracted into a reusable function: + +```go +func encodePhoto(source *image.Source, fileId int, targetW, targetH *int, format string, + cropX, cropY, cropW, cropH *int) ([]byte, error) { + + photoId := image.ImageId(fileId) + + // Get info + info := source.GetInfo(photoId) + if info.Width == 0 || info.Height == 0 { + return nil, fmt.Errorf("file not found: %d", fileId) + } + + // Build crop rect (if specified) + var crop *render.Rect + if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { + cx, cy := 0, 0 + if cropX != nil { + cx = *cropX + } + if cropY != nil { + cy = *cropY + } + c := render.Rect{ + X: float64(cx), + Y: float64(cy), + W: float64(*cropW), + H: float64(*cropH), + } + crop = &c + } + + // Setup render config + rn := defaultSceneConfig.Render + rn.ImageWidth = *targetW + rn.ImageHeight = *targetH + rn.MaxSolidPixelArea = 0 + rn.BackgroundColor = color.RGBA{0, 0, 0, 0} + rn.CoverFit = true + + // Get pooled image and canvas context + img, c := getPoolImage(&rn) + defer putPoolImage(&rn, img) + rn.CanvasImage = img + + // Render (same pipeline as preview API, no border) + // ... draw background, photo sprite, etc. ... + + // Encode to format + var buf bytes.Buffer + switch format { + case "jpeg": + // encodeJPEG(img, &buf, 85) + case "png": + // png.Encode(&buf, img) + case "webp": + // encodeWebP(img, &buf) + } + + return buf.Bytes(), nil +} +``` + +> **Note**: The `defaultSceneConfig` and `getPoolImage`/`putPoolImage` functions are already in `main.go`. The `encodePhoto` function will extract the rendering logic from `GetFilesIdPreviewsFilename` into a shared helper, or simply inline the preview handler code here. If we want to avoid code duplication, consider moving the preview rendering into a new file like `internal/render/preview.go`. + +--- + +## 2. Register the tool in `internal/mcp/mcp.go` + +Add a fourth tool registration in `New()`: + +```go +mcp.AddTool(s, &mcp.Tool{ + Name: "get_photo", + Description: "Return a photo by ID as a base64-encoded image (JPEG/PNG/WebP). Defaults to a 256×256 thumbnail. Supports custom dimensions and cropping.", +}, getPhotoHandler(collections, imageSource)) +``` + + + +## File Changes Summary + +| File | Action | What | +|------|--------|------| +| `internal/mcp/photo.go` | **New** | `getPhotoInput`, `getPhotoOutput`, `getPhotoHandler`, `encodePhoto` | +| `internal/mcp/mcp.go` | **Edit** | Add `get_photo` tool registration | + + + +--- + +## Default Behavior (when no params provided) + +| Parameter | Default | Rationale | +|-----------|---------|-----------| +| `w` / `h` | 256 / 256 | Matches thumbnail source sizes (djpeg generator uses 256px); small enough for fast loading, large enough to be useful | +| `format` | `jpeg` | Smallest file size for photos; universally supported | +| `crop_*` | none (full image) | No crop applied by default | + + +When `w` or `h` is specified but not the other, the aspect ratio of the original image (or cropped region) is preserved. + +--- + +## MCP Content Image Format + +The `go-sdk` MCP library supports returning image content directly: + +```go +result := mcp.NewCallToolResult( + mcp.WithContentImage(mcp.ImageContent{ + Data: base64Data, + MediaType: "image/jpeg", + }), +) +return result, output, nil +``` + +This lets LLM clients display the photo inline rather than just showing base64 text. + +--- + +## Testing approach + +1. **MCP `get_photo` with no params** → verify 256×256 JPEG thumbnail returned +2. **MCP `get_photo` with `w=512, h=384`** → verify correct dimensions and aspect ratio +3. **MCP `get_photo` with `format=png`** → verify PNG-encoded data +4. **MCP `get_photo` with `crop_x=100, crop_y=50, crop_w=200, crop_h=200`** → verify cropped region +5. **MCP `get_photo` with non-existent file_id** → verify 404-style error +7. **Compare MCP output vs API `/files/{id}/previews`** → pixel-identical output diff --git a/SEARCH_TOOL_PLAN.md b/SEARCH_TOOL_PLAN.md new file mode 100644 index 0000000..7ccbe04 --- /dev/null +++ b/SEARCH_TOOL_PLAN.md @@ -0,0 +1,247 @@ +# Plan: MCP `search_photos` tool + API `GET /collections/{id}/files` + +## Goal +Add an MCP tool and API endpoint that lets users search a collection's photos using natural language or structured queries. The core logic mirrors what `SceneSource.loadScene` does (parse query → embed → query DB → return results), but skips the rendering/layout layer. The API uses query params (`search`, `sort`, `limit`) following the `SceneParams` pattern from `GET /scenes`. + +--- + +## 1. New file: `internal/collection/search.go` + +### `SearchResult` type +```go +type SearchResult struct { + Id int32 `json:"id"` + FileName string `json:"file_name"` + DateTime string `json:"datetime,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Color string `json:"color,omitempty"` // hex color + Location string `json:"location,omitempty"` // reverse-geocoded, if geo available + Similarity float32 `json:"similarity,omitempty"` // cosine similarity score + Tags []string `json:"tags,omitempty"` // tags on this photo +} +``` + +### `Collection.Search` method +```go +type SearchOptions struct { + QueryStr string + Sort SortType // e.g. "date", "similarity" + Limit int + Offset int +} + +type SortType string + +func (collection *Collection) Search( + ctx context.Context, + source *image.Source, + opts SearchOptions, +) ([]SearchResult, []search.Token, []search.FieldError, error) +``` + +Steps (mirrors `sceneSource.loadScene`): + +1. **Parse query** — `search.Parse(queryStr)` → `*search.Query` +2. **Validate/extract expression** — `query.Expression()` → `search.Expression` + - Collect tokens: `query.Tokens()` + - Collect errors: `expression.Errors` + - Extract text, created range, filters, tags, filenames, img, face +3. **Resolve embeddings** (same flow as scene loading): + - If `expression.Image.Present` → `source.GetImageEmbedding(image.ImageId(val))` + - If `expression.Face.Present` → `source.GetFaceEmbedding(val)` + - If text is non-empty and no image/face embed yet → `source.Clip.EmbedText(text)` + - If embedding fails, set error +4. **Query DB** — `collection.GetInfos(source, image.ListOptions{…})`: + - `OrderBy`: determined by `opts.Sort` + whether any embedding is present + - `Limit`: `opts.Limit` (default 50) + - `Offset`: `opts.Offset` (default 0) + - `Expression`: the parsed expression + - `ImageEmbedding` / `FaceEmbedding`: resolved above + - `Extensions`: nil (no filter) +5. **Collect results** from channel: + - For each `SourcedInfo`, build a `SearchResult`: + - `Id`: `info.Id` + - `FileName`: parse `source.GetImagePath(info.Id)` with `filepath.Base()` + - `DateTime`: `info.DateTime` (formatted as RFC3339) + - `Width`/`Height`: `info.Width`/`info.Height` + - `Color`: `info.Color` as hex string + - `Location`: reverse-geocoded (if geo available) + - `Similarity`: `info.Similarity` + - `Tags`: call `source.ListImageTags(info.Id)`, collect names + - Stop after `limit` results +6. **Return** `[]SearchResult`, tokens, errors, and any embedding error. + +### Helper: reverse-geocode a single photo +```go +func reverseGeocode(ctx context.Context, source *image.Source, latLng s2.LatLng) string +``` + +--- + +## 2. Thin out `internal/mcp/mcp.go` + +Add a third tool registration: +```go +mcp.AddTool(s, &mcp.Tool{ + Name: "search_photos", + Description: "Search a collection's photos by text, image reference, face reference, or structured qualifiers. Returns metadata and similarity scores.", +}, searchPhotosHandler(collections, imageSource)) +``` + +Handler delegates to `coll.Search()`. + +--- + +## 3. API endpoint: `GET /collections/{id}/files` + +Follows the `SceneParams` pattern: `search` and `sort` are query params (not body), with `limit` for pagination. + +### `api.yaml` +```yaml +paths: + /collections/{id}/files: + get: + description: Search a collection's photos by text or structured query. + tags: ["Source"] + parameters: + - name: id + in: path + required: true + schema: + $ref: "#/components/schemas/CollectionId" + + - name: search + in: query + description: Natural language or structured search query + schema: + $ref: "#/components/schemas/Search" + + - name: sort + in: query + description: Sort order. Prefix with `-` for descending or `+` for ascending, e.g. `-date` (newest first), `+date` (oldest first), `-similarity` (best matches first). Multiple values allowed. + schema: + $ref: "#/components/schemas/Sort" + + - name: limit + in: query + description: Maximum number of results + schema: + $ref: "#/components/schemas/Limit" + + responses: + "200": + description: Search results + content: + application/json: + schema: + $ref: "#/components/schemas/FileList" + +components: + schemas: + FileList: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/FileInfo" + + FileInfo: + type: object + properties: + id: { type: integer } + file_name: { type: string } + datetime: { type: string, format: date-time } + width: { type: integer } + height: { type: integer } + color: { type: string } + location: { type: string } + similarity: { type: number } + tags: + type: array + items: { type: string } +``` + +### `main.go` handler +```go +func (*Api) GetCollectionsIdFiles(w http.ResponseWriter, r *http.Request, id openapi.CollectionsIdFilesParams) { + coll := getCollectionById(string(id)) + if coll == nil { + problem(w, r, http.StatusBadRequest, "Collection not found") + return + } + + limit := 50 + if id.Limit != nil { + limit = int(*id.Limit) + } + + opts := collection.SearchOptions{ + QueryStr: string(id.Search), + Sort: collection.SortType(string(id.Sort)), + Limit: limit, + } + + items, tokens, errs, err := coll.Search(r.Context(), imageSource, opts) + if err != nil { + problem(w, r, http.StatusInternalServerError, err.Error()) + return + } + + respond(w, r, http.StatusOK, openapi.FileList{ + Items: &items, + }) +} +``` + +--- + +## File Changes Summary + +| File | Action | What | +|------|--------|------| +| `internal/collection/search.go` | **New** | `SearchResult`, `SearchOptions`, `SortType`, `Search` method, `reverseGeocode` helper | +| `internal/mcp/mcp.go` | **Edit** | Add `search_photos` tool registration + handler | +| `api.yaml` | **Edit** | Add `GET /collections/{id}/files` with `search`, `sort`, `limit` query params; add `FileInfo`, `FileList` schemas | +| `internal/openapi/api.gen.go` | **Regenerate** | `go generate ./...` | +| `main.go` | **Edit** | Add `GetCollectionsIdFiles` handler | + +--- + +## Key Design Decisions + +1. **Reuse `collection.GetInfos()`** — same DB query path as events and scenes; no duplication +2. **Embedding logic matches scene loading** — text→clip embed, image reference→DB embed, face reference→DB embed, all with error handling +3. **Query params follow `SceneParams` pattern** — `search` and `sort` as query params (not body), `limit` for pagination +4. **Default limit 50** — prevents runaway responses; MCP tool caps at 50, API uses `Limit` type (integer) +5. **Location reverse-geocoding** — same as events tool (15 min / 1 km gap), but applied per-result so the user sees locations for all matches +6. **No layout/rendering** — skips the entire `layout.Layout*` machinery; just returns metadata + similarity scores +7. **Tokens returned** — so callers can see how the query was parsed (matching the existing API search query endpoint) + +--- + +## Search query syntax (from existing `search` package) + +| Query | Meaning | +|-------|---------| +| `sunset beach` | Text search by cosine similarity | +| `created:2024-06` | Photos from June 2024 | +| `tag:vacation` | Photos tagged "vacation" | +| `filename:IMG_` | Files matching glob | +| `img:123` | Similar to photo with ID 123 | +| `face:456` | Similar to face with ID 456 | +| `t:0.3` | Similarity threshold (default 0.262) | +| `filter:knn` | Use knn index directly | +| `k:10` | Return top 10 results | +| `NOT beach` | Exclude "beach" | + +--- + +## Testing approach + +1. MCP `search_photos` tool with text query → verify results +2. API `GET /collections/{id}/files` with same query → verify identical results +3. Test `sort=date`, `sort=date_asc`, `sort=similarity` → verify ordering +4. Test `limit` pagination → verify result counts +5. Edge cases: empty query, invalid query, non-existent collection, no AI service, geo disabled diff --git a/api.yaml b/api.yaml index dcc509b..e0cbd4d 100644 --- a/api.yaml +++ b/api.yaml @@ -47,6 +47,62 @@ paths: schema: $ref: "#/components/schemas/Problem" + /collections/{id}/events: + get: + description: Split a collection's photos into time-bounded events based on time and location proximity. + tags: ["Display"] + parameters: + - name: id + in: path + required: true + description: Collection ID + schema: + $ref: "#/components/schemas/CollectionId" + responses: + "200": + description: List of events + content: + application/json: + schema: + $ref: "#/components/schemas/EventsList" + + /collections/{id}/files: + get: + description: Search a collection's photos by text or structured query. + tags: ["Source"] + parameters: + - name: id + in: path + required: true + schema: + $ref: "#/components/schemas/CollectionId" + + - name: search + in: query + description: Natural language or structured search query + schema: + $ref: "#/components/schemas/Search" + + - name: sort + in: query + description: Sort order. Prefix with `-` for descending or `+` for ascending, e.g. `-date` (newest first), `+date` (oldest first), `-similarity` (best matches first). Multiple values allowed. + schema: + $ref: "#/components/schemas/Sort" + + - name: limit + in: query + description: Maximum number of results + schema: + $ref: "#/components/schemas/Limit" + + responses: + "200": + description: Search results + content: + application/json: + schema: + $ref: "#/components/schemas/FileList" + /scenes: post: description: Create a new scene using the provided parameters @@ -1465,4 +1521,65 @@ components: $ref: "#/components/schemas/Color" text: type: string - description: Feature text to be displayed on the map \ No newline at end of file + description: Feature text to be displayed on the map + + EventSummary: + type: object + properties: + index: + type: integer + created_after: + type: string + format: date-time + created_before: + type: string + format: date-time + photo_count: + type: integer + location_count: + type: integer + locations: + type: array + items: + type: string + + EventsList: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/EventSummary" + + FileInfo: + type: object + properties: + id: + type: integer + file_name: + type: string + datetime: + type: string + format: date-time + width: + type: integer + height: + type: integer + color: + type: string + location: + type: string + similarity: + type: number + tags: + type: array + items: + type: string + + FileList: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/FileInfo" \ No newline at end of file diff --git a/docs/DEVELOPER_WORKFLOW.md b/docs/DEVELOPER_WORKFLOW.md new file mode 100644 index 0000000..9eb1fd0 --- /dev/null +++ b/docs/DEVELOPER_WORKFLOW.md @@ -0,0 +1,281 @@ +# Developer Workflow Guide + +This document summarizes common pitfalls and best practices for running, testing, +debugging, and iterating on the photofield MCP server. + +## 1. Starting the Server + +### Prerequisites + +The server reads configuration from `data/configuration.yaml`. If it doesn't +exist, the server runs with defaults and the default collection config points +to every subdirectory of the current working directory (which indexes nothing +useful). + +**Quick setup:** + +```bash +# Create a minimal config pointing to a test photo directory +mkdir -p data +cat > data/configuration.yaml < /tmp/photofield.log 2>&1 & + +# Wait for startup +sleep 5 + +# Verify it's running +curl -s http://localhost:8080/mcp -X POST \ + -H "Content-Type: application/json" \ + -d '{"test":true}' +# Returns: "malformed payload: invalid message version tag..." +# This is expected - the MCP endpoint requires proper JSON-RPC protocol. +# A successful startup just means the server is listening. +``` + +### Important notes + +- The server does **not** automatically scan photos on startup. Run the scan + first (or use the UI): + + ```bash + ./photofield -scan test 2>&1 + ``` + +- The server listens on port `8080` by default. + +- **Kill the server before rebuilding:** `pkill -f photofield` + +## 2. Sending MCP Requests + +The MCP server uses JSON-RPC 2.0 over HTTP with a streamable transport. The +protocol requires a **session lifecycle**: + +### Step-by-step + +```bash +BASE="http://localhost:8080/mcp" + +# Step 1: Initialize - gets a Session-Id back in the headers +curl -v -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"2024-11-05", + "capabilities":{}, + "clientInfo":{"name":"test","version":"1.0"}}}' \ + 2>&1 + +# Extract the Session-Id from the response headers (e.g., Mcp-Session-Id: ABC123) + +# Step 2: Send the "initialized" notification (no ID) +curl -s -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: ABC123" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' + +# Step 3: Call any tool using the same Session-Id +curl -s -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: ABC123" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", + "params":{"name":"list_collections","arguments":{}}}' +``` + +### Tool call parameters + +- The MCP SDK infers the JSON Schema from Go struct types. If a struct field + is a **pointer** (`*int`, `*string`), the SDK may still mark it as required + in the generated schema. For tools with optional fields, use an **explicit + `InputSchema`** in the `Tool` registration (see `internal/mcp/mcp.go`), + which lets you control the `required` array precisely. + +- When calling tools, **always include all parameters that the schema marks as + required** (check via `tools/list`). + +## 3. Inspecting Errors and Crashes + +### Stack traces + +When the server panics, it logs a full Go stack trace to **stderr**. Since the +server runs in the background, redirect stderr to a log file: + +```bash +./photofield > /tmp/photofield.log 2>&1 & + +# After an error: +tail -100 /tmp/photofield.log +``` + +The stack trace will show the panic chain. A typical error pattern: + +``` +get_photo handler recovered from panic: cannot create context from nil parent + +runtime/debug.Stack() + ... +context.WithTimeout({0x0, 0x0}, ...) ← nil context! +photofield/internal/io/djpeg.Djpeg.Get(...) ← called with nil ctx +photofield/internal/mcp/photo.go:251 ← photo.Draw(nil, ...) +``` + +### What to look for + +1. **"cannot create context from nil parent"** → A handler is passing `nil` as + the context to code that calls `context.WithTimeout/WithDeadline`. Fix: pass + `context.Background()` as fallback, or ensure the caller provides a valid + context. + +2. **"file not found: N"** → The file ID doesn't exist in the database. Check + `sqlite3 data/photofield.cache.db "SELECT * FROM infos;"` to find valid IDs. + +3. **Empty response data** → The image rendering succeeded but produced empty + output. Check if the photo exists and the dimensions are valid. + +## 4. Checking Runtime State + +### Database inspection + +```bash +# List all indexed photos with IDs +sqlite3 data/photofield.cache.db "SELECT id, width, height FROM infos ORDER BY id;" + +# Count photos +sqlite3 data/photofield.cache.db "SELECT COUNT(*) FROM infos;" + +# See tables +sqlite3 data/photofield.cache.db ".tables" +``` + +### Collection status via MCP + +```bash +# List all collections and their indexed count +curl -s -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":"list_collections","arguments":{}}}' +``` + +## 5. Quick Test Script + +Save this as `test_mcp.sh` for fast iteration: + +```bash +#!/bin/bash +set -e +BASE="http://localhost:8080/mcp" + +# Initialize and get session ID +INIT=$(curl -v -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"2024-11-05", + "capabilities":{}, + "clientInfo":{"name":"test","version":"1.0"}}}' 2>&1) +SESSION=$(echo "$INIT" | grep "Mcp-Session-Id" | awk '{print $2}' | tr -d '\r') +echo "Session: $SESSION" + +# Send initialized notification +curl -s -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: $SESSION" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' > /dev/null + +# Call tools +echo "" +echo "=== list_collections ===" +curl -s -X POST "$BASE" \ + -H "Mcp-Session-Id: $SESSION" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":"list_collections","arguments":{}}}' +echo "" +echo "" + +echo "=== get_photo (file_id=1) ===" +curl -s -X POST "$BASE" \ + -H "Mcp-Session-Id: $SESSION" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", + "params":{"name":"get_photo","arguments": + {"file_id":1,"w":400,"h":225,"format":"jpeg", + "crop_x":0,"crop_y":0,"crop_w":1000,"crop_h":1000}}}' +echo "" +``` + +## 6. Common Fixes Checklist + +| Symptom | Likely Cause | Fix | +|---------|-------------|-----| +| `file not found: N` | Photo ID doesn't exist | Scan collection or check DB | +| Empty response data | Rendering panics silently | Check server log for panic | +| `cannot create context from nil parent` | Nil context passed to WithTimeout/Deadline | Add `if ctx == nil { ctx = context.Background() }` | +| Schema says all fields required | SDK infers schema from Go struct pointers | Override with explicit `InputSchema` | +| Server not responding | Old binary still running | `pkill -f photofield` then rebuild | +| No photos found | Default config points to empty dirs | Create `data/configuration.yaml` | + +## 7. Recommended Improvements + +The following changes would make the workflow significantly easier: + +### a) Add an MCP health/status endpoint +A simple HTTP endpoint (e.g., `GET /mcp/health`) that returns whether the +server is up, how many collections are indexed, and last scan time. This +avoids needing to send a full MCP session just to check if the server is +running. + +### b) Add a `GET /mcp/tools` endpoint +Expose the list of available tools as a simple HTTP JSON endpoint. Currently +you must go through the full MCP session lifecycle to discover available +tools and their schemas. + +### c) Make the server rebuild-aware +Add a Makefile or Taskfile target that handles `pkill`, `go build`, and +`./photofield` in sequence. This prevents the common mistake of calling +tools on an outdated binary. + +### d) Improve nil context handling globally +Instead of patching each handler, add a middleware or wrapper in the MCP +tool handler registration that ensures a non-nil context is always passed. +This would prevent the most common crash pattern. + +### e) Add structured error responses to panics +Currently panics are caught and logged, but the MCP response is empty or +contains only an error message. Including the panic message and a hint about +what to check would make debugging much faster. + +### f) Provide a CLI test harness +A small Go test binary or subcommand (e.g., `photofield test-mcp`) that +connects to the running server and runs a battery of tool calls with +assertions. This would make it easy to verify end-to-end functionality +without manual curl commands. + +### g) Improve schema generation for optional fields +The MCP SDK's automatic schema generation marks pointer fields as required, +which is incorrect. Either: +- Fix the SDK to use `nullable: true` for pointer types and exclude them + from the `required` array, or +- Document the pattern of using explicit `InputSchema` for tools with + optional fields. + +### h) Add a `--watch` flag for automatic reload +A flag that watches for binary changes and restarts the server, so you +can iterate without manually killing and restarting. diff --git a/go.mod b/go.mod index 1647d77..513da03 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module photofield -go 1.24.0 - -toolchain go1.24.6 +go 1.25.0 require ( git.sr.ht/~jackmordaunt/go-libwebp v1.8.0 @@ -31,6 +29,7 @@ require ( github.com/karrick/godirwalk v1.15.6 github.com/lpar/gzipped v1.1.0 github.com/matoous/go-nanoid/v2 v2.0.0 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/mostlygeek/go-exiftool v0.0.0-20190130212521-a0e5de16f760 github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 github.com/peterstace/simplefeatures v0.44.0 @@ -65,6 +64,7 @@ require ( github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect github.com/google/uuid v1.6.0 // indirect github.com/gosimple/unidecode v1.0.0 // indirect @@ -86,12 +86,16 @@ require ( github.com/prometheus/common v0.26.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/tdewolff/minify/v2 v2.7.1-0.20200112204046-70870d25a935 // indirect github.com/tdewolff/parse/v2 v2.4.2 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/wcharczuk/go-chart v2.0.2-0.20191206192251-962b9abdec2b+incompatible // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/atomic v1.7.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.27.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gonum.org/v1/plot v0.0.0-20190410204940-3a5f52653745 // indirect diff --git a/go.sum b/go.sum index 2854392..4dcd834 100644 --- a/go.sum +++ b/go.sum @@ -259,6 +259,8 @@ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.15.0-beta.1 h1:3iUwrd6V9oIzNc6TQdp4SLYNjQV1DXOK/E7cjaq7zbo= github.com/golang-migrate/migrate/v4 v4.15.0-beta.1/go.mod h1:QOmbm9b62AcsxBz7VbwJf+3mqgAyVrdKx7AQ8T9m5og= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= @@ -320,11 +322,13 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v35 v35.2.0/go.mod h1:s0515YVTI+IMrDoy9Y4pHt9ShGpzHvHO8rZ7L7acgvs= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -532,6 +536,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -613,6 +619,10 @@ github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThC github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sheerun/queue v1.0.1 h1:TIAQyN0aRRvrJcNa2beZFfxwuxrfXBc9Mj+UWDNH7Ao= github.com/sheerun/queue v1.0.1/go.mod h1:YtjrWT5jymvCLo/lEWDk3sv7A1Kgj0qcl3SZx7Zmcfo= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= @@ -674,6 +684,8 @@ github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVT github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -822,6 +834,8 @@ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20170517211232-f52d1811a629/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -907,8 +921,8 @@ golang.org/x/sys v0.0.0-20210521090106-6ca3eb03dfc2/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -988,8 +1002,8 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/collection/events.go b/internal/collection/events.go new file mode 100644 index 0000000..81fdd39 --- /dev/null +++ b/internal/collection/events.go @@ -0,0 +1,126 @@ +package collection + +import ( + "context" + "time" + + "github.com/golang/geo/s2" + + "photofield/internal/image" +) + +const ( + eventGapTime = time.Hour + locationGapTime = 15 * 60 // seconds + locationGapDist = 1.0 // km +) + +// EventSummary represents a time-bounded event within a collection. +type EventSummary struct { + Index int `json:"index"` + CreatedAfter string `json:"created_after"` + CreatedBefore string `json:"created_before"` + PhotoCount int `json:"photo_count"` + LocationCount int `json:"location_count"` + Locations []string `json:"locations,omitempty"` +} + +// SplitIntoEvents splits a collection's photos into events based on time and +// location proximity. Photos are grouped when they fall within the same day +// and are no more than eventGapTime apart. Reverse-geocoding is applied to +// photo locations that are >1km apart and >15 minutes apart. +func (collection *Collection) SplitIntoEvents(ctx context.Context, source *image.Source) ([]EventSummary, error) { + infos, _ := collection.GetInfos(source, image.ListOptions{}) + + var events []EventSummary + var current *EventSummary + var lastPhotoTime time.Time + var lastLocTime time.Time + var lastLatLng s2.LatLng + locations := make(map[string]struct{}) + + for info := range infos { + photoTime := info.DateTime + if photoTime.IsZero() { + continue + } + + if current == nil { + current = &EventSummary{ + CreatedAfter: photoTime.Format(time.RFC3339), + } + } else { + elapsed := photoTime.Sub(lastPhotoTime) + if elapsed < 0 { + elapsed = -elapsed + } + if elapsed > eventGapTime || !sameDay(lastPhotoTime, photoTime) { + // Finalize previous event + current.CreatedBefore = lastPhotoTime.Format(time.RFC3339) + current.Locations = make([]string, 0, len(locations)) + for loc := range locations { + current.Locations = append(current.Locations, loc) + } + events = append(events, *current) + + // Start new event + current = &EventSummary{ + CreatedAfter: photoTime.Format(time.RFC3339), + } + locations = make(map[string]struct{}) + } + } + + current.PhotoCount++ + lastPhotoTime = photoTime + + // Location tracking + if source.Geo != nil && source.Geo.Available() { + lastLocCheck := lastLocTime.Sub(photoTime) + if lastLocCheck < 0 { + lastLocCheck = -lastLocCheck + } + queryLocation := lastLocTime.IsZero() || lastLocCheck > time.Duration(locationGapTime)*time.Second + if queryLocation && image.IsValidLatLng(info.LatLng) { + lastLocTime = photoTime + dist := image.AngleToKm(lastLatLng.Distance(info.LatLng)) + if dist > locationGapDist { + location, err := source.Geo.ReverseGeocode(ctx, info.LatLng) + if err == nil { + locations[location] = struct{}{} + lastLatLng = info.LatLng + } + } + } + } + } + + // Finalize last event + if current != nil && current.PhotoCount > 0 { + current.CreatedBefore = lastPhotoTime.Format(time.RFC3339) + current.Locations = make([]string, 0, len(locations)) + for loc := range locations { + current.Locations = append(current.Locations, loc) + } + events = append(events, *current) + } + + // Assign indices + for i := range events { + events[i].Index = i + 1 + events[i].LocationCount = len(events[i].Locations) + } + + if events == nil { + events = make([]EventSummary, 0) + } + + return events, nil +} + +// sameDay reports whether a and b are on the same calendar day. +func sameDay(a, b time.Time) bool { + y1, m1, d1 := a.Date() + y2, m2, d2 := b.Date() + return y1 == y2 && m1 == m2 && d1 == d2 +} diff --git a/internal/collection/search.go b/internal/collection/search.go new file mode 100644 index 0000000..47ffe37 --- /dev/null +++ b/internal/collection/search.go @@ -0,0 +1,232 @@ +package collection + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/golang/geo/s2" + + "photofield/internal/ai" + "photofield/internal/image" + "photofield/internal/layout" + "photofield/internal/search" +) + +// SortType represents a sort specification for search results. +// Supports +/- prefixes for descending/ascending, and supports +// multiple sort fields (e.g. "-similarity,+date"). +type SortType string + +// SortOrder maps a SortType to an image.ListOrder. +// Returns the first recognized order, or DateDesc as default. +func SortOrder(s SortType) image.ListOrder { + if s == "" { + return image.DateDesc + } + lo := layout.OrderFromSort(string(s)) + return image.ListOrder(lo) +} + +// SortOrders parses a sort string and returns a slice of ListOrders. +// The primary sort is the first element; secondary sorts are appended. +// Supports formats like "-similarity,+date" or just "-date". +func SortOrders(s SortType) []image.ListOrder { + if s == "" { + return []image.ListOrder{image.DateDesc} + } + orders := make([]image.ListOrder, 0) + parts := strings.Split(string(s), ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + lo := layout.OrderFromSort(part) + if lo != layout.None { + orders = append(orders, image.ListOrder(lo)) + } + } + if len(orders) == 0 { + return []image.ListOrder{image.DateDesc} + } + return orders +} + +// SortPrimaryOrder returns the primary sort order. +func SortPrimaryOrder(s SortType) image.ListOrder { + orders := SortOrders(s) + if len(orders) == 0 { + return image.DateDesc + } + return orders[0] +} + +// SortIsSimilarity returns true if the primary sort is similarity-based. +func SortIsSimilarity(s SortType) bool { + lo := SortPrimaryOrder(s) + return lo == image.SimilarityDesc || lo == image.SimilarityAsc +} + +// SearchOptions holds the parameters for a collection search. +type SearchOptions struct { + QueryStr string + Sort SortType + Limit int +} + +// SearchResult represents a single search result with metadata. +type SearchResult struct { + Id int32 `json:"id"` + FileName string `json:"file_name"` + DateTime string `json:"datetime,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Color string `json:"color,omitempty"` // hex color + Location string `json:"location,omitempty"` // reverse-geocoded, if geo available + Similarity float32 `json:"similarity,omitempty"` + Tags []string `json:"tags,omitempty"` // tags on this photo +} + +// Search searches a collection's photos by text, image reference, face reference, +// or structured qualifiers. Returns metadata and similarity scores. +func (collection *Collection) Search( + ctx context.Context, + source *image.Source, + opts SearchOptions, +) ([]SearchResult, []search.Token, []search.FieldMeta, error) { + // 1. Parse query + var tokens []search.Token + var expr search.Expression + var parseErr error + + if opts.QueryStr != "" { + q, err := search.Parse(opts.QueryStr) + if err != nil { + return nil, nil, nil, fmt.Errorf("parse failed: %w", err) + } + tokens = q.Tokens() + expr, parseErr = q.Expression() + } + + // 2. Resolve embeddings + var imageEmbedding ai.Embedding + var faceEmbedding ai.Embedding + + if parseErr == nil && expr.Image.Present { + emb, err := source.GetImageEmbedding(image.ImageId(expr.Image.Value)) + if err != nil { + return nil, tokens, expr.Errors, fmt.Errorf("image embed failed: %w", err) + } + imageEmbedding = emb + } + + if parseErr == nil && imageEmbedding == nil && expr.Face.Present { + emb, err := source.GetFaceEmbedding(int(expr.Face.Value)) + if err != nil { + return nil, tokens, expr.Errors, fmt.Errorf("face embed failed: %w", err) + } + faceEmbedding = emb + } + + if parseErr == nil && imageEmbedding == nil && expr.Text != "" { + emb, err := source.Clip.EmbedText(expr.Text) + if err != nil { + return nil, tokens, expr.Errors, fmt.Errorf("text embed failed: %w", err) + } + imageEmbedding = emb + } + + // 3. Determine sort order + order := SortPrimaryOrder(opts.Sort) + + // No default threshold — let all results through and let the caller + // control filtering via `t:X` if desired. (The original loadScene used + // 0.262 for non-similarity sorts but that was too aggressive for some + // datasets.) + + // 4. Query DB + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + + infos, _ := collection.GetInfos(source, image.ListOptions{ + OrderBy: order, + Limit: limit, + Expression: expr, + ImageEmbedding: imageEmbedding, + FaceEmbedding: faceEmbedding, + }) + + // 5. Collect results + results := make([]SearchResult, 0) + var lastLocTime time.Time + var lastLatLng s2.LatLng + + for info := range infos { + res := SearchResult{ + Id: int32(info.Id), + FileName: filepath.Base(getImagePath(source, info.Id)), + Similarity: info.Similarity, + } + + if !info.DateTime.IsZero() { + res.DateTime = info.DateTime.Format(time.RFC3339) + } + res.Width = info.Width + res.Height = info.Height + + // Color as hex string + if info.Color != 0 { + res.Color = fmt.Sprintf("#%06x", info.Color&0xFFFFFF) + } + + // Reverse-geocode (same logic as events: 15 min / 1 km gap) + if source.Geo != nil && source.Geo.Available() && image.IsValidLatLng(info.LatLng) { + lastLocCheck := lastLocTime.Sub(info.DateTime) + if lastLocCheck < 0 { + lastLocCheck = -lastLocCheck + } + queryLocation := lastLocTime.IsZero() || lastLocCheck > 15*time.Minute + if queryLocation { + lastLocTime = info.DateTime + dist := image.AngleToKm(lastLatLng.Distance(info.LatLng)) + if dist > 1.0 { + location, err := source.Geo.ReverseGeocode(ctx, info.LatLng) + if err == nil { + lastLatLng = info.LatLng + // Assign location to the last result in results (if any) + if len(results) > 0 { + results[len(results)-1].Location = location + } + } + } + } + } + + // Tags + tagNames := make([]string, 0) + for t := range source.ListImageTags(info.Id) { + tagNames = append(tagNames, t.Name) + } + sort.Strings(tagNames) + res.Tags = tagNames + + results = append(results, res) + } + + if results == nil { + results = make([]SearchResult, 0) + } + + return results, tokens, expr.Errors, parseErr +} + +func getImagePath(source *image.Source, id image.ImageId) string { + path, _ := source.GetImagePath(id) + return path +} diff --git a/internal/image/database.go b/internal/image/database.go index 50ca8e9..308e11e 100644 --- a/internal/image/database.go +++ b/internal/image/database.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "net/http" + "os" "path/filepath" "sort" "strconv" @@ -1978,6 +1979,11 @@ func (source *Database) listWithPrefixIds(prefixIds []int64, options ListOptions } go func() { + defer func() { + if r := recover(); r != nil { + fmt.Fprintln(os.Stderr, "listWithPrefixIds recovered from panic:", r) + } + }() if options.Batch == 0 { defer metrics.Elapsed("list infos sqlite")() } @@ -2406,11 +2412,9 @@ func (source *Database) List(dirs []string, options ListOptions) (<-chan Sourced concurrent := (len(prefixIds) + batchSize - 1) / batchSize if concurrent <= 1 { - log.Printf("list infos dirs %d\n", len(prefixIds)) options.Batch = 0 return source.listWithPrefixIds(prefixIds, options) } - log.Printf("list infos dirs %d batches %d\n", len(prefixIds), concurrent) out := make(chan SourcedInfo, 1000) tags := options.Expression.Tags.Values() deps := Dependencies{ @@ -2426,6 +2430,11 @@ func (source *Database) List(dirs []string, options ListOptions) (<-chan Sourced return out, deps } go func() { + defer func() { + if r := recover(); r != nil { + fmt.Fprintln(os.Stderr, "List recovered from panic:", r) + } + }() defer metrics.Elapsed("list infos sqlite")() var channels []<-chan SourcedInfo for i := 0; i < concurrent; i++ { diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..38ab2ac --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,333 @@ +// Package mcp provides a Model Context Protocol (MCP) server for the +// photofield application, mounted onto the chi HTTP router. +package mcp + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "photofield/internal/collection" + "photofield/internal/image" +) + +// Server holds the MCP server instance and its chi-mountable HTTP handler. +type Server struct { + srv *mcp.Server + handler http.Handler + serverBaseURL string // e.g. "http://localhost:8080" — used to build absolute image URLs +} + +// New creates a new MCP server for photofield with the given data sources +// and registers all available tools. The serverBaseURL parameter is the +// absolute URL at which the photofield API is accessible (e.g. "http://localhost:8080"). +// This is used to construct absolute image URLs for embedding in markdown etc. +// Callers should mount handler() on a chi router, e.g.: +// +// r.Mount("/mcp", s.handler()) +func New(collections *[]collection.Collection, imageSource *image.Source, serverBaseURL string) (*Server, error) { + s := mcp.NewServer(&mcp.Implementation{ + Name: "photofield", + Version: "dev", + }, nil) + + // Handler closure captures collections and imageSource. + mcp.AddTool(s, &mcp.Tool{ + Name: "list_collections", + Description: "List all photo collections available in the library with their current indexed status. " + + "Use this first to discover which collections exist, their IDs, how many photos are indexed, " + + "and when indexing last occurred. The collection ID from the response is required for all other " + + "tools (events, search_photos, get_photo). This tool has no input parameters — call it with an empty object {}. " + + "Returns indexed_count (how many photos have been processed) and indexed_at (timestamp of last indexing). " + + "If indexed_count is 0 or indexed_at is missing, the collection has not been indexed yet.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + }, listCollections(collections, imageSource)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "events", + Description: "Split a collection's photos into chronological events based on time gaps. Photos on different " + + "calendar days, or more than 1 hour apart (within the same day), are placed in separate events. Returns " + + "metadata summaries only (photo count, date ranges, number of distinct locations, location names) — NOT " + + "the photo images themselves. Uses reverse-geocoded location names for photos that are more than 1 km " + + "apart AND more than 15 minutes apart (to avoid excessive geocoding API calls). Best used after " + + "list_collections to pick a collection_id, then before search_photos to get high-level context about " + + "where and when photos were taken.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "collection_id": map[string]any{"type": "string", "description": "The collection ID from list_collections. Use the 'id' field from the collection object returned by list_collections."}, + }, + "required": []string{"collection_id"}, + }, + }, eventsHandler(collections, imageSource)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "search_photos", + Description: "Search a collection's photos using natural language text, visual similarity to another image, " + + "or similarity to a detected face. This is the primary discovery tool for finding specific photos. Returns " + + "metadata summaries (file name, date, dimensions, dominant color, location, tags, similarity score) — NOT " + + "the image data itself. Use get_photo with the returned file_id to retrieve actual images and their embeddable URLs.\n\n" + + "QUERY TYPES:\n" + + "- Text search: e.g. 'red car on highway' — uses CLIP embeddings to find semantically similar images, sorted by match quality\n" + + "- Image similarity: e.g. 'img:1234' — finds photos visually similar to the given image ID\n" + + "- Face similarity: e.g. 'face:5678' — finds photos containing similar faces\n\n" + + "COMBINABLE QUALIFIERS (can mix with text search or use standalone):\n" + + "- tag:name — filter by tag (e.g. 'vacation', 'fav')\n" + + "- filename:text — filter by filename (supports * and ? wildcards, e.g. 'filename:*.png')\n" + + "- created:YYYY-MM-DD — filter by date (supports ranges like 'created:2023-01-01..2023-12-31', wildcards like 'created:*-12-25', and operators like 'created:>=2023-06-15')\n" + + "- t:X — similarity threshold filter (0.15-0.30, where higher = more strict; e.g. 'beach sunset t:0.25')\n" + + "- dedup:X — filter duplicates by similarity (0-1, e.g. 'dedup:0.9' keeps only photos <90% similar to each other)\n\n" + + "EXAMPLES:\n" + + "- 'beach sunset' — semantically search for beach/sunset photos\n" + + "- 'beach sunset t:0.25' — find beach sunsets with at least 0.25 similarity\n" + + "- 'created:2023-06..2023-08 tag:vacation' — vacation photos from summer 2023\n" + + "- 'img:100 tag:fav' — favorited photos similar to image 100\n\n" + + "PARAMETERS:\n" + + "- collection_id (required): From list_collections\n" + + "- query (required): Search query as described above\n" + + "- sort (optional): Controls result ordering. Default is '-date' (newest first). Options: '-date' " + + "(newest), '+date' (oldest), '-similarity' (best match first), '-similarity,+date' (best match, then " + + "newest). The '-' prefix means descending, '+' means ascending. Multiple fields can be combined with commas.\n" + + "- limit (optional): Maximum number of results. Default is 50. Use a smaller value (10-20) for quick " + + "previews, or larger (100-200) for comprehensive result sets. Results beyond the limit are silently discarded.\n\n" + + "WORKFLOW: Call search_photos to find candidates → examine the results → call get_photo on specific file_ids to see actual images and get their embeddable URLs.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "collection_id": map[string]any{"type": "string", "description": "The collection ID from list_collections."}, + "query": map[string]any{"type": "string", "description": "Search query: natural language text ('red car on highway'), image similarity ('img:1234'), face similarity ('face:5678'), or combined with qualifiers ('beach sunset tag:vacation created:2023-06'). Required."}, + "sort": map[string]any{"type": [3]string{"null", "string"}, "description": "Sort order. Default is '-date' (newest first). Options: '-date', '+date', '-similarity', '-similarity,+date'. Descending uses '-', ascending uses '+'."}, + "limit": map[string]any{"type": [2]string{"null", "integer"}, "description": "Max results. Default 50. Use 10-20 for quick previews, 100-200 for comprehensive sets."}, + }, + "required": []string{"collection_id", "query"}, + }, + }, searchPhotosHandler(collections, imageSource)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "get_photo", + Description: "Retrieve a photo as a base64-encoded image with rich metadata and embeddable URLs. This is the only tool that returns actual image data.\n\n" + + "CRITICAL DEFAULT BEHAVIOR — ALWAYS CALL WITH ONLY file_id FIRST:\n" + + "When you call get_photo with ONLY the file_id parameter (no w, h, crop, or format), it returns a small " + + "256x256 pixel thumbnail as JPEG. This is the recommended default for: browsing search results, getting a " + + "quick overview, identifying content at a glance, and most everyday use cases. Small thumbnails are fast, " + + "efficient, and usually sufficient for identifying what a photo contains.\n\n" + + "ONLY add extra parameters when you genuinely need more detail:\n" + + "- w/h: Use ONLY when the thumbnail is too small to make out details. E.g., if you need to read text in a " + + "sign, identify a distant person, or examine architectural details. Range: 1-4096. Omit both for the default " + + "256x256 thumbnail.\n" + + "- format: Rarely needed. Options: 'jpeg' (default, best for photos), 'png' (lossless, good for " + + "screenshots/diagrams), 'webp' (smaller file size, modern format). Use default jpeg unless you have a specific need.\n" + + "- crop_x/crop_y/crop_w/crop_h: Use ONLY when you need to zoom into a specific region of the photo. " + + "Coordinates are in the ORIGINAL image's pixel space (not the output dimensions). All four must be " + + "specified together. The crop is applied before resizing by w/h. Example: to zoom into a face, you'd " + + "need to know approximate coordinates from metadata or previous calls.\n\n" + + "EMBEDDABLE URL (returned in structured metadata — use for markdown, HTML, etc.):\n" + + "- image_url: Absolute URL to the medium thumbnail (M: 320x320) if available, or original image URL as fallback. Use this for embedding images in markdown or HTML.\n" + + "- thumbnail[].url: URLs to pre-sized thumbnail variants (S=120px, SM=240px, M=320px, B=640px, XL=1280px)\n" + + "- faces[].preview_url: Direct URL to each face's cropped preview image (200x200)\n\n" + + "OUTPUT METADATA (returned alongside the image):\n" + + "- image_url: Absolute URL to medium thumbnail (M) or original image (for markdown embedding)\n" + + "- width/height: The rendered output dimensions\n" + + "- orig_width/orig_height: The original image's native resolution\n" + + "- path/filename/extension: Original file path details\n" + + "- video: true if this is a video file\n" + + "- created_at: Creation date in ISO 8601 format\n" + + "- tags: Detected semantic tags with file counts\n" + + "- faces: Detected faces with bounding box coordinates and confidence scores\n" + + "- latlng: GPS coordinates if available\n" + + "- location: Reverse-geocoded location string (e.g. 'Paris, France')\n" + + "- thumbnails: Available thumbnail variants with their sizes and URLs\n\n" + + "WORKFLOW: Use list_collections → events/search_photos for discovery → get_photo(file_id) for thumbnails → " + + "get_photo(file_id, w=800, h=600) only when you need to inspect details. Use the returned image_url for markdown embedding.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file_id": map[string]any{"type": "integer", "description": "The photo file ID (required). Obtain from search_photos results or get_photo output."}, + "w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target width in pixels (1-4096). OMIT for default 256x256 thumbnail. ONLY specify when you need larger output to inspect details that are unclear in the thumbnail."}, + "h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target height in pixels (1-4096). OMIT for default 256x256 thumbnail. ONLY specify when you need larger output to inspect details that are unclear in the thumbnail."}, + "format": map[string]any{"type": [2]string{"null", "string"}, "description": "Output format. Default: 'jpeg'. Options: 'jpeg' (recommended for photos, best quality/size balance), 'png' (lossless, use for screenshots/text), 'webp' (smaller files, modern). Rarely need to change from default."}, + "crop_x": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop left edge in ORIGINAL image pixels. Use with crop_y/crop_w/crop_h to zoom into a specific region. Coordinates are in the original image's pixel space, not the output dimensions."}, + "crop_y": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop top edge in ORIGINAL image pixels. Must be used with crop_w and crop_h."}, + "crop_w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop width in ORIGINAL image pixels. Must be used with crop_x, crop_y, and crop_h."}, + "crop_h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop height in ORIGINAL image pixels. Must be used with crop_x, crop_y, and crop_w."}, + }, + "required": []string{"file_id"}, + }, + }, getPhotoHandler(collections, imageSource, serverBaseURL)) + + h := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { + return s + }, nil) + + // Wrap with panic recovery to prevent server crashes from tool handler panics + wrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var written bool + // Wrap ResponseWriter to detect if WriteHeader was called + wrappedW := &responseWriterWrapper{ResponseWriter: w, wroteHeader: &written} + defer func() { + if rec := recover(); rec != nil { + fmt.Fprintln(os.Stderr, "MCP handler recovered from panic:", rec) + // Try to write an error response if not already written + if !written { + wrappedW.Header().Set("Content-Type", "application/json") + wrappedW.WriteHeader(http.StatusInternalServerError) + } + } + }() + h.ServeHTTP(wrappedW, r) + }) + + return &Server{srv: s, handler: wrappedHandler}, nil +} + +// --- list_collections --- + +type collectionsInput struct{} + +type collectionsOutput struct { + Items []collection.Collection `json:"items"` +} + +func listCollections(collections *[]collection.Collection, imageSource *image.Source) mcp.ToolHandlerFor[collectionsInput, collectionsOutput] { + return func(_ context.Context, _ *mcp.CallToolRequest, _ collectionsInput) (*mcp.CallToolResult, collectionsOutput, error) { + var items []collection.Collection + for i := range *collections { + c := &(*collections)[i] + c.UpdateIndexedAt(imageSource) + items = append(items, *c) + } + if items == nil { + items = make([]collection.Collection, 0) + } + return nil, collectionsOutput{Items: items}, nil + } +} + +// --- events --- + +type eventsInput struct { + CollectionId string `json:"collection_id" jsonschema:"The collection ID from list_collections. Use the 'id' field from the collection object returned by list_collections."` +} + +type eventsOutput struct { + Events []collection.EventSummary `json:"events"` +} + +func eventsHandler(collections *[]collection.Collection, imageSource *image.Source) mcp.ToolHandlerFor[eventsInput, eventsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, input eventsInput) (*mcp.CallToolResult, eventsOutput, error) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintln(os.Stderr, "events handler recovered from panic:", r) + } + }() + // Find the collection + var coll *collection.Collection + for i := range *collections { + if (*collections)[i].Id == input.CollectionId { + coll = &(*collections)[i] + break + } + } + if coll == nil { + return nil, eventsOutput{}, nil + } + + // Delegate to collection method + events, err := coll.SplitIntoEvents(ctx, imageSource) + if err != nil { + return nil, eventsOutput{}, err + } + return nil, eventsOutput{Events: events}, nil + } +} + +// --- search_photos --- + +type searchPhotosInput struct { + CollectionId string `json:"collection_id" jsonschema:"The collection ID from list_collections."` + Query string `json:"query" jsonschema:"Search query: natural language text ('red car on highway'), image similarity ('img:1234'), face similarity ('face:5678'), or combined with qualifiers ('beach sunset tag:vacation created:2023-06'). Required."` + Sort *string `json:"sort" jsonschema:"Sort order. Default: \"-date\" (newest first). Options: \"-date\", \"+date\", \"-similarity\", \"-similarity,+date\". \"-\" = descending, \"+\" = ascending. Multiple fields separated by commas."` + Limit *int `json:"limit" jsonschema:"Max results. Default 50. Use 10-20 for quick previews, 100-200 for comprehensive sets."` +} + +type searchPhotosOutput struct { + Items []collection.SearchResult `json:"items"` +} + +func searchPhotosHandler(collections *[]collection.Collection, imageSource *image.Source) mcp.ToolHandlerFor[searchPhotosInput, searchPhotosOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, input searchPhotosInput) (*mcp.CallToolResult, searchPhotosOutput, error) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintln(os.Stderr, "search_photos handler recovered from panic:", r) + } + }() + // Find the collection + var coll *collection.Collection + for i := range *collections { + if (*collections)[i].Id == input.CollectionId { + coll = &(*collections)[i] + break + } + } + if coll == nil { + return nil, searchPhotosOutput{}, nil + } + + limit := 50 + if input.Limit != nil && *input.Limit > 0 { + limit = *input.Limit + } + + sort := collection.SortType("") + if input.Sort != nil && *input.Sort != "" { + sort = collection.SortType(*input.Sort) + } + + opts := collection.SearchOptions{ + QueryStr: input.Query, + Sort: sort, + Limit: limit, + } + + items, _, _, err := coll.Search(ctx, imageSource, opts) + if err != nil { + return nil, searchPhotosOutput{}, err + } + if items == nil { + items = make([]collection.SearchResult, 0) + } + return nil, searchPhotosOutput{Items: items}, nil + } +} + +// responseWriterWrapper wraps http.ResponseWriter to track if WriteHeader was called. +type responseWriterWrapper struct { + http.ResponseWriter + wroteHeader *bool +} + +func (w *responseWriterWrapper) WriteHeader(statusCode int) { + if !*w.wroteHeader { + *w.wroteHeader = true + w.ResponseWriter.WriteHeader(statusCode) + } +} + +func (w *responseWriterWrapper) Write(b []byte) (int, error) { + if !*w.wroteHeader { + *w.wroteHeader = true + w.ResponseWriter.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(b) +} + +// Handler returns the http.Handler for mounting onto a chi router. +func (s *Server) Handler() http.Handler { + return s.handler +} diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go new file mode 100644 index 0000000..dca228b --- /dev/null +++ b/internal/mcp/photo.go @@ -0,0 +1,541 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + goimage "image" + "image/color" + "image/draw" + "image/png" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/tdewolff/canvas" + "github.com/tdewolff/canvas/rasterizer" + + "photofield/internal/codec" + jpegcodec "photofield/internal/codec/jpeg" + webpjack "photofield/internal/codec/webp/jack" + webpjackdyn "photofield/internal/codec/webp/jack/dynamic" + webpjacktra "photofield/internal/codec/webp/jack/transpiled" + "photofield/internal/collection" + "photofield/internal/image" + "photofield/internal/io" + "photofield/internal/render" +) + +// imagePoolKey is a key for the image pool cache. +type imagePoolKey struct { + Width int + Height int + Mem codec.ImageMem +} + +// tilePools caches image pools by dimensions to avoid recreating them. +var tilePools sync.Map + +// getPool creates or retrieves an image pool for the given dimensions. +func getPool(config *render.Render) *sync.Pool { + key := imagePoolKey{ + Width: config.ImageWidth, + Height: config.ImageHeight, + Mem: config.ImageMem, + } + if pool, ok := tilePools.Load(key); ok { + return pool.(*sync.Pool) + } + + pool := &sync.Pool{} + switch config.ImageMem { + case codec.ImageMemNRGBA: + pool.New = func() interface{} { + return goimage.NewNRGBA(goimage.Rect(0, 0, config.ImageWidth, config.ImageHeight)) + } + case codec.ImageMemPaletted: + pool.New = func() interface{} { + return goimage.NewPaletted( + goimage.Rect(0, 0, config.ImageWidth, config.ImageHeight), + color.Palette{ + color.RGBA{0x00, 0x00, 0x00, 0x00}, + color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}, + }, + ) + } + default: + pool.New = func() interface{} { + return goimage.NewRGBA(goimage.Rect(0, 0, config.ImageWidth, config.ImageHeight)) + } + } + stored, _ := tilePools.LoadOrStore(key, pool) + return stored.(*sync.Pool) +} + +// getPoolImage gets an image from the pool and creates a canvas context. +func getPoolImage(config *render.Render) (draw.Image, *canvas.Context) { + pool := getPool(config) + img := pool.Get().(draw.Image) + r := rasterizer.New(img, 1.0) + c := canvas.NewContext(r) + c.SetView(canvas.Identity) + return img, c +} + +// putPoolImage returns an image to the pool. +func putPoolImage(config *render.Render, img draw.Image) { + pool := getPool(config) + pool.Put(img) +} + +// getPhotoInput contains the parameters for the get_photo MCP tool. +type getPhotoInput struct { + FileId int `json:"file_id" jsonschema:"The photo file ID to retrieve"` + + // Dimensions - if omitted, defaults to thumbnail (256x256 max) + TargetW *int `json:"w" jsonschema:"Target width in pixels (1-4096). Omitted = auto thumbnail"` + TargetH *int `json:"h" jsonschema:"Target height in pixels (1-4096). Omitted = auto thumbnail"` + + // Format - if omitted, defaults to JPEG + Format *string `json:"format" jsonschema:"Output format: jpeg, png, or webp (default jpeg)"` + + // Cropping - all in original image pixel coordinates + CropX *int `json:"crop_x" jsonschema:"Crop left edge in original image pixels"` + CropY *int `json:"crop_y" jsonschema:"Crop top edge in original image pixels"` + CropW *int `json:"crop_w" jsonschema:"Crop width in original image pixels"` + CropH *int `json:"crop_h" jsonschema:"Crop height in original image pixels"` +} + +// getPhotoOutput contains the response for the get_photo MCP tool. +// The `data` and `mimeType` fields mirror MCP ImageContent for the embedded image, +// while the remaining fields provide rich photo metadata (mirrors PhotoRegionData). +type getPhotoOutput struct { + Data string `json:"data"` // base64-encoded image data (matches MCP ImageContent) + MimeType string `json:"mimeType"` // MIME type (matches MCP ImageContent) + Width int `json:"width"` // rendered output width in pixels + Height int `json:"height"` // rendered output height in pixels + OrigWidth int `json:"orig_width"` // original image width in pixels + OrigHeight int `json:"orig_height"` // original image height in pixels + Path string `json:"path"` // original file path + Filename string `json:"filename"` // original file name with extension + Extension string `json:"extension"` // file extension (e.g. ".jpg") + Video bool `json:"video"` // true if the file is a video + CreatedAt string `json:"created_at"` // file creation time in RFC 3339 + Tags []SimpleTag `json:"tags,omitempty"` + Faces []FaceInfo `json:"faces,omitempty"` + Location string `json:"location,omitempty"` // reverse-geocoded location + LatLng *LatLng `json:"latlng,omitempty"` // GPS coordinates + Thumbnails []Thumbnail `json:"thumbnails,omitempty"` // available thumbnail variants + ImageUrl string `json:"image_url"` // absolute URL to the medium thumbnail (M) or original (for markdown embedding) +} + +// FaceInfo represents detected face data for a photo. +type FaceInfo struct { + Id int `json:"id"` + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` + Confidence int `json:"confidence"` + PreviewUrl string `json:"preview_url,omitempty"` // absolute URL to cropped face preview image +} + +// LatLng holds GPS coordinates. +type LatLng struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} + +// SimpleTag represents a tag with a string ID. +type SimpleTag struct { + Id string `json:"id"` + Name string `json:"name"` + FileCount int `json:"file_count"` +} + +// Thumbnail describes an available thumbnail variant. +type Thumbnail struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Width int `json:"width"` + Height int `json:"height"` + Filename string `json:"filename"` + Url string `json:"url,omitempty"` // absolute URL to the thumbnail variant +} + +// getPhotoHandler handles the get_photo MCP tool request. +// serverBaseURL is the absolute URL of the photofield API (e.g. "http://localhost:8080"). +func getPhotoHandler(_ *[]collection.Collection, imageSource *image.Source, serverBaseURL string) mcp.ToolHandlerFor[getPhotoInput, getPhotoOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, input getPhotoInput) (*mcp.CallToolResult, getPhotoOutput, error) { + // Ensure we have a valid context - fall back to Background if nil. + if ctx == nil { + ctx = context.Background() + } + var panicked any + defer func() { + if r := recover(); r != nil { + panicked = r + fmt.Fprintf(os.Stderr, "get_photo handler recovered from panic: %v\n%s", r, stackTrace()) + } + }() + + // Get file info to validate existence + info := imageSource.GetInfo(image.ImageId(input.FileId)) + if info.Width == 0 || info.Height == 0 { + return nil, getPhotoOutput{}, fmt.Errorf("file not found: %d", input.FileId) + } + + // Determine target dimensions + targetW, targetH := input.TargetW, input.TargetH + if targetW == nil || targetH == nil { + // Default to small thumbnail: 256x256 max (like thumbnail sources) + w := 256 + h := 256 + targetW = &w + targetH = &h + } + + // Parse and validate format + formatStr := "jpeg" + if input.Format != nil && *input.Format != "" { + formatStr = *input.Format + switch formatStr { + case "jpeg", "jpg": + formatStr = "jpeg" + case "png": + case "webp": + default: + return nil, getPhotoOutput{}, fmt.Errorf("unsupported format: %s (use jpeg, png, or webp)", *input.Format) + } + } + + // Encode image data + imageData, err := encodePhoto(ctx, imageSource, image.ImageId(input.FileId), *targetW, *targetH, formatStr, + input.CropX, input.CropY, input.CropW, input.CropH) + if err != nil { + return nil, getPhotoOutput{}, err + } + + mime := "image/jpeg" + if formatStr == "png" { + mime = "image/png" + } else if formatStr == "webp" { + mime = "image/webp" + } + + if panicked != nil { + return nil, getPhotoOutput{}, fmt.Errorf("internal error rendering photo: %v", panicked) + } + + // Gather metadata + metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, serverBaseURL, *targetW, *targetH, formatStr) + + // Return a CallToolResult with a proper MCP ImageContent block + // (type: "image", data: , mimeType: ) instead of letting the + // SDK serialize the output struct as generic JSON text content. + // The SDK will marshal the typed output into StructuredContent automatically. + // + // NOTE: Pass raw image bytes ([]byte) directly, NOT a pre-encoded base64 + // string. Go's json.Marshal on []byte performs base64 encoding — feeding it + // a pre-encoded base64 string causes double-encoding: json.Marshal([]byte( + // "base64(image)")) → base64(base64(image)) → 400 from downstream consumers. + b64Data := base64.StdEncoding.EncodeToString(imageData) + res := &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.ImageContent{ + Data: imageData, // raw bytes — SDK base64-encodes for JSON wire + MIMEType: mime, + }, + }, + } + return res, getPhotoOutput{ + Data: b64Data, + MimeType: mime, + ImageUrl: metadata.ImageUrl, + Width: *targetW, + Height: *targetH, + OrigWidth: info.Width, + OrigHeight: info.Height, + Path: metadata.Path, + Filename: metadata.Filename, + Extension: metadata.Extension, + Video: metadata.Video, + CreatedAt: metadata.CreatedAt, + Tags: metadata.Tags, + Faces: metadata.Faces, + Location: metadata.Location, + LatLng: metadata.LatLng, + Thumbnails: metadata.Thumbnails, + }, nil + } +} + +// encodePhoto renders and encodes a photo to the specified format. +func encodePhoto(ctx context.Context, source *image.Source, fileId image.ImageId, targetW, targetH int, format string, + cropX, cropY, cropW, cropH *int) ([]byte, error) { + + // Get file info + info := source.GetInfo(fileId) + if info.Width == 0 || info.Height == 0 { + return nil, fmt.Errorf("file not found: %d", fileId) + } + + // Validate dimensions + if targetW < 1 || targetW > 4096 || targetH < 1 || targetH > 4096 { + return nil, fmt.Errorf("dimensions %dx%d out of range (1-4096)", targetW, targetH) + } + + // Create render config (similar to defaultSceneConfig.Render) + rn := render.Render{ + TileSize: 256, + ImageWidth: targetW, + ImageHeight: targetH, + MaxSolidPixelArea: 0, // Force full render, no solid color optimization + BackgroundColor: color.RGBA{0, 0, 0, 0}, + CoverFit: true, + ImageMem: codec.ImageMemRGBA, + QualityPreset: render.QualityPresetFast, + } + + // Get pooled image and canvas context + img, c := getPoolImage(&rn) + defer putPoolImage(&rn, img) + + rn.CanvasImage = img + + // Reset view and setup coordinates + c.ResetView() + c.SetView(canvas.Identity.Translate(0, float64(targetH))) + + // Draw background + draw.Draw(img, img.Bounds(), &goimage.Uniform{rn.BackgroundColor}, goimage.Point{}, draw.Src) + + // Setup photo with full content area bounds (no border) + photo := &render.Photo{ + Id: fileId, + } + photo.Sprite.Rect = render.Rect{ + X: 0, + Y: 0, + W: float64(targetW), + H: float64(targetH), + } + + // Build optional crop rect + var crop render.Rect + if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { + cx, cy := 0, 0 + if cropX != nil { + cx = *cropX + } + if cropY != nil { + cy = *cropY + } + crop = render.Rect{ + X: float64(cx), + Y: float64(cy), + W: float64(*cropW), + H: float64(*cropH), + } + } + + // Draw photo using existing rendering logic + photo.Draw(ctx, &rn, nil, c, render.Scales{Tile: 1.0}, source, false, crop) + + // Encode to requested format + var buf bytes.Buffer + quality := 80 + if rn.QualityPreset == render.QualityPresetHigh { + quality = 100 + } + + switch format { + case "jpeg": + if err := jpegcodec.Encode(&buf, img, quality); err != nil { + return nil, fmt.Errorf("error encoding JPEG: %w", err) + } + case "png": + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("error encoding PNG: %w", err) + } + case "webp": + // WebP encoding - try the available encoders + encoders := []func(w *bytes.Buffer, img goimage.Image, quality int) error{ + func(w *bytes.Buffer, img goimage.Image, quality int) error { + return webpjack.Encode(w, img, quality) + }, + func(w *bytes.Buffer, img goimage.Image, quality int) error { + return webpjackdyn.Encode(w, img, quality) + }, + func(w *bytes.Buffer, img goimage.Image, quality int) error { + return webpjacktra.Encode(w, img, quality) + }, + } + encoded := false + for _, enc := range encoders { + err := enc(&buf, img, quality) + if err == nil { + encoded = true + break + } + buf.Reset() + } + if !encoded { + // Fallback to JPEG if no WebP encoder works + if err := jpegcodec.Encode(&buf, img, quality); err != nil { + return nil, fmt.Errorf("error encoding JPEG (WebP fallback): %w", err) + } + } + default: + return nil, fmt.Errorf("unsupported format: %s", format) + } + + return buf.Bytes(), nil +} + +// photoMetadata holds all metadata fields for a photo. +type photoMetadata struct { + Path string + Filename string + Extension string + Video bool + CreatedAt string + ImageUrl string + Tags []SimpleTag + Faces []FaceInfo + Location string + LatLng *LatLng + Thumbnails []Thumbnail +} + +// gatherPhotoMetadata collects all metadata for a photo by file ID. +// serverBaseURL is the absolute API base URL (e.g. "http://localhost:8080"). +// targetW/targetH/format are used to construct the image and preview URLs. +// Mirrors the logic from layout.common.go:getRegionFromPhoto. +func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, info image.Info, serverBaseURL string, targetW, targetH int, format string) photoMetadata { + originalPath, _ := source.GetImagePath(image.ImageId(fileId)) + location := "" + var latlng *LatLng + if image.IsValidLatLng(info.LatLng) { + latlng = &LatLng{ + Lat: info.LatLng.Lat.Degrees(), + Lng: info.LatLng.Lng.Degrees(), + } + location, _ = source.Geo.ReverseGeocode(ctx, info.LatLng) + } + + isVideo := source.IsSupportedVideo(originalPath) + extension := filepath.Ext(originalPath) + filename := filepath.Base(originalPath) + + // Gather thumbnails from each source + var thumbnails []Thumbnail + originalSize := io.Size{X: info.Width, Y: info.Height} + basename := strings.TrimSuffix(filename, extension) + for _, s := range source.Sources { + if !s.Exists(ctx, io.ImageId(fileId), originalPath) { + continue + } + size := s.Size(originalSize) + ext := s.Ext() + if ext == "" { + ext = extension + } + thumbFilename := fmt.Sprintf("%s_%s%s", basename, s.Name(), ext) + thumbnails = append(thumbnails, Thumbnail{ + Name: s.Name(), + DisplayName: s.DisplayName(), + Width: size.X, + Height: size.Y, + Filename: thumbFilename, + Url: serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/variants/" + s.Name() + "/" + thumbFilename, + }) + } + sort.Slice(thumbnails, func(i, j int) bool { + a, b := &thumbnails[i], &thumbnails[j] + aa, bb := a.Width*a.Height, b.Width*b.Height + if aa != bb { + return aa < bb + } + return a.Name < b.Name + }) + + // Gather tags + tags := make([]SimpleTag, 0) + for t := range source.ListImageTags(image.ImageId(fileId)) { + tags = append(tags, SimpleTag{ + Id: t.Name, + Name: t.Name, + FileCount: t.FileCount, + }) + } + + // Gather face detections + faceInfos := source.GetFacesByFileId(image.ImageId(fileId)) + faces := make([]FaceInfo, 0, len(faceInfos)) + for _, f := range faceInfos { + // Build face preview URL: crop a square around the face + faceCropSize := f.W + if f.H > faceCropSize { + faceCropSize = f.H + } + // Center the crop on the face + cropX := f.X + (f.W-faceCropSize)/2 + if cropX < 0 { + cropX = 0 + } + cropY := f.Y + (f.H-faceCropSize)/2 + if cropY < 0 { + cropY = 0 + } + faces = append(faces, FaceInfo{ + Id: f.Id, + X: f.X, + Y: f.Y, + W: f.W, + H: f.H, + Confidence: f.Confidence, + PreviewUrl: serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/face.jpg?w=200&h=200&crop_x=" + fmt.Sprintf("%d", cropX) + "&crop_y=" + fmt.Sprintf("%d", cropY) + "&crop_w=" + fmt.Sprintf("%d", faceCropSize) + "&crop_h=" + fmt.Sprintf("%d", faceCropSize), + }) + } + + // Build image URL: use medium thumbnail (M: 320x320) if available, otherwise original + imgUrl := "" + for _, thumb := range thumbnails { + if thumb.Name == "M" && thumb.Url != "" { + imgUrl = thumb.Url + break + } + } + // Fallback to original if no medium thumbnail found + if imgUrl == "" { + imgUrl = serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/variants/" + filename + } + + return photoMetadata{ + Path: originalPath, + Filename: filename, + Extension: extension, + Video: isVideo, + ImageUrl: imgUrl, + CreatedAt: info.DateTime.Format("2006-01-02T15:04:05Z07:00"), + Tags: tags, + Faces: faces, + Location: location, + LatLng: latlng, + Thumbnails: thumbnails, + } +} + +// stackTrace returns a formatted stack trace for logging panics. +func stackTrace() string { + const maxStack = 32 + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + return string(buf[:n]) +} diff --git a/internal/openapi/api.gen.go b/internal/openapi/api.gen.go index 83e05e6..a7b873f 100644 --- a/internal/openapi/api.gen.go +++ b/internal/openapi/api.gen.go @@ -103,6 +103,21 @@ type DocsCapability struct { Url string `json:"url"` } +// EventSummary defines model for EventSummary. +type EventSummary struct { + CreatedAfter *time.Time `json:"created_after,omitempty"` + CreatedBefore *time.Time `json:"created_before,omitempty"` + Index *int `json:"index,omitempty"` + LocationCount *int `json:"location_count,omitempty"` + Locations *[]string `json:"locations,omitempty"` + PhotoCount *int `json:"photo_count,omitempty"` +} + +// EventsList defines model for EventsList. +type EventsList struct { + Items *[]EventSummary `json:"items,omitempty"` +} + // A validated and typed search query expression, types omitted as this is subject to many changes. type Expression map[string]interface{} @@ -112,6 +127,24 @@ type FileBinary string // FileId defines model for FileId. type FileId int +// FileInfo defines model for FileInfo. +type FileInfo struct { + Color *string `json:"color,omitempty"` + Datetime *time.Time `json:"datetime,omitempty"` + FileName *string `json:"file_name,omitempty"` + Height *int `json:"height,omitempty"` + Id *int `json:"id,omitempty"` + Location *string `json:"location,omitempty"` + Similarity *float32 `json:"similarity,omitempty"` + Tags *[]string `json:"tags,omitempty"` + Width *int `json:"width,omitempty"` +} + +// FileList defines model for FileList. +type FileList struct { + Items *[]FileInfo `json:"items,omitempty"` +} + // GeoJSON FeatureCollection type GeoJSON struct { // Array of GeoJSON features representing photos in the scene @@ -352,6 +385,18 @@ type TagIdPathParam TagId // TaskIdPathParam defines model for TaskIdPathParam. type TaskIdPathParam TaskId +// GetCollectionsIdFilesParams defines parameters for GetCollectionsIdFiles. +type GetCollectionsIdFilesParams struct { + // Natural language or structured search query + Search *Search `json:"search,omitempty"` + + // Sort order. Prefix with `-` for descending or `+` for ascending, e.g. `-date` (newest first), `+date` (oldest first), `-similarity` (best matches first). Multiple values allowed. + Sort *Sort `json:"sort,omitempty"` + + // Maximum number of results + Limit *Limit `json:"limit,omitempty"` +} + // GetFilesIdPreviewsFilenameParams defines parameters for GetFilesIdPreviewsFilename. type GetFilesIdPreviewsFilenameParams struct { // Target width in pixels. If omitted, uses original width or scales proportionally with height. @@ -522,6 +567,12 @@ type ServerInterface interface { // (GET /collections/{id}) GetCollectionsId(w http.ResponseWriter, r *http.Request, id CollectionId) + // (GET /collections/{id}/events) + GetCollectionsIdEvents(w http.ResponseWriter, r *http.Request, id CollectionId) + + // (GET /collections/{id}/files) + GetCollectionsIdFiles(w http.ResponseWriter, r *http.Request, id CollectionId, params GetCollectionsIdFilesParams) + // (GET /files/{id}) GetFilesId(w http.ResponseWriter, r *http.Request, id FileIdPathParam) @@ -650,6 +701,94 @@ func (siw *ServerInterfaceWrapper) GetCollectionsId(w http.ResponseWriter, r *ht handler(w, r.WithContext(ctx)) } +// GetCollectionsIdEvents operation middleware +func (siw *ServerInterfaceWrapper) GetCollectionsIdEvents(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "id" ------------- + var id CollectionId + + err = runtime.BindStyledParameter("simple", false, "id", chi.URLParam(r, "id"), &id) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter id: %s", err), http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCollectionsIdEvents(w, r, id) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// GetCollectionsIdFiles operation middleware +func (siw *ServerInterfaceWrapper) GetCollectionsIdFiles(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "id" ------------- + var id CollectionId + + err = runtime.BindStyledParameter("simple", false, "id", chi.URLParam(r, "id"), &id) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter id: %s", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetCollectionsIdFilesParams + + // ------------- Optional query parameter "search" ------------- + if paramValue := r.URL.Query().Get("search"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "search", r.URL.Query(), ¶ms.Search) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter search: %s", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort" ------------- + if paramValue := r.URL.Query().Get("sort"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "sort", r.URL.Query(), ¶ms.Sort) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter sort: %s", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + if paramValue := r.URL.Query().Get("limit"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter limit: %s", err), http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCollectionsIdFiles(w, r, id, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + // GetFilesId operation middleware func (siw *ServerInterfaceWrapper) GetFilesId(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -1790,6 +1929,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/collections/{id}", wrapper.GetCollectionsId) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/collections/{id}/events", wrapper.GetCollectionsIdEvents) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/collections/{id}/files", wrapper.GetCollectionsIdFiles) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/files/{id}", wrapper.GetFilesId) }) diff --git a/main.go b/main.go index db95a1a..69a8a37 100644 --- a/main.go +++ b/main.go @@ -62,6 +62,7 @@ import ( pfio "photofield/internal/io" "photofield/internal/io/bench" "photofield/internal/layout" + "photofield/internal/mcp" "photofield/internal/metrics" "photofield/internal/openapi" "photofield/internal/render" @@ -267,6 +268,23 @@ func getCollectionById(id string) *collection.Collection { return nil } +func ptr[T any](v T) *T { return &v } + +func parseTime(s string) *time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil + } + return &t +} + +func ptrToStrSlice(s []string) *[]string { + if len(s) == 0 { + return nil + } + return &s +} + func pushApiRequest(request ApiRequest) { requestsMutex.Lock() requests = append(requests, request) @@ -505,7 +523,98 @@ func (*Api) GetCollectionsId(w http.ResponseWriter, r *http.Request, id openapi. } } - problem(w, r, http.StatusNotFound, "Scene not found") + problem(w, r, http.StatusNotFound, "Collection not found") +} + +func (*Api) GetCollectionsIdEvents(w http.ResponseWriter, r *http.Request, id openapi.CollectionId) { + coll := getCollectionById(string(id)) + if coll == nil { + problem(w, r, http.StatusBadRequest, "Collection not found") + return + } + + items, err := coll.SplitIntoEvents(r.Context(), imageSource) + if err != nil { + problem(w, r, http.StatusInternalServerError, err.Error()) + return + } + + apiItems := make([]openapi.EventSummary, len(items)) + for i, e := range items { + apiItems[i] = openapi.EventSummary{ + Index: ptr(e.Index), + CreatedAfter: parseTime(e.CreatedAfter), + CreatedBefore: parseTime(e.CreatedBefore), + PhotoCount: ptr(e.PhotoCount), + LocationCount: ptr(e.LocationCount), + Locations: ptrToStrSlice(e.Locations), + } + } + + respond(w, r, http.StatusOK, openapi.EventsList{Items: &apiItems}) +} + +func (*Api) GetCollectionsIdFiles(w http.ResponseWriter, r *http.Request, id openapi.CollectionId, params openapi.GetCollectionsIdFilesParams) { + coll := getCollectionById(string(id)) + if coll == nil { + problem(w, r, http.StatusBadRequest, "Collection not found") + return + } + + limit := 50 + if params.Limit != nil && int(*params.Limit) > 0 { + limit = int(*params.Limit) + } + + opts := collection.SearchOptions{ + Limit: limit, + } + if params.Search != nil { + opts.QueryStr = string(*params.Search) + } + if params.Sort != nil { + opts.Sort = collection.SortType(string(*params.Sort)) + } + + items, _, _, err := coll.Search(r.Context(), imageSource, opts) + if err != nil { + problem(w, r, http.StatusInternalServerError, err.Error()) + return + } + + if items == nil { + items = make([]collection.SearchResult, 0) + } + + apiItems := make([]openapi.FileInfo, len(items)) + for i, item := range items { + apiItems[i] = openapi.FileInfo{ + Id: ptr(int(item.Id)), + FileName: &item.FileName, + Similarity: ptr(item.Similarity), + } + if item.DateTime != "" { + t := parseTime(item.DateTime) + apiItems[i].Datetime = t + } + if item.Width != 0 { + apiItems[i].Width = ptr(item.Width) + } + if item.Height != 0 { + apiItems[i].Height = ptr(item.Height) + } + if item.Color != "" { + apiItems[i].Color = &item.Color + } + if item.Location != "" { + apiItems[i].Location = &item.Location + } + if item.Tags != nil { + apiItems[i].Tags = ptrToStrSlice(item.Tags) + } + } + + respond(w, r, http.StatusOK, openapi.FileList{Items: &apiItems}) } func taskDisplayOrder(taskType string) int { @@ -2110,6 +2219,12 @@ func detectEncoderSupport() { } func main() { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "PANIC: %v\n", r) + os.Exit(1) + } + }() var err error startupTime = time.Now() @@ -2336,6 +2451,35 @@ func main() { r.Mount("/debug", middleware.Profiler()) r.Handle("/debug/fgprof", fgprof.Handler()) + // MCP server — construct base URL for image URLs + mcpServerBaseURL := os.Getenv("PHOTOFIELD_MCP_BASE_URL") + if mcpServerBaseURL == "" { + // Default to http://localhost:{port} based on the configured address + host := "localhost" + port := "8080" + if addr != "" { + // Parse address like ":8080" or "0.0.0.0:8080" + if h, p, err := net.SplitHostPort(addr); err == nil { + host = h + if host == "" || host == "0.0.0.0" { + host = "localhost" + } + port = p + } + } + mcpServerBaseURL = "http://" + host + ":" + port + } + srv, err := mcp.New(&collections, imageSource, mcpServerBaseURL) + if err != nil { + log.Fatalf("failed to create MCP server: %v", err) + } + mcpPrefix := os.Getenv("PHOTOFIELD_MCP_PREFIX") + if mcpPrefix == "" { + mcpPrefix = "/mcp" + } + r.Mount(mcpPrefix, srv.Handler()) + log.Printf("MCP server mounted at %s", mcpPrefix) + msg := "" if apiPrefix != "/" { // Hardcode well-known mime types, see https://github.com/golang/go/issues/32350 diff --git a/test_mcp.sh b/test_mcp.sh new file mode 100644 index 0000000..e2f7411 --- /dev/null +++ b/test_mcp.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +BASE_URL="http://localhost:8080/mcp" + +# Step 1: Initialize +echo "=== Step 1: Initialize ===" +INITIALIZE_RESP=$(curl -s -D- -X POST "$BASE_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}') +echo "$INITIALIZE_RESP" | head -10 +echo "" + +# Extract session ID +SESSION_ID=$(echo "$INITIALIZE_RESP" | grep "Mcp-Session-Id" | tr -d '\r' | sed 's/.*Mcp-Session-Id: //') +echo "Session ID: $SESSION_ID" + +# Step 2: Send initialized notification +echo "" +echo "=== Step 2: Send initialized notification ===" +curl -s -D- -X POST "$BASE_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' 2>&1 | head -5 +echo "" + +# Step 3: Call get_photo +echo "" +echo "=== Step 3: Call get_photo ===" +curl -s -D- -X POST "$BASE_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_photo","arguments":{"file_id":1,"w":400,"h":225,"format":"jpeg","crop_x":0,"crop_y":0,"crop_w":4000,"crop_h":2250}}}' 2>&1 From 443bb0b3981cf3897d69bd36bfb57e4bf7527dce Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 13:16:47 +0200 Subject: [PATCH 02/40] fix: clean up MCP server code - Remove debugging panic recovery from internal/image/database.go - Remove duplicate Data/MimeType from getPhotoOutput (image returned via MCP ImageContent) - Remove unused encoding/base64 import from photo.go - Consolidate GET_PHOTO_PLAN.md, SEARCH_TOOL_PLAN.md, docs/DEVELOPER_WORKFLOW.md into internal/mcp/AGENTS.md (developer workflow) and README.md (tool reference) --- GET_PHOTO_PLAN.md | 255 --------------------------------- SEARCH_TOOL_PLAN.md | 247 -------------------------------- docs/DEVELOPER_WORKFLOW.md | 281 ------------------------------------- internal/image/database.go | 13 +- internal/mcp/AGENTS.md | 134 ++++++++++++++++++ internal/mcp/README.md | 111 +++++++++++++++ internal/mcp/photo.go | 26 +--- 7 files changed, 254 insertions(+), 813 deletions(-) delete mode 100644 GET_PHOTO_PLAN.md delete mode 100644 SEARCH_TOOL_PLAN.md delete mode 100644 docs/DEVELOPER_WORKFLOW.md create mode 100644 internal/mcp/AGENTS.md create mode 100644 internal/mcp/README.md diff --git a/GET_PHOTO_PLAN.md b/GET_PHOTO_PLAN.md deleted file mode 100644 index 65492fb..0000000 --- a/GET_PHOTO_PLAN.md +++ /dev/null @@ -1,255 +0,0 @@ -# Plan: MCP `get_photo` tool - -## Goal -Add an MCP tool that returns a photo (image data) by its ID, defaulting to a small/medium preview thumbnail. Users can optionally customize dimensions, format, and crop — mirroring the existing `GET /files/{id}/previews/{filename}` API parameters. - ---- - -## 1. New file: `internal/mcp/photo.go` - -Contains the input/output types and handler for the `get_photo` tool. - -### `getPhotoInput` struct - -```go -type getPhotoInput struct { - CollectionId string `json:"collection_id" jsonschema:"The collection ID containing the photo"` - FileId int `json:"file_id" jsonschema:"The photo file ID to retrieve"` - - // Dimensions — if omitted, defaults to thumbnail (256×256 max) - TargetW *int `json:"w" jsonschema:"Target width in pixels (1-4096). Omitted = auto thumbnail"` - TargetH *int `json:"h" jsonschema:"Target height in pixels (1-4096). Omitted = auto thumbnail"` - - // Format — if omitted, defaults to JPEG - Format *string `json:"format" jsonschema:"Output format: jpeg, png, or webp (default jpeg)"` - - // Cropping — all in original image pixel coordinates - CropX *int `json:"crop_x" jsonschema:"Crop left edge in original image pixels"` - CropY *int `json:"crop_y" jsonschema:"Crop top edge in original image pixels"` - CropW *int `json:"crop_w" jsonschema:"Crop width in original image pixels"` - CropH *int `json:"crop_h" jsonschema:"Crop height in original image pixels"` -} -``` - -### `getPhotoOutput` struct - -```go -type getPhotoOutput struct { - Data string `json:"data"` // base64-encoded image bytes - Format string `json:"format"` // "jpeg", "png", or "webp" - Width int `json:"width"` // output width in pixels - Height int `json:"height"` // output height in pixels - Media string `json:"media"` // MIME type (e.g. "image/jpeg") -} -``` - -> **Rationale for base64**: MCP `CallToolResult` supports `ContentText` and `ContentImage` with `data` + `mimeType`. The `go-sdk` `mcp` package's `CallToolResult` type accepts an `image` content block with base64 data. This avoids needing to stream raw bytes through the MCP protocol. - -### `getPhotoHandler` function - -```go -func getPhotoHandler(collections *[]collection.Collection, imageSource *image.Source) mcp.ToolHandlerFor[getPhotoInput, getPhotoOutput] { - return func(ctx context.Context, _ *mcp.CallToolRequest, input getPhotoInput) (*mcp.CallToolResult, getPhotoOutput, error) { - defer func() { - if r := recover(); r != nil { - fmt.Fprintln(os.Stderr, "get_photo handler recovered from panic:", r) - } - }() - - // 1. Resolve collection (optional — file_id is absolute in the DB) - // Skip collection validation since file IDs are global within a source. - // Just verify the file exists in the DB. - - // 2. Get file info to validate existence - info := imageSource.GetInfo(image.ImageId(input.FileId)) - if info.Width == 0 || info.Height == 0 { - return nil, getPhotoOutput{}, fmt.Errorf("file not found: %d", input.FileId) - } - - // 3. Determine target dimensions - targetW, targetH := input.TargetW, input.TargetH - if targetW == nil || targetH == nil { - // Default to small thumbnail: 256×256 max (like thumbnail sources) - targetW = intPtr(256) - targetH = intPtr(256) - } - - // 4. Parse format - formatStr := "jpeg" - if input.Format != nil && *input.Format != "" { - formatStr = strings.ToLower(*input.Format) - } - // Validate format - switch formatStr { - case "jpeg", "jpg": - formatStr = "jpeg" - case "png": - case "webp": - default: - return nil, getPhotoOutput{}, fmt.Errorf("unsupported format: %s (use jpeg, png, or webp)", formatStr) - } - - // 5. Encode image data using the preview render pipeline - // Reuse the same approach as GetFilesIdPreviewsFilename in main.go: - // - Create render config (defaultSceneConfig.Render) - // - Set ImageWidth, ImageHeight, MaxSolidPixelArea=0 - // - Render photo to canvas - // - Encode to requested format - // - Base64-encode the result - imageData, err := encodePhoto(imageSource, input.FileId, targetW, targetH, formatStr, - input.CropX, input.CropY, input.CropW, input.CropH) - if err != nil { - return nil, getPhotoOutput{}, err - } - - mime := "image/jpeg" - if formatStr == "png" { - mime = "image/png" - } else if formatStr == "webp" { - mime = "image/webp" - } - - return nil, getPhotoOutput{ - Data: base64.StdEncoding.EncodeToString(imageData), - Format: formatStr, - Width: *targetW, - Height: *targetH, - Media: mime, - }, nil - } -} -``` - -### `encodePhoto` helper - -This mirrors the preview endpoint logic from `main.go:GetFilesIdPreviewsFilename`, extracted into a reusable function: - -```go -func encodePhoto(source *image.Source, fileId int, targetW, targetH *int, format string, - cropX, cropY, cropW, cropH *int) ([]byte, error) { - - photoId := image.ImageId(fileId) - - // Get info - info := source.GetInfo(photoId) - if info.Width == 0 || info.Height == 0 { - return nil, fmt.Errorf("file not found: %d", fileId) - } - - // Build crop rect (if specified) - var crop *render.Rect - if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { - cx, cy := 0, 0 - if cropX != nil { - cx = *cropX - } - if cropY != nil { - cy = *cropY - } - c := render.Rect{ - X: float64(cx), - Y: float64(cy), - W: float64(*cropW), - H: float64(*cropH), - } - crop = &c - } - - // Setup render config - rn := defaultSceneConfig.Render - rn.ImageWidth = *targetW - rn.ImageHeight = *targetH - rn.MaxSolidPixelArea = 0 - rn.BackgroundColor = color.RGBA{0, 0, 0, 0} - rn.CoverFit = true - - // Get pooled image and canvas context - img, c := getPoolImage(&rn) - defer putPoolImage(&rn, img) - rn.CanvasImage = img - - // Render (same pipeline as preview API, no border) - // ... draw background, photo sprite, etc. ... - - // Encode to format - var buf bytes.Buffer - switch format { - case "jpeg": - // encodeJPEG(img, &buf, 85) - case "png": - // png.Encode(&buf, img) - case "webp": - // encodeWebP(img, &buf) - } - - return buf.Bytes(), nil -} -``` - -> **Note**: The `defaultSceneConfig` and `getPoolImage`/`putPoolImage` functions are already in `main.go`. The `encodePhoto` function will extract the rendering logic from `GetFilesIdPreviewsFilename` into a shared helper, or simply inline the preview handler code here. If we want to avoid code duplication, consider moving the preview rendering into a new file like `internal/render/preview.go`. - ---- - -## 2. Register the tool in `internal/mcp/mcp.go` - -Add a fourth tool registration in `New()`: - -```go -mcp.AddTool(s, &mcp.Tool{ - Name: "get_photo", - Description: "Return a photo by ID as a base64-encoded image (JPEG/PNG/WebP). Defaults to a 256×256 thumbnail. Supports custom dimensions and cropping.", -}, getPhotoHandler(collections, imageSource)) -``` - - - -## File Changes Summary - -| File | Action | What | -|------|--------|------| -| `internal/mcp/photo.go` | **New** | `getPhotoInput`, `getPhotoOutput`, `getPhotoHandler`, `encodePhoto` | -| `internal/mcp/mcp.go` | **Edit** | Add `get_photo` tool registration | - - - ---- - -## Default Behavior (when no params provided) - -| Parameter | Default | Rationale | -|-----------|---------|-----------| -| `w` / `h` | 256 / 256 | Matches thumbnail source sizes (djpeg generator uses 256px); small enough for fast loading, large enough to be useful | -| `format` | `jpeg` | Smallest file size for photos; universally supported | -| `crop_*` | none (full image) | No crop applied by default | - - -When `w` or `h` is specified but not the other, the aspect ratio of the original image (or cropped region) is preserved. - ---- - -## MCP Content Image Format - -The `go-sdk` MCP library supports returning image content directly: - -```go -result := mcp.NewCallToolResult( - mcp.WithContentImage(mcp.ImageContent{ - Data: base64Data, - MediaType: "image/jpeg", - }), -) -return result, output, nil -``` - -This lets LLM clients display the photo inline rather than just showing base64 text. - ---- - -## Testing approach - -1. **MCP `get_photo` with no params** → verify 256×256 JPEG thumbnail returned -2. **MCP `get_photo` with `w=512, h=384`** → verify correct dimensions and aspect ratio -3. **MCP `get_photo` with `format=png`** → verify PNG-encoded data -4. **MCP `get_photo` with `crop_x=100, crop_y=50, crop_w=200, crop_h=200`** → verify cropped region -5. **MCP `get_photo` with non-existent file_id** → verify 404-style error -7. **Compare MCP output vs API `/files/{id}/previews`** → pixel-identical output diff --git a/SEARCH_TOOL_PLAN.md b/SEARCH_TOOL_PLAN.md deleted file mode 100644 index 7ccbe04..0000000 --- a/SEARCH_TOOL_PLAN.md +++ /dev/null @@ -1,247 +0,0 @@ -# Plan: MCP `search_photos` tool + API `GET /collections/{id}/files` - -## Goal -Add an MCP tool and API endpoint that lets users search a collection's photos using natural language or structured queries. The core logic mirrors what `SceneSource.loadScene` does (parse query → embed → query DB → return results), but skips the rendering/layout layer. The API uses query params (`search`, `sort`, `limit`) following the `SceneParams` pattern from `GET /scenes`. - ---- - -## 1. New file: `internal/collection/search.go` - -### `SearchResult` type -```go -type SearchResult struct { - Id int32 `json:"id"` - FileName string `json:"file_name"` - DateTime string `json:"datetime,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Color string `json:"color,omitempty"` // hex color - Location string `json:"location,omitempty"` // reverse-geocoded, if geo available - Similarity float32 `json:"similarity,omitempty"` // cosine similarity score - Tags []string `json:"tags,omitempty"` // tags on this photo -} -``` - -### `Collection.Search` method -```go -type SearchOptions struct { - QueryStr string - Sort SortType // e.g. "date", "similarity" - Limit int - Offset int -} - -type SortType string - -func (collection *Collection) Search( - ctx context.Context, - source *image.Source, - opts SearchOptions, -) ([]SearchResult, []search.Token, []search.FieldError, error) -``` - -Steps (mirrors `sceneSource.loadScene`): - -1. **Parse query** — `search.Parse(queryStr)` → `*search.Query` -2. **Validate/extract expression** — `query.Expression()` → `search.Expression` - - Collect tokens: `query.Tokens()` - - Collect errors: `expression.Errors` - - Extract text, created range, filters, tags, filenames, img, face -3. **Resolve embeddings** (same flow as scene loading): - - If `expression.Image.Present` → `source.GetImageEmbedding(image.ImageId(val))` - - If `expression.Face.Present` → `source.GetFaceEmbedding(val)` - - If text is non-empty and no image/face embed yet → `source.Clip.EmbedText(text)` - - If embedding fails, set error -4. **Query DB** — `collection.GetInfos(source, image.ListOptions{…})`: - - `OrderBy`: determined by `opts.Sort` + whether any embedding is present - - `Limit`: `opts.Limit` (default 50) - - `Offset`: `opts.Offset` (default 0) - - `Expression`: the parsed expression - - `ImageEmbedding` / `FaceEmbedding`: resolved above - - `Extensions`: nil (no filter) -5. **Collect results** from channel: - - For each `SourcedInfo`, build a `SearchResult`: - - `Id`: `info.Id` - - `FileName`: parse `source.GetImagePath(info.Id)` with `filepath.Base()` - - `DateTime`: `info.DateTime` (formatted as RFC3339) - - `Width`/`Height`: `info.Width`/`info.Height` - - `Color`: `info.Color` as hex string - - `Location`: reverse-geocoded (if geo available) - - `Similarity`: `info.Similarity` - - `Tags`: call `source.ListImageTags(info.Id)`, collect names - - Stop after `limit` results -6. **Return** `[]SearchResult`, tokens, errors, and any embedding error. - -### Helper: reverse-geocode a single photo -```go -func reverseGeocode(ctx context.Context, source *image.Source, latLng s2.LatLng) string -``` - ---- - -## 2. Thin out `internal/mcp/mcp.go` - -Add a third tool registration: -```go -mcp.AddTool(s, &mcp.Tool{ - Name: "search_photos", - Description: "Search a collection's photos by text, image reference, face reference, or structured qualifiers. Returns metadata and similarity scores.", -}, searchPhotosHandler(collections, imageSource)) -``` - -Handler delegates to `coll.Search()`. - ---- - -## 3. API endpoint: `GET /collections/{id}/files` - -Follows the `SceneParams` pattern: `search` and `sort` are query params (not body), with `limit` for pagination. - -### `api.yaml` -```yaml -paths: - /collections/{id}/files: - get: - description: Search a collection's photos by text or structured query. - tags: ["Source"] - parameters: - - name: id - in: path - required: true - schema: - $ref: "#/components/schemas/CollectionId" - - - name: search - in: query - description: Natural language or structured search query - schema: - $ref: "#/components/schemas/Search" - - - name: sort - in: query - description: Sort order. Prefix with `-` for descending or `+` for ascending, e.g. `-date` (newest first), `+date` (oldest first), `-similarity` (best matches first). Multiple values allowed. - schema: - $ref: "#/components/schemas/Sort" - - - name: limit - in: query - description: Maximum number of results - schema: - $ref: "#/components/schemas/Limit" - - responses: - "200": - description: Search results - content: - application/json: - schema: - $ref: "#/components/schemas/FileList" - -components: - schemas: - FileList: - type: object - properties: - items: - type: array - items: - $ref: "#/components/schemas/FileInfo" - - FileInfo: - type: object - properties: - id: { type: integer } - file_name: { type: string } - datetime: { type: string, format: date-time } - width: { type: integer } - height: { type: integer } - color: { type: string } - location: { type: string } - similarity: { type: number } - tags: - type: array - items: { type: string } -``` - -### `main.go` handler -```go -func (*Api) GetCollectionsIdFiles(w http.ResponseWriter, r *http.Request, id openapi.CollectionsIdFilesParams) { - coll := getCollectionById(string(id)) - if coll == nil { - problem(w, r, http.StatusBadRequest, "Collection not found") - return - } - - limit := 50 - if id.Limit != nil { - limit = int(*id.Limit) - } - - opts := collection.SearchOptions{ - QueryStr: string(id.Search), - Sort: collection.SortType(string(id.Sort)), - Limit: limit, - } - - items, tokens, errs, err := coll.Search(r.Context(), imageSource, opts) - if err != nil { - problem(w, r, http.StatusInternalServerError, err.Error()) - return - } - - respond(w, r, http.StatusOK, openapi.FileList{ - Items: &items, - }) -} -``` - ---- - -## File Changes Summary - -| File | Action | What | -|------|--------|------| -| `internal/collection/search.go` | **New** | `SearchResult`, `SearchOptions`, `SortType`, `Search` method, `reverseGeocode` helper | -| `internal/mcp/mcp.go` | **Edit** | Add `search_photos` tool registration + handler | -| `api.yaml` | **Edit** | Add `GET /collections/{id}/files` with `search`, `sort`, `limit` query params; add `FileInfo`, `FileList` schemas | -| `internal/openapi/api.gen.go` | **Regenerate** | `go generate ./...` | -| `main.go` | **Edit** | Add `GetCollectionsIdFiles` handler | - ---- - -## Key Design Decisions - -1. **Reuse `collection.GetInfos()`** — same DB query path as events and scenes; no duplication -2. **Embedding logic matches scene loading** — text→clip embed, image reference→DB embed, face reference→DB embed, all with error handling -3. **Query params follow `SceneParams` pattern** — `search` and `sort` as query params (not body), `limit` for pagination -4. **Default limit 50** — prevents runaway responses; MCP tool caps at 50, API uses `Limit` type (integer) -5. **Location reverse-geocoding** — same as events tool (15 min / 1 km gap), but applied per-result so the user sees locations for all matches -6. **No layout/rendering** — skips the entire `layout.Layout*` machinery; just returns metadata + similarity scores -7. **Tokens returned** — so callers can see how the query was parsed (matching the existing API search query endpoint) - ---- - -## Search query syntax (from existing `search` package) - -| Query | Meaning | -|-------|---------| -| `sunset beach` | Text search by cosine similarity | -| `created:2024-06` | Photos from June 2024 | -| `tag:vacation` | Photos tagged "vacation" | -| `filename:IMG_` | Files matching glob | -| `img:123` | Similar to photo with ID 123 | -| `face:456` | Similar to face with ID 456 | -| `t:0.3` | Similarity threshold (default 0.262) | -| `filter:knn` | Use knn index directly | -| `k:10` | Return top 10 results | -| `NOT beach` | Exclude "beach" | - ---- - -## Testing approach - -1. MCP `search_photos` tool with text query → verify results -2. API `GET /collections/{id}/files` with same query → verify identical results -3. Test `sort=date`, `sort=date_asc`, `sort=similarity` → verify ordering -4. Test `limit` pagination → verify result counts -5. Edge cases: empty query, invalid query, non-existent collection, no AI service, geo disabled diff --git a/docs/DEVELOPER_WORKFLOW.md b/docs/DEVELOPER_WORKFLOW.md deleted file mode 100644 index 9eb1fd0..0000000 --- a/docs/DEVELOPER_WORKFLOW.md +++ /dev/null @@ -1,281 +0,0 @@ -# Developer Workflow Guide - -This document summarizes common pitfalls and best practices for running, testing, -debugging, and iterating on the photofield MCP server. - -## 1. Starting the Server - -### Prerequisites - -The server reads configuration from `data/configuration.yaml`. If it doesn't -exist, the server runs with defaults and the default collection config points -to every subdirectory of the current working directory (which indexes nothing -useful). - -**Quick setup:** - -```bash -# Create a minimal config pointing to a test photo directory -mkdir -p data -cat > data/configuration.yaml < /tmp/photofield.log 2>&1 & - -# Wait for startup -sleep 5 - -# Verify it's running -curl -s http://localhost:8080/mcp -X POST \ - -H "Content-Type: application/json" \ - -d '{"test":true}' -# Returns: "malformed payload: invalid message version tag..." -# This is expected - the MCP endpoint requires proper JSON-RPC protocol. -# A successful startup just means the server is listening. -``` - -### Important notes - -- The server does **not** automatically scan photos on startup. Run the scan - first (or use the UI): - - ```bash - ./photofield -scan test 2>&1 - ``` - -- The server listens on port `8080` by default. - -- **Kill the server before rebuilding:** `pkill -f photofield` - -## 2. Sending MCP Requests - -The MCP server uses JSON-RPC 2.0 over HTTP with a streamable transport. The -protocol requires a **session lifecycle**: - -### Step-by-step - -```bash -BASE="http://localhost:8080/mcp" - -# Step 1: Initialize - gets a Session-Id back in the headers -curl -v -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize", - "params":{"protocolVersion":"2024-11-05", - "capabilities":{}, - "clientInfo":{"name":"test","version":"1.0"}}}' \ - 2>&1 - -# Extract the Session-Id from the response headers (e.g., Mcp-Session-Id: ABC123) - -# Step 2: Send the "initialized" notification (no ID) -curl -s -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -H "Mcp-Session-Id: ABC123" \ - -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' - -# Step 3: Call any tool using the same Session-Id -curl -s -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -H "Mcp-Session-Id: ABC123" \ - -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", - "params":{"name":"list_collections","arguments":{}}}' -``` - -### Tool call parameters - -- The MCP SDK infers the JSON Schema from Go struct types. If a struct field - is a **pointer** (`*int`, `*string`), the SDK may still mark it as required - in the generated schema. For tools with optional fields, use an **explicit - `InputSchema`** in the `Tool` registration (see `internal/mcp/mcp.go`), - which lets you control the `required` array precisely. - -- When calling tools, **always include all parameters that the schema marks as - required** (check via `tools/list`). - -## 3. Inspecting Errors and Crashes - -### Stack traces - -When the server panics, it logs a full Go stack trace to **stderr**. Since the -server runs in the background, redirect stderr to a log file: - -```bash -./photofield > /tmp/photofield.log 2>&1 & - -# After an error: -tail -100 /tmp/photofield.log -``` - -The stack trace will show the panic chain. A typical error pattern: - -``` -get_photo handler recovered from panic: cannot create context from nil parent - -runtime/debug.Stack() - ... -context.WithTimeout({0x0, 0x0}, ...) ← nil context! -photofield/internal/io/djpeg.Djpeg.Get(...) ← called with nil ctx -photofield/internal/mcp/photo.go:251 ← photo.Draw(nil, ...) -``` - -### What to look for - -1. **"cannot create context from nil parent"** → A handler is passing `nil` as - the context to code that calls `context.WithTimeout/WithDeadline`. Fix: pass - `context.Background()` as fallback, or ensure the caller provides a valid - context. - -2. **"file not found: N"** → The file ID doesn't exist in the database. Check - `sqlite3 data/photofield.cache.db "SELECT * FROM infos;"` to find valid IDs. - -3. **Empty response data** → The image rendering succeeded but produced empty - output. Check if the photo exists and the dimensions are valid. - -## 4. Checking Runtime State - -### Database inspection - -```bash -# List all indexed photos with IDs -sqlite3 data/photofield.cache.db "SELECT id, width, height FROM infos ORDER BY id;" - -# Count photos -sqlite3 data/photofield.cache.db "SELECT COUNT(*) FROM infos;" - -# See tables -sqlite3 data/photofield.cache.db ".tables" -``` - -### Collection status via MCP - -```bash -# List all collections and their indexed count -curl -s -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -H "Mcp-Session-Id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", - "params":{"name":"list_collections","arguments":{}}}' -``` - -## 5. Quick Test Script - -Save this as `test_mcp.sh` for fast iteration: - -```bash -#!/bin/bash -set -e -BASE="http://localhost:8080/mcp" - -# Initialize and get session ID -INIT=$(curl -v -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize", - "params":{"protocolVersion":"2024-11-05", - "capabilities":{}, - "clientInfo":{"name":"test","version":"1.0"}}}' 2>&1) -SESSION=$(echo "$INIT" | grep "Mcp-Session-Id" | awk '{print $2}' | tr -d '\r') -echo "Session: $SESSION" - -# Send initialized notification -curl -s -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Mcp-Session-Id: $SESSION" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' > /dev/null - -# Call tools -echo "" -echo "=== list_collections ===" -curl -s -X POST "$BASE" \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", - "params":{"name":"list_collections","arguments":{}}}' -echo "" -echo "" - -echo "=== get_photo (file_id=1) ===" -curl -s -X POST "$BASE" \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", - "params":{"name":"get_photo","arguments": - {"file_id":1,"w":400,"h":225,"format":"jpeg", - "crop_x":0,"crop_y":0,"crop_w":1000,"crop_h":1000}}}' -echo "" -``` - -## 6. Common Fixes Checklist - -| Symptom | Likely Cause | Fix | -|---------|-------------|-----| -| `file not found: N` | Photo ID doesn't exist | Scan collection or check DB | -| Empty response data | Rendering panics silently | Check server log for panic | -| `cannot create context from nil parent` | Nil context passed to WithTimeout/Deadline | Add `if ctx == nil { ctx = context.Background() }` | -| Schema says all fields required | SDK infers schema from Go struct pointers | Override with explicit `InputSchema` | -| Server not responding | Old binary still running | `pkill -f photofield` then rebuild | -| No photos found | Default config points to empty dirs | Create `data/configuration.yaml` | - -## 7. Recommended Improvements - -The following changes would make the workflow significantly easier: - -### a) Add an MCP health/status endpoint -A simple HTTP endpoint (e.g., `GET /mcp/health`) that returns whether the -server is up, how many collections are indexed, and last scan time. This -avoids needing to send a full MCP session just to check if the server is -running. - -### b) Add a `GET /mcp/tools` endpoint -Expose the list of available tools as a simple HTTP JSON endpoint. Currently -you must go through the full MCP session lifecycle to discover available -tools and their schemas. - -### c) Make the server rebuild-aware -Add a Makefile or Taskfile target that handles `pkill`, `go build`, and -`./photofield` in sequence. This prevents the common mistake of calling -tools on an outdated binary. - -### d) Improve nil context handling globally -Instead of patching each handler, add a middleware or wrapper in the MCP -tool handler registration that ensures a non-nil context is always passed. -This would prevent the most common crash pattern. - -### e) Add structured error responses to panics -Currently panics are caught and logged, but the MCP response is empty or -contains only an error message. Including the panic message and a hint about -what to check would make debugging much faster. - -### f) Provide a CLI test harness -A small Go test binary or subcommand (e.g., `photofield test-mcp`) that -connects to the running server and runs a battery of tool calls with -assertions. This would make it easy to verify end-to-end functionality -without manual curl commands. - -### g) Improve schema generation for optional fields -The MCP SDK's automatic schema generation marks pointer fields as required, -which is incorrect. Either: -- Fix the SDK to use `nullable: true` for pointer types and exclude them - from the `required` array, or -- Document the pattern of using explicit `InputSchema` for tools with - optional fields. - -### h) Add a `--watch` flag for automatic reload -A flag that watches for binary changes and restarts the server, so you -can iterate without manually killing and restarting. diff --git a/internal/image/database.go b/internal/image/database.go index 308e11e..50ca8e9 100644 --- a/internal/image/database.go +++ b/internal/image/database.go @@ -7,7 +7,6 @@ import ( "fmt" "log" "net/http" - "os" "path/filepath" "sort" "strconv" @@ -1979,11 +1978,6 @@ func (source *Database) listWithPrefixIds(prefixIds []int64, options ListOptions } go func() { - defer func() { - if r := recover(); r != nil { - fmt.Fprintln(os.Stderr, "listWithPrefixIds recovered from panic:", r) - } - }() if options.Batch == 0 { defer metrics.Elapsed("list infos sqlite")() } @@ -2412,9 +2406,11 @@ func (source *Database) List(dirs []string, options ListOptions) (<-chan Sourced concurrent := (len(prefixIds) + batchSize - 1) / batchSize if concurrent <= 1 { + log.Printf("list infos dirs %d\n", len(prefixIds)) options.Batch = 0 return source.listWithPrefixIds(prefixIds, options) } + log.Printf("list infos dirs %d batches %d\n", len(prefixIds), concurrent) out := make(chan SourcedInfo, 1000) tags := options.Expression.Tags.Values() deps := Dependencies{ @@ -2430,11 +2426,6 @@ func (source *Database) List(dirs []string, options ListOptions) (<-chan Sourced return out, deps } go func() { - defer func() { - if r := recover(); r != nil { - fmt.Fprintln(os.Stderr, "List recovered from panic:", r) - } - }() defer metrics.Elapsed("list infos sqlite")() var channels []<-chan SourcedInfo for i := 0; i < concurrent; i++ { diff --git a/internal/mcp/AGENTS.md b/internal/mcp/AGENTS.md new file mode 100644 index 0000000..036920b --- /dev/null +++ b/internal/mcp/AGENTS.md @@ -0,0 +1,134 @@ +# Agent Developer Workflow Guide + +This guide covers running, testing, debugging, and iterating on the photofield MCP server. + +## 1. Starting the Server + +### Prerequisites + +The server reads configuration from `data/configuration.yaml`. If it doesn't +exist, the server runs with defaults and the default collection config points +to every subdirectory of the current working directory (which indexes nothing +useful). + +**Quick setup:** + +```bash +mkdir -p data +cat > data/configuration.yaml < /tmp/photofield.log 2>&1 & +sleep 5 +``` + +**Important:** The server does **not** auto-scan photos. Run a scan first: + +```bash +./photofield -scan test +``` + +The server listens on port `8080` by default. Kill with `pkill -f photofield` +before rebuilding. + +## 2. Sending MCP Requests + +The MCP server uses JSON-RPC 2.0 over HTTP with streamable transport. You need +a **session lifecycle**: + +```bash +BASE="http://localhost:8080/mcp" + +# Step 1: Initialize — gets a Session-Id back in headers +curl -v -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"2024-11-05", + "capabilities":{}, + "clientInfo":{"name":"test","version":"1.0"}}}' \ + 2>&1 + +# Step 2: Send initialized notification (no ID) +curl -s -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: ABC123" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' + +# Step 3: Call any tool +curl -s -X POST "$BASE" \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: ABC123" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", + "params":{"name":"list_collections","arguments":{}}}' +``` + +### Optional fields + +When a struct field is a **pointer** (`*int`, `*string`), the MCP SDK may still +mark it as required in the generated schema. Use the explicit `InputSchema` in +the tool registration (see `mcp.go`) to control the `required` array precisely. +When calling tools, include only the parameters the schema marks as required. + +## 3. Inspecting Errors and Crashes + +### Stack traces + +Panics are caught and logged to **stderr**. Redirect stderr to a log file: + +```bash +./photofield > /tmp/photofield.log 2>&1 & +tail -100 /tmp/photofield.log +``` + +Common patterns: + +1. **"cannot create context from nil parent"** → Handler passes `nil` context. + Fix: add `if ctx == nil { ctx = context.Background() }` in the handler. + +2. **"file not found: N"** → File ID doesn't exist. Check `sqlite3 data/photofield.cache.db "SELECT id FROM infos;"`. + +3. **Empty response data** → Rendering panicked silently. Check server log. + +## 4. Checking Runtime State + +### Database inspection + +```bash +sqlite3 data/photofield.cache.db "SELECT id, width, height FROM infos ORDER BY id;" +sqlite3 data/photofield.cache.db ".tables" +``` + +### Collection status via MCP + +```bash +curl -s -X POST "$BASE" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":"list_collections","arguments":{}}}' +``` + +## 5. Common Fixes + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `file not found: N` | Photo ID doesn't exist | Scan collection or check DB | +| Empty response data | Rendering panic | Check server log for panic | +| `cannot create context from nil parent` | Nil context passed to `WithTimeout` | Add `if ctx == nil { ctx = context.Background() }` | +| Schema says all fields required | SDK infers schema from Go struct pointers | Use explicit `InputSchema` | +| Server not responding | Old binary running | `pkill -f photofield` then rebuild | +| No photos found | Default config points to empty dirs | Create `data/configuration.yaml` | + +## 6. Test Script + +See `test_mcp.sh` in the repo root for a quick end-to-end test. diff --git a/internal/mcp/README.md b/internal/mcp/README.md new file mode 100644 index 0000000..2a13b23 --- /dev/null +++ b/internal/mcp/README.md @@ -0,0 +1,111 @@ +# MCP Tools for Photofield + +Four MCP tools expose photofield's photo library to AI agents: +`list_collections`, `events`, `search_photos`, `get_photo`. + +## Tool Reference + +### `list_collections` + +List all photo collections with indexed counts and timestamps. + +**Input:** `{}` (no parameters) + +**Output:** Array of collections, each with `id`, `name`, `indexed_count`, `indexed_at`. + +**Use this first** — the collection ID is required for all other tools. + +--- + +### `events` + +Split a collection's photos into time-bounded events. Photos on different +calendar days, or more than 1 hour apart (same day), form separate events. +Returns metadata only (counts, date ranges, locations) — not images. + +**Parameters:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `collection_id` | string | yes | From `list_collections` | + +**Output:** Array of `EventSummary` objects with `index`, `created_after`, `created_before`, `photo_count`, `location_count`, `locations`. + +**Workflow:** `list_collections` → pick a collection → `events` → get high-level context → `search_photos` for details. + +--- + +### `search_photos` + +Search photos by natural language, image similarity (`img:N`), face similarity +(`face:N`), or structured qualifiers. Returns metadata summaries — not images. + +**Parameters:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `collection_id` | string | yes | From `list_collections` | +| `query` | string | yes | Search query (see syntax below) | +| `sort` | string | no | `-date` (default, newest first), `+date`, `-similarity`, or `-similarity,+date` | +| `limit` | int | no | Max results (default 50). Use 10–20 for previews, 100–200 for full sets. | + +**Query syntax:** +| Query | Meaning | +|-------|---------| +| `sunset beach` | Natural language search via CLIP embeddings | +| `created:2024-06` | Photos from June 2024 | +| `tag:vacation` | Photos tagged "vacation" | +| `filename:IMG_` | Files matching glob (supports `*` and `?`) | +| `img:123` | Visually similar to photo ID 123 | +| `face:456` | Similar to face ID 456 | +| `t:0.3` | Minimum similarity threshold (0.15–0.30) | +| `dedup:0.9` | Remove near-duplicates (<90% similarity) | + +Qualifiers are combinable: `'created:2023-06..2023-08 tag:vacation'`. + +**Output:** Array of `SearchResult` objects with `id`, `file_name`, `datetime`, `width`, `height`, `color`, `location`, `similarity`, `tags`. + +**Workflow:** `search_photos` → examine results → `get_photo(file_id)` to see images. + +--- + +### `get_photo` + +Retrieve a photo as an embedded image with rich metadata. This is the **only** +tool that returns actual image data. + +**Parameters:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_id` | integer | yes | From `search_photos` results | +| `w` | int | no | Target width (1–4096). Omit for default 256px thumbnail. | +| `h` | int | no | Target height (1–4096). Omit for default 256px thumbnail. | +| `format` | string | no | `jpeg` (default), `png`, or `webp` | +| `crop_x` | int | no | Crop left edge in original image pixels | +| `crop_y` | int | no | Crop top edge in original image pixels | +| `crop_w` | int | no | Crop width in original image pixels | +| `crop_h` | int | no | Crop height in original image pixels | + +**Default behavior:** Returns a 256×256 JPEG thumbnail. This is the recommended +default for browsing — fast and token-efficient. Only add `w`/`h` when you need +to inspect details (e.g., read text in a sign). + +**Cropping:** All crop coordinates are in the **original** image's pixel space. +All four crop params must be specified together. The crop is applied before +resizing. + +**Output:** The image is returned as an MCP `ImageContent` block. Structured +metadata includes `width`, `height`, `orig_width`, `orig_height`, `path`, +`filename`, `extension`, `video`, `created_at`, `tags`, `faces`, `latlng`, +`location`, `thumbnails`, `image_url`. + +**Workflow:** `list_collections` → `search_photos` → `get_photo(file_id)` for +thumbnails → `get_photo(file_id, w=800, h=600)` only when you need details. + +## Full Workflow Example + +``` +1. list_collections({}) → pick "vacation" +2. events({collection_id: "vacation"}) → 12 events, "Paris, France" +3. search_photos({collection_id: "vacation", query: "eiffel tower"}) → 8 results +4. get_photo({file_id: 123}) → thumbnail JPEG +5. get_photo({file_id: 123, w: 800, h: 600}) → larger preview +``` diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index dca228b..22d5aac 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -3,7 +3,6 @@ package mcp import ( "bytes" "context" - "encoding/base64" "fmt" goimage "image" "image/color" @@ -111,12 +110,10 @@ type getPhotoInput struct { CropH *int `json:"crop_h" jsonschema:"Crop height in original image pixels"` } -// getPhotoOutput contains the response for the get_photo MCP tool. -// The `data` and `mimeType` fields mirror MCP ImageContent for the embedded image, -// while the remaining fields provide rich photo metadata (mirrors PhotoRegionData). +// getPhotoOutput contains the structured metadata for the get_photo MCP tool. +// The actual image is returned as an MCP ImageContent block in CallToolResult.Content, +// separate from this structured output (which holds metadata like tags, faces, etc.). type getPhotoOutput struct { - Data string `json:"data"` // base64-encoded image data (matches MCP ImageContent) - MimeType string `json:"mimeType"` // MIME type (matches MCP ImageContent) Width int `json:"width"` // rendered output width in pixels Height int `json:"height"` // rendered output height in pixels OrigWidth int `json:"orig_width"` // original image width in pixels @@ -235,27 +232,18 @@ func getPhotoHandler(_ *[]collection.Collection, imageSource *image.Source, serv // Gather metadata metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, serverBaseURL, *targetW, *targetH, formatStr) - // Return a CallToolResult with a proper MCP ImageContent block - // (type: "image", data: , mimeType: ) instead of letting the - // SDK serialize the output struct as generic JSON text content. - // The SDK will marshal the typed output into StructuredContent automatically. - // - // NOTE: Pass raw image bytes ([]byte) directly, NOT a pre-encoded base64 - // string. Go's json.Marshal on []byte performs base64 encoding — feeding it - // a pre-encoded base64 string causes double-encoding: json.Marshal([]byte( - // "base64(image)")) → base64(base64(image)) → 400 from downstream consumers. - b64Data := base64.StdEncoding.EncodeToString(imageData) + // Return a CallToolResult with an MCP ImageContent block for the embedded + // image, plus the typed output struct as structured_content for metadata. + // The SDK handles base64 encoding for the JSON wire format. res := &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.ImageContent{ - Data: imageData, // raw bytes — SDK base64-encodes for JSON wire + Data: imageData, MIMEType: mime, }, }, } return res, getPhotoOutput{ - Data: b64Data, - MimeType: mime, ImageUrl: metadata.ImageUrl, Width: *targetW, Height: *targetH, From c8624304d8372732faa1abf1731a51e79f2284ba Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 13:30:29 +0200 Subject: [PATCH 03/40] add /health endpoint to MCP server --- main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/main.go b/main.go index 69a8a37..14eebbc 100644 --- a/main.go +++ b/main.go @@ -2446,6 +2446,11 @@ func main() { var api Api r.Mount("/", openapi.Handler(&api)) r.Mount("/metrics", promhttp.Handler()) + r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"status":"ok"}`) + }) }) r.Mount("/debug", middleware.Profiler()) From 5c55021fb0323e231f192bf03bba6156040b6384 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 14:13:56 +0200 Subject: [PATCH 04/40] tools: add mcp-test.sh harness for testing MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old test_mcp.sh with a single, self-contained bash script that handles all MCP server interaction: - Automatic server lifecycle (auto-detect running server or start one) - Session handshake (initialize → notification → call) with session ID extraction from HTTP headers - SSE response parsing (strips "event: message / data:" prefixes) - Clean structured output with dynamic array detection - Multiple argument modes: JSON string, --key val pairs, or -- separator - --verbose flag for full JSON on every call - --quick smoke test (default: list_collections, or custom tool) - Interactive REPL for ad-hoc exploration - Environment variables for customization (port, binary path, URL) Update AGENTS.md to reference the harness instead of manual curl commands with the full session lifecycle. Signed-off-by: AI Agent --- internal/mcp/AGENTS.md | 123 ++++++++---- test_mcp.sh | 36 ---- tools/mcp-test.sh | 415 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 504 insertions(+), 70 deletions(-) delete mode 100644 test_mcp.sh create mode 100755 tools/mcp-test.sh diff --git a/internal/mcp/AGENTS.md b/internal/mcp/AGENTS.md index 036920b..317626a 100644 --- a/internal/mcp/AGENTS.md +++ b/internal/mcp/AGENTS.md @@ -40,39 +40,58 @@ sleep 5 The server listens on port `8080` by default. Kill with `pkill -f photofield` before rebuilding. -## 2. Sending MCP Requests +## 2. Calling MCP Tools -The MCP server uses JSON-RPC 2.0 over HTTP with streamable transport. You need -a **session lifecycle**: +Use `tools/mcp-test.sh` for all MCP tool calls. It handles the session +handshake, SSE parsing, and session ID management automatically. + +### Basic usage ```bash -BASE="http://localhost:8080/mcp" - -# Step 1: Initialize — gets a Session-Id back in headers -curl -v -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize", - "params":{"protocolVersion":"2024-11-05", - "capabilities":{}, - "clientInfo":{"name":"test","version":"1.0"}}}' \ - 2>&1 - -# Step 2: Send initialized notification (no ID) -curl -s -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Mcp-Session-Id: ABC123" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' - -# Step 3: Call any tool -curl -s -X POST "$BASE" \ - -H "Content-Type: application/json" \ - -H "Mcp-Session-Id: ABC123" \ - -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", - "params":{"name":"list_collections","arguments":{}}}' +# Call a tool with JSON args +./tools/mcp-test.sh call list_collections '{}' + +# Call with named args (auto-detects --key val pairs) +./tools/mcp-test.sh call search_photos --query 'beach' --collection_id 'test' --limit 3 + +# Verbose mode — always shows full JSON +./tools/mcp-test.sh --verbose call get_photo --file_id 1 --w 200 + +# Quick smoke test (list_collections only) +./tools/mcp-test.sh quick + +# Interactive REPL +./tools/mcp-test.sh shell ``` +### Arguments + +- **JSON mode**: `./tools/mcp-test.sh call ''` +- **Named args**: `./tools/mcp-test.sh call --key val` (auto-detected) +- **Explicit named**: `./tools/mcp-test.sh call -- --key val` (forces mode) + +### Environment + +| Variable | Default | Purpose | +|----------|---------|---------| +| `MCPT_PORT` | `8080` | Server port | +| `MCPT_BIN` | `./photofield` | Path to binary | +| `MCPT_DATA_DIR` | `./data` | Data directory | +| `MCPT_START` | `true` | Auto-start if not running | +| `MCPT_URL` | (derived) | Full URL (overrides PORT) | + +### Output + +Non-verbose mode shows a clean summary: +``` +✓ list_collections — 22 items +✓ events — 19 events +✓ get_photo +``` + +Errors show with a red ✗ and the error message. Set `--verbose` for full JSON +on every call, or use it with `quick` for the full response. + ### Optional fields When a struct field is a **pointer** (`*int`, `*string`), the MCP SDK may still @@ -112,10 +131,14 @@ sqlite3 data/photofield.cache.db ".tables" ### Collection status via MCP ```bash -curl -s -X POST "$BASE" \ - -H "Mcp-Session-Id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", - "params":{"name":"list_collections","arguments":{}}}' +# List all collections +./tools/mcp-test.sh call list_collections '{}' + +# Check a specific collection's events +./tools/mcp-test.sh call events --collection_id 'test' + +# Search photos +./tools/mcp-test.sh call search_photos --query 'faces' --collection_id 'test' --limit 5 ``` ## 5. Common Fixes @@ -129,6 +152,38 @@ curl -s -X POST "$BASE" \ | Server not responding | Old binary running | `pkill -f photofield` then rebuild | | No photos found | Default config points to empty dirs | Create `data/configuration.yaml` | -## 6. Test Script +## 6. Testing + +### Quick smoke test -See `test_mcp.sh` in the repo root for a quick end-to-end test. +```bash +./tools/mcp-test.sh quick +``` + +### Manual tool testing + +```bash +# Test a specific tool with arguments +./tools/mcp-test.sh call get_photo --file_id 1 + +# Test error handling +./tools/mcp-test.sh call get_photo --file_id 999999 + +# Verbose output for debugging +./tools/mcp-test.sh --verbose call search_photos --query 'test' --collection_id 'test' +``` + +### From another directory + +The harness auto-detects the `photofield` binary relative to the repo root. +To call it from elsewhere: + +```bash +MCPT_BIN=/path/to/photofield ./tools/mcp-test.sh call list_collections '{}' +``` + +Or use a custom URL: + +```bash +MCPT_URL=http://remote-host:9000/mcp ./tools/mcp-test.sh call list_collections '{}' +``` diff --git a/test_mcp.sh b/test_mcp.sh deleted file mode 100644 index e2f7411..0000000 --- a/test_mcp.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -set -e - -BASE_URL="http://localhost:8080/mcp" - -# Step 1: Initialize -echo "=== Step 1: Initialize ===" -INITIALIZE_RESP=$(curl -s -D- -X POST "$BASE_URL" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}') -echo "$INITIALIZE_RESP" | head -10 -echo "" - -# Extract session ID -SESSION_ID=$(echo "$INITIALIZE_RESP" | grep "Mcp-Session-Id" | tr -d '\r' | sed 's/.*Mcp-Session-Id: //') -echo "Session ID: $SESSION_ID" - -# Step 2: Send initialized notification -echo "" -echo "=== Step 2: Send initialized notification ===" -curl -s -D- -X POST "$BASE_URL" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -H "Mcp-Session-Id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' 2>&1 | head -5 -echo "" - -# Step 3: Call get_photo -echo "" -echo "=== Step 3: Call get_photo ===" -curl -s -D- -X POST "$BASE_URL" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -H "Mcp-Session-Id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_photo","arguments":{"file_id":1,"w":400,"h":225,"format":"jpeg","crop_x":0,"crop_y":0,"crop_w":4000,"crop_h":2250}}}' 2>&1 diff --git a/tools/mcp-test.sh b/tools/mcp-test.sh new file mode 100755 index 0000000..0a3921d --- /dev/null +++ b/tools/mcp-test.sh @@ -0,0 +1,415 @@ +#!/bin/bash +# mcp-test.sh — MCP server test harness for AI agents +# +# Call MCP tools directly without boilerplate. The harness handles: +# - Server lifecycle (auto-start / auto-detect / auto-stop) +# - MCP session handshake (initialize → notified → call) +# - JSON-RPC requests with proper session headers +# - SSE response parsing → clean JSON output +# +# USAGE: +# mcp-test.sh call Call a tool with JSON args +# mcp-test.sh call --key val [--key2 v] Call with named args +# mcp-test.sh quick Smoke test (list_collections) +# mcp-test.sh shell Interactive REPL +# mcp-test.sh -- Print this help +# +# ENV: +# MCPT_PORT — Server port (default: 8080) +# MCPT_BIN — Path to photofield binary +# MCPT_DATA_DIR — Path to data directory +# MCPT_START — Auto-start server (default: true) +# MCPT_URL — Full server URL (overrides PORT) + +set -uo pipefail + +# ─── Config ─── +PORT="${MCPT_PORT:-8080}" +URL="${MCPT_URL:-http://localhost:${PORT}/mcp}" +BIN="${MCPT_BIN:-$(cd "$(dirname "$0")/../.." && pwd)/photofield}" +DATA_DIR="${MCPT_DATA_DIR:-$(pwd)/data}" +AUTO_START="${MCPT_START:-true}" +VERBOSE=0 + +# ─── Color helpers ─── +if [[ -t 1 ]]; then + COL_GREEN='\033[0;32m'; COL_RED='\033[0;31m'; COL_CYAN='\033[0;36m' + COL_BOLD='\033[1m'; COL_RESET='\033[0m' +else + COL_GREEN=''; COL_RED=''; COL_CYAN=''; COL_BOLD=''; COL_RESET='' +fi + +log_ok() { echo "${COL_GREEN}✓${COL_RESET} $*"; } +log_fail() { echo "${COL_RED}✗${COL_RESET} $*" >&2; } +log_info() { echo "${COL_CYAN}ℹ${COL_RESET} $*" >&2; } +log_step() { echo "${COL_BOLD}--- $*${COL_RESET}" >&2; } + +# ─── Server Management ─── +_server_pid_file="/tmp/photofield-mcp-test.pid" + +server_start() { + if curl -s --max-time 2 "${URL}" &>/dev/null; then + log_info "Server already running on port ${PORT}" + _ServerManaged=false + return 0 + fi + + if [[ ! -x "$BIN" ]]; then + log_fail "Server binary not found: ${BIN}" + echo " Set MCPT_BIN=/path/to/photofield to override" >&2 + return 1 + fi + + log_step "Starting MCP server..." + nohup "$BIN" > /tmp/photofield-mcp-test.log 2>&1 & + _ServerManaged=true + echo $! > "$_server_pid_file" + log_info "PID: $! (log: /tmp/photofield-mcp-test.log)" + + local waited=0 + while (( waited < 30 )); do + if curl -s --max-time 2 "${URL}" &>/dev/null; then + log_ok "Server is ready" + return 0 + fi + sleep 0.5 + waited=$((waited + 1)) + done + + log_fail "Server failed to start within 30s" + tail -20 /tmp/photofield-mcp-test.log >&2 + return 1 +} + +# ─── MCP Session & Requests ─── +_SESSION_ID="" +_REQUEST_ID=0 + +session_init() { + # Send initialize request and capture headers to get session ID + local resp + resp=$(curl -s -D /tmp/mcp-headers-$$ \ + -X POST "${URL}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2024-11-05", + "capabilities":{}, + "clientInfo":{"name":"mcp-test","version":"0.1"} + }}' 2>/dev/null) + + # Extract session ID from HTTP headers + _SESSION_ID=$(grep -i "Mcp-Session-Id" /tmp/mcp-headers-$$ 2>/dev/null | head -1 | tr -d '\r' | sed 's/.*[Mm]cp-[Ss]ession-[Ii]d:[[:space:]]*//') + rm -f /tmp/mcp-headers-$$ + + if [[ -z "$_SESSION_ID" ]]; then + log_info "No session ID (server may not require one)" + else + log_info "Session: ${_SESSION_ID}" + fi + + # Send initialized notification + local hdrs=(-H "Content-Type: application/json") + [[ -n "$_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_SESSION_ID}") + curl -s -X POST "${URL}" \ + "${hdrs[@]}" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' 2>/dev/null || true +} + +# Parse SSE response: extract JSON from "data: {...}" lines +_sse_parse() { + local raw="$1" + # Extract the last JSON data line from SSE + echo "$raw" | sed -n 's/^data: //p' | tail -1 +} + +# Call a tool, return cleaned JSON on stdout +mcp_call() { + local tool="$1" + shift + local args="$*" + [[ -z "$args" ]] && args="{}" + _REQUEST_ID=$((_REQUEST_ID + 1)) + + local hdrs=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") + [[ -n "$_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_SESSION_ID}") + + local raw + raw=$(curl -s --max-time 30 -X POST "${URL}" \ + "${hdrs[@]}" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":${_REQUEST_ID},\"method\":\"tools/call\",\"params\":{\"name\":\"${tool}\",\"arguments\":${args}}}" \ + 2>/dev/null) + + _sse_parse "$raw" +} + +# Build JSON args from --key val pairs +build_args() { + local result="{" + local first=true + while [[ $# -gt 0 ]]; do + local key="$1" val="$2" + shift 2 + # Strip leading -- from key name + key="${key#--}" + [[ "$first" != "true" ]] && result+="," + first=false + if [[ "$val" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + result+="\"${key}\":${val}" + elif [[ "$val" == "true" || "$val" == "false" ]]; then + result+="\"${key}\":${val}" + elif [[ "$val" == "null" ]]; then + result+="\"${key}\":null" + else + val=$(printf '%s' "$val" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g') + result+="\"${key}\":\"${val}\"" + fi + done + result+="}" + echo "$result" +} + +# ─── Output formatting ─── +print_result() { + local tool="$1" resp="$2" + + # Check for JSON-RPC error field + local rpc_error rpc_error_msg + rpc_error=$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null) + if [[ -n "$rpc_error" ]]; then + echo "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${rpc_error}" + [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null + return 1 + fi + + # Check for result.isError (MCP tools can return errors in result) + local is_error + is_error=$(echo "$resp" | jq -r '.result.isError // false' 2>/dev/null) + + # Check for content in result (MCP uses content: [{type:"text",text:"..."}]) + local content_type content_text + content_type=$(echo "$resp" | jq -r '.result.content[0].type // empty' 2>/dev/null) + content_text=$(echo "$resp" | jq -r '.result.content[0].text // empty' 2>/dev/null) + + if [[ -n "$content_text" ]]; then + # If isError flag is set, this is an error response + if [[ "$is_error" == "true" ]]; then + echo "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${content_text}" + [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null + return 1 + fi + + if [[ "$VERBOSE" == "1" ]]; then + # Full JSON output + # Try to parse the text as JSON for pretty-printing + local inner_json + inner_json=$(echo "$content_text" | jq '.' 2>/dev/null) + if [[ -n "$inner_json" ]]; then + echo "$inner_json" + else + echo "$content_text" + fi + return 0 + fi + + # Normal response — try to parse content as JSON for a clean summary + local inner_json + inner_json=$(echo "$content_text" | jq '.' 2>/dev/null) + if [[ -n "$inner_json" ]]; then + # Content is JSON — show a summary + # Find top-level array keys and report their counts + local summary_label + summary_label=$(echo "$inner_json" | jq -r ' + [ to_entries[] | select(.value | type == "array") ] | + if length > 0 then + (map("\(.key): \(.value | length)") | join(", ")) + else + "ok" + end + ' 2>/dev/null) + echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${summary_label}" + # Print the parsed JSON + echo "$inner_json" | jq '.' 2>/dev/null || echo "$inner_json" + else + # Plain text response + if [[ ${#content_text} -lt 500 ]]; then + echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" + echo "$content_text" + else + echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${#content_text} chars" + echo "$content_text" | head -c 500 + echo "..." + fi + fi + return 0 + fi + + # No content — check for structured content + local has_structured + has_structured=$(echo "$resp" | jq -e '.result.structuredContent' &>/dev/null && echo yes || echo no) + if [[ "$has_structured" == "yes" ]]; then + local sc_json + sc_json=$(echo "$resp" | jq -r '.result.structuredContent' 2>/dev/null) + if [[ -n "$sc_json" ]]; then + echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" + echo "$sc_json" | jq '.' 2>/dev/null || echo "$sc_json" + return 0 + fi + fi + + # No result at all + echo "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — no result" + [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null || echo "$resp" + return 1 +} + +# ─── REPL ─── +run_repl() { + echo "" + echo "${COL_BOLD}MCP Test Shell${COL_RESET} (type 'help' for commands, 'quit' to exit)" + echo "" + + session_init + + while true; do + printf "${COL_CYAN}>${COL_RESET} " + read -r line + [[ -z "$line" ]] && continue + [[ "$line" == "quit" || "$line" == "exit" ]] && break + [[ "$line" == "help" ]] && { + echo " call Call a tool with JSON args" + echo " call --key val ... Call with named args" + echo " quit Exit" + continue + } + + if [[ "$line" =~ ^call[[:space:]]+ ]]; then + local rest="${line#call }" + local tool args + if [[ "$rest" =~ ^([^[:space:]]+)[[:space:]]+(--.*)$ ]]; then + tool="${BASH_REMATCH[1]}" + args=$(build_args ${BASH_REMATCH[2]}) + elif [[ "$rest" =~ ^([^[:space:]]+)[[:space:]]+(.*)$ ]]; then + tool="${BASH_REMATCH[1]}" + args="${BASH_REMATCH[2]}" + [[ -z "$args" ]] && args="{}" + else + tool="$rest" + args="{}" + fi + local resp + resp=$(mcp_call "$tool" "$args") + print_result "$tool" "$resp" + else + echo "Unknown command: $line (type 'help')" + fi + done +} + +# ─── CLI ─── +# Parse args: --verbose/--v must come before subcommand +while [[ $# -gt 0 ]]; do + case "$1" in + --verbose|-v) VERBOSE=1; shift ;; + --help|-h|--) cmd="help"; shift ;; + --quick|quick) cmd="quick"; QUICK_ARG="${2:-}"; shift; [[ -n "$QUICK_ARG" ]] && shift ;; + --shell|shell) cmd="shell"; shift ;; + call) + cmd="call" + shift + break # rest are call args + ;; + *) cmd="unknown"; break ;; + esac +done + +# Parse call args: tool [-- key val ...] or tool +call_tool="" call_json="" named_mode=false +if [[ "$cmd" == "call" ]]; then + if [[ $# -eq 0 ]]; then + cmd="help" + elif [[ "$1" == "--" ]]; then + shift + named_mode=true + call_tool="" + else + call_tool="$1" + shift + if [[ $# -gt 0 && "$1" == "--" ]]; then + shift + named_mode=true + elif [[ $# -gt 0 && "$1" =~ ^--[a-zA-Z] ]]; then + # Auto-detect named args + named_mode=true + fi + fi +fi + +# ─── Execute ─── +case "$cmd" in + help) + cat < Call a tool with JSON args + mcp-test.sh call --key val [--key2 v] Call with named args + mcp-test.sh quick [tool args] Smoke test (default: list_collections) + mcp-test.sh shell Interactive REPL + mcp-test.sh -- Print this help + + ENV: + MCPT_PORT — Server port (default: 8080) + MCPT_BIN — Path to photofield binary + MCPT_DATA_DIR — Path to data directory + MCPT_START — Auto-start server (default: true) + MCPT_URL — Full server URL (overrides PORT) +EOF + exit 0 + ;; + quick) + [[ "$AUTO_START" == "true" ]] && server_start + session_init + # Quick arg format: "tool_name args_json" (default: list_collections '{}') + quick_tool="${QUICK_ARG%% *}" + quick_args="${QUICK_ARG#* }" + [[ "$quick_tool" == "$quick_args" ]] && quick_args="{}" # no space = no args + [[ -z "$quick_tool" ]] && quick_tool="list_collections" && quick_args="{}" + resp=$(mcp_call "$quick_tool" "$quick_args") + if print_result "$quick_tool" "$resp"; then + log_ok "Quick test passed" + else + log_fail "Quick test failed" + exit 1 + fi + ;; + shell) + [[ "$AUTO_START" == "true" ]] && server_start + run_repl + ;; + call) + if [[ -z "$call_tool" ]]; then + echo "Usage: $0 call " >&2 + exit 1 + fi + + if [[ "$named_mode" == "true" ]]; then + args_json=$(build_args "$@") + elif [[ $# -gt 0 ]]; then + args_json="$*" + else + args_json="{}" + fi + + [[ "$AUTO_START" == "true" ]] && server_start + session_init + resp=$(mcp_call "$call_tool" "$args_json") + print_result "$call_tool" "$resp" + ;; + *) + echo "Unknown command: $cmd" >&2 + exit 1 + ;; +esac From 8dc4e162079f285e0b8e60d3cc1a9b73ce3cc976 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 14:36:35 +0200 Subject: [PATCH 05/40] fix: default no-args shows help, fix BIN path resolution - Initialize cmd=help before arg loop so 'set -u' doesn't fail when called with no arguments (shows help instead of unbound variable) - Fix BIN default path: tools/../.. was resolving to the repo root instead of the mcp/ directory where the photofield binary lives --- tools/mcp-test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/mcp-test.sh b/tools/mcp-test.sh index 0a3921d..2ba92a7 100755 --- a/tools/mcp-test.sh +++ b/tools/mcp-test.sh @@ -26,7 +26,7 @@ set -uo pipefail # ─── Config ─── PORT="${MCPT_PORT:-8080}" URL="${MCPT_URL:-http://localhost:${PORT}/mcp}" -BIN="${MCPT_BIN:-$(cd "$(dirname "$0")/../.." && pwd)/photofield}" +BIN="${MCPT_BIN:-$(cd "$(dirname "$0")/.." && pwd)/photofield}" DATA_DIR="${MCPT_DATA_DIR:-$(pwd)/data}" AUTO_START="${MCPT_START:-true}" VERBOSE=0 @@ -308,6 +308,7 @@ run_repl() { # ─── CLI ─── # Parse args: --verbose/--v must come before subcommand +cmd="help" while [[ $# -gt 0 ]]; do case "$1" in --verbose|-v) VERBOSE=1; shift ;; From 6268095b3c4b28916e9211124bc47a19ed24bc64 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 15:46:24 +0200 Subject: [PATCH 06/40] refactor: replace mcp-test.sh with unified agent-test.sh harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the MCP-specific mcp-test.sh with a general-purpose agent-test.sh that covers server lifecycle, generic HTTP API calls, and MCP tool calls: Server management: server start — auto-detect running server or launch one server stop — graceful stop via PID file server restart — stop + start server status — show PID and port status server kill — kill PID file + port listeners (safety) API commands: api [body] Generic HTTP call (GET/POST/PUT/DELETE) api --key val Named-arg body construction MCP commands: mcp call MCP tool call (JSON or named args) mcp quick [tool args] Smoke test (default: list_collections) mcp shell Interactive REPL Other changes: - Renamed env vars: MCPT_* → AGT_* for consistency - Add AGT_API_BASE for custom API base URL - Fix pkill hanging issue (use pgrep + kill instead) - Fix BIN path resolution (tools/.. not tools/../../) - Fix no-args showing help (initialize cmd=help before loop) - Update AGENTS.md to reference agent-test.sh Signed-off-by: AI Agent --- internal/mcp/AGENTS.md | 47 +-- tools/agent-test.sh | 639 +++++++++++++++++++++++++++++++++++++++++ tools/mcp-test.sh | 416 --------------------------- 3 files changed, 663 insertions(+), 439 deletions(-) create mode 100755 tools/agent-test.sh delete mode 100755 tools/mcp-test.sh diff --git a/internal/mcp/AGENTS.md b/internal/mcp/AGENTS.md index 317626a..362e30f 100644 --- a/internal/mcp/AGENTS.md +++ b/internal/mcp/AGENTS.md @@ -42,43 +42,44 @@ before rebuilding. ## 2. Calling MCP Tools -Use `tools/mcp-test.sh` for all MCP tool calls. It handles the session +Use `tools/agent-test.sh` for all MCP tool calls. It handles the session handshake, SSE parsing, and session ID management automatically. ### Basic usage ```bash # Call a tool with JSON args -./tools/mcp-test.sh call list_collections '{}' +./tools/agent-test.sh mcp call list_collections '{}' # Call with named args (auto-detects --key val pairs) -./tools/mcp-test.sh call search_photos --query 'beach' --collection_id 'test' --limit 3 +./tools/agent-test.sh mcp call search_photos --query 'beach' --collection_id 'test' --limit 3 # Verbose mode — always shows full JSON -./tools/mcp-test.sh --verbose call get_photo --file_id 1 --w 200 +./tools/agent-test.sh --verbose mcp call get_photo --file_id 1 --w 200 # Quick smoke test (list_collections only) -./tools/mcp-test.sh quick +./tools/agent-test.sh mcp quick # Interactive REPL -./tools/mcp-test.sh shell +./tools/agent-test.sh mcp shell ``` ### Arguments -- **JSON mode**: `./tools/mcp-test.sh call ''` -- **Named args**: `./tools/mcp-test.sh call --key val` (auto-detected) -- **Explicit named**: `./tools/mcp-test.sh call -- --key val` (forces mode) +- **JSON mode**: `./tools/agent-test.sh mcp call ''` +- **Named args**: `./tools/agent-test.sh mcp call --key val` (auto-detected) +- **Explicit named**: `./tools/agent-test.sh mcp call -- --key val` (forces mode) ### Environment | Variable | Default | Purpose | |----------|---------|---------| -| `MCPT_PORT` | `8080` | Server port | -| `MCPT_BIN` | `./photofield` | Path to binary | -| `MCPT_DATA_DIR` | `./data` | Data directory | -| `MCPT_START` | `true` | Auto-start if not running | -| `MCPT_URL` | (derived) | Full URL (overrides PORT) | +| `AGT_PORT` | `8080` | Server port | +| `AGT_BIN` | `./photofield` | Path to binary | +| `AGT_DATA_DIR` | `./data` | Data directory | +| `AGT_START` | `true` | Auto-start if not running | +| `AGT_URL` | (derived) | Full MCP endpoint URL | +| `AGT_API_BASE` | `http://localhost:$PORT` | Base URL for generic API calls | ### Output @@ -132,13 +133,13 @@ sqlite3 data/photofield.cache.db ".tables" ```bash # List all collections -./tools/mcp-test.sh call list_collections '{}' +./tools/agent-test.sh mcp call list_collections '{}' # Check a specific collection's events -./tools/mcp-test.sh call events --collection_id 'test' +./tools/agent-test.sh mcp call events --collection_id 'test' # Search photos -./tools/mcp-test.sh call search_photos --query 'faces' --collection_id 'test' --limit 5 +./tools/agent-test.sh mcp call search_photos --query 'faces' --collection_id 'test' --limit 5 ``` ## 5. Common Fixes @@ -157,20 +158,20 @@ sqlite3 data/photofield.cache.db ".tables" ### Quick smoke test ```bash -./tools/mcp-test.sh quick +./tools/agent-test.sh mcp quick ``` ### Manual tool testing ```bash # Test a specific tool with arguments -./tools/mcp-test.sh call get_photo --file_id 1 +./tools/agent-test.sh mcp call get_photo --file_id 1 # Test error handling -./tools/mcp-test.sh call get_photo --file_id 999999 +./tools/agent-test.sh mcp call get_photo --file_id 999999 # Verbose output for debugging -./tools/mcp-test.sh --verbose call search_photos --query 'test' --collection_id 'test' +./tools/agent-test.sh --verbose mcp call search_photos --query 'test' --collection_id 'test' ``` ### From another directory @@ -179,11 +180,11 @@ The harness auto-detects the `photofield` binary relative to the repo root. To call it from elsewhere: ```bash -MCPT_BIN=/path/to/photofield ./tools/mcp-test.sh call list_collections '{}' +AGT_BIN=/path/to/photofield ./tools/agent-test.sh mcp call list_collections '{}' ``` Or use a custom URL: ```bash -MCPT_URL=http://remote-host:9000/mcp ./tools/mcp-test.sh call list_collections '{}' +AGT_URL=http://remote-host:9000/mcp ./tools/agent-test.sh mcp call list_collections '{}' ``` diff --git a/tools/agent-test.sh b/tools/agent-test.sh new file mode 100755 index 0000000..6367ada --- /dev/null +++ b/tools/agent-test.sh @@ -0,0 +1,639 @@ +#!/bin/bash +# agent-test.sh — Unified harness for testing photofield MCP server +# +# Covers server lifecycle, generic HTTP API calls, and MCP tool calls. +# +# USAGE: +# agent-test.sh --help Print this help +# agent-test.sh --verbose Verbose output (env override) +# +# agent-test.sh server start Start server (auto-detect / launch) +# agent-test.sh server stop Stop via PID file +# agent-test.sh server restart Stop + start +# agent-test.sh server status Check if running +# agent-test.sh server kill Aggressive pkill (photofield + exiftool) +# +# agent-test.sh api [body] Generic HTTP call +# agent-test.sh api --key val Named-arg body +# +# agent-test.sh mcp call MCP tool call (JSON or named) +# agent-test.sh mcp call --key val MCP tool call (named args) +# agent-test.sh mcp quick [tool args] Smoke test +# agent-test.sh mcp shell Interactive REPL +# +# ENV: +# AGT_PORT — Server port (default: 8080) +# AGT_BIN — Path to photofield binary +# AGT_DATA_DIR — Path to data directory +# AGT_START — Auto-start server (default: true) +# AGT_URL — Full MCP endpoint URL (overrides PORT) +# AGT_API_BASE — API base URL (default: http://localhost:$PORT) + +set -uo pipefail + +# ─── Config ─── +PORT="${AGT_PORT:-8080}" +API_BASE="${AGT_API_BASE:-http://localhost:${PORT}}" +MCP_URL="${AGT_URL:-${API_BASE}/mcp}" +BIN="${AGT_BIN:-$(cd "$(dirname "$0")/.." && pwd)/photofield}" +DATA_DIR="${AGT_DATA_DIR:-$(pwd)/data}" +AUTO_START="${AGT_START:-true}" +VERBOSE=0 +_SERVER_MANAGED=false + +# ─── Paths ─── +_pid_file="/tmp/photofield-agent-test.pid" +_headers_file="/tmp/agent-test-headers-$$" + +# ─── Colors ─── +if [[ -t 1 ]]; then + COL_GREEN='\033[0;32m'; COL_RED='\033[0;31m'; COL_CYAN='\033[0;36m' + COL_BOLD='\033[1m'; COL_RESET='\033[0m' +else + COL_GREEN=''; COL_RED=''; COL_CYAN=''; COL_BOLD=''; COL_RESET='' +fi + +log_ok() { echo "${COL_GREEN}✓${COL_RESET} $*"; } +log_fail() { echo "${COL_RED}✗${COL_RESET} $*" >&2; } +log_info() { echo "${COL_CYAN}ℹ${COL_RESET} $*" >&2; } +log_step() { echo "${COL_BOLD}--- $*${COL_RESET}" >&2; } + +# ─── Server Management ─── +server_is_running() { + # Check PID file first + if [[ -f "$_pid_file" ]]; then + local pid + pid=$(cat "$_pid_file" 2>/dev/null) + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + return 0 + fi + rm -f "$_pid_file" + fi + # Fall back to port check + curl -s --max-time 1 "${MCP_URL}" &>/dev/null +} + +server_start() { + if server_is_running; then + log_info "Server already running on port ${PORT}" + _SERVER_MANAGED=false + return 0 + fi + + if [[ ! -x "$BIN" ]]; then + log_fail "Server binary not found: ${BIN}" + echo " Set AGT_BIN=/path/to/photofield to override" >&2 + return 1 + fi + + log_step "Starting MCP server..." + nohup "$BIN" > /tmp/photofield-agent-test.log 2>&1 & + _SERVER_MANAGED=true + local pid=$! + echo "$pid" > "$_pid_file" + log_info "PID: ${pid} (log: /tmp/photofield-agent-test.log)" + + local waited=0 + while (( waited < 30 )); do + if curl -s --max-time 2 "${MCP_URL}" &>/dev/null; then + log_ok "Server is ready" + return 0 + fi + sleep 0.5 + waited=$((waited + 1)) + done + + log_fail "Server failed to start within 30s" + tail -20 /tmp/photofield-agent-test.log >&2 + return 1 +} + +server_stop() { + if [[ -f "$_pid_file" ]]; then + local pid + pid=$(cat "$_pid_file" 2>/dev/null) + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + log_info "Stopping server (PID ${pid})..." + kill "$pid" 2>/dev/null || true + # Wait for it to die + local w=0 + while (( w < 10 )); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.3 + w=$((w + 1)) + done + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true + log_ok "Server stopped" + fi + rm -f "$_pid_file" + else + log_info "No PID file found" + # Port may still be in use + if server_is_running; then + log_info "Server running but no PID — use 'server kill' for aggressive stop" + return 1 + fi + log_ok "Nothing to stop" + fi +} + +server_restart() { + server_stop + sleep 1 + server_start +} + +server_status() { + if server_is_running; then + local pid="" + if [[ -f "$_pid_file" ]]; then + pid=$(cat "$_pid_file" 2>/dev/null) + fi + log_ok "Server running on port ${PORT}" + [[ -n "$pid" ]] && log_info "PID: ${pid}" + # Check which processes are using the port + local procs + procs=$(ss -tlnp "sport = :${PORT}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | head -5 | tr '\n' ',' | sed 's/,$//') + [[ -n "$procs" ]] && log_info "Port listeners: ${procs}" + return 0 + else + log_info "Server not running on port ${PORT}" + return 1 + fi +} + +server_kill() { + log_step "Killing server processes on port ${PORT}..." + local count=0 + # Kill by PID file first (most targeted) + if [[ -f "$_pid_file" ]]; then + local pid + pid=$(cat "$_pid_file" 2>/dev/null) + if [[ -n "$pid" ]]; then + kill -9 "$pid" 2>/dev/null || true + count=$((count + 1)) + fi + rm -f "$_pid_file" + fi + # Kill anything listening on our port (catches server children, exiftool, etc.) + local p + for p in $(ss -tlnp "sport = :${PORT}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' || true); do + kill -9 "$p" 2>/dev/null || true + count=$((count + 1)) + done + sleep 0.3 + # Double-check (safety net) + for p in $(ss -tlnp "sport = :${PORT}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' || true); do + kill -9 "$p" 2>/dev/null || true + count=$((count + 1)) + done + log_ok "Killed ${count} process(es) on port ${PORT}" +} + +# ─── Generic API Calls ─── +api_call() { + local method="${1^^}" # uppercase + local url="$2" + shift 2 + + local body="" hdrs=(-X "$method") + local named_mode=false + + # Check if remaining args look like named --key val pairs + if [[ $# -gt 0 && "$1" == "--" ]]; then + shift + named_mode=true + elif [[ $# -gt 0 && "$1" =~ ^--[a-zA-Z] ]]; then + named_mode=true + fi + + if [[ "$named_mode" == "true" ]]; then + body=$(build_args "$@") + elif [[ $# -gt 0 ]]; then + body="$*" + fi + + [[ "$method" == "GET" || "$method" == "HEAD" ]] && body="" + + log_info "→ ${method} ${url}" + [[ -n "$body" ]] && log_info "Body: ${body:0:200}" + + local status_code tmpfile + tmpfile=$(mktemp /tmp/agent-test-raw-XXXXXX) + status_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \ + "${hdrs[@]}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d "$body" \ + "$url" 2>/dev/null) + + local raw + raw=$(cat "$tmpfile"; rm -f "$tmpfile") + + # Try to pretty-print as JSON + local pretty + pretty=$(echo "$raw" | jq '.' 2>/dev/null) + if [[ -n "$pretty" ]]; then + echo "${COL_GREEN}HTTP ${status_code}${COL_RESET}" + echo "$pretty" + else + echo "${COL_GREEN}HTTP ${status_code}${COL_RESET} (plain)" + if [[ ${#raw} -lt 500 ]]; then + echo "$raw" + else + echo "${raw:0:500}... [${#raw} chars total]" + fi + fi +} + +# ─── MCP Session & Requests ─── +_MCP_SESSION_ID="" +_MCP_REQUEST_ID=0 + +session_init() { + local resp + resp=$(curl -s -D "$_headers_file" \ + -X POST "${MCP_URL}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2024-11-05", + "capabilities":{}, + "clientInfo":{"name":"agent-test","version":"0.1"} + }}' 2>/dev/null) + + _MCP_SESSION_ID=$(grep -i "Mcp-Session-Id" "$_headers_file" 2>/dev/null | head -1 | tr -d '\r' | sed 's/.*[Mm]cp-[Ss]ession-[Ii]d:[[:space:]]*//') + rm -f "$_headers_file" + + if [[ -z "$_MCP_SESSION_ID" ]]; then + log_info "No session ID (server may not require one)" + else + log_info "Session: ${_MCP_SESSION_ID}" + fi + + # Send initialized notification + local hdrs=(-H "Content-Type: application/json") + [[ -n "$_MCP_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_MCP_SESSION_ID}") + curl -s -X POST "${MCP_URL}" \ + "${hdrs[@]}" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' 2>/dev/null || true +} + +# Parse SSE response: extract JSON from "data: {...}" lines +_sse_parse() { + local raw="$1" + echo "$raw" | sed -n 's/^data: //p' | tail -1 +} + +# Call a tool, return cleaned JSON on stdout +mcp_call() { + local tool="$1" + shift + local args="$*" + [[ -z "$args" ]] && args="{}" + _MCP_REQUEST_ID=$((_MCP_REQUEST_ID + 1)) + + local hdrs=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") + [[ -n "$_MCP_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_MCP_SESSION_ID}") + + local raw + raw=$(curl -s --max-time 30 -X POST "${MCP_URL}" \ + "${hdrs[@]}" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":${_MCP_REQUEST_ID},\"method\":\"tools/call\",\"params\":{\"name\":\"${tool}\",\"arguments\":${args}}}" \ + 2>/dev/null) + + _sse_parse "$raw" +} + +# Build JSON args from --key val pairs +build_args() { + local result="{" + local first=true + while [[ $# -gt 0 ]]; do + local key="$1" val="$2" + shift 2 + key="${key#--}" + [[ "$first" != "true" ]] && result+="," + first=false + if [[ "$val" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + result+="\"${key}\":${val}" + elif [[ "$val" == "true" || "$val" == "false" ]]; then + result+="\"${key}\":${val}" + elif [[ "$val" == "null" ]]; then + result+="\"${key}\":null" + else + val=$(printf '%s' "$val" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g') + result+="\"${key}\":\"${val}\"" + fi + done + result+="}" + echo "$result" +} + +# ─── Output formatting ─── +print_result() { + local tool="$1" resp="$2" + + # Check for JSON-RPC error field + local rpc_error + rpc_error=$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null) + if [[ -n "$rpc_error" ]]; then + echo "${COL_RED}✗${COL_RED}${COL_BOLD} ${tool}${COL_RESET} — ${rpc_error}" + [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null + return 1 + fi + + # Check for result.isError (MCP tools can return errors in result) + local is_error + is_error=$(echo "$resp" | jq -r '.result.isError // false' 2>/dev/null) + + # Check for content in result + local content_text + content_text=$(echo "$resp" | jq -r '.result.content[0].text // empty' 2>/dev/null) + + if [[ -n "$content_text" ]]; then + if [[ "$is_error" == "true" ]]; then + echo "${COL_RED}✗${COL_BOLD} ${tool}${COL_RESET} — ${content_text}" + [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null + return 1 + fi + + if [[ "$VERBOSE" == "1" ]]; then + local inner_json + inner_json=$(echo "$content_text" | jq '.' 2>/dev/null) + if [[ -n "$inner_json" ]]; then + echo "$inner_json" + else + echo "$content_text" + fi + return 0 + fi + + # Normal response — try to parse content as JSON for a clean summary + local inner_json + inner_json=$(echo "$content_text" | jq '.' 2>/dev/null) + if [[ -n "$inner_json" ]]; then + # Find top-level array keys and report their counts + local summary_label + summary_label=$(echo "$inner_json" | jq -r ' + [ to_entries[] | select(.value | type == "array") ] | + if length > 0 then + (map("\(.key): \(.value | length)") | join(", ")) + else + "ok" + end + ' 2>/dev/null) + echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${summary_label}" + echo "$inner_json" | jq '.' 2>/dev/null || echo "$inner_json" + else + # Plain text response + if [[ ${#content_text} -lt 500 ]]; then + echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" + echo "$content_text" + else + echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${#content_text} chars" + echo "$content_text" | head -c 500 + echo "..." + fi + fi + return 0 + fi + + # Check for structured content + local has_structured + has_structured=$(echo "$resp" | jq -e '.result.structuredContent' &>/dev/null && echo yes || echo no) + if [[ "$has_structured" == "yes" ]]; then + local sc_json + sc_json=$(echo "$resp" | jq -r '.result.structuredContent' 2>/dev/null) + if [[ -n "$sc_json" ]]; then + echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" + echo "$sc_json" | jq '.' 2>/dev/null || echo "$sc_json" + return 0 + fi + fi + + # No result at all + echo "${COL_RED}✗${COL_BOLD} ${tool}${COL_RESET} — no result" + [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null || echo "$resp" + return 1 +} + +# ─── MCP REPL ─── +run_repl() { + echo "" + echo "${COL_BOLD}Agent Test Shell${COL_RESET} (type 'help' for commands, 'quit' to exit)" + echo "" + + session_init + + while true; do + printf "${COL_CYAN}>${COL_RESET} " + read -r line + [[ -z "$line" ]] && continue + [[ "$line" == "quit" || "$line" == "exit" ]] && break + [[ "$line" == "help" ]] && { + echo " call Call a tool with JSON args" + echo " call --key val ... Call with named args" + echo " api [body] Generic HTTP call" + echo " quit Exit" + continue + } + + local tool args + if [[ "$line" =~ ^api[[:space:]]+ ]]; then + local rest="${line#api }" + local method url body + if [[ "$rest" =~ ^([A-Z]+)[[:space:]]+([^[:space:]]+)(.*)$ ]]; then + method="${BASH_REMATCH[1]}" + url="${BASH_REMATCH[2]}" + body="${BASH_REMATCH[3]}" + [[ -z "$body" ]] && body="{}" + echo "$(api_call "$method" "$url" "$body")" + else + echo "Usage: api [body]" + fi + elif [[ "$line" =~ ^call[[:space:]]+ ]]; then + local rest="${line#call }" + if [[ "$rest" =~ ^([^[:space:]]+)[[:space:]]+(--.*)$ ]]; then + tool="${BASH_REMATCH[1]}" + args=$(build_args ${BASH_REMATCH[2]}) + elif [[ "$rest" =~ ^([^[:space:]]+)[[:space:]]+(.*)$ ]]; then + tool="${BASH_REMATCH[1]}" + args="${BASH_REMATCH[2]}" + [[ -z "$args" ]] && args="{}" + else + tool="$rest" + args="{}" + fi + local resp + resp=$(mcp_call "$tool" "$args") + print_result "$tool" "$resp" + else + echo "Unknown command: $line (type 'help')" + fi + done +} + +# ─── Help ─── +print_help() { + cat <<'EOF' +agent-test.sh — Unified harness for testing photofield MCP server + +Usage: + agent-test.sh [options] [args...] + +Options: + --verbose, -v Verbose output (also AGT_VERBOSE=1) + --help, -h, -- Print this help + +Server commands: + server start Start server (auto-detect running or launch) + server stop Stop via PID file (graceful) + server restart Stop + start + server status Show PID and port status + server kill Kill PID file + port listeners + +API commands: + api [body] Generic HTTP call (GET/POST/PUT/DELETE) + api --key val Named-arg body construction + +MCP commands: + mcp call Call a tool with JSON args + mcp call --key val Call a tool with named args + mcp quick [tool args] Smoke test (default: list_collections) + mcp shell Interactive REPL + +Environment variables: + AGT_PORT — Server port (default: 8080) + AGT_BIN — Path to photofield binary + AGT_DATA_DIR — Path to data directory + AGT_START — Auto-start server (default: true) + AGT_URL — Full MCP endpoint URL + AGT_API_BASE — API base URL (default: http://localhost:$PORT) + AGT_VERBOSE — Verbose output (1 = yes) +EOF +} + +# ─── CLI ─── +# Top-level flags: --verbose/--v before subcommand +cmd="help" +subcmd="" +verbose_override=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --verbose|-v|-V) VERBOSE=1; verbose_override=1; shift ;; + --help|-h|--) cmd="help"; shift ;; + server) cmd="server"; shift; subcmd="${1:-help}"; shift ;; + api) cmd="api"; shift; break ;; + mcp) + cmd="mcp" + shift + subcmd="${1:-help}" + shift + break + ;; + *) + # If we got here with cmd="help" and no prior match, treat as unknown + if [[ "$cmd" == "help" ]]; then + cmd="help" + fi + break + ;; + esac +done + +# ─── Execute ─── +case "$cmd" in + help) + print_help + exit 0 + ;; + + server) + case "$subcmd" in + start) server_start ;; + stop) server_stop ;; + restart) server_restart ;; + status) server_status ;; + kill) server_kill ;; + *) + echo "Usage: agent-test.sh server " >&2 + exit 1 + ;; + esac + ;; + + api) + if [[ $# -lt 2 ]]; then + echo "Usage: agent-test.sh api [body]" >&2 + exit 1 + fi + api_call "$@" + ;; + + mcp) + case "$subcmd" in + call) + # Parse: call_tool [-- key val ...] or call_tool + call_tool="" + named_mode=false + if [[ $# -eq 0 ]]; then + echo "Usage: agent-test.sh mcp call " >&2 + exit 1 + fi + call_tool="$1" + shift + if [[ $# -gt 0 && "$1" == "--" ]]; then + shift + named_mode=true + elif [[ $# -gt 0 && "$1" =~ ^--[a-zA-Z] ]]; then + named_mode=true + fi + + if [[ "$named_mode" == "true" ]]; then + args_json=$(build_args "$@") + elif [[ $# -gt 0 ]]; then + args_json="$*" + else + args_json="{}" + fi + + [[ "$AUTO_START" == "true" ]] && server_start + session_init + resp=$(mcp_call "$call_tool" "$args_json") + print_result "$call_tool" "$resp" + ;; + quick) + [[ "$AUTO_START" == "true" ]] && server_start + session_init + quick_arg="${1:-list_collections}" + quick_tool="${quick_arg%% *}" + quick_args="${quick_arg#* }" + [[ "$quick_tool" == "$quick_args" ]] && quick_args="{}" + [[ -z "$quick_tool" ]] && quick_tool="list_collections" && quick_args="{}" + resp=$(mcp_call "$quick_tool" "$quick_args") + if print_result "$quick_tool" "$resp"; then + log_ok "Quick test passed" + else + log_fail "Quick test failed" + exit 1 + fi + ;; + shell) + [[ "$AUTO_START" == "true" ]] && server_start + run_repl + ;; + *) + echo "Usage: agent-test.sh mcp [args...]" >&2 + exit 1 + ;; + esac + ;; + + *) + echo "Unknown command: $cmd" >&2 + print_help >&2 + exit 1 + ;; +esac diff --git a/tools/mcp-test.sh b/tools/mcp-test.sh deleted file mode 100755 index 2ba92a7..0000000 --- a/tools/mcp-test.sh +++ /dev/null @@ -1,416 +0,0 @@ -#!/bin/bash -# mcp-test.sh — MCP server test harness for AI agents -# -# Call MCP tools directly without boilerplate. The harness handles: -# - Server lifecycle (auto-start / auto-detect / auto-stop) -# - MCP session handshake (initialize → notified → call) -# - JSON-RPC requests with proper session headers -# - SSE response parsing → clean JSON output -# -# USAGE: -# mcp-test.sh call Call a tool with JSON args -# mcp-test.sh call --key val [--key2 v] Call with named args -# mcp-test.sh quick Smoke test (list_collections) -# mcp-test.sh shell Interactive REPL -# mcp-test.sh -- Print this help -# -# ENV: -# MCPT_PORT — Server port (default: 8080) -# MCPT_BIN — Path to photofield binary -# MCPT_DATA_DIR — Path to data directory -# MCPT_START — Auto-start server (default: true) -# MCPT_URL — Full server URL (overrides PORT) - -set -uo pipefail - -# ─── Config ─── -PORT="${MCPT_PORT:-8080}" -URL="${MCPT_URL:-http://localhost:${PORT}/mcp}" -BIN="${MCPT_BIN:-$(cd "$(dirname "$0")/.." && pwd)/photofield}" -DATA_DIR="${MCPT_DATA_DIR:-$(pwd)/data}" -AUTO_START="${MCPT_START:-true}" -VERBOSE=0 - -# ─── Color helpers ─── -if [[ -t 1 ]]; then - COL_GREEN='\033[0;32m'; COL_RED='\033[0;31m'; COL_CYAN='\033[0;36m' - COL_BOLD='\033[1m'; COL_RESET='\033[0m' -else - COL_GREEN=''; COL_RED=''; COL_CYAN=''; COL_BOLD=''; COL_RESET='' -fi - -log_ok() { echo "${COL_GREEN}✓${COL_RESET} $*"; } -log_fail() { echo "${COL_RED}✗${COL_RESET} $*" >&2; } -log_info() { echo "${COL_CYAN}ℹ${COL_RESET} $*" >&2; } -log_step() { echo "${COL_BOLD}--- $*${COL_RESET}" >&2; } - -# ─── Server Management ─── -_server_pid_file="/tmp/photofield-mcp-test.pid" - -server_start() { - if curl -s --max-time 2 "${URL}" &>/dev/null; then - log_info "Server already running on port ${PORT}" - _ServerManaged=false - return 0 - fi - - if [[ ! -x "$BIN" ]]; then - log_fail "Server binary not found: ${BIN}" - echo " Set MCPT_BIN=/path/to/photofield to override" >&2 - return 1 - fi - - log_step "Starting MCP server..." - nohup "$BIN" > /tmp/photofield-mcp-test.log 2>&1 & - _ServerManaged=true - echo $! > "$_server_pid_file" - log_info "PID: $! (log: /tmp/photofield-mcp-test.log)" - - local waited=0 - while (( waited < 30 )); do - if curl -s --max-time 2 "${URL}" &>/dev/null; then - log_ok "Server is ready" - return 0 - fi - sleep 0.5 - waited=$((waited + 1)) - done - - log_fail "Server failed to start within 30s" - tail -20 /tmp/photofield-mcp-test.log >&2 - return 1 -} - -# ─── MCP Session & Requests ─── -_SESSION_ID="" -_REQUEST_ID=0 - -session_init() { - # Send initialize request and capture headers to get session ID - local resp - resp=$(curl -s -D /tmp/mcp-headers-$$ \ - -X POST "${URL}" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ - "protocolVersion":"2024-11-05", - "capabilities":{}, - "clientInfo":{"name":"mcp-test","version":"0.1"} - }}' 2>/dev/null) - - # Extract session ID from HTTP headers - _SESSION_ID=$(grep -i "Mcp-Session-Id" /tmp/mcp-headers-$$ 2>/dev/null | head -1 | tr -d '\r' | sed 's/.*[Mm]cp-[Ss]ession-[Ii]d:[[:space:]]*//') - rm -f /tmp/mcp-headers-$$ - - if [[ -z "$_SESSION_ID" ]]; then - log_info "No session ID (server may not require one)" - else - log_info "Session: ${_SESSION_ID}" - fi - - # Send initialized notification - local hdrs=(-H "Content-Type: application/json") - [[ -n "$_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_SESSION_ID}") - curl -s -X POST "${URL}" \ - "${hdrs[@]}" \ - -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' 2>/dev/null || true -} - -# Parse SSE response: extract JSON from "data: {...}" lines -_sse_parse() { - local raw="$1" - # Extract the last JSON data line from SSE - echo "$raw" | sed -n 's/^data: //p' | tail -1 -} - -# Call a tool, return cleaned JSON on stdout -mcp_call() { - local tool="$1" - shift - local args="$*" - [[ -z "$args" ]] && args="{}" - _REQUEST_ID=$((_REQUEST_ID + 1)) - - local hdrs=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") - [[ -n "$_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_SESSION_ID}") - - local raw - raw=$(curl -s --max-time 30 -X POST "${URL}" \ - "${hdrs[@]}" \ - -d "{\"jsonrpc\":\"2.0\",\"id\":${_REQUEST_ID},\"method\":\"tools/call\",\"params\":{\"name\":\"${tool}\",\"arguments\":${args}}}" \ - 2>/dev/null) - - _sse_parse "$raw" -} - -# Build JSON args from --key val pairs -build_args() { - local result="{" - local first=true - while [[ $# -gt 0 ]]; do - local key="$1" val="$2" - shift 2 - # Strip leading -- from key name - key="${key#--}" - [[ "$first" != "true" ]] && result+="," - first=false - if [[ "$val" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then - result+="\"${key}\":${val}" - elif [[ "$val" == "true" || "$val" == "false" ]]; then - result+="\"${key}\":${val}" - elif [[ "$val" == "null" ]]; then - result+="\"${key}\":null" - else - val=$(printf '%s' "$val" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g') - result+="\"${key}\":\"${val}\"" - fi - done - result+="}" - echo "$result" -} - -# ─── Output formatting ─── -print_result() { - local tool="$1" resp="$2" - - # Check for JSON-RPC error field - local rpc_error rpc_error_msg - rpc_error=$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null) - if [[ -n "$rpc_error" ]]; then - echo "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${rpc_error}" - [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null - return 1 - fi - - # Check for result.isError (MCP tools can return errors in result) - local is_error - is_error=$(echo "$resp" | jq -r '.result.isError // false' 2>/dev/null) - - # Check for content in result (MCP uses content: [{type:"text",text:"..."}]) - local content_type content_text - content_type=$(echo "$resp" | jq -r '.result.content[0].type // empty' 2>/dev/null) - content_text=$(echo "$resp" | jq -r '.result.content[0].text // empty' 2>/dev/null) - - if [[ -n "$content_text" ]]; then - # If isError flag is set, this is an error response - if [[ "$is_error" == "true" ]]; then - echo "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${content_text}" - [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null - return 1 - fi - - if [[ "$VERBOSE" == "1" ]]; then - # Full JSON output - # Try to parse the text as JSON for pretty-printing - local inner_json - inner_json=$(echo "$content_text" | jq '.' 2>/dev/null) - if [[ -n "$inner_json" ]]; then - echo "$inner_json" - else - echo "$content_text" - fi - return 0 - fi - - # Normal response — try to parse content as JSON for a clean summary - local inner_json - inner_json=$(echo "$content_text" | jq '.' 2>/dev/null) - if [[ -n "$inner_json" ]]; then - # Content is JSON — show a summary - # Find top-level array keys and report their counts - local summary_label - summary_label=$(echo "$inner_json" | jq -r ' - [ to_entries[] | select(.value | type == "array") ] | - if length > 0 then - (map("\(.key): \(.value | length)") | join(", ")) - else - "ok" - end - ' 2>/dev/null) - echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${summary_label}" - # Print the parsed JSON - echo "$inner_json" | jq '.' 2>/dev/null || echo "$inner_json" - else - # Plain text response - if [[ ${#content_text} -lt 500 ]]; then - echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" - echo "$content_text" - else - echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${#content_text} chars" - echo "$content_text" | head -c 500 - echo "..." - fi - fi - return 0 - fi - - # No content — check for structured content - local has_structured - has_structured=$(echo "$resp" | jq -e '.result.structuredContent' &>/dev/null && echo yes || echo no) - if [[ "$has_structured" == "yes" ]]; then - local sc_json - sc_json=$(echo "$resp" | jq -r '.result.structuredContent' 2>/dev/null) - if [[ -n "$sc_json" ]]; then - echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" - echo "$sc_json" | jq '.' 2>/dev/null || echo "$sc_json" - return 0 - fi - fi - - # No result at all - echo "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — no result" - [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null || echo "$resp" - return 1 -} - -# ─── REPL ─── -run_repl() { - echo "" - echo "${COL_BOLD}MCP Test Shell${COL_RESET} (type 'help' for commands, 'quit' to exit)" - echo "" - - session_init - - while true; do - printf "${COL_CYAN}>${COL_RESET} " - read -r line - [[ -z "$line" ]] && continue - [[ "$line" == "quit" || "$line" == "exit" ]] && break - [[ "$line" == "help" ]] && { - echo " call Call a tool with JSON args" - echo " call --key val ... Call with named args" - echo " quit Exit" - continue - } - - if [[ "$line" =~ ^call[[:space:]]+ ]]; then - local rest="${line#call }" - local tool args - if [[ "$rest" =~ ^([^[:space:]]+)[[:space:]]+(--.*)$ ]]; then - tool="${BASH_REMATCH[1]}" - args=$(build_args ${BASH_REMATCH[2]}) - elif [[ "$rest" =~ ^([^[:space:]]+)[[:space:]]+(.*)$ ]]; then - tool="${BASH_REMATCH[1]}" - args="${BASH_REMATCH[2]}" - [[ -z "$args" ]] && args="{}" - else - tool="$rest" - args="{}" - fi - local resp - resp=$(mcp_call "$tool" "$args") - print_result "$tool" "$resp" - else - echo "Unknown command: $line (type 'help')" - fi - done -} - -# ─── CLI ─── -# Parse args: --verbose/--v must come before subcommand -cmd="help" -while [[ $# -gt 0 ]]; do - case "$1" in - --verbose|-v) VERBOSE=1; shift ;; - --help|-h|--) cmd="help"; shift ;; - --quick|quick) cmd="quick"; QUICK_ARG="${2:-}"; shift; [[ -n "$QUICK_ARG" ]] && shift ;; - --shell|shell) cmd="shell"; shift ;; - call) - cmd="call" - shift - break # rest are call args - ;; - *) cmd="unknown"; break ;; - esac -done - -# Parse call args: tool [-- key val ...] or tool -call_tool="" call_json="" named_mode=false -if [[ "$cmd" == "call" ]]; then - if [[ $# -eq 0 ]]; then - cmd="help" - elif [[ "$1" == "--" ]]; then - shift - named_mode=true - call_tool="" - else - call_tool="$1" - shift - if [[ $# -gt 0 && "$1" == "--" ]]; then - shift - named_mode=true - elif [[ $# -gt 0 && "$1" =~ ^--[a-zA-Z] ]]; then - # Auto-detect named args - named_mode=true - fi - fi -fi - -# ─── Execute ─── -case "$cmd" in - help) - cat < Call a tool with JSON args - mcp-test.sh call --key val [--key2 v] Call with named args - mcp-test.sh quick [tool args] Smoke test (default: list_collections) - mcp-test.sh shell Interactive REPL - mcp-test.sh -- Print this help - - ENV: - MCPT_PORT — Server port (default: 8080) - MCPT_BIN — Path to photofield binary - MCPT_DATA_DIR — Path to data directory - MCPT_START — Auto-start server (default: true) - MCPT_URL — Full server URL (overrides PORT) -EOF - exit 0 - ;; - quick) - [[ "$AUTO_START" == "true" ]] && server_start - session_init - # Quick arg format: "tool_name args_json" (default: list_collections '{}') - quick_tool="${QUICK_ARG%% *}" - quick_args="${QUICK_ARG#* }" - [[ "$quick_tool" == "$quick_args" ]] && quick_args="{}" # no space = no args - [[ -z "$quick_tool" ]] && quick_tool="list_collections" && quick_args="{}" - resp=$(mcp_call "$quick_tool" "$quick_args") - if print_result "$quick_tool" "$resp"; then - log_ok "Quick test passed" - else - log_fail "Quick test failed" - exit 1 - fi - ;; - shell) - [[ "$AUTO_START" == "true" ]] && server_start - run_repl - ;; - call) - if [[ -z "$call_tool" ]]; then - echo "Usage: $0 call " >&2 - exit 1 - fi - - if [[ "$named_mode" == "true" ]]; then - args_json=$(build_args "$@") - elif [[ $# -gt 0 ]]; then - args_json="$*" - else - args_json="{}" - fi - - [[ "$AUTO_START" == "true" ]] && server_start - session_init - resp=$(mcp_call "$call_tool" "$args_json") - print_result "$call_tool" "$resp" - ;; - *) - echo "Unknown command: $cmd" >&2 - exit 1 - ;; -esac From 16443c5303e755943927dffdcd87f88c96b1568c Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 15:59:58 +0200 Subject: [PATCH 07/40] fix: use $'...' quoting for color variables so ANSI escapes are interpreted --- tools/agent-test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/agent-test.sh b/tools/agent-test.sh index 6367ada..5f0ffe1 100755 --- a/tools/agent-test.sh +++ b/tools/agent-test.sh @@ -47,8 +47,8 @@ _headers_file="/tmp/agent-test-headers-$$" # ─── Colors ─── if [[ -t 1 ]]; then - COL_GREEN='\033[0;32m'; COL_RED='\033[0;31m'; COL_CYAN='\033[0;36m' - COL_BOLD='\033[1m'; COL_RESET='\033[0m' + COL_GREEN=$'\033[0;32m'; COL_RED=$'\033[0;31m'; COL_CYAN=$'\033[0;36m' + COL_BOLD=$'\033[1m'; COL_RESET=$'\033[0m' else COL_GREEN=''; COL_RED=''; COL_CYAN=''; COL_BOLD=''; COL_RESET='' fi From 5e942658795b97879baa93ccc67aca1febcab56b Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 16:11:42 +0200 Subject: [PATCH 08/40] format: unify output formatting across all log and print functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All log_ok/log_fail/log_info/log_step now use printf and output to stderr - log_step changed from '---' to '▶' for visual consistency - log_ok also outputs to stderr (was stdout) so status messages don't mix with tool results - print_result patterns unified: ✓/✗ symbol, then tool name in bold, then '— summary' (or no summary for plain text) - Removed redundant COL_RED in RPC error line - Added missing COL_RED prefix in 'no result' line - API output: HTTP status moved to stderr via log_info, body on stdout - REPL header uses cyan color consistent with prompt --- tools/agent-test.sh | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tools/agent-test.sh b/tools/agent-test.sh index 5f0ffe1..00e5e2e 100755 --- a/tools/agent-test.sh +++ b/tools/agent-test.sh @@ -53,10 +53,10 @@ else COL_GREEN=''; COL_RED=''; COL_CYAN=''; COL_BOLD=''; COL_RESET='' fi -log_ok() { echo "${COL_GREEN}✓${COL_RESET} $*"; } -log_fail() { echo "${COL_RED}✗${COL_RESET} $*" >&2; } -log_info() { echo "${COL_CYAN}ℹ${COL_RESET} $*" >&2; } -log_step() { echo "${COL_BOLD}--- $*${COL_RESET}" >&2; } +log_ok() { printf '%s\n' "${COL_GREEN}✓${COL_RESET} $*" >&2; } +log_fail() { printf '%s\n' "${COL_RED}✗${COL_RESET} $*" >&2; } +log_info() { printf '%s\n' "${COL_CYAN}ℹ${COL_RESET} $*" >&2; } +log_step() { printf '%s\n' "${COL_CYAN}▶${COL_RESET} $*" >&2; } # ─── Server Management ─── server_is_running() { @@ -82,7 +82,7 @@ server_start() { if [[ ! -x "$BIN" ]]; then log_fail "Server binary not found: ${BIN}" - echo " Set AGT_BIN=/path/to/photofield to override" >&2 + log_info "Set AGT_BIN=/path/to/photofield to override" return 1 fi @@ -90,7 +90,7 @@ server_start() { nohup "$BIN" > /tmp/photofield-agent-test.log 2>&1 & _SERVER_MANAGED=true local pid=$! - echo "$pid" > "$_pid_file" + printf '%s\n' "$pid" > "$_pid_file" log_info "PID: ${pid} (log: /tmp/photofield-agent-test.log)" local waited=0 @@ -230,18 +230,18 @@ api_call() { local raw raw=$(cat "$tmpfile"; rm -f "$tmpfile") + log_info "HTTP ${status_code}" + # Try to pretty-print as JSON local pretty pretty=$(echo "$raw" | jq '.' 2>/dev/null) if [[ -n "$pretty" ]]; then - echo "${COL_GREEN}HTTP ${status_code}${COL_RESET}" echo "$pretty" else - echo "${COL_GREEN}HTTP ${status_code}${COL_RESET} (plain)" if [[ ${#raw} -lt 500 ]]; then echo "$raw" else - echo "${raw:0:500}... [${#raw} chars total]" + printf '%s\n' "${raw:0:500}... [${#raw} chars total]" fi fi } @@ -338,7 +338,7 @@ print_result() { local rpc_error rpc_error=$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null) if [[ -n "$rpc_error" ]]; then - echo "${COL_RED}✗${COL_RED}${COL_BOLD} ${tool}${COL_RESET} — ${rpc_error}" + printf '%s\n' "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${rpc_error}" [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null return 1 fi @@ -353,7 +353,7 @@ print_result() { if [[ -n "$content_text" ]]; then if [[ "$is_error" == "true" ]]; then - echo "${COL_RED}✗${COL_BOLD} ${tool}${COL_RESET} — ${content_text}" + printf '%s\n' "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${content_text}" [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null return 1 fi @@ -383,15 +383,15 @@ print_result() { "ok" end ' 2>/dev/null) - echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${summary_label}" + printf '%s\n' "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${summary_label}" echo "$inner_json" | jq '.' 2>/dev/null || echo "$inner_json" else # Plain text response if [[ ${#content_text} -lt 500 ]]; then - echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" + printf '%s\n' "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" echo "$content_text" else - echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${#content_text} chars" + printf '%s\n' "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — ${#content_text} chars" echo "$content_text" | head -c 500 echo "..." fi @@ -406,14 +406,14 @@ print_result() { local sc_json sc_json=$(echo "$resp" | jq -r '.result.structuredContent' 2>/dev/null) if [[ -n "$sc_json" ]]; then - echo "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" + printf '%s\n' "${COL_GREEN}✓${COL_RESET} ${COL_BOLD}${tool}${COL_RESET}" echo "$sc_json" | jq '.' 2>/dev/null || echo "$sc_json" return 0 fi fi # No result at all - echo "${COL_RED}✗${COL_BOLD} ${tool}${COL_RESET} — no result" + printf '%s\n' "${COL_RED}✗${COL_RESET} ${COL_BOLD}${tool}${COL_RESET} — no result" [[ "$VERBOSE" == "1" ]] && echo "$resp" | jq '.' 2>/dev/null || echo "$resp" return 1 } @@ -421,7 +421,7 @@ print_result() { # ─── MCP REPL ─── run_repl() { echo "" - echo "${COL_BOLD}Agent Test Shell${COL_RESET} (type 'help' for commands, 'quit' to exit)" + printf '%s\n' "${COL_CYAN}Agent Test Shell${COL_RESET} (type 'help' for commands, 'quit' to exit)" echo "" session_init From d5db22f0252bd9bfac4f7a9f90cb305500e9bb18 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 16:26:41 +0200 Subject: [PATCH 09/40] docs: note stderr/stdout separation in agent-test.sh output --- internal/mcp/AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/mcp/AGENTS.md b/internal/mcp/AGENTS.md index 362e30f..953c4f8 100644 --- a/internal/mcp/AGENTS.md +++ b/internal/mcp/AGENTS.md @@ -93,6 +93,10 @@ Non-verbose mode shows a clean summary: Errors show with a red ✗ and the error message. Set `--verbose` for full JSON on every call, or use it with `quick` for the full response. +All status messages (✓, ✗, ℹ, ▶) go to stderr. Tool results (JSON bodies, +search results, image data) go to stdout. This separation lets you pipe the +output without mixing status lines with data. + ### Optional fields When a struct field is a **pointer** (`*int`, `*string`), the MCP SDK may still From 5d60286ba42768ac934ecec97d9308416affcb12 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 18:09:35 +0200 Subject: [PATCH 10/40] fix(mcp): resolve errors, location bug, crop validation, and dead code - events.go: propagate GetInfos error instead of silently ignoring - events.go: reset lastLatLng when starting a new event to avoid stale cross-event distance comparisons - search.go: propagate GetInfos error instead of silently ignoring - search.go: assign geocoded location to current photo instead of the previous result - photo.go: validate crop bounds against image dimensions before rendering, returning a clear error on overflow - api.gen.go: remove empty if blocks in query parameter parsing (BindQueryParameter handles the logic; the empty blocks were dead code from code generation) --- internal/collection/events.go | 7 ++++++- internal/collection/search.go | 10 +++++----- internal/mcp/photo.go | 27 ++++++++++++++++++--------- internal/openapi/api.gen.go | 12 ------------ 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/internal/collection/events.go b/internal/collection/events.go index 81fdd39..fcdeb5d 100644 --- a/internal/collection/events.go +++ b/internal/collection/events.go @@ -2,6 +2,7 @@ package collection import ( "context" + "fmt" "time" "github.com/golang/geo/s2" @@ -30,7 +31,10 @@ type EventSummary struct { // and are no more than eventGapTime apart. Reverse-geocoding is applied to // photo locations that are >1km apart and >15 minutes apart. func (collection *Collection) SplitIntoEvents(ctx context.Context, source *image.Source) ([]EventSummary, error) { - infos, _ := collection.GetInfos(source, image.ListOptions{}) + infos, err := collection.GetInfos(source, image.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("get infos: %w", err) + } var events []EventSummary var current *EventSummary @@ -68,6 +72,7 @@ func (collection *Collection) SplitIntoEvents(ctx context.Context, source *image CreatedAfter: photoTime.Format(time.RFC3339), } locations = make(map[string]struct{}) + lastLatLng = s2.LatLng{} // reset reference point for new event } } diff --git a/internal/collection/search.go b/internal/collection/search.go index 47ffe37..b721392 100644 --- a/internal/collection/search.go +++ b/internal/collection/search.go @@ -154,13 +154,16 @@ func (collection *Collection) Search( limit = 50 } - infos, _ := collection.GetInfos(source, image.ListOptions{ + infos, err := collection.GetInfos(source, image.ListOptions{ OrderBy: order, Limit: limit, Expression: expr, ImageEmbedding: imageEmbedding, FaceEmbedding: faceEmbedding, }) + if err != nil { + return nil, tokens, expr.Errors, fmt.Errorf("get infos: %w", err) + } // 5. Collect results results := make([]SearchResult, 0) @@ -199,10 +202,7 @@ func (collection *Collection) Search( location, err := source.Geo.ReverseGeocode(ctx, info.LatLng) if err == nil { lastLatLng = info.LatLng - // Assign location to the last result in results (if any) - if len(results) > 0 { - results[len(results)-1].Location = location - } + res.Location = location // assign to current photo } } } diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index 22d5aac..12cfea2 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -278,6 +278,22 @@ func encodePhoto(ctx context.Context, source *image.Source, fileId image.ImageId return nil, fmt.Errorf("dimensions %dx%d out of range (1-4096)", targetW, targetH) } + // Validate crop bounds + if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { + cx := 0 + cy := 0 + if cropX != nil { + cx = *cropX + } + if cropY != nil { + cy = *cropY + } + if cx < 0 || cy < 0 || cx+*cropW > info.Width || cy+*cropH > info.Height { + return nil, fmt.Errorf("crop bounds (%d,%d)+(%dx%d) exceed image size (%dx%d)", + cx, cy, *cropW, *cropH, info.Width, info.Height) + } + } + // Create render config (similar to defaultSceneConfig.Render) rn := render.Render{ TileSize: 256, @@ -317,16 +333,9 @@ func encodePhoto(ctx context.Context, source *image.Source, fileId image.ImageId // Build optional crop rect var crop render.Rect if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { - cx, cy := 0, 0 - if cropX != nil { - cx = *cropX - } - if cropY != nil { - cy = *cropY - } crop = render.Rect{ - X: float64(cx), - Y: float64(cy), + X: float64(*cropX), + Y: float64(*cropY), W: float64(*cropW), H: float64(*cropH), } diff --git a/internal/openapi/api.gen.go b/internal/openapi/api.gen.go index a7b873f..749a83b 100644 --- a/internal/openapi/api.gen.go +++ b/internal/openapi/api.gen.go @@ -746,10 +746,6 @@ func (siw *ServerInterfaceWrapper) GetCollectionsIdFiles(w http.ResponseWriter, var params GetCollectionsIdFilesParams // ------------- Optional query parameter "search" ------------- - if paramValue := r.URL.Query().Get("search"); paramValue != "" { - - } - err = runtime.BindQueryParameter("form", true, false, "search", r.URL.Query(), ¶ms.Search) if err != nil { http.Error(w, fmt.Sprintf("Invalid format for parameter search: %s", err), http.StatusBadRequest) @@ -757,10 +753,6 @@ func (siw *ServerInterfaceWrapper) GetCollectionsIdFiles(w http.ResponseWriter, } // ------------- Optional query parameter "sort" ------------- - if paramValue := r.URL.Query().Get("sort"); paramValue != "" { - - } - err = runtime.BindQueryParameter("form", true, false, "sort", r.URL.Query(), ¶ms.Sort) if err != nil { http.Error(w, fmt.Sprintf("Invalid format for parameter sort: %s", err), http.StatusBadRequest) @@ -768,10 +760,6 @@ func (siw *ServerInterfaceWrapper) GetCollectionsIdFiles(w http.ResponseWriter, } // ------------- Optional query parameter "limit" ------------- - if paramValue := r.URL.Query().Get("limit"); paramValue != "" { - - } - err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit) if err != nil { http.Error(w, fmt.Sprintf("Invalid format for parameter limit: %s", err), http.StatusBadRequest) From bb67df9127d0de06e1d07cab359f821d88f8bb49 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 21:38:53 +0200 Subject: [PATCH 11/40] Add get_photo_metadata tool; remove metadata from get_photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add get_photo_metadata MCP tool that returns all photo metadata as structured JSON without image data (tags, faces, location, thumbnails, dimensions, etc.) - Remove structured metadata from get_photo — it now returns only the image content. Agents no longer waste bandwidth parsing metadata they don't use. - Split getPhotoOutput into separate empty type (get_photo) and full struct (getPhotoMetadataOutput) for proper typing. --- internal/mcp/AGENTS.md | 3 ++ internal/mcp/mcp.go | 49 ++++++++++++--------- internal/mcp/photo.go | 96 ++++++++++++++++++++++++++++++------------ 3 files changed, 102 insertions(+), 46 deletions(-) diff --git a/internal/mcp/AGENTS.md b/internal/mcp/AGENTS.md index 953c4f8..6283efa 100644 --- a/internal/mcp/AGENTS.md +++ b/internal/mcp/AGENTS.md @@ -171,6 +171,9 @@ sqlite3 data/photofield.cache.db ".tables" # Test a specific tool with arguments ./tools/agent-test.sh mcp call get_photo --file_id 1 +# Test get_photo_metadata (metadata-only, no image data) +./tools/agent-test.sh mcp call get_photo_metadata --file_id 1 + # Test error handling ./tools/agent-test.sh mcp call get_photo --file_id 999999 diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 38ab2ac..abf7b8a 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -109,9 +109,35 @@ func New(collections *[]collection.Collection, imageSource *image.Source, server }, }, searchPhotosHandler(collections, imageSource)) + mcp.AddTool(s, &mcp.Tool{ + Name: "get_photo_metadata", + Description: "Retrieve all photo metadata as structured JSON without the image data. Useful for inspecting tags, faces, location, dimensions, and thumbnail URLs without downloading the image.\n\n" + + "OUTPUT METADATA:\n" + + "- image_url: Absolute URL to medium thumbnail (M: 320x320) or original image (for markdown embedding)\n" + + "- width/height: The rendered output dimensions (same as orig when no resize is applied)\n" + + "- orig_width/orig_height: The original image's native resolution\n" + + "- path/filename/extension: Original file path details\n" + + "- video: true if the file is a video\n" + + "- created_at: Creation date in ISO 8601 format\n" + + "- tags: Detected semantic tags with file counts\n" + + "- faces: Detected faces with bounding box coordinates (x,y,w,h) and confidence scores\n" + + "- latlng: GPS coordinates if available\n" + + "- location: Reverse-geocoded location string (e.g. 'Paris, France')\n" + + "- thumbnails: Available thumbnail variants with their sizes and absolute URLs\n" + + "- faces[].preview_url: Direct URL to each face's cropped preview image (200x200)\n\n" + + "WORKFLOW: Use list_collections → events/search_photos for discovery → get_photo_metadata(file_id) to inspect all metadata → get_photo(file_id) only when you need the actual image data.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file_id": map[string]any{"type": "integer", "description": "The photo file ID (required). Obtain from search_photos results."}, + }, + "required": []string{"file_id"}, + }, + }, getPhotoMetadataHandler(collections, imageSource, serverBaseURL)) + mcp.AddTool(s, &mcp.Tool{ Name: "get_photo", - Description: "Retrieve a photo as a base64-encoded image with rich metadata and embeddable URLs. This is the only tool that returns actual image data.\n\n" + + Description: "Retrieve a photo as a base64-encoded image. This is the only tool that returns actual image data.\n\n" + "CRITICAL DEFAULT BEHAVIOR — ALWAYS CALL WITH ONLY file_id FIRST:\n" + "When you call get_photo with ONLY the file_id parameter (no w, h, crop, or format), it returns a small " + "256x256 pixel thumbnail as JPEG. This is the recommended default for: browsing search results, getting a " + @@ -126,25 +152,8 @@ func New(collections *[]collection.Collection, imageSource *image.Source, server "- crop_x/crop_y/crop_w/crop_h: Use ONLY when you need to zoom into a specific region of the photo. " + "Coordinates are in the ORIGINAL image's pixel space (not the output dimensions). All four must be " + "specified together. The crop is applied before resizing by w/h. Example: to zoom into a face, you'd " + - "need to know approximate coordinates from metadata or previous calls.\n\n" + - "EMBEDDABLE URL (returned in structured metadata — use for markdown, HTML, etc.):\n" + - "- image_url: Absolute URL to the medium thumbnail (M: 320x320) if available, or original image URL as fallback. Use this for embedding images in markdown or HTML.\n" + - "- thumbnail[].url: URLs to pre-sized thumbnail variants (S=120px, SM=240px, M=320px, B=640px, XL=1280px)\n" + - "- faces[].preview_url: Direct URL to each face's cropped preview image (200x200)\n\n" + - "OUTPUT METADATA (returned alongside the image):\n" + - "- image_url: Absolute URL to medium thumbnail (M) or original image (for markdown embedding)\n" + - "- width/height: The rendered output dimensions\n" + - "- orig_width/orig_height: The original image's native resolution\n" + - "- path/filename/extension: Original file path details\n" + - "- video: true if this is a video file\n" + - "- created_at: Creation date in ISO 8601 format\n" + - "- tags: Detected semantic tags with file counts\n" + - "- faces: Detected faces with bounding box coordinates and confidence scores\n" + - "- latlng: GPS coordinates if available\n" + - "- location: Reverse-geocoded location string (e.g. 'Paris, France')\n" + - "- thumbnails: Available thumbnail variants with their sizes and URLs\n\n" + - "WORKFLOW: Use list_collections → events/search_photos for discovery → get_photo(file_id) for thumbnails → " + - "get_photo(file_id, w=800, h=600) only when you need to inspect details. Use the returned image_url for markdown embedding.", + "need to know approximate coordinates from metadata (use get_photo_metadata first).\n\n" + + "WORKFLOW: Use list_collections → events/search_photos for discovery → get_photo_metadata(file_id) to inspect dimensions and coordinates → get_photo(file_id) for the image → get_photo(file_id, w=800, h=600) only when you need to inspect details.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index 12cfea2..bf90b88 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -110,10 +110,18 @@ type getPhotoInput struct { CropH *int `json:"crop_h" jsonschema:"Crop height in original image pixels"` } -// getPhotoOutput contains the structured metadata for the get_photo MCP tool. -// The actual image is returned as an MCP ImageContent block in CallToolResult.Content, -// separate from this structured output (which holds metadata like tags, faces, etc.). -type getPhotoOutput struct { +// getPhotoMetadataInput contains the parameters for the get_photo_metadata MCP tool. +type getPhotoMetadataInput struct { + FileId int `json:"file_id" jsonschema:"The photo file ID to retrieve metadata for"` +} + +// getPhotoOutput is the empty output type for get_photo — this tool returns only +// the image (as MCP ImageContent), no structured metadata. Metadata is available +// via the separate get_photo_metadata tool. +type getPhotoOutput struct{} + +// getPhotoMetadataOutput contains the structured metadata for the get_photo_metadata MCP tool. +type getPhotoMetadataOutput struct { Width int `json:"width"` // rendered output width in pixels Height int `json:"height"` // rendered output height in pixels OrigWidth int `json:"orig_width"` // original image width in pixels @@ -165,6 +173,61 @@ type Thumbnail struct { Url string `json:"url,omitempty"` // absolute URL to the thumbnail variant } +// getPhotoMetadataHandler handles the get_photo_metadata MCP tool request. +// Returns all photo metadata without the image data — useful for inspecting +// tags, faces, location, thumbnails, and dimensions without downloading the image. +// serverBaseURL is the absolute URL of the photofield API (e.g. "http://localhost:8080"). +func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Source, serverBaseURL string) mcp.ToolHandlerFor[getPhotoMetadataInput, getPhotoMetadataOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, input getPhotoMetadataInput) (*mcp.CallToolResult, getPhotoMetadataOutput, error) { + // Ensure we have a valid context - fall back to Background if nil. + if ctx == nil { + ctx = context.Background() + } + var panicked any + defer func() { + if r := recover(); r != nil { + panicked = r + fmt.Fprintf(os.Stderr, "get_photo_metadata handler recovered from panic: %v\n%s", r, stackTrace()) + } + }() + + // Get file info to validate existence + info := imageSource.GetInfo(image.ImageId(input.FileId)) + if info.Width == 0 || info.Height == 0 { + return nil, getPhotoMetadataOutput{}, fmt.Errorf("file not found: %d", input.FileId) + } + + // Gather metadata using the same logic as get_photo + metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, serverBaseURL, info.Width, info.Height, "jpeg") + + if panicked != nil { + return nil, getPhotoMetadataOutput{}, fmt.Errorf("internal error reading photo metadata: %v", panicked) + } + + // Return only structured metadata — no image content block. + res := &mcp.CallToolResult{ + Content: []mcp.Content{}, + } + return res, getPhotoMetadataOutput{ + ImageUrl: metadata.ImageUrl, + Width: info.Width, + Height: info.Height, + OrigWidth: info.Width, + OrigHeight: info.Height, + Path: metadata.Path, + Filename: metadata.Filename, + Extension: metadata.Extension, + Video: metadata.Video, + CreatedAt: metadata.CreatedAt, + Tags: metadata.Tags, + Faces: metadata.Faces, + Location: metadata.Location, + LatLng: metadata.LatLng, + Thumbnails: metadata.Thumbnails, + }, nil + } +} + // getPhotoHandler handles the get_photo MCP tool request. // serverBaseURL is the absolute URL of the photofield API (e.g. "http://localhost:8080"). func getPhotoHandler(_ *[]collection.Collection, imageSource *image.Source, serverBaseURL string) mcp.ToolHandlerFor[getPhotoInput, getPhotoOutput] { @@ -229,11 +292,8 @@ func getPhotoHandler(_ *[]collection.Collection, imageSource *image.Source, serv return nil, getPhotoOutput{}, fmt.Errorf("internal error rendering photo: %v", panicked) } - // Gather metadata - metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, serverBaseURL, *targetW, *targetH, formatStr) - - // Return a CallToolResult with an MCP ImageContent block for the embedded - // image, plus the typed output struct as structured_content for metadata. + // Return only the image as MCP ImageContent — no structured metadata. + // Use get_photo_metadata(file_id) to retrieve tags, faces, dimensions, URLs, etc. // The SDK handles base64 encoding for the JSON wire format. res := &mcp.CallToolResult{ Content: []mcp.Content{ @@ -243,23 +303,7 @@ func getPhotoHandler(_ *[]collection.Collection, imageSource *image.Source, serv }, }, } - return res, getPhotoOutput{ - ImageUrl: metadata.ImageUrl, - Width: *targetW, - Height: *targetH, - OrigWidth: info.Width, - OrigHeight: info.Height, - Path: metadata.Path, - Filename: metadata.Filename, - Extension: metadata.Extension, - Video: metadata.Video, - CreatedAt: metadata.CreatedAt, - Tags: metadata.Tags, - Faces: metadata.Faces, - Location: metadata.Location, - LatLng: metadata.LatLng, - Thumbnails: metadata.Thumbnails, - }, nil + return res, getPhotoOutput{}, nil } } From 07dd15faf74bad1aa423ffa47f050a929c68b7b6 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 13 Jun 2026 21:45:01 +0200 Subject: [PATCH 12/40] fix: discard Dependencies from GetInfos instead of treating as error GetInfos returns (<-chan SourcedInfo, Dependencies), not an error. Treating Dependencies as an error with %w produced malformed output like %!w(image.Dependencies=[...]). Properly discard the second return value in both events.go and search.go. --- internal/collection/events.go | 6 +----- internal/collection/search.go | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/internal/collection/events.go b/internal/collection/events.go index fcdeb5d..8adfa48 100644 --- a/internal/collection/events.go +++ b/internal/collection/events.go @@ -2,7 +2,6 @@ package collection import ( "context" - "fmt" "time" "github.com/golang/geo/s2" @@ -31,10 +30,7 @@ type EventSummary struct { // and are no more than eventGapTime apart. Reverse-geocoding is applied to // photo locations that are >1km apart and >15 minutes apart. func (collection *Collection) SplitIntoEvents(ctx context.Context, source *image.Source) ([]EventSummary, error) { - infos, err := collection.GetInfos(source, image.ListOptions{}) - if err != nil { - return nil, fmt.Errorf("get infos: %w", err) - } + infos, _ := collection.GetInfos(source, image.ListOptions{}) var events []EventSummary var current *EventSummary diff --git a/internal/collection/search.go b/internal/collection/search.go index b721392..00cf90e 100644 --- a/internal/collection/search.go +++ b/internal/collection/search.go @@ -154,16 +154,13 @@ func (collection *Collection) Search( limit = 50 } - infos, err := collection.GetInfos(source, image.ListOptions{ + infos, _ := collection.GetInfos(source, image.ListOptions{ OrderBy: order, Limit: limit, Expression: expr, ImageEmbedding: imageEmbedding, FaceEmbedding: faceEmbedding, }) - if err != nil { - return nil, tokens, expr.Errors, fmt.Errorf("get infos: %w", err) - } // 5. Collect results results := make([]SearchResult, 0) From 107b458710228a88b62ff8c6cb77d96afe763a3f Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Tue, 16 Jun 2026 22:29:45 +0200 Subject: [PATCH 13/40] fix(photo): populate Content for get_photo_metadata so MCP clients can read structured output The getPhotoMetadataHandler was returning Content: []mcp.Content{} (explicit empty array), which prevented the Go SDK from auto-populating content with JSON text from StructuredContent. MCP clients that only read content[0].text saw an empty array and returned nothing. By returning nil for the *CallToolResult pointer, the SDK auto-populates content with the JSON-serialized metadata, fixing the MCP gateway tool call. --- internal/mcp/photo.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index bf90b88..820dce3 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -205,10 +205,9 @@ func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Sour } // Return only structured metadata — no image content block. - res := &mcp.CallToolResult{ - Content: []mcp.Content{}, - } - return res, getPhotoMetadataOutput{ + // Leave Content nil so the SDK auto-populates it with JSON text + // from StructuredContent (required for MCP clients that only read content). + return nil, getPhotoMetadataOutput{ ImageUrl: metadata.ImageUrl, Width: info.Width, Height: info.Height, From fe9f191e560871227ced212d584dde37196dcad2 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 20 Jun 2026 23:23:26 +0200 Subject: [PATCH 14/40] refactor(mcp): derive base URL from request Host header with listener-derived fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace static serverBaseURL with atomic.Value set per-request from Host header - Add scheme detection (HTTP/HTTPS via r.TLS) - Fall back to listener-derived default when Host absent - Update New() signature: serverBaseURL string → addr string - Update getPhotoMetadataHandler and getPhotoHandler to accept *Server - Remove PHOTOFIELD_MCP_BASE_URL env var handling from main() - Add 'task agent' command in Taskfile.yml - Rename agent-test.sh → agent.sh and genericize MCP references - Update internal/mcp/AGENTS.md doc references --- Taskfile.yml | 6 ++ internal/mcp/AGENTS.md | 44 ++++++------ internal/mcp/mcp.go | 77 +++++++++++++++------ internal/mcp/photo.go | 8 +-- main.go | 22 +----- tools/{agent-test.sh => agent.sh} | 108 +++++++++++++++--------------- 6 files changed, 145 insertions(+), 120 deletions(-) rename tools/{agent-test.sh => agent.sh} (83%) diff --git a/Taskfile.yml b/Taskfile.yml index f5f2337..73df90e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -48,6 +48,7 @@ tasks: - "echo ' task docs: Run the docs frontend in development mode'" - "echo ' task e2e: Run end-to-end tests in watch mode'" - "echo ' task release:local: Build, package, and create a local Docker image'" + - "echo ' task agent -- mcp call : Test MCP tools via the agent harness'" silent: true commit:analyze: @@ -62,6 +63,11 @@ tasks: - git diff --cached || echo "No staged changes" silent: true + agent: + desc: Forward arguments to tools/agent.sh (the MCP testing harness) + cmds: + - bash tools/agent.sh {{.CLI_ARGS}} + added: desc: Create a new changelog entry for added features cmds: [changie new -k Added -e] diff --git a/internal/mcp/AGENTS.md b/internal/mcp/AGENTS.md index 6283efa..428ecf6 100644 --- a/internal/mcp/AGENTS.md +++ b/internal/mcp/AGENTS.md @@ -40,35 +40,35 @@ sleep 5 The server listens on port `8080` by default. Kill with `pkill -f photofield` before rebuilding. -## 2. Calling MCP Tools +## 2. Calling Tools -Use `tools/agent-test.sh` for all MCP tool calls. It handles the session +Use `tools/agent.sh` for all tool calls. It handles the session handshake, SSE parsing, and session ID management automatically. ### Basic usage ```bash # Call a tool with JSON args -./tools/agent-test.sh mcp call list_collections '{}' +./tools/agent.sh mcp call list_collections '{}' # Call with named args (auto-detects --key val pairs) -./tools/agent-test.sh mcp call search_photos --query 'beach' --collection_id 'test' --limit 3 +./tools/agent.sh mcp call search_photos --query 'beach' --collection_id 'test' --limit 3 # Verbose mode — always shows full JSON -./tools/agent-test.sh --verbose mcp call get_photo --file_id 1 --w 200 +./tools/agent.sh --verbose mcp call get_photo --file_id 1 --w 200 # Quick smoke test (list_collections only) -./tools/agent-test.sh mcp quick +./tools/agent.sh mcp quick # Interactive REPL -./tools/agent-test.sh mcp shell +./tools/agent.sh mcp shell ``` ### Arguments -- **JSON mode**: `./tools/agent-test.sh mcp call ''` -- **Named args**: `./tools/agent-test.sh mcp call --key val` (auto-detected) -- **Explicit named**: `./tools/agent-test.sh mcp call -- --key val` (forces mode) +- **JSON mode**: `./tools/agent.sh mcp call ''` +- **Named args**: `./tools/agent.sh mcp call --key val` (auto-detected) +- **Explicit named**: `./tools/agent.sh mcp call -- --key val` (forces mode) ### Environment @@ -78,7 +78,7 @@ handshake, SSE parsing, and session ID management automatically. | `AGT_BIN` | `./photofield` | Path to binary | | `AGT_DATA_DIR` | `./data` | Data directory | | `AGT_START` | `true` | Auto-start if not running | -| `AGT_URL` | (derived) | Full MCP endpoint URL | +| `AGT_URL` | (derived) | Full endpoint URL | | `AGT_API_BASE` | `http://localhost:$PORT` | Base URL for generic API calls | ### Output @@ -133,17 +133,17 @@ sqlite3 data/photofield.cache.db "SELECT id, width, height FROM infos ORDER BY i sqlite3 data/photofield.cache.db ".tables" ``` -### Collection status via MCP +### Collection status via the harness ```bash # List all collections -./tools/agent-test.sh mcp call list_collections '{}' +./tools/agent.sh mcp call list_collections '{}' # Check a specific collection's events -./tools/agent-test.sh mcp call events --collection_id 'test' +./tools/agent.sh mcp call events --collection_id 'test' # Search photos -./tools/agent-test.sh mcp call search_photos --query 'faces' --collection_id 'test' --limit 5 +./tools/agent.sh mcp call search_photos --query 'faces' --collection_id 'test' --limit 5 ``` ## 5. Common Fixes @@ -162,23 +162,23 @@ sqlite3 data/photofield.cache.db ".tables" ### Quick smoke test ```bash -./tools/agent-test.sh mcp quick +./tools/agent.sh mcp quick ``` ### Manual tool testing ```bash # Test a specific tool with arguments -./tools/agent-test.sh mcp call get_photo --file_id 1 +./tools/agent.sh mcp call get_photo --file_id 1 # Test get_photo_metadata (metadata-only, no image data) -./tools/agent-test.sh mcp call get_photo_metadata --file_id 1 +./tools/agent.sh mcp call get_photo_metadata --file_id 1 # Test error handling -./tools/agent-test.sh mcp call get_photo --file_id 999999 +./tools/agent.sh mcp call get_photo --file_id 999999 # Verbose output for debugging -./tools/agent-test.sh --verbose mcp call search_photos --query 'test' --collection_id 'test' +./tools/agent.sh --verbose mcp call search_photos --query 'test' --collection_id 'test' ``` ### From another directory @@ -187,11 +187,11 @@ The harness auto-detects the `photofield` binary relative to the repo root. To call it from elsewhere: ```bash -AGT_BIN=/path/to/photofield ./tools/agent-test.sh mcp call list_collections '{}' +AGT_BIN=/path/to/photofield ./tools/agent.sh mcp call list_collections '{}' ``` Or use a custom URL: ```bash -AGT_URL=http://remote-host:9000/mcp ./tools/agent-test.sh mcp call list_collections '{}' +AGT_URL=http://remote-host:9000/mcp ./tools/agent.sh mcp call list_collections '{}' ``` diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index abf7b8a..dcac975 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -5,8 +5,10 @@ package mcp import ( "context" "fmt" + "net" "net/http" "os" + "sync/atomic" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -16,26 +18,29 @@ import ( // Server holds the MCP server instance and its chi-mountable HTTP handler. type Server struct { - srv *mcp.Server - handler http.Handler - serverBaseURL string // e.g. "http://localhost:8080" — used to build absolute image URLs + srv *mcp.Server + handler http.Handler + baseURL atomic.Value // set from request Host header per request (stores string) } // New creates a new MCP server for photofield with the given data sources -// and registers all available tools. The serverBaseURL parameter is the -// absolute URL at which the photofield API is accessible (e.g. "http://localhost:8080"). -// This is used to construct absolute image URLs for embedding in markdown etc. -// Callers should mount handler() on a chi router, e.g.: +// and registers all available tools. The base URL is derived at request time +// from the incoming request's Host header, with `addr` used as a fallback +// default (derived from the listener address). Callers should mount handler() +// on a chi router, e.g.: // // r.Mount("/mcp", s.handler()) -func New(collections *[]collection.Collection, imageSource *image.Source, serverBaseURL string) (*Server, error) { - s := mcp.NewServer(&mcp.Implementation{ +func New(collections *[]collection.Collection, imageSource *image.Source, addr string) (*Server, error) { + sdkSrv := mcp.NewServer(&mcp.Implementation{ Name: "photofield", Version: "dev", }, nil) - // Handler closure captures collections and imageSource. - mcp.AddTool(s, &mcp.Tool{ + // Handler closures capture collections, imageSource, and a pointer to this Server + // so they can read the current base URL at request time. + srv := &Server{srv: sdkSrv} + + mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "list_collections", Description: "List all photo collections available in the library with their current indexed status. " + "Use this first to discover which collections exist, their IDs, how many photos are indexed, " + @@ -49,7 +54,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, server }, }, listCollections(collections, imageSource)) - mcp.AddTool(s, &mcp.Tool{ + mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "events", Description: "Split a collection's photos into chronological events based on time gaps. Photos on different " + "calendar days, or more than 1 hour apart (within the same day), are placed in separate events. Returns " + @@ -67,7 +72,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, server }, }, eventsHandler(collections, imageSource)) - mcp.AddTool(s, &mcp.Tool{ + mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "search_photos", Description: "Search a collection's photos using natural language text, visual similarity to another image, " + "or similarity to a detected face. This is the primary discovery tool for finding specific photos. Returns " + @@ -109,7 +114,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, server }, }, searchPhotosHandler(collections, imageSource)) - mcp.AddTool(s, &mcp.Tool{ + mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo_metadata", Description: "Retrieve all photo metadata as structured JSON without the image data. Useful for inspecting tags, faces, location, dimensions, and thumbnail URLs without downloading the image.\n\n" + "OUTPUT METADATA:\n" + @@ -133,9 +138,9 @@ func New(collections *[]collection.Collection, imageSource *image.Source, server }, "required": []string{"file_id"}, }, - }, getPhotoMetadataHandler(collections, imageSource, serverBaseURL)) + }, getPhotoMetadataHandler(collections, imageSource, srv)) - mcp.AddTool(s, &mcp.Tool{ + mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo", Description: "Retrieve a photo as a base64-encoded image. This is the only tool that returns actual image data.\n\n" + "CRITICAL DEFAULT BEHAVIOR — ALWAYS CALL WITH ONLY file_id FIRST:\n" + @@ -168,14 +173,42 @@ func New(collections *[]collection.Collection, imageSource *image.Source, server }, "required": []string{"file_id"}, }, - }, getPhotoHandler(collections, imageSource, serverBaseURL)) + }, getPhotoHandler(collections, imageSource, srv)) h := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { - return s + return sdkSrv }, nil) - // Wrap with panic recovery to prevent server crashes from tool handler panics + // Derive a default base URL from the listener address for fallback when + // the Host header is absent (e.g. behind certain reverse proxies). + var fallbackAddr string + if addr != "" { + _, p, err := net.SplitHostPort(addr) + if err != nil { + p = addr // might be a bare port like "8080" + } + if p == "" { + p = "8080" + } + fallbackAddr = net.JoinHostPort("localhost", p) + } else { + fallbackAddr = "localhost:8080" + } + + // Wrap with panic recovery to prevent server crashes from tool handler panics. + // Also extract the Host header from each request and store it in srv.baseURL + // so that tool handlers can construct absolute image URLs. wrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + if host == "" { + host = fallbackAddr + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + srv.baseURL.Store(scheme + "://" + host) + var written bool // Wrap ResponseWriter to detect if WriteHeader was called wrappedW := &responseWriterWrapper{ResponseWriter: w, wroteHeader: &written} @@ -192,7 +225,11 @@ func New(collections *[]collection.Collection, imageSource *image.Source, server h.ServeHTTP(wrappedW, r) }) - return &Server{srv: s, handler: wrappedHandler}, nil + // Initialize baseURL with the fallback default; the wrappedHandler + // overwrites it per-request. + srv.baseURL.Store("http://" + fallbackAddr) + + return &Server{srv: sdkSrv, handler: wrappedHandler}, nil } // --- list_collections --- diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index 820dce3..51b287f 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -176,8 +176,7 @@ type Thumbnail struct { // getPhotoMetadataHandler handles the get_photo_metadata MCP tool request. // Returns all photo metadata without the image data — useful for inspecting // tags, faces, location, thumbnails, and dimensions without downloading the image. -// serverBaseURL is the absolute URL of the photofield API (e.g. "http://localhost:8080"). -func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Source, serverBaseURL string) mcp.ToolHandlerFor[getPhotoMetadataInput, getPhotoMetadataOutput] { +func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[getPhotoMetadataInput, getPhotoMetadataOutput] { return func(ctx context.Context, _ *mcp.CallToolRequest, input getPhotoMetadataInput) (*mcp.CallToolResult, getPhotoMetadataOutput, error) { // Ensure we have a valid context - fall back to Background if nil. if ctx == nil { @@ -198,7 +197,7 @@ func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Sour } // Gather metadata using the same logic as get_photo - metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, serverBaseURL, info.Width, info.Height, "jpeg") + metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string), info.Width, info.Height, "jpeg") if panicked != nil { return nil, getPhotoMetadataOutput{}, fmt.Errorf("internal error reading photo metadata: %v", panicked) @@ -228,8 +227,7 @@ func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Sour } // getPhotoHandler handles the get_photo MCP tool request. -// serverBaseURL is the absolute URL of the photofield API (e.g. "http://localhost:8080"). -func getPhotoHandler(_ *[]collection.Collection, imageSource *image.Source, serverBaseURL string) mcp.ToolHandlerFor[getPhotoInput, getPhotoOutput] { +func getPhotoHandler(_ *[]collection.Collection, imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[getPhotoInput, getPhotoOutput] { return func(ctx context.Context, _ *mcp.CallToolRequest, input getPhotoInput) (*mcp.CallToolResult, getPhotoOutput, error) { // Ensure we have a valid context - fall back to Background if nil. if ctx == nil { diff --git a/main.go b/main.go index 14eebbc..f0053ca 100644 --- a/main.go +++ b/main.go @@ -2456,25 +2456,9 @@ func main() { r.Mount("/debug", middleware.Profiler()) r.Handle("/debug/fgprof", fgprof.Handler()) - // MCP server — construct base URL for image URLs - mcpServerBaseURL := os.Getenv("PHOTOFIELD_MCP_BASE_URL") - if mcpServerBaseURL == "" { - // Default to http://localhost:{port} based on the configured address - host := "localhost" - port := "8080" - if addr != "" { - // Parse address like ":8080" or "0.0.0.0:8080" - if h, p, err := net.SplitHostPort(addr); err == nil { - host = h - if host == "" || host == "0.0.0.0" { - host = "localhost" - } - port = p - } - } - mcpServerBaseURL = "http://" + host + ":" + port - } - srv, err := mcp.New(&collections, imageSource, mcpServerBaseURL) + // MCP server — base URL is derived from request Host header at runtime, + // falling back to the listener address if the Host header is absent. + srv, err := mcp.New(&collections, imageSource, addr) if err != nil { log.Fatalf("failed to create MCP server: %v", err) } diff --git a/tools/agent-test.sh b/tools/agent.sh similarity index 83% rename from tools/agent-test.sh rename to tools/agent.sh index 00e5e2e..7ccb106 100755 --- a/tools/agent-test.sh +++ b/tools/agent.sh @@ -1,32 +1,32 @@ #!/bin/bash -# agent-test.sh — Unified harness for testing photofield MCP server +# agent.sh — Unified harness for testing the photofield server # -# Covers server lifecycle, generic HTTP API calls, and MCP tool calls. +# Covers server lifecycle, generic HTTP API calls, and tool calls. # # USAGE: -# agent-test.sh --help Print this help -# agent-test.sh --verbose Verbose output (env override) +# agent.sh --help Print this help +# agent.sh --verbose Verbose output (env override) # -# agent-test.sh server start Start server (auto-detect / launch) -# agent-test.sh server stop Stop via PID file -# agent-test.sh server restart Stop + start -# agent-test.sh server status Check if running -# agent-test.sh server kill Aggressive pkill (photofield + exiftool) +# agent.sh server start Start server (auto-detect / launch) +# agent.sh server stop Stop via PID file +# agent.sh server restart Stop + start +# agent.sh server status Check if running +# agent.sh server kill Aggressive pkill (photofield + exiftool) # -# agent-test.sh api [body] Generic HTTP call -# agent-test.sh api --key val Named-arg body +# agent.sh api [body] Generic HTTP call +# agent.sh api --key val Named-arg body # -# agent-test.sh mcp call MCP tool call (JSON or named) -# agent-test.sh mcp call --key val MCP tool call (named args) -# agent-test.sh mcp quick [tool args] Smoke test -# agent-test.sh mcp shell Interactive REPL +# agent.sh mcp call Tool call (JSON or named args) +# agent.sh mcp call --key val Tool call (named args) +# agent.sh mcp quick [tool args] Smoke test +# agent.sh mcp shell Interactive REPL # # ENV: # AGT_PORT — Server port (default: 8080) # AGT_BIN — Path to photofield binary # AGT_DATA_DIR — Path to data directory # AGT_START — Auto-start server (default: true) -# AGT_URL — Full MCP endpoint URL (overrides PORT) +# AGT_URL — Full endpoint URL (overrides PORT) # AGT_API_BASE — API base URL (default: http://localhost:$PORT) set -uo pipefail @@ -34,7 +34,7 @@ set -uo pipefail # ─── Config ─── PORT="${AGT_PORT:-8080}" API_BASE="${AGT_API_BASE:-http://localhost:${PORT}}" -MCP_URL="${AGT_URL:-${API_BASE}/mcp}" +ENDPOINT_URL="${AGT_URL:-${API_BASE}/mcp}" BIN="${AGT_BIN:-$(cd "$(dirname "$0")/.." && pwd)/photofield}" DATA_DIR="${AGT_DATA_DIR:-$(pwd)/data}" AUTO_START="${AGT_START:-true}" @@ -42,8 +42,8 @@ VERBOSE=0 _SERVER_MANAGED=false # ─── Paths ─── -_pid_file="/tmp/photofield-agent-test.pid" -_headers_file="/tmp/agent-test-headers-$$" +_pid_file="/tmp/photofield-agent.pid" +_headers_file="/tmp/agent-headers-$$" # ─── Colors ─── if [[ -t 1 ]]; then @@ -70,7 +70,7 @@ server_is_running() { rm -f "$_pid_file" fi # Fall back to port check - curl -s --max-time 1 "${MCP_URL}" &>/dev/null + curl -s --max-time 1 "${ENDPOINT_URL}" &>/dev/null } server_start() { @@ -86,16 +86,16 @@ server_start() { return 1 fi - log_step "Starting MCP server..." - nohup "$BIN" > /tmp/photofield-agent-test.log 2>&1 & + log_step "Starting server..." + nohup "$BIN" > /tmp/photofield-agent.log 2>&1 & _SERVER_MANAGED=true local pid=$! printf '%s\n' "$pid" > "$_pid_file" - log_info "PID: ${pid} (log: /tmp/photofield-agent-test.log)" + log_info "PID: ${pid} (log: /tmp/photofield-agent.log)" local waited=0 while (( waited < 30 )); do - if curl -s --max-time 2 "${MCP_URL}" &>/dev/null; then + if curl -s --max-time 2 "${ENDPOINT_URL}" &>/dev/null; then log_ok "Server is ready" return 0 fi @@ -104,7 +104,7 @@ server_start() { done log_fail "Server failed to start within 30s" - tail -20 /tmp/photofield-agent-test.log >&2 + tail -20 /tmp/photofield-agent.log >&2 return 1 } @@ -219,7 +219,7 @@ api_call() { [[ -n "$body" ]] && log_info "Body: ${body:0:200}" local status_code tmpfile - tmpfile=$(mktemp /tmp/agent-test-raw-XXXXXX) + tmpfile=$(mktemp /tmp/agent-raw-XXXXXX) status_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \ "${hdrs[@]}" \ -H "Content-Type: application/json" \ @@ -246,35 +246,35 @@ api_call() { fi } -# ─── MCP Session & Requests ─── -_MCP_SESSION_ID="" -_MCP_REQUEST_ID=0 +# ─── Session & Requests ─── +_SESSION_ID="" +_REQUEST_ID=0 session_init() { local resp resp=$(curl -s -D "$_headers_file" \ - -X POST "${MCP_URL}" \ + -X POST "${ENDPOINT_URL}" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ "protocolVersion":"2024-11-05", "capabilities":{}, - "clientInfo":{"name":"agent-test","version":"0.1"} + "clientInfo":{"name":"agent","version":"0.1"} }}' 2>/dev/null) - _MCP_SESSION_ID=$(grep -i "Mcp-Session-Id" "$_headers_file" 2>/dev/null | head -1 | tr -d '\r' | sed 's/.*[Mm]cp-[Ss]ession-[Ii]d:[[:space:]]*//') + _SESSION_ID=$(grep -i "Mcp-Session-Id" "$_headers_file" 2>/dev/null | head -1 | tr -d '\r' | sed 's/.*[Mm]cp-[Ss]ession-[Ii]d:[[:space:]]*//') rm -f "$_headers_file" - if [[ -z "$_MCP_SESSION_ID" ]]; then + if [[ -z "$_SESSION_ID" ]]; then log_info "No session ID (server may not require one)" else - log_info "Session: ${_MCP_SESSION_ID}" + log_info "Session: ${_SESSION_ID}" fi # Send initialized notification local hdrs=(-H "Content-Type: application/json") - [[ -n "$_MCP_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_MCP_SESSION_ID}") - curl -s -X POST "${MCP_URL}" \ + [[ -n "$_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_SESSION_ID}") + curl -s -X POST "${ENDPOINT_URL}" \ "${hdrs[@]}" \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' 2>/dev/null || true } @@ -286,20 +286,20 @@ _sse_parse() { } # Call a tool, return cleaned JSON on stdout -mcp_call() { +call_tool() { local tool="$1" shift local args="$*" [[ -z "$args" ]] && args="{}" - _MCP_REQUEST_ID=$((_MCP_REQUEST_ID + 1)) + _REQUEST_ID=$((_REQUEST_ID + 1)) local hdrs=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") - [[ -n "$_MCP_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_MCP_SESSION_ID}") + [[ -n "$_SESSION_ID" ]] && hdrs+=(-H "Mcp-Session-Id: ${_SESSION_ID}") local raw - raw=$(curl -s --max-time 30 -X POST "${MCP_URL}" \ + raw=$(curl -s --max-time 30 -X POST "${ENDPOINT_URL}" \ "${hdrs[@]}" \ - -d "{\"jsonrpc\":\"2.0\",\"id\":${_MCP_REQUEST_ID},\"method\":\"tools/call\",\"params\":{\"name\":\"${tool}\",\"arguments\":${args}}}" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":${_REQUEST_ID},\"method\":\"tools/call\",\"params\":{\"name\":\"${tool}\",\"arguments\":${args}}}" \ 2>/dev/null) _sse_parse "$raw" @@ -343,7 +343,7 @@ print_result() { return 1 fi - # Check for result.isError (MCP tools can return errors in result) + # Check for result.isError (tools can return errors in result) local is_error is_error=$(echo "$resp" | jq -r '.result.isError // false' 2>/dev/null) @@ -418,7 +418,7 @@ print_result() { return 1 } -# ─── MCP REPL ─── +# ─── REPL ─── run_repl() { echo "" printf '%s\n' "${COL_CYAN}Agent Test Shell${COL_RESET} (type 'help' for commands, 'quit' to exit)" @@ -466,7 +466,7 @@ run_repl() { args="{}" fi local resp - resp=$(mcp_call "$tool" "$args") + resp=$(call_tool "$tool" "$args") print_result "$tool" "$resp" else echo "Unknown command: $line (type 'help')" @@ -477,10 +477,10 @@ run_repl() { # ─── Help ─── print_help() { cat <<'EOF' -agent-test.sh — Unified harness for testing photofield MCP server +agent.sh — Unified harness for testing the photofield server Usage: - agent-test.sh [options] [args...] + agent.sh [options] [args...] Options: --verbose, -v Verbose output (also AGT_VERBOSE=1) @@ -497,7 +497,7 @@ API commands: api [body] Generic HTTP call (GET/POST/PUT/DELETE) api --key val Named-arg body construction -MCP commands: +Tool commands: mcp call Call a tool with JSON args mcp call --key val Call a tool with named args mcp quick [tool args] Smoke test (default: list_collections) @@ -508,7 +508,7 @@ Environment variables: AGT_BIN — Path to photofield binary AGT_DATA_DIR — Path to data directory AGT_START — Auto-start server (default: true) - AGT_URL — Full MCP endpoint URL + AGT_URL — Full endpoint URL AGT_API_BASE — API base URL (default: http://localhost:$PORT) AGT_VERBOSE — Verbose output (1 = yes) EOF @@ -558,7 +558,7 @@ case "$cmd" in status) server_status ;; kill) server_kill ;; *) - echo "Usage: agent-test.sh server " >&2 + echo "Usage: agent.sh server " >&2 exit 1 ;; esac @@ -566,7 +566,7 @@ case "$cmd" in api) if [[ $# -lt 2 ]]; then - echo "Usage: agent-test.sh api [body]" >&2 + echo "Usage: agent.sh api [body]" >&2 exit 1 fi api_call "$@" @@ -579,7 +579,7 @@ case "$cmd" in call_tool="" named_mode=false if [[ $# -eq 0 ]]; then - echo "Usage: agent-test.sh mcp call " >&2 + echo "Usage: agent.sh mcp call " >&2 exit 1 fi call_tool="$1" @@ -601,7 +601,7 @@ case "$cmd" in [[ "$AUTO_START" == "true" ]] && server_start session_init - resp=$(mcp_call "$call_tool" "$args_json") + resp=$(call_tool "$call_tool" "$args_json") print_result "$call_tool" "$resp" ;; quick) @@ -612,7 +612,7 @@ case "$cmd" in quick_args="${quick_arg#* }" [[ "$quick_tool" == "$quick_args" ]] && quick_args="{}" [[ -z "$quick_tool" ]] && quick_tool="list_collections" && quick_args="{}" - resp=$(mcp_call "$quick_tool" "$quick_args") + resp=$(call_tool "$quick_tool" "$quick_args") if print_result "$quick_tool" "$resp"; then log_ok "Quick test passed" else @@ -625,7 +625,7 @@ case "$cmd" in run_repl ;; *) - echo "Usage: agent-test.sh mcp [args...]" >&2 + echo "Usage: agent.sh mcp [args...]" >&2 exit 1 ;; esac From f03b99c0b8234c24bbe28ebe4e7fa8713ceaf138 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 08:15:44 +0200 Subject: [PATCH 15/40] Fix aspect ratio for previews --- main.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index f0053ca..e114629 100644 --- a/main.go +++ b/main.go @@ -1616,8 +1616,18 @@ func (*Api) GetFilesIdPreviewsFilename(w http.ResponseWriter, r *http.Request, i return } - img, c := getPoolImage(&rn) - defer putPoolImage(&rn, img) + var img draw.Image + var c *canvas.Context + if params.W != nil && params.H != nil && rn.ImageWidth == rn.ImageHeight { + img, c = getPoolImage(&rn) + defer putPoolImage(&rn, img) + } else { + img = goimage.NewRGBA( + goimage.Rect(0, 0, rn.ImageWidth, rn.ImageHeight), + ) + renderer := rasterizer.New(img, 1.0) + c = canvas.NewContext(renderer) + } rn.CanvasImage = img rn.MaxSolidPixelArea = 0 // Force full render, no solid color optimization @@ -1734,8 +1744,8 @@ func parsePreviewDimensions(origW, origH int, reqW, reqH *int) (w, h int, err er } // Validate - if w < 1 || w > 4096 || h < 1 || h > 4096 { - return 0, 0, fmt.Errorf("dimensions %dx%d out of range (1-4096)", w, h) + if w < 1 || h < 1 { + return 0, 0, fmt.Errorf("invalid dimensions: width and height must be positive") } return w, h, nil From 9e47cabbe132f70400283cb2fc22f9331a173107 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 09:21:52 +0200 Subject: [PATCH 16/40] docs(local-dev): clarify -- separator and verbose flag forms Add 'Read This First' section explaining the two -- roles: - First --: task runner separator (mandatory for every command) - Second --: explicit named-arg boundary (rarely needed) Fixes: - Remove misleading 'explicit named' argument mode row - Document --verbose, -v, -V forms and AGT_VERBOSE=1 env var - Clarify output streams (log_* to stderr, tool results to stdout) - Clarify server stdout/stderr goes to log file via nohup - Add stale PID cleanup and session warning notes - Remove --verbose from API section since it doesn't change truncation --- .agents/skills/local-dev/SKILL.md | 343 ++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 .agents/skills/local-dev/SKILL.md diff --git a/.agents/skills/local-dev/SKILL.md b/.agents/skills/local-dev/SKILL.md new file mode 100644 index 0000000..e2b9f5c --- /dev/null +++ b/.agents/skills/local-dev/SKILL.md @@ -0,0 +1,343 @@ +--- +name: local-dev +description: >- + Run, test, and debug the photofield server locally. Use when building, + running, or testing the server, making API calls, calling MCP tools, or + inspecting runtime state. Covers server lifecycle via `task agent -- server`, + generic HTTP calls via `task agent -- api`, MCP tool calls, database inspection, + error debugging, and common fixes. +--- + +# Local Development — Running and Testing the Server + +This skill covers building, running, testing, and debugging the photofield +server on your local machine. + +All tool invocation, server management, and API calls go through +`task agent`, which forwards arguments to `tools/agent.sh` — a unified +harness that handles the server lifecycle, generic HTTP calls, and MCP tool +invocation with session management, SSE parsing, and named-arg parsing. + +## The `--` Separator — Read This First + +**Every command starts with `task agent --`.** The `--` after `agent` is +mandatory — there is no form that omits it. Without it, the `task` runner +tries to find a task named after the next word and fails: + +```bash +# ❌ FAILS — task runner looks for a task named "server" +task agent server status +task: Task "server" does not exist + +# ✅ WORKS — `--` tells task that "server" is an argument to the `agent` task +task agent -- server status +``` + +### Two `--` in one command + +Some commands end up with two `--` in a row. They serve different roles: + +| `--` | Role | Example | +|------|------|--------| +| **First `--`** | Task separator — required for every command | `task agent -- server status` | +| **Second `--`** | (rare) Explicit named-arg boundary | `task agent -- mcp call -- --key val` | + +The second `--` is only needed when you want to force `agent.sh` into +named-arg mode even if the first arg doesn't start with `--`. In practice, +`agent.sh` auto-detects named args, so the second `--` is almost never +needed. + +### Verbose flag forms + +The verbose flag accepts `--verbose`, `-v`, and `-V`. It must come **after** +the task separator `--`: + +```bash +task agent -- --verbose mcp call get_photo --file_id 1 +task agent -- -v mcp call get_photo --file_id 1 +task agent -- -V mcp call get_photo --file_id 1 +``` + +Or avoid the flag entirely via environment variable: +```bash +AGT_VERBOSE=1 task agent -- mcp call get_photo --file_id 1 +``` + +## 1. Build + +```bash +go build -o photofield . +``` + +Kill any old instance before rebuilding: + +```bash +task agent -- server kill +``` + +## 2. Configuration + +The server reads `data/configuration.yaml`. Without it, collections point to +empty directories. + +**Minimal setup:** + +```bash +mkdir -p data +cat > data/configuration.yaml <` to manage the server process. Every +call requires the `--` after `agent`: + +```bash +# Start (auto-detects if already running) +task agent -- server start + +# Stop gracefully (uses PID file) +task agent -- server stop + +# Restart +task agent -- server restart + +# Check status (shows PID and port listeners) +task agent -- server status + +# Aggressive kill (PID file + all port listeners including exiftool) +task agent -- server kill +``` + +**How it works:** `server start` launches the binary with `nohup` and writes a +PID file to `/tmp/photofield-agent.pid`. It then polls the server endpoint until +ready (up to 30s). `server stop` reads the PID file and sends SIGTERM. +`server kill` sends SIGKILL to the PID and anything else listening on the port. + +**Important:** The server does **not** auto-scan. After starting, run a scan: + +```bash +./photofield -scan test +# or from another directory: +AGT_BIN=/path/to/photofield task agent -- server start && ./photofield -scan test +``` + +The server listens on port `8080` by default (override with `AGT_PORT`). + +**Note:** With `AGT_START=true` (the default), `mcp call`, `mcp quick`, and +`mcp shell` will auto-start the server if it is not already running. The +`server start`, `server stop`, `server restart`, `server status`, and +`server kill` commands manage the process regardless of `AGT_START`. + +### Stale PID cleanup + +If the server crashes without a clean shutdown, the PID file may become stale. +The harness auto-detects this: `server start` will clean up a dead PID file +and launch a new instance. `server status` also removes stale entries. + +## 4. MCP Tool Calls + +Use `task agent -- mcp` to call MCP tools. The harness handles the session +handshake (initialize + initialized notification), session ID extraction from +response headers, SSE response parsing, and named-arg to JSON conversion. + +### Tool commands + +```bash +# Call a tool with JSON args +task agent -- mcp call list_collections '{}' + +# Call with named args (auto-detects --key val pairs) +task agent -- mcp call search_photos --query 'beach' --collection_id 'test' --limit 3 + +# Verbose mode — shows full raw JSON response +task agent -- --verbose mcp call get_photo --file_id 1 --w 200 +# Also accepts -v or -V: task agent -- -v mcp call get_photo --file_id 1 +# Or via env (no --verbose flag at all): +# AGT_VERBOSE=1 task agent -- mcp call get_photo --file_id 1 --w 200 + +# Smoke test (calls list_collections by default) +task agent -- mcp quick + +# Smoke test with a specific tool +task agent -- mcp quick get_photo --file_id 1 + +# Interactive REPL +task agent -- mcp shell +``` + +### Argument modes + +| Mode | Syntax | +|------|--------| +| JSON | `task agent -- mcp call ''` | +| Named | `task agent -- mcp call --key val` | + +Named args are auto-converted to JSON: numbers stay numeric, `true`/`false` +become booleans, `null` stays null, everything else is quoted as strings. +`agent.sh` auto-detects the named-arg mode when the first token starts with +`--`, so no explicit `-- --` boundary is ever needed. + +### Output + +Non-verbose mode shows a clean summary: +``` +✓ list_collections — 22 items +✓ events — 19 events +✓ get_photo +``` + +Errors show with a red ✗ and the error message. Set `--verbose` (or `-v` +or `-V`) for full raw JSON on every call. + +**Output streams:** The `log_*` helpers (`ℹ`, `▶`) go to stderr. Tool result +summaries (`✓`, `✗`) and raw JSON output go to stdout. This lets you pipe tool +results: `task agent -- mcp call list_collections '{}' | jq '.collections'`. + +### From another directory + +```bash +AGT_BIN=/path/to/photofield task agent -- mcp call list_collections '{}' +AGT_URL=http://remote-host:9000/mcp task agent -- mcp call list_collections '{}' +``` + +## 5. Generic API Calls + +Use `task agent -- api` for arbitrary HTTP calls to any server endpoint. This is +useful for testing non-MCP routes, debugging, or calling endpoints that don't +have a dedicated tool. + +```bash +# GET request +task agent -- api GET http://localhost:8080/api/health + +# POST with JSON body +task agent -- api POST http://localhost:8080/api/collections \ + '{"name":"my-collection","dirs":["/path/to/photos"]}' + +# POST with named args (auto-constructs JSON body) +task agent -- api POST http://localhost:8080/api/collections \ + --name my-collection --dirs /path/to/photos + +# PUT / DELETE +task agent -- api DELETE http://localhost:8080/api/collections/test +``` + +The output shows the HTTP status code, pretty-printed JSON when possible, and +the response body (truncated if over 500 chars). Note: `AGT_VERBOSE=1` does +not currently change the truncation behavior for API calls. + +## 6. Test the Server + +### Smoke test + +```bash +task agent -- mcp quick +``` + +### Tool tests + +```bash +# Basic call +task agent -- mcp call get_photo --file_id 1 + +# Metadata-only call +task agent -- mcp call get_photo_metadata --file_id 1 + +# Error handling +task agent -- mcp call get_photo --file_id 999999 + +# Verbose debugging +task agent -- --verbose mcp call search_photos --query 'test' --collection_id 'test' +``` + +### API tests + +```bash +# Check health +task agent -- api GET http://localhost:8080/api/health + +# List collections via API (alternative to mcp call) +task agent -- api GET http://localhost:8080/api/collections +``` + +## 7. Inspect Errors and Crashes + +The harness captures the server's **entire stdout and stderr** to +`/tmp/photofield-agent.log` via `nohup`. Panics and errors appear in this log: + +```bash +tail -100 /tmp/photofield-agent.log +``` + +### Session warnings + +If the server does not return a `Mcp-Session-Id` header, the harness continues +without one (some MCP servers don't require it). You will see a benign info +message: `No session ID (server may not require one)`. + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `cannot create context from nil parent` | Nil context passed to `WithTimeout` | Add `if ctx == nil { ctx = context.Background() }` in handler | +| `file not found: N` | Photo ID doesn't exist | Scan collection or check DB | +| Empty response data | Rendering panic | Check server log | +| Schema says all fields required | SDK infers from Go struct pointers | Use explicit `InputSchema` in tool registration | +| Server not responding | Old binary running | `task agent -- server kill` then rebuild | +| No photos found | Config points to empty dirs | Create `data/configuration.yaml` | + +## 8. Inspect Runtime State + +### Database + +```bash +sqlite3 data/photofield.cache.db "SELECT id, width, height FROM infos ORDER BY id;" +sqlite3 data/photofield.cache.db ".tables" +``` + +### Via the harness + +```bash +# List collections +task agent -- mcp call list_collections '{}' + +# Check events for a collection +task agent -- mcp call events --collection_id 'test' + +# Search photos +task agent -- mcp call search_photos --query 'faces' --collection_id 'test' --limit 5 +``` + +## Environment Variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `AGT_PORT` | `8080` | Server port | +| `AGT_BIN` | `./photofield` | Path to binary | +| `AGT_DATA_DIR` | `./data` | Data directory | +| `AGT_START` | `true` | Auto-start server if not running | +| `AGT_URL` | (derived) | Full MCP endpoint URL | +| `AGT_API_BASE` | `http://localhost:$PORT` | Base URL for API calls | +| `AGT_VERBOSE` | `0` | Verbose output | + +## Quick Reference + +| Command | Purpose | +|---------|---------| +| `go build -o photofield .` | Build the server | +| `./photofield -scan ` | Scan a collection | +| `task agent -- server start` | Start the server | +| `task agent -- server stop` | Stop the server | +| `task agent -- server restart` | Restart the server | +| `task agent -- server status` | Show PID/port status | +| `task agent -- server kill` | Kill server processes | +| `task agent -- mcp call ` | Call an MCP tool | +| `task agent -- mcp quick [tool]` | Smoke test | +| `task agent -- mcp shell` | Interactive REPL | +| `task agent -- api [body]` | Generic HTTP call | +| `task agent -- -v ` | Verbose output (`--verbose`, `-V` also accepted) | +| `sqlite3 data/photofield.cache.db ...` | Inspect the database | From c52d8af49e315d77f4ebf46aada4bcd82a18fd68 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 09:22:19 +0200 Subject: [PATCH 17/40] refactor: consolidate dev docs into local-dev skill, fix agent.sh verbose handling - Remove internal/mcp/AGENTS.md and internal/mcp/README.md (superseded by .agents/skills/local-dev/SKILL.md) - Fix agent.sh: use AGT_VERBOSE env var as default, reject misplaced --verbose after subcommand name, extract subcmd from remaining args --- internal/mcp/AGENTS.md | 197 ----------------------------------------- internal/mcp/README.md | 111 ----------------------- tools/agent.sh | 28 ++++-- 3 files changed, 22 insertions(+), 314 deletions(-) delete mode 100644 internal/mcp/AGENTS.md delete mode 100644 internal/mcp/README.md diff --git a/internal/mcp/AGENTS.md b/internal/mcp/AGENTS.md deleted file mode 100644 index 428ecf6..0000000 --- a/internal/mcp/AGENTS.md +++ /dev/null @@ -1,197 +0,0 @@ -# Agent Developer Workflow Guide - -This guide covers running, testing, debugging, and iterating on the photofield MCP server. - -## 1. Starting the Server - -### Prerequisites - -The server reads configuration from `data/configuration.yaml`. If it doesn't -exist, the server runs with defaults and the default collection config points -to every subdirectory of the current working directory (which indexes nothing -useful). - -**Quick setup:** - -```bash -mkdir -p data -cat > data/configuration.yaml < /tmp/photofield.log 2>&1 & -sleep 5 -``` - -**Important:** The server does **not** auto-scan photos. Run a scan first: - -```bash -./photofield -scan test -``` - -The server listens on port `8080` by default. Kill with `pkill -f photofield` -before rebuilding. - -## 2. Calling Tools - -Use `tools/agent.sh` for all tool calls. It handles the session -handshake, SSE parsing, and session ID management automatically. - -### Basic usage - -```bash -# Call a tool with JSON args -./tools/agent.sh mcp call list_collections '{}' - -# Call with named args (auto-detects --key val pairs) -./tools/agent.sh mcp call search_photos --query 'beach' --collection_id 'test' --limit 3 - -# Verbose mode — always shows full JSON -./tools/agent.sh --verbose mcp call get_photo --file_id 1 --w 200 - -# Quick smoke test (list_collections only) -./tools/agent.sh mcp quick - -# Interactive REPL -./tools/agent.sh mcp shell -``` - -### Arguments - -- **JSON mode**: `./tools/agent.sh mcp call ''` -- **Named args**: `./tools/agent.sh mcp call --key val` (auto-detected) -- **Explicit named**: `./tools/agent.sh mcp call -- --key val` (forces mode) - -### Environment - -| Variable | Default | Purpose | -|----------|---------|---------| -| `AGT_PORT` | `8080` | Server port | -| `AGT_BIN` | `./photofield` | Path to binary | -| `AGT_DATA_DIR` | `./data` | Data directory | -| `AGT_START` | `true` | Auto-start if not running | -| `AGT_URL` | (derived) | Full endpoint URL | -| `AGT_API_BASE` | `http://localhost:$PORT` | Base URL for generic API calls | - -### Output - -Non-verbose mode shows a clean summary: -``` -✓ list_collections — 22 items -✓ events — 19 events -✓ get_photo -``` - -Errors show with a red ✗ and the error message. Set `--verbose` for full JSON -on every call, or use it with `quick` for the full response. - -All status messages (✓, ✗, ℹ, ▶) go to stderr. Tool results (JSON bodies, -search results, image data) go to stdout. This separation lets you pipe the -output without mixing status lines with data. - -### Optional fields - -When a struct field is a **pointer** (`*int`, `*string`), the MCP SDK may still -mark it as required in the generated schema. Use the explicit `InputSchema` in -the tool registration (see `mcp.go`) to control the `required` array precisely. -When calling tools, include only the parameters the schema marks as required. - -## 3. Inspecting Errors and Crashes - -### Stack traces - -Panics are caught and logged to **stderr**. Redirect stderr to a log file: - -```bash -./photofield > /tmp/photofield.log 2>&1 & -tail -100 /tmp/photofield.log -``` - -Common patterns: - -1. **"cannot create context from nil parent"** → Handler passes `nil` context. - Fix: add `if ctx == nil { ctx = context.Background() }` in the handler. - -2. **"file not found: N"** → File ID doesn't exist. Check `sqlite3 data/photofield.cache.db "SELECT id FROM infos;"`. - -3. **Empty response data** → Rendering panicked silently. Check server log. - -## 4. Checking Runtime State - -### Database inspection - -```bash -sqlite3 data/photofield.cache.db "SELECT id, width, height FROM infos ORDER BY id;" -sqlite3 data/photofield.cache.db ".tables" -``` - -### Collection status via the harness - -```bash -# List all collections -./tools/agent.sh mcp call list_collections '{}' - -# Check a specific collection's events -./tools/agent.sh mcp call events --collection_id 'test' - -# Search photos -./tools/agent.sh mcp call search_photos --query 'faces' --collection_id 'test' --limit 5 -``` - -## 5. Common Fixes - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `file not found: N` | Photo ID doesn't exist | Scan collection or check DB | -| Empty response data | Rendering panic | Check server log for panic | -| `cannot create context from nil parent` | Nil context passed to `WithTimeout` | Add `if ctx == nil { ctx = context.Background() }` | -| Schema says all fields required | SDK infers schema from Go struct pointers | Use explicit `InputSchema` | -| Server not responding | Old binary running | `pkill -f photofield` then rebuild | -| No photos found | Default config points to empty dirs | Create `data/configuration.yaml` | - -## 6. Testing - -### Quick smoke test - -```bash -./tools/agent.sh mcp quick -``` - -### Manual tool testing - -```bash -# Test a specific tool with arguments -./tools/agent.sh mcp call get_photo --file_id 1 - -# Test get_photo_metadata (metadata-only, no image data) -./tools/agent.sh mcp call get_photo_metadata --file_id 1 - -# Test error handling -./tools/agent.sh mcp call get_photo --file_id 999999 - -# Verbose output for debugging -./tools/agent.sh --verbose mcp call search_photos --query 'test' --collection_id 'test' -``` - -### From another directory - -The harness auto-detects the `photofield` binary relative to the repo root. -To call it from elsewhere: - -```bash -AGT_BIN=/path/to/photofield ./tools/agent.sh mcp call list_collections '{}' -``` - -Or use a custom URL: - -```bash -AGT_URL=http://remote-host:9000/mcp ./tools/agent.sh mcp call list_collections '{}' -``` diff --git a/internal/mcp/README.md b/internal/mcp/README.md deleted file mode 100644 index 2a13b23..0000000 --- a/internal/mcp/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# MCP Tools for Photofield - -Four MCP tools expose photofield's photo library to AI agents: -`list_collections`, `events`, `search_photos`, `get_photo`. - -## Tool Reference - -### `list_collections` - -List all photo collections with indexed counts and timestamps. - -**Input:** `{}` (no parameters) - -**Output:** Array of collections, each with `id`, `name`, `indexed_count`, `indexed_at`. - -**Use this first** — the collection ID is required for all other tools. - ---- - -### `events` - -Split a collection's photos into time-bounded events. Photos on different -calendar days, or more than 1 hour apart (same day), form separate events. -Returns metadata only (counts, date ranges, locations) — not images. - -**Parameters:** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `collection_id` | string | yes | From `list_collections` | - -**Output:** Array of `EventSummary` objects with `index`, `created_after`, `created_before`, `photo_count`, `location_count`, `locations`. - -**Workflow:** `list_collections` → pick a collection → `events` → get high-level context → `search_photos` for details. - ---- - -### `search_photos` - -Search photos by natural language, image similarity (`img:N`), face similarity -(`face:N`), or structured qualifiers. Returns metadata summaries — not images. - -**Parameters:** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `collection_id` | string | yes | From `list_collections` | -| `query` | string | yes | Search query (see syntax below) | -| `sort` | string | no | `-date` (default, newest first), `+date`, `-similarity`, or `-similarity,+date` | -| `limit` | int | no | Max results (default 50). Use 10–20 for previews, 100–200 for full sets. | - -**Query syntax:** -| Query | Meaning | -|-------|---------| -| `sunset beach` | Natural language search via CLIP embeddings | -| `created:2024-06` | Photos from June 2024 | -| `tag:vacation` | Photos tagged "vacation" | -| `filename:IMG_` | Files matching glob (supports `*` and `?`) | -| `img:123` | Visually similar to photo ID 123 | -| `face:456` | Similar to face ID 456 | -| `t:0.3` | Minimum similarity threshold (0.15–0.30) | -| `dedup:0.9` | Remove near-duplicates (<90% similarity) | - -Qualifiers are combinable: `'created:2023-06..2023-08 tag:vacation'`. - -**Output:** Array of `SearchResult` objects with `id`, `file_name`, `datetime`, `width`, `height`, `color`, `location`, `similarity`, `tags`. - -**Workflow:** `search_photos` → examine results → `get_photo(file_id)` to see images. - ---- - -### `get_photo` - -Retrieve a photo as an embedded image with rich metadata. This is the **only** -tool that returns actual image data. - -**Parameters:** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `file_id` | integer | yes | From `search_photos` results | -| `w` | int | no | Target width (1–4096). Omit for default 256px thumbnail. | -| `h` | int | no | Target height (1–4096). Omit for default 256px thumbnail. | -| `format` | string | no | `jpeg` (default), `png`, or `webp` | -| `crop_x` | int | no | Crop left edge in original image pixels | -| `crop_y` | int | no | Crop top edge in original image pixels | -| `crop_w` | int | no | Crop width in original image pixels | -| `crop_h` | int | no | Crop height in original image pixels | - -**Default behavior:** Returns a 256×256 JPEG thumbnail. This is the recommended -default for browsing — fast and token-efficient. Only add `w`/`h` when you need -to inspect details (e.g., read text in a sign). - -**Cropping:** All crop coordinates are in the **original** image's pixel space. -All four crop params must be specified together. The crop is applied before -resizing. - -**Output:** The image is returned as an MCP `ImageContent` block. Structured -metadata includes `width`, `height`, `orig_width`, `orig_height`, `path`, -`filename`, `extension`, `video`, `created_at`, `tags`, `faces`, `latlng`, -`location`, `thumbnails`, `image_url`. - -**Workflow:** `list_collections` → `search_photos` → `get_photo(file_id)` for -thumbnails → `get_photo(file_id, w=800, h=600)` only when you need details. - -## Full Workflow Example - -``` -1. list_collections({}) → pick "vacation" -2. events({collection_id: "vacation"}) → 12 events, "Paris, France" -3. search_photos({collection_id: "vacation", query: "eiffel tower"}) → 8 results -4. get_photo({file_id: 123}) → thumbnail JPEG -5. get_photo({file_id: 123, w: 800, h: 600}) → larger preview -``` diff --git a/tools/agent.sh b/tools/agent.sh index 7ccb106..0f9f97a 100755 --- a/tools/agent.sh +++ b/tools/agent.sh @@ -5,7 +5,7 @@ # # USAGE: # agent.sh --help Print this help -# agent.sh --verbose Verbose output (env override) +# agent.sh --verbose Verbose output (must precede subcommand; env override: AGT_VERBOSE=1) # # agent.sh server start Start server (auto-detect / launch) # agent.sh server stop Stop via PID file @@ -38,7 +38,7 @@ ENDPOINT_URL="${AGT_URL:-${API_BASE}/mcp}" BIN="${AGT_BIN:-$(cd "$(dirname "$0")/.." && pwd)/photofield}" DATA_DIR="${AGT_DATA_DIR:-$(pwd)/data}" AUTO_START="${AGT_START:-true}" -VERBOSE=0 +VERBOSE=${AGT_VERBOSE:-0} _SERVER_MANAGED=false # ─── Paths ─── @@ -483,7 +483,7 @@ Usage: agent.sh [options] [args...] Options: - --verbose, -v Verbose output (also AGT_VERBOSE=1) + --verbose, -v Global verbosity flag (must precede subcommand; also AGT_VERBOSE=1) --help, -h, -- Print this help Server commands: @@ -524,13 +524,26 @@ while [[ $# -gt 0 ]]; do case "$1" in --verbose|-v|-V) VERBOSE=1; verbose_override=1; shift ;; --help|-h|--) cmd="help"; shift ;; - server) cmd="server"; shift; subcmd="${1:-help}"; shift ;; + server) + cmd="server" + shift + # Detect misplaced --verbose (position 2) and fail loudly + if [[ $# -gt 0 && "$1" == "--verbose" ]]; then + echo "Error: --verbose must come before subcommand. Use: agent.sh --verbose server " >&2 + exit 1 + fi + subcmd="${1:-help}" + shift + ;; api) cmd="api"; shift; break ;; mcp) cmd="mcp" shift - subcmd="${1:-help}" - shift + # Detect misplaced --verbose (position 2) and fail loudly + if [[ $# -gt 0 && "$1" == "--verbose" ]]; then + echo "Error: --verbose must come before subcommand. Use: agent.sh --verbose mcp " >&2 + exit 1 + fi break ;; *) @@ -544,6 +557,9 @@ while [[ $# -gt 0 ]]; do done # ─── Execute ─── +# For mcp/server, subcmd is the first remaining arg after the main loop +[[ -z "$subcmd" && $# -gt 0 ]] && subcmd="$1" && shift + case "$cmd" in help) print_help From f3acbb2c82785c0d367f0bc387fdc7d8a6214ada Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 09:25:21 +0200 Subject: [PATCH 18/40] refactor(local-dev): replace task agent with direct ./tools/agent.sh calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the task agent wrapper entirely — it only added a confusing layer of -- separators. All commands now call ./tools/agent.sh directly. - Remove 'task agent --' from all examples, description, and quick ref - Remove obsolete 'Read This First' section about -- separator - Simplify verbose flag docs (now just flags after the script) - Remove agent task from Taskfile.yml and its help text --- .agents/skills/local-dev/SKILL.md | 150 ++++++++++++------------------ Taskfile.yml | 7 +- 2 files changed, 61 insertions(+), 96 deletions(-) diff --git a/.agents/skills/local-dev/SKILL.md b/.agents/skills/local-dev/SKILL.md index e2b9f5c..2822818 100644 --- a/.agents/skills/local-dev/SKILL.md +++ b/.agents/skills/local-dev/SKILL.md @@ -3,8 +3,8 @@ name: local-dev description: >- Run, test, and debug the photofield server locally. Use when building, running, or testing the server, making API calls, calling MCP tools, or - inspecting runtime state. Covers server lifecycle via `task agent -- server`, - generic HTTP calls via `task agent -- api`, MCP tool calls, database inspection, + inspecting runtime state. Covers server lifecycle via `./tools/agent.sh server`, + generic HTTP calls via `./tools/agent.sh api`, MCP tool calls, database inspection, error debugging, and common fixes. --- @@ -14,53 +14,24 @@ This skill covers building, running, testing, and debugging the photofield server on your local machine. All tool invocation, server management, and API calls go through -`task agent`, which forwards arguments to `tools/agent.sh` — a unified -harness that handles the server lifecycle, generic HTTP calls, and MCP tool -invocation with session management, SSE parsing, and named-arg parsing. +`./tools/agent.sh` — a unified harness that handles the server lifecycle, +generic HTTP calls, and MCP tool invocation with session management, SSE +parsing, and named-arg parsing. -## The `--` Separator — Read This First +## Verbose Flag -**Every command starts with `task agent --`.** The `--` after `agent` is -mandatory — there is no form that omits it. Without it, the `task` runner -tries to find a task named after the next word and fails: +The harness accepts `--verbose`, `-v`, and `-V`. Any of these can be placed +directly after `./tools/agent.sh`: ```bash -# ❌ FAILS — task runner looks for a task named "server" -task agent server status -task: Task "server" does not exist - -# ✅ WORKS — `--` tells task that "server" is an argument to the `agent` task -task agent -- server status -``` - -### Two `--` in one command - -Some commands end up with two `--` in a row. They serve different roles: - -| `--` | Role | Example | -|------|------|--------| -| **First `--`** | Task separator — required for every command | `task agent -- server status` | -| **Second `--`** | (rare) Explicit named-arg boundary | `task agent -- mcp call -- --key val` | - -The second `--` is only needed when you want to force `agent.sh` into -named-arg mode even if the first arg doesn't start with `--`. In practice, -`agent.sh` auto-detects named args, so the second `--` is almost never -needed. - -### Verbose flag forms - -The verbose flag accepts `--verbose`, `-v`, and `-V`. It must come **after** -the task separator `--`: - -```bash -task agent -- --verbose mcp call get_photo --file_id 1 -task agent -- -v mcp call get_photo --file_id 1 -task agent -- -V mcp call get_photo --file_id 1 +./tools/agent.sh --verbose mcp call get_photo --file_id 1 +./tools/agent.sh -v mcp call get_photo --file_id 1 +./tools/agent.sh -V mcp call get_photo --file_id 1 ``` Or avoid the flag entirely via environment variable: ```bash -AGT_VERBOSE=1 task agent -- mcp call get_photo --file_id 1 +AGT_VERBOSE=1 ./tools/agent.sh mcp call get_photo --file_id 1 ``` ## 1. Build @@ -72,7 +43,7 @@ go build -o photofield . Kill any old instance before rebuilding: ```bash -task agent -- server kill +./tools/agent.sh server kill ``` ## 2. Configuration @@ -94,24 +65,23 @@ EOF ## 3. Server Lifecycle -Use `task agent -- server ` to manage the server process. Every -call requires the `--` after `agent`: +Use `./tools/agent.sh server ` to manage the server process: ```bash # Start (auto-detects if already running) -task agent -- server start +./tools/agent.sh server start # Stop gracefully (uses PID file) -task agent -- server stop +./tools/agent.sh server stop # Restart -task agent -- server restart +./tools/agent.sh server restart # Check status (shows PID and port listeners) -task agent -- server status +./tools/agent.sh server status # Aggressive kill (PID file + all port listeners including exiftool) -task agent -- server kill +./tools/agent.sh server kill ``` **How it works:** `server start` launches the binary with `nohup` and writes a @@ -124,7 +94,7 @@ ready (up to 30s). `server stop` reads the PID file and sends SIGTERM. ```bash ./photofield -scan test # or from another directory: -AGT_BIN=/path/to/photofield task agent -- server start && ./photofield -scan test +AGT_BIN=/path/to/photofield ./tools/agent.sh server start && ./photofield -scan test ``` The server listens on port `8080` by default (override with `AGT_PORT`). @@ -142,7 +112,7 @@ and launch a new instance. `server status` also removes stale entries. ## 4. MCP Tool Calls -Use `task agent -- mcp` to call MCP tools. The harness handles the session +Use `./tools/agent.sh mcp` to call MCP tools. The harness handles the session handshake (initialize + initialized notification), session ID extraction from response headers, SSE response parsing, and named-arg to JSON conversion. @@ -150,33 +120,33 @@ response headers, SSE response parsing, and named-arg to JSON conversion. ```bash # Call a tool with JSON args -task agent -- mcp call list_collections '{}' +./tools/agent.sh mcp call list_collections '{}' # Call with named args (auto-detects --key val pairs) -task agent -- mcp call search_photos --query 'beach' --collection_id 'test' --limit 3 +./tools/agent.sh mcp call search_photos --query 'beach' --collection_id 'test' --limit 3 # Verbose mode — shows full raw JSON response -task agent -- --verbose mcp call get_photo --file_id 1 --w 200 -# Also accepts -v or -V: task agent -- -v mcp call get_photo --file_id 1 +./tools/agent.sh --verbose mcp call get_photo --file_id 1 --w 200 +# Also accepts -v or -V: ./tools/agent.sh -v mcp call get_photo --file_id 1 # Or via env (no --verbose flag at all): -# AGT_VERBOSE=1 task agent -- mcp call get_photo --file_id 1 --w 200 +# AGT_VERBOSE=1 ./tools/agent.sh mcp call get_photo --file_id 1 --w 200 # Smoke test (calls list_collections by default) -task agent -- mcp quick +./tools/agent.sh mcp quick # Smoke test with a specific tool -task agent -- mcp quick get_photo --file_id 1 +./tools/agent.sh mcp quick get_photo --file_id 1 # Interactive REPL -task agent -- mcp shell +./tools/agent.sh mcp shell ``` ### Argument modes | Mode | Syntax | |------|--------| -| JSON | `task agent -- mcp call ''` | -| Named | `task agent -- mcp call --key val` | +| JSON | `./tools/agent.sh mcp call ''` | +| Named | `./tools/agent.sh mcp call --key val` | Named args are auto-converted to JSON: numbers stay numeric, `true`/`false` become booleans, `null` stays null, everything else is quoted as strings. @@ -197,35 +167,35 @@ or `-V`) for full raw JSON on every call. **Output streams:** The `log_*` helpers (`ℹ`, `▶`) go to stderr. Tool result summaries (`✓`, `✗`) and raw JSON output go to stdout. This lets you pipe tool -results: `task agent -- mcp call list_collections '{}' | jq '.collections'`. +results: `./tools/agent.sh mcp call list_collections '{}' | jq '.collections'`. ### From another directory ```bash -AGT_BIN=/path/to/photofield task agent -- mcp call list_collections '{}' -AGT_URL=http://remote-host:9000/mcp task agent -- mcp call list_collections '{}' +AGT_BIN=/path/to/photofield ./tools/agent.sh mcp call list_collections '{}' +AGT_URL=http://remote-host:9000/mcp ./tools/agent.sh mcp call list_collections '{}' ``` ## 5. Generic API Calls -Use `task agent -- api` for arbitrary HTTP calls to any server endpoint. This is +Use `./tools/agent.sh api` for arbitrary HTTP calls to any server endpoint. This is useful for testing non-MCP routes, debugging, or calling endpoints that don't have a dedicated tool. ```bash # GET request -task agent -- api GET http://localhost:8080/api/health +./tools/agent.sh api GET http://localhost:8080/api/health # POST with JSON body -task agent -- api POST http://localhost:8080/api/collections \ +./tools/agent.sh api POST http://localhost:8080/api/collections \ '{"name":"my-collection","dirs":["/path/to/photos"]}' # POST with named args (auto-constructs JSON body) -task agent -- api POST http://localhost:8080/api/collections \ +./tools/agent.sh api POST http://localhost:8080/api/collections \ --name my-collection --dirs /path/to/photos # PUT / DELETE -task agent -- api DELETE http://localhost:8080/api/collections/test +./tools/agent.sh api DELETE http://localhost:8080/api/collections/test ``` The output shows the HTTP status code, pretty-printed JSON when possible, and @@ -237,33 +207,33 @@ not currently change the truncation behavior for API calls. ### Smoke test ```bash -task agent -- mcp quick +./tools/agent.sh mcp quick ``` ### Tool tests ```bash # Basic call -task agent -- mcp call get_photo --file_id 1 +./tools/agent.sh mcp call get_photo --file_id 1 # Metadata-only call -task agent -- mcp call get_photo_metadata --file_id 1 +./tools/agent.sh mcp call get_photo_metadata --file_id 1 # Error handling -task agent -- mcp call get_photo --file_id 999999 +./tools/agent.sh mcp call get_photo --file_id 999999 # Verbose debugging -task agent -- --verbose mcp call search_photos --query 'test' --collection_id 'test' +./tools/agent.sh --verbose mcp call search_photos --query 'test' --collection_id 'test' ``` ### API tests ```bash # Check health -task agent -- api GET http://localhost:8080/api/health +./tools/agent.sh api GET http://localhost:8080/api/health # List collections via API (alternative to mcp call) -task agent -- api GET http://localhost:8080/api/collections +./tools/agent.sh api GET http://localhost:8080/api/collections ``` ## 7. Inspect Errors and Crashes @@ -287,7 +257,7 @@ message: `No session ID (server may not require one)`. | `file not found: N` | Photo ID doesn't exist | Scan collection or check DB | | Empty response data | Rendering panic | Check server log | | Schema says all fields required | SDK infers from Go struct pointers | Use explicit `InputSchema` in tool registration | -| Server not responding | Old binary running | `task agent -- server kill` then rebuild | +| Server not responding | Old binary running | `./tools/agent.sh server kill` then rebuild | | No photos found | Config points to empty dirs | Create `data/configuration.yaml` | ## 8. Inspect Runtime State @@ -303,13 +273,13 @@ sqlite3 data/photofield.cache.db ".tables" ```bash # List collections -task agent -- mcp call list_collections '{}' +./tools/agent.sh mcp call list_collections '{}' # Check events for a collection -task agent -- mcp call events --collection_id 'test' +./tools/agent.sh mcp call events --collection_id 'test' # Search photos -task agent -- mcp call search_photos --query 'faces' --collection_id 'test' --limit 5 +./tools/agent.sh mcp call search_photos --query 'faces' --collection_id 'test' --limit 5 ``` ## Environment Variables @@ -330,14 +300,14 @@ task agent -- mcp call search_photos --query 'faces' --collection_id 'test' --li |---------|---------| | `go build -o photofield .` | Build the server | | `./photofield -scan ` | Scan a collection | -| `task agent -- server start` | Start the server | -| `task agent -- server stop` | Stop the server | -| `task agent -- server restart` | Restart the server | -| `task agent -- server status` | Show PID/port status | -| `task agent -- server kill` | Kill server processes | -| `task agent -- mcp call ` | Call an MCP tool | -| `task agent -- mcp quick [tool]` | Smoke test | -| `task agent -- mcp shell` | Interactive REPL | -| `task agent -- api [body]` | Generic HTTP call | -| `task agent -- -v ` | Verbose output (`--verbose`, `-V` also accepted) | +| `./tools/agent.sh server start` | Start the server | +| `./tools/agent.sh server stop` | Stop the server | +| `./tools/agent.sh server restart` | Restart the server | +| `./tools/agent.sh server status` | Show PID/port status | +| `./tools/agent.sh server kill` | Kill server processes | +| `./tools/agent.sh mcp call ` | Call an MCP tool | +| `./tools/agent.sh mcp quick [tool]` | Smoke test | +| `./tools/agent.sh mcp shell` | Interactive REPL | +| `./tools/agent.sh api [body]` | Generic HTTP call | +| `./tools/agent.sh -v ` | Verbose output (`--verbose`, `-V` also accepted) | | `sqlite3 data/photofield.cache.db ...` | Inspect the database | diff --git a/Taskfile.yml b/Taskfile.yml index 73df90e..86ae7e2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -48,7 +48,7 @@ tasks: - "echo ' task docs: Run the docs frontend in development mode'" - "echo ' task e2e: Run end-to-end tests in watch mode'" - "echo ' task release:local: Build, package, and create a local Docker image'" - - "echo ' task agent -- mcp call : Test MCP tools via the agent harness'" + silent: true commit:analyze: @@ -63,11 +63,6 @@ tasks: - git diff --cached || echo "No staged changes" silent: true - agent: - desc: Forward arguments to tools/agent.sh (the MCP testing harness) - cmds: - - bash tools/agent.sh {{.CLI_ARGS}} - added: desc: Create a new changelog entry for added features cmds: [changie new -k Added -e] From 0ef313601ee7dfaeed6542367771fdbd1c939bfa Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 09:27:43 +0200 Subject: [PATCH 19/40] local-dev: consolidate verbose flag and smoke test docs to reduce confusion --- .agents/skills/local-dev/SKILL.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.agents/skills/local-dev/SKILL.md b/.agents/skills/local-dev/SKILL.md index 2822818..6b59da1 100644 --- a/.agents/skills/local-dev/SKILL.md +++ b/.agents/skills/local-dev/SKILL.md @@ -127,9 +127,6 @@ response headers, SSE response parsing, and named-arg to JSON conversion. # Verbose mode — shows full raw JSON response ./tools/agent.sh --verbose mcp call get_photo --file_id 1 --w 200 -# Also accepts -v or -V: ./tools/agent.sh -v mcp call get_photo --file_id 1 -# Or via env (no --verbose flag at all): -# AGT_VERBOSE=1 ./tools/agent.sh mcp call get_photo --file_id 1 --w 200 # Smoke test (calls list_collections by default) ./tools/agent.sh mcp quick @@ -162,8 +159,7 @@ Non-verbose mode shows a clean summary: ✓ get_photo ``` -Errors show with a red ✗ and the error message. Set `--verbose` (or `-v` -or `-V`) for full raw JSON on every call. +Errors show with a red ✗ and the error message. Set `--verbose` (see Verbose Flag above) for full raw JSON on every call. **Output streams:** The `log_*` helpers (`ℹ`, `▶`) go to stderr. Tool result summaries (`✓`, `✗`) and raw JSON output go to stdout. This lets you pipe tool @@ -204,12 +200,6 @@ not currently change the truncation behavior for API calls. ## 6. Test the Server -### Smoke test - -```bash -./tools/agent.sh mcp quick -``` - ### Tool tests ```bash @@ -309,5 +299,5 @@ sqlite3 data/photofield.cache.db ".tables" | `./tools/agent.sh mcp quick [tool]` | Smoke test | | `./tools/agent.sh mcp shell` | Interactive REPL | | `./tools/agent.sh api [body]` | Generic HTTP call | -| `./tools/agent.sh -v ` | Verbose output (`--verbose`, `-V` also accepted) | +| `./tools/agent.sh --verbose ` | Verbose output (see Verbose Flag above for `-v`/`-V`/env var) | | `sqlite3 data/photofield.cache.db ...` | Inspect the database | From 189ac3323f9139d90fdee3bbfb3ce489a842eaad Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 09:44:38 +0200 Subject: [PATCH 20/40] docs: add MCP server overview documentation - New docs/mcp-server.md with use cases, tool reference, client config, and AI server prerequisites - Add MCP Server section to VitePress sidebar under Features --- docs/.vitepress/config.mts | 1 + docs/mcp-server.md | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 docs/mcp-server.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 242c64d..d535e26 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -43,6 +43,7 @@ export default defineConfig({ { text: 'Search', link: '/features/search' }, { text: 'Tags', link: '/features/tags' }, { text: 'Reverse Geolocation', link: '/features/geolocation' }, + { text: 'MCP Server', link: '/mcp-server' }, ] }, { diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 0000000..e46b9ff --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,56 @@ +# MCP Server + +The Photofield MCP server lets LLM agents search and retrieve photos from your Photofield instance through a simple, agent-friendly interface that avoids manual browser navigation and raw API calls. It runs on the same port as the main server at `/mcp` by default. + +> **Experimental.** The MCP integration is early and not yet fully explored. It is a read-only interface, so feel free to experiment without worry. + +## Use Cases + +- **Find specific moments**: "Find the selfie I made in front of a brachiosaurus at the zoo" or "Show me photos where I'm wearing the red hat." The agent uses semantic search to locate exactly what you're thinking of. +- **Trip exploration**: "What did I do in Barcelona on the 14th of July?" The agent groups photos into chronological events, extracts location names, and walks you through your day. +- **Photo retrieval**: "Show me that picture of the sunset behind the lighthouse from the Sicily trip." The agent finds and displays the actual image. +- **Collection overview**: "How many photos do I have in total?" or "What collections are available?" The agent inspects collection metadata without user intervention. + +## What It Does + +Five tools are available: + +| Tool | Purpose | +|---|---| +| `list_collections` | List all photo collections, their IDs, and indexed photo counts | +| `events` | Split a collection into chronological events (by day and location) | +| `search_photos` | Search photos using natural language, image similarity, or face similarity | +| `get_photo_metadata` | Retrieve structured metadata for a single photo: tags, faces, GPS coordinates, dimensions | +| `get_photo` | Retrieve an actual photo image as a base64-encoded image, with options for resizing and cropping | + +## Client Configuration + +Point your MCP client at the Photofield MCP endpoint: + +```json +{ + "mcpServers": { + "photofield": { + "url": "http://localhost:8080/mcp", + "transport": "http", + "directTools": true + } + } +} +``` + +## AI / Semantic Search + +Text-based semantic search requires the [photofield-ai](https://github.com/SmilyOrg/photofield-ai) server to be running and configured in `configuration.yaml`: + +```yaml +ai: + textual: + host: http://localhost:8081 + visual: + host: http://localhost:8081 + faces: + host: http://localhost:8081 +``` + +Without the AI server, `search_photos` still works for tag, date, and filename filters, but text-based semantic search and face/image similarity searches will return no results. From d0471ac32f199c1ee8a524985fcdaa11e4f06477 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 10:04:35 +0200 Subject: [PATCH 21/40] Optimize MCP tool response payloads by removing low-signal fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collection.go: add omitempty to Layout, Sort, Limit, IndexLimit, ExpandSubdirs, ExpandSort — omits empty/default values from list_collections responses - events.go: add omitempty to Index and LocationCount — omits zero values from event summaries - photo.go (get_photo_metadata): - Remove redundant width/height fields (were identical to orig_*) - Remove filename and extension (derivable from path) - Remove thumbnails array (~2KB of redundant cache variant URLs) - Replace image_url with original_url pointing to full-res variant - Remove Thumbnail struct and all thumbnail gathering logic - Remove unused targetW/targetH/format parameters from gatherPhotoMetadata - Add omitempty to Video field Reduces get_photo_metadata response from ~2.3KB to ~310 bytes (87%). --- internal/collection/collection.go | 12 +-- internal/collection/events.go | 4 +- internal/mcp/photo.go | 160 ++++++++++-------------------- 3 files changed, 59 insertions(+), 117 deletions(-) diff --git a/internal/collection/collection.go b/internal/collection/collection.go index 6fe5f76..92c447a 100644 --- a/internal/collection/collection.go +++ b/internal/collection/collection.go @@ -15,12 +15,12 @@ import ( type Collection struct { Id string `json:"id"` Name string `json:"name"` - Layout string `json:"layout"` - Sort string `json:"sort"` - Limit int `json:"limit"` - IndexLimit int `json:"index_limit"` - ExpandSubdirs bool `json:"expand_subdirs"` - ExpandSort string `json:"expand_sort"` + Layout string `json:"layout,omitempty"` + Sort string `json:"sort,omitempty"` + Limit int `json:"limit,omitempty"` + IndexLimit int `json:"index_limit,omitempty"` + ExpandSubdirs bool `json:"expand_subdirs,omitempty"` + ExpandSort string `json:"expand_sort,omitempty"` Dirs []string `json:"dirs"` IndexedAt *time.Time `json:"indexed_at,omitempty"` IndexedCount int `json:"indexed_count"` diff --git a/internal/collection/events.go b/internal/collection/events.go index 8adfa48..5769721 100644 --- a/internal/collection/events.go +++ b/internal/collection/events.go @@ -17,11 +17,11 @@ const ( // EventSummary represents a time-bounded event within a collection. type EventSummary struct { - Index int `json:"index"` + Index int `json:"index,omitempty"` CreatedAfter string `json:"created_after"` CreatedBefore string `json:"created_before"` PhotoCount int `json:"photo_count"` - LocationCount int `json:"location_count"` + LocationCount int `json:"location_count,omitempty"` Locations []string `json:"locations,omitempty"` } diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index 51b287f..53d9737 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -11,8 +11,7 @@ import ( "os" "path/filepath" "runtime" - "sort" - "strings" + "sync" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -122,21 +121,16 @@ type getPhotoOutput struct{} // getPhotoMetadataOutput contains the structured metadata for the get_photo_metadata MCP tool. type getPhotoMetadataOutput struct { - Width int `json:"width"` // rendered output width in pixels - Height int `json:"height"` // rendered output height in pixels - OrigWidth int `json:"orig_width"` // original image width in pixels - OrigHeight int `json:"orig_height"` // original image height in pixels - Path string `json:"path"` // original file path - Filename string `json:"filename"` // original file name with extension - Extension string `json:"extension"` // file extension (e.g. ".jpg") - Video bool `json:"video"` // true if the file is a video - CreatedAt string `json:"created_at"` // file creation time in RFC 3339 - Tags []SimpleTag `json:"tags,omitempty"` - Faces []FaceInfo `json:"faces,omitempty"` - Location string `json:"location,omitempty"` // reverse-geocoded location - LatLng *LatLng `json:"latlng,omitempty"` // GPS coordinates - Thumbnails []Thumbnail `json:"thumbnails,omitempty"` // available thumbnail variants - ImageUrl string `json:"image_url"` // absolute URL to the medium thumbnail (M) or original (for markdown embedding) + Width int `json:"width"` // original image width in pixels + Height int `json:"height"` // original image height in pixels + Path string `json:"path"` // original file path + Video bool `json:"video,omitempty"` // true if the file is a video + CreatedAt string `json:"created_at"` // file creation time in RFC 3339 + Tags []SimpleTag `json:"tags,omitempty"` + Faces []FaceInfo `json:"faces,omitempty"` + Location string `json:"location,omitempty"` // reverse-geocoded location + LatLng *LatLng `json:"latlng,omitempty"` // GPS coordinates + OriginalUrl string `json:"original_url"` // absolute URL to the original image (full-resolution variant) } // FaceInfo represents detected face data for a photo. @@ -163,17 +157,7 @@ type SimpleTag struct { FileCount int `json:"file_count"` } -// Thumbnail describes an available thumbnail variant. -type Thumbnail struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Width int `json:"width"` - Height int `json:"height"` - Filename string `json:"filename"` - Url string `json:"url,omitempty"` // absolute URL to the thumbnail variant -} - -// getPhotoMetadataHandler handles the get_photo_metadata MCP tool request. +// getPhotoMetadataHandler handles the get_photo_metadata tool request. // Returns all photo metadata without the image data — useful for inspecting // tags, faces, location, thumbnails, and dimensions without downloading the image. func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[getPhotoMetadataInput, getPhotoMetadataOutput] { @@ -197,31 +181,26 @@ func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Sour } // Gather metadata using the same logic as get_photo - metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string), info.Width, info.Height, "jpeg") + metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string)) if panicked != nil { return nil, getPhotoMetadataOutput{}, fmt.Errorf("internal error reading photo metadata: %v", panicked) } - // Return only structured metadata — no image content block. + // Return only structured metadata — no image content block. // Leave Content nil so the SDK auto-populates it with JSON text // from StructuredContent (required for MCP clients that only read content). return nil, getPhotoMetadataOutput{ - ImageUrl: metadata.ImageUrl, - Width: info.Width, - Height: info.Height, - OrigWidth: info.Width, - OrigHeight: info.Height, - Path: metadata.Path, - Filename: metadata.Filename, - Extension: metadata.Extension, - Video: metadata.Video, - CreatedAt: metadata.CreatedAt, - Tags: metadata.Tags, - Faces: metadata.Faces, - Location: metadata.Location, - LatLng: metadata.LatLng, - Thumbnails: metadata.Thumbnails, + OriginalUrl: metadata.OriginalUrl, + Width: info.Width, + Height: info.Height, + Path: metadata.Path, + Video: metadata.Video, + CreatedAt: metadata.CreatedAt, + Tags: metadata.Tags, + Faces: metadata.Faces, + Location: metadata.Location, + LatLng: metadata.LatLng, }, nil } } @@ -438,24 +417,19 @@ func encodePhoto(ctx context.Context, source *image.Source, fileId image.ImageId // photoMetadata holds all metadata fields for a photo. type photoMetadata struct { - Path string - Filename string - Extension string - Video bool - CreatedAt string - ImageUrl string - Tags []SimpleTag - Faces []FaceInfo - Location string - LatLng *LatLng - Thumbnails []Thumbnail + Path string + Video bool + CreatedAt string + OriginalUrl string + Tags []SimpleTag + Faces []FaceInfo + Location string + LatLng *LatLng } // gatherPhotoMetadata collects all metadata for a photo by file ID. // serverBaseURL is the absolute API base URL (e.g. "http://localhost:8080"). -// targetW/targetH/format are used to construct the image and preview URLs. -// Mirrors the logic from layout.common.go:getRegionFromPhoto. -func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, info image.Info, serverBaseURL string, targetW, targetH int, format string) photoMetadata { +func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, info image.Info, serverBaseURL string) photoMetadata { originalPath, _ := source.GetImagePath(image.ImageId(fileId)) location := "" var latlng *LatLng @@ -468,40 +442,20 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, } isVideo := source.IsSupportedVideo(originalPath) - extension := filepath.Ext(originalPath) filename := filepath.Base(originalPath) - // Gather thumbnails from each source - var thumbnails []Thumbnail - originalSize := io.Size{X: info.Width, Y: info.Height} - basename := strings.TrimSuffix(filename, extension) + // Build original image URL: use the 'original' variant (full-resolution source copy) + var originalUrl string for _, s := range source.Sources { - if !s.Exists(ctx, io.ImageId(fileId), originalPath) { + if s.Name() != "original" { continue } - size := s.Size(originalSize) - ext := s.Ext() - if ext == "" { - ext = extension + if !s.Exists(ctx, io.ImageId(fileId), originalPath) { + continue } - thumbFilename := fmt.Sprintf("%s_%s%s", basename, s.Name(), ext) - thumbnails = append(thumbnails, Thumbnail{ - Name: s.Name(), - DisplayName: s.DisplayName(), - Width: size.X, - Height: size.Y, - Filename: thumbFilename, - Url: serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/variants/" + s.Name() + "/" + thumbFilename, - }) + originalUrl = serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/variants/" + s.Name() + "/" + filename + break } - sort.Slice(thumbnails, func(i, j int) bool { - a, b := &thumbnails[i], &thumbnails[j] - aa, bb := a.Width*a.Height, b.Width*b.Height - if aa != bb { - return aa < bb - } - return a.Name < b.Name - }) // Gather tags tags := make([]SimpleTag, 0) @@ -542,31 +496,19 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, }) } - // Build image URL: use medium thumbnail (M: 320x320) if available, otherwise original - imgUrl := "" - for _, thumb := range thumbnails { - if thumb.Name == "M" && thumb.Url != "" { - imgUrl = thumb.Url - break - } + if originalUrl == "" { + originalUrl = serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/variants/" + filename } - // Fallback to original if no medium thumbnail found - if imgUrl == "" { - imgUrl = serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/variants/" + filename - } - + return photoMetadata{ - Path: originalPath, - Filename: filename, - Extension: extension, - Video: isVideo, - ImageUrl: imgUrl, - CreatedAt: info.DateTime.Format("2006-01-02T15:04:05Z07:00"), - Tags: tags, - Faces: faces, - Location: location, - LatLng: latlng, - Thumbnails: thumbnails, + Path: originalPath, + Video: isVideo, + OriginalUrl: originalUrl, + CreatedAt: info.DateTime.Format("2006-01-02T15:04:05Z07:00"), + Tags: tags, + Faces: faces, + Location: location, + LatLng: latlng, } } From b22d521d23b97e7beb7829b48a2d9e89191d461d Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 10:16:57 +0200 Subject: [PATCH 22/40] =?UTF-8?q?Add=20preview=5Furl=20to=20photo=20metada?= =?UTF-8?q?ta=20=E2=80=94=20~400px=20dynamically-resized=20image=20URL=20r?= =?UTF-8?q?eady=20for=20markdown=20embedding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/mcp/photo.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index 53d9737..68fce68 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "sync" @@ -130,6 +131,7 @@ type getPhotoMetadataOutput struct { Faces []FaceInfo `json:"faces,omitempty"` Location string `json:"location,omitempty"` // reverse-geocoded location LatLng *LatLng `json:"latlng,omitempty"` // GPS coordinates + PreviewUrl string `json:"preview_url"` // absolute URL to a ~400px wide preview image (for direct markdown embedding: ![name](preview_url)) OriginalUrl string `json:"original_url"` // absolute URL to the original image (full-resolution variant) } @@ -191,6 +193,7 @@ func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Sour // Leave Content nil so the SDK auto-populates it with JSON text // from StructuredContent (required for MCP clients that only read content). return nil, getPhotoMetadataOutput{ + PreviewUrl: metadata.PreviewUrl, OriginalUrl: metadata.OriginalUrl, Width: info.Width, Height: info.Height, @@ -420,6 +423,7 @@ type photoMetadata struct { Path string Video bool CreatedAt string + PreviewUrl string OriginalUrl string Tags []SimpleTag Faces []FaceInfo @@ -443,6 +447,13 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, isVideo := source.IsSupportedVideo(originalPath) filename := filepath.Base(originalPath) + previewFilename := strings.TrimSuffix(filename, filepath.Ext(filename)) + "_preview.jpg" + + // Build preview URL: use /previews/ endpoint with ~400px width for direct markdown embedding + var previewUrl string + if originalPath != "" { + previewUrl = serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/previews/" + previewFilename + "?w=400" + } // Build original image URL: use the 'original' variant (full-resolution source copy) var originalUrl string @@ -503,8 +514,9 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, return photoMetadata{ Path: originalPath, Video: isVideo, - OriginalUrl: originalUrl, CreatedAt: info.DateTime.Format("2006-01-02T15:04:05Z07:00"), + PreviewUrl: previewUrl, + OriginalUrl: originalUrl, Tags: tags, Faces: faces, Location: location, From b09c45be6a6883a31ffb19b2856014f34faf9add Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 10:24:00 +0200 Subject: [PATCH 23/40] =?UTF-8?q?Refine=20MCP=20tool=20descriptions=20?= =?UTF-8?q?=E2=80=94=20tighten=20noise,=20add=20sort=20options=20and=20sea?= =?UTF-8?q?rch=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list_collections: remove empty-object {} boilerplate, redundant field descriptions - events: remove 1hr/calendar-day split logic and 1km/15min geocoding thresholds — internal details not actionable for the agent - search_photos: add full SORT OPTIONS section (all 8 shuffle/sort modes plus multi-field), 4 new examples, keep query qualifiers and query types since the agent needs them; remove redundant PARAMS section and parameter docs already in the schema - get_photo_metadata: replace 12-field output listing with concise guidance — show photos with preview_url, link to original_url - get_photo: add strong ⚠️ directive to always start with file_id alone, tighten all parameter descriptions - Net: 46 insertions, 75 deletions --- internal/mcp/mcp.go | 121 +++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 75 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index dcac975..7308624 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -42,11 +42,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr s mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "list_collections", - Description: "List all photo collections available in the library with their current indexed status. " + - "Use this first to discover which collections exist, their IDs, how many photos are indexed, " + - "and when indexing last occurred. The collection ID from the response is required for all other " + - "tools (events, search_photos, get_photo). This tool has no input parameters — call it with an empty object {}. " + - "Returns indexed_count (how many photos have been processed) and indexed_at (timestamp of last indexing). " + + Description: "List all photo collections. Use this first — the collection_id from the response is required by all other tools. " + "If indexed_count is 0 or indexed_at is missing, the collection has not been indexed yet.", InputSchema: map[string]any{ "type": "object", @@ -56,13 +52,8 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr s mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "events", - Description: "Split a collection's photos into chronological events based on time gaps. Photos on different " + - "calendar days, or more than 1 hour apart (within the same day), are placed in separate events. Returns " + - "metadata summaries only (photo count, date ranges, number of distinct locations, location names) — NOT " + - "the photo images themselves. Uses reverse-geocoded location names for photos that are more than 1 km " + - "apart AND more than 15 minutes apart (to avoid excessive geocoding API calls). Best used after " + - "list_collections to pick a collection_id, then before search_photos to get high-level context about " + - "where and when photos were taken.", + Description: "Split a collection's photos into chronological events. Returns metadata summaries (photo count, date ranges, location count) — NOT the photo images themselves. " + + "Use after list_collections, before search_photos, to get high-level context about where and when photos were taken.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -74,41 +65,45 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr s mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "search_photos", - Description: "Search a collection's photos using natural language text, visual similarity to another image, " + - "or similarity to a detected face. This is the primary discovery tool for finding specific photos. Returns " + - "metadata summaries (file name, date, dimensions, dominant color, location, tags, similarity score) — NOT " + - "the image data itself. Use get_photo with the returned file_id to retrieve actual images and their embeddable URLs.\n\n" + + Description: "Search a collection's photos by text, image reference (img:ID), or face reference (face:ID). Returns metadata summaries — NOT the image data. " + + "Results beyond the limit are silently discarded. Use get_photo_metadata on results to get preview_url for markdown embedding, or get_photo for the full image.\n\n" + "QUERY TYPES:\n" + - "- Text search: e.g. 'red car on highway' — uses CLIP embeddings to find semantically similar images, sorted by match quality\n" + + "- Text search: e.g. 'red car on highway' — uses CLIP embeddings, sorted by match quality\n" + "- Image similarity: e.g. 'img:1234' — finds photos visually similar to the given image ID\n" + "- Face similarity: e.g. 'face:5678' — finds photos containing similar faces\n\n" + - "COMBINABLE QUALIFIERS (can mix with text search or use standalone):\n" + + "COMBINABLE QUALIFIERS (mix with text or use standalone):\n" + "- tag:name — filter by tag (e.g. 'vacation', 'fav')\n" + "- filename:text — filter by filename (supports * and ? wildcards, e.g. 'filename:*.png')\n" + "- created:YYYY-MM-DD — filter by date (supports ranges like 'created:2023-01-01..2023-12-31', wildcards like 'created:*-12-25', and operators like 'created:>=2023-06-15')\n" + "- t:X — similarity threshold filter (0.15-0.30, where higher = more strict; e.g. 'beach sunset t:0.25')\n" + "- dedup:X — filter duplicates by similarity (0-1, e.g. 'dedup:0.9' keeps only photos <90% similar to each other)\n\n" + + "SORT OPTIONS (passed as the 'sort' parameter):\n" + + "- -date (default) — newest first\n" + + "- +date — oldest first\n" + + "- -similarity — best matches first\n" + + "- +similarity — worst matches first\n" + + "- +shuffle-hourly — random within each hour\n" + + "- +shuffle-daily — random within each day\n" + + "- +shuffle-weekly — random within each week\n" + + "- +shuffle-monthly — random within each month\n" + + "- Multiple fields: e.g. '-similarity,+date' (sort by similarity, break ties newest first)\n\n" + "EXAMPLES:\n" + "- 'beach sunset' — semantically search for beach/sunset photos\n" + "- 'beach sunset t:0.25' — find beach sunsets with at least 0.25 similarity\n" + "- 'created:2023-06..2023-08 tag:vacation' — vacation photos from summer 2023\n" + - "- 'img:100 tag:fav' — favorited photos similar to image 100\n\n" + - "PARAMETERS:\n" + - "- collection_id (required): From list_collections\n" + - "- query (required): Search query as described above\n" + - "- sort (optional): Controls result ordering. Default is '-date' (newest first). Options: '-date' " + - "(newest), '+date' (oldest), '-similarity' (best match first), '-similarity,+date' (best match, then " + - "newest). The '-' prefix means descending, '+' means ascending. Multiple fields can be combined with commas.\n" + - "- limit (optional): Maximum number of results. Default is 50. Use a smaller value (10-20) for quick " + - "previews, or larger (100-200) for comprehensive result sets. Results beyond the limit are silently discarded.\n\n" + - "WORKFLOW: Call search_photos to find candidates → examine the results → call get_photo on specific file_ids to see actual images and get their embeddable URLs.", + "- 'img:100 tag:fav' — favorited photos similar to image 100\n" + + "- 'dog' sort:-similarity — dogs sorted by relevance\n" + + "- 'dog' sort:+shuffle-daily — random order, but grouped by day\n" + + "- 'portrait' sort:-similarity,+date — best portraits first, newest tiebreak\n" + + "- 'filename:IMG_*.jpg' — all IMG_ photos, oldest first\n\n" + + "WORKFLOW: search_photos → get_photo_metadata(file_id) to get preview_url → embed directly in markdown, or get_photo(file_id) for the image.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "collection_id": map[string]any{"type": "string", "description": "The collection ID from list_collections."}, - "query": map[string]any{"type": "string", "description": "Search query: natural language text ('red car on highway'), image similarity ('img:1234'), face similarity ('face:5678'), or combined with qualifiers ('beach sunset tag:vacation created:2023-06'). Required."}, - "sort": map[string]any{"type": [3]string{"null", "string"}, "description": "Sort order. Default is '-date' (newest first). Options: '-date', '+date', '-similarity', '-similarity,+date'. Descending uses '-', ascending uses '+'."}, - "limit": map[string]any{"type": [2]string{"null", "integer"}, "description": "Max results. Default 50. Use 10-20 for quick previews, 100-200 for comprehensive sets."}, + "query": map[string]any{"type": "string", "description": "Search query: natural language text, image similarity (img:ID), or face similarity (face:ID)."}, + "sort": map[string]any{"type": [3]string{"null", "string"}, "description": "Sort order. '-date' (newest) by default. Options: +date, -similarity, +similarity, +shuffle-hourly, +shuffle-daily, +shuffle-weekly, +shuffle-monthly, or comma-joined like '-similarity,+date'."}, + "limit": map[string]any{"type": [2]string{"null", "integer"}, "description": "Max results. Default 50. Results beyond limit are silently discarded."}, }, "required": []string{"collection_id", "query"}, }, @@ -116,21 +111,9 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr s mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo_metadata", - Description: "Retrieve all photo metadata as structured JSON without the image data. Useful for inspecting tags, faces, location, dimensions, and thumbnail URLs without downloading the image.\n\n" + - "OUTPUT METADATA:\n" + - "- image_url: Absolute URL to medium thumbnail (M: 320x320) or original image (for markdown embedding)\n" + - "- width/height: The rendered output dimensions (same as orig when no resize is applied)\n" + - "- orig_width/orig_height: The original image's native resolution\n" + - "- path/filename/extension: Original file path details\n" + - "- video: true if the file is a video\n" + - "- created_at: Creation date in ISO 8601 format\n" + - "- tags: Detected semantic tags with file counts\n" + - "- faces: Detected faces with bounding box coordinates (x,y,w,h) and confidence scores\n" + - "- latlng: GPS coordinates if available\n" + - "- location: Reverse-geocoded location string (e.g. 'Paris, France')\n" + - "- thumbnails: Available thumbnail variants with their sizes and absolute URLs\n" + - "- faces[].preview_url: Direct URL to each face's cropped preview image (200x200)\n\n" + - "WORKFLOW: Use list_collections → events/search_photos for discovery → get_photo_metadata(file_id) to inspect all metadata → get_photo(file_id) only when you need the actual image data.", + Description: "Retrieve structured photo metadata (dimensions, path, dates, tags, faces, location, URLs). " + + "Show photos using preview_url and link to original_url for full resolution (use HTML: ). " + + "Use after list_collections, events, or search_photos to inspect details on specific file_ids.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -142,34 +125,22 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr s mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo", - Description: "Retrieve a photo as a base64-encoded image. This is the only tool that returns actual image data.\n\n" + - "CRITICAL DEFAULT BEHAVIOR — ALWAYS CALL WITH ONLY file_id FIRST:\n" + - "When you call get_photo with ONLY the file_id parameter (no w, h, crop, or format), it returns a small " + - "256x256 pixel thumbnail as JPEG. This is the recommended default for: browsing search results, getting a " + - "quick overview, identifying content at a glance, and most everyday use cases. Small thumbnails are fast, " + - "efficient, and usually sufficient for identifying what a photo contains.\n\n" + - "ONLY add extra parameters when you genuinely need more detail:\n" + - "- w/h: Use ONLY when the thumbnail is too small to make out details. E.g., if you need to read text in a " + - "sign, identify a distant person, or examine architectural details. Range: 1-4096. Omit both for the default " + - "256x256 thumbnail.\n" + - "- format: Rarely needed. Options: 'jpeg' (default, best for photos), 'png' (lossless, good for " + - "screenshots/diagrams), 'webp' (smaller file size, modern format). Use default jpeg unless you have a specific need.\n" + - "- crop_x/crop_y/crop_w/crop_h: Use ONLY when you need to zoom into a specific region of the photo. " + - "Coordinates are in the ORIGINAL image's pixel space (not the output dimensions). All four must be " + - "specified together. The crop is applied before resizing by w/h. Example: to zoom into a face, you'd " + - "need to know approximate coordinates from metadata (use get_photo_metadata first).\n\n" + - "WORKFLOW: Use list_collections → events/search_photos for discovery → get_photo_metadata(file_id) to inspect dimensions and coordinates → get_photo(file_id) for the image → get_photo(file_id, w=800, h=600) only when you need to inspect details.", + Description: "Retrieve a photo as a base64-encoded image. This is the only tool that returns actual image data. " + + "Default (file_id only): 256x256 JPEG thumbnail. Format: jpeg (default), png, webp. " + + "Crop params (crop_x/y/w/h) are in original image pixel coordinates; all four must be specified together. " + + "⚠️ Only pass optional parameters (w, h, crop) when investigating specific details — always start with file_id alone.\n\n" + + "WORKFLOW: search_photos → get_photo_metadata(file_id) for dimensions/preview_url → get_photo(file_id) for the image.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ - "file_id": map[string]any{"type": "integer", "description": "The photo file ID (required). Obtain from search_photos results or get_photo output."}, - "w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target width in pixels (1-4096). OMIT for default 256x256 thumbnail. ONLY specify when you need larger output to inspect details that are unclear in the thumbnail."}, - "h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target height in pixels (1-4096). OMIT for default 256x256 thumbnail. ONLY specify when you need larger output to inspect details that are unclear in the thumbnail."}, - "format": map[string]any{"type": [2]string{"null", "string"}, "description": "Output format. Default: 'jpeg'. Options: 'jpeg' (recommended for photos, best quality/size balance), 'png' (lossless, use for screenshots/text), 'webp' (smaller files, modern). Rarely need to change from default."}, - "crop_x": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop left edge in ORIGINAL image pixels. Use with crop_y/crop_w/crop_h to zoom into a specific region. Coordinates are in the original image's pixel space, not the output dimensions."}, - "crop_y": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop top edge in ORIGINAL image pixels. Must be used with crop_w and crop_h."}, - "crop_w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop width in ORIGINAL image pixels. Must be used with crop_x, crop_y, and crop_h."}, - "crop_h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop height in ORIGINAL image pixels. Must be used with crop_x, crop_y, and crop_w."}, + "file_id": map[string]any{"type": "integer", "description": "The photo file ID."}, + "w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target width in pixels (1-4096). Omit unless investigating details — always start with the default 256x256 thumbnail."}, + "h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target height in pixels (1-4096). Omit unless investigating details — always start with the default 256x256 thumbnail."}, + "format": map[string]any{"type": [2]string{"null", "string"}, "description": "Output format. Default: jpeg. Options: jpeg, png, webp."}, + "crop_x": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop left edge in original image pixels. Must specify all four crop params together."}, + "crop_y": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop top edge in original image pixels."}, + "crop_w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop width in original image pixels."}, + "crop_h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop height in original image pixels."}, }, "required": []string{"file_id"}, }, @@ -258,7 +229,7 @@ func listCollections(collections *[]collection.Collection, imageSource *image.So // --- events --- type eventsInput struct { - CollectionId string `json:"collection_id" jsonschema:"The collection ID from list_collections. Use the 'id' field from the collection object returned by list_collections."` + CollectionId string `json:"collection_id" jsonschema:"The collection ID from list_collections."` } type eventsOutput struct { @@ -297,9 +268,9 @@ func eventsHandler(collections *[]collection.Collection, imageSource *image.Sour type searchPhotosInput struct { CollectionId string `json:"collection_id" jsonschema:"The collection ID from list_collections."` - Query string `json:"query" jsonschema:"Search query: natural language text ('red car on highway'), image similarity ('img:1234'), face similarity ('face:5678'), or combined with qualifiers ('beach sunset tag:vacation created:2023-06'). Required."` - Sort *string `json:"sort" jsonschema:"Sort order. Default: \"-date\" (newest first). Options: \"-date\", \"+date\", \"-similarity\", \"-similarity,+date\". \"-\" = descending, \"+\" = ascending. Multiple fields separated by commas."` - Limit *int `json:"limit" jsonschema:"Max results. Default 50. Use 10-20 for quick previews, 100-200 for comprehensive sets."` + Query string `json:"query" jsonschema:"Search query: natural language text, image similarity (img:ID), or face similarity (face:ID)."` + Sort *string `json:"sort" jsonschema:"Sort order. '-date' (newest) by default. Options: +date, -similarity, +similarity, +shuffle-hourly, +shuffle-daily, +shuffle-weekly, +shuffle-monthly, or comma-joined like '-similarity,+date'."` + Limit *int `json:"limit" jsonschema:"Max results. Default 50. Results beyond limit are silently discarded."` } type searchPhotosOutput struct { From a315216141056afd485e40579e2baf1635156b57 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 10:38:08 +0200 Subject: [PATCH 24/40] thread configurable apiPrefix into MCP file URL construction - Add apiPrefix to Server struct and New() parameter - Extract fileURL() helper to avoid double-slashes when prefix is empty or root - Route apiPrefix from main.go through mcp.New() to gatherPhotoMetadata() - original_url keeps source filename (no _original suffix) --- internal/mcp/mcp.go | 14 ++++++++------ internal/mcp/photo.go | 23 ++++++++++++++++------- main.go | 2 +- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 7308624..392d206 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -18,19 +18,21 @@ import ( // Server holds the MCP server instance and its chi-mountable HTTP handler. type Server struct { - srv *mcp.Server - handler http.Handler - baseURL atomic.Value // set from request Host header per request (stores string) + srv *mcp.Server + handler http.Handler + baseURL atomic.Value // set from request Host header per request (stores string) + apiPrefix string // e.g. "/api" — used for constructing file URLs } // New creates a new MCP server for photofield with the given data sources // and registers all available tools. The base URL is derived at request time // from the incoming request's Host header, with `addr` used as a fallback -// default (derived from the listener address). Callers should mount handler() +// default (derived from the listener address). `apiPrefix` is the HTTP route +// prefix for file endpoints (e.g. "/api"). Callers should mount handler() // on a chi router, e.g.: // // r.Mount("/mcp", s.handler()) -func New(collections *[]collection.Collection, imageSource *image.Source, addr string) (*Server, error) { +func New(collections *[]collection.Collection, imageSource *image.Source, addr, apiPrefix string) (*Server, error) { sdkSrv := mcp.NewServer(&mcp.Implementation{ Name: "photofield", Version: "dev", @@ -38,7 +40,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr s // Handler closures capture collections, imageSource, and a pointer to this Server // so they can read the current base URL at request time. - srv := &Server{srv: sdkSrv} + srv := &Server{srv: sdkSrv, apiPrefix: apiPrefix} mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "list_collections", diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index 68fce68..32357b9 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -183,7 +183,7 @@ func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Sour } // Gather metadata using the same logic as get_photo - metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string)) + metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string), srv.apiPrefix) if panicked != nil { return nil, getPhotoMetadataOutput{}, fmt.Errorf("internal error reading photo metadata: %v", panicked) @@ -431,9 +431,18 @@ type photoMetadata struct { LatLng *LatLng } +// fileURL builds an absolute URL to a file endpoint, handling an empty or root apiPrefix. +func fileURL(serverBaseURL, apiPrefix, path string) string { + if apiPrefix == "" || apiPrefix == "/" { + return serverBaseURL + path + } + return serverBaseURL + apiPrefix + path +} + // gatherPhotoMetadata collects all metadata for a photo by file ID. // serverBaseURL is the absolute API base URL (e.g. "http://localhost:8080"). -func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, info image.Info, serverBaseURL string) photoMetadata { +// apiPrefix is the HTTP route prefix for file endpoints (e.g. "/api" or ""). +func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, info image.Info, serverBaseURL, apiPrefix string) photoMetadata { originalPath, _ := source.GetImagePath(image.ImageId(fileId)) location := "" var latlng *LatLng @@ -449,10 +458,10 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, filename := filepath.Base(originalPath) previewFilename := strings.TrimSuffix(filename, filepath.Ext(filename)) + "_preview.jpg" - // Build preview URL: use /previews/ endpoint with ~400px width for direct markdown embedding + // Build preview URL: use /api/files/{id}/previews/{filename} with ~400px width for direct markdown embedding var previewUrl string if originalPath != "" { - previewUrl = serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/previews/" + previewFilename + "?w=400" + previewUrl = fileURL(serverBaseURL, apiPrefix, "/files/"+fmt.Sprintf("%d", fileId)+"/previews/"+previewFilename+"?w=400") } // Build original image URL: use the 'original' variant (full-resolution source copy) @@ -464,7 +473,7 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, if !s.Exists(ctx, io.ImageId(fileId), originalPath) { continue } - originalUrl = serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/variants/" + s.Name() + "/" + filename + originalUrl = fileURL(serverBaseURL, apiPrefix, "/files/"+fmt.Sprintf("%d", fileId)+"/variants/"+s.Name()+"/"+filename) break } @@ -503,12 +512,12 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, W: f.W, H: f.H, Confidence: f.Confidence, - PreviewUrl: serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/face.jpg?w=200&h=200&crop_x=" + fmt.Sprintf("%d", cropX) + "&crop_y=" + fmt.Sprintf("%d", cropY) + "&crop_w=" + fmt.Sprintf("%d", faceCropSize) + "&crop_h=" + fmt.Sprintf("%d", faceCropSize), + PreviewUrl: fileURL(serverBaseURL, apiPrefix, "/files/"+fmt.Sprintf("%d", fileId)+"/face.jpg?w=200&h=200&crop_x="+fmt.Sprintf("%d", cropX)+"&crop_y="+fmt.Sprintf("%d", cropY)+"&crop_w="+fmt.Sprintf("%d", faceCropSize)+"&crop_h="+fmt.Sprintf("%d", faceCropSize)), }) } if originalUrl == "" { - originalUrl = serverBaseURL + "/files/" + fmt.Sprintf("%d", fileId) + "/variants/" + filename + originalUrl = fileURL(serverBaseURL, apiPrefix, "/files/"+fmt.Sprintf("%d", fileId)+"/variants/"+filename) } return photoMetadata{ diff --git a/main.go b/main.go index e114629..37396dd 100644 --- a/main.go +++ b/main.go @@ -2468,7 +2468,7 @@ func main() { // MCP server — base URL is derived from request Host header at runtime, // falling back to the listener address if the Host header is absent. - srv, err := mcp.New(&collections, imageSource, addr) + srv, err := mcp.New(&collections, imageSource, addr, apiPrefix) if err != nil { log.Fatalf("failed to create MCP server: %v", err) } From 5a45a6dd84f2dd361e9119f83552d62b79ecede3 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 10:58:43 +0200 Subject: [PATCH 25/40] Fix MCP tool descriptions to prevent raw HTML output - get_photo_metadata: replaced instruction to use HTML with warning to not output raw HTML or bare URLs (MCP client won't render them) - get_photo: cleaned up InputSchema field alignment - Various InputSchema formatting consistency fixes --- internal/mcp/mcp.go | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 392d206..cb0761f 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -21,7 +21,7 @@ type Server struct { srv *mcp.Server handler http.Handler baseURL atomic.Value // set from request Host header per request (stores string) - apiPrefix string // e.g. "/api" — used for constructing file URLs + apiPrefix string // e.g. "/api" — used for constructing file URLs } // New creates a new MCP server for photofield with the given data sources @@ -57,7 +57,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, Description: "Split a collection's photos into chronological events. Returns metadata summaries (photo count, date ranges, location count) — NOT the photo images themselves. " + "Use after list_collections, before search_photos, to get high-level context about where and when photos were taken.", InputSchema: map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{ "collection_id": map[string]any{"type": "string", "description": "The collection ID from list_collections. Use the 'id' field from the collection object returned by list_collections."}, }, @@ -100,12 +100,12 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, "- 'filename:IMG_*.jpg' — all IMG_ photos, oldest first\n\n" + "WORKFLOW: search_photos → get_photo_metadata(file_id) to get preview_url → embed directly in markdown, or get_photo(file_id) for the image.", InputSchema: map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{ "collection_id": map[string]any{"type": "string", "description": "The collection ID from list_collections."}, "query": map[string]any{"type": "string", "description": "Search query: natural language text, image similarity (img:ID), or face similarity (face:ID)."}, - "sort": map[string]any{"type": [3]string{"null", "string"}, "description": "Sort order. '-date' (newest) by default. Options: +date, -similarity, +similarity, +shuffle-hourly, +shuffle-daily, +shuffle-weekly, +shuffle-monthly, or comma-joined like '-similarity,+date'."}, - "limit": map[string]any{"type": [2]string{"null", "integer"}, "description": "Max results. Default 50. Results beyond limit are silently discarded."}, + "sort": map[string]any{"type": [3]string{"null", "string"}, "description": "Sort order. '-date' (newest) by default. Options: +date, -similarity, +similarity, +shuffle-hourly, +shuffle-daily, +shuffle-weekly, +shuffle-monthly, or comma-joined like '-similarity,+date'."}, + "limit": map[string]any{"type": [2]string{"null", "integer"}, "description": "Max results. Default 50. Results beyond limit are silently discarded."}, }, "required": []string{"collection_id", "query"}, }, @@ -114,10 +114,12 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo_metadata", Description: "Retrieve structured photo metadata (dimensions, path, dates, tags, faces, location, URLs). " + - "Show photos using preview_url and link to original_url for full resolution (use HTML: ). " + + "Returns preview_url and original_url fields. " + + "⚠️ Do NOT output raw HTML () or bare URLs to display photos — the MCP client will not render them. " + + "Use preview_url directly in markdown syntax (![alt](url)). " + "Use after list_collections, events, or search_photos to inspect details on specific file_ids.", InputSchema: map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{ "file_id": map[string]any{"type": "integer", "description": "The photo file ID (required). Obtain from search_photos results."}, }, @@ -128,21 +130,22 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo", Description: "Retrieve a photo as a base64-encoded image. This is the only tool that returns actual image data. " + + "⚠️ DO NOT output raw HTML () or bare image URLs — the MCP client will not render them. Always call get_photo(file_id) instead. " + "Default (file_id only): 256x256 JPEG thumbnail. Format: jpeg (default), png, webp. " + "Crop params (crop_x/y/w/h) are in original image pixel coordinates; all four must be specified together. " + "⚠️ Only pass optional parameters (w, h, crop) when investigating specific details — always start with file_id alone.\n\n" + "WORKFLOW: search_photos → get_photo_metadata(file_id) for dimensions/preview_url → get_photo(file_id) for the image.", InputSchema: map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{ "file_id": map[string]any{"type": "integer", "description": "The photo file ID."}, - "w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target width in pixels (1-4096). Omit unless investigating details — always start with the default 256x256 thumbnail."}, - "h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target height in pixels (1-4096). Omit unless investigating details — always start with the default 256x256 thumbnail."}, - "format": map[string]any{"type": [2]string{"null", "string"}, "description": "Output format. Default: jpeg. Options: jpeg, png, webp."}, - "crop_x": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop left edge in original image pixels. Must specify all four crop params together."}, - "crop_y": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop top edge in original image pixels."}, - "crop_w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop width in original image pixels."}, - "crop_h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop height in original image pixels."}, + "w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target width in pixels (1-4096). Omit unless investigating details — always start with the default 256x256 thumbnail."}, + "h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Target height in pixels (1-4096). Omit unless investigating details — always start with the default 256x256 thumbnail."}, + "format": map[string]any{"type": [2]string{"null", "string"}, "description": "Output format. Default: jpeg. Options: jpeg, png, webp."}, + "crop_x": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop left edge in original image pixels. Must specify all four crop params together."}, + "crop_y": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop top edge in original image pixels."}, + "crop_w": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop width in original image pixels."}, + "crop_h": map[string]any{"type": [2]string{"null", "integer"}, "description": "Crop height in original image pixels."}, }, "required": []string{"file_id"}, }, From f2e275d47d3028b0a231374720f7cc5371c9108c Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 11:04:27 +0200 Subject: [PATCH 26/40] improve(mcp): add photo verification guidance to tool descriptions Add instructions to MCP tool descriptions encouraging the agent to use get_photo to visually verify photo content before showing results to users. Emphasizes checking the final results while allowing judgment on intermediate/browsing results to avoid wasting tokens. - search_photos: warns about verifying key results, suggests checking top 1-3 matches for browse results - get_photo_metadata: notes metadata alone may be unreliable - get_photo: framed as verification tool with key rule to check before showing to user --- internal/mcp/mcp.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index cb0761f..fb7d64d 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -68,6 +68,8 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "search_photos", Description: "Search a collection's photos by text, image reference (img:ID), or face reference (face:ID). Returns metadata summaries — NOT the image data. " + + "⚠️ Use get_photo(file_id) to verify key results visually before showing photos to the user — metadata and similarity scores can be misleading. " + + "Use your judgment on when to verify: for single specific results, definitely check; for browsing large sets, verify only the top matches.\n\n" + "Results beyond the limit are silently discarded. Use get_photo_metadata on results to get preview_url for markdown embedding, or get_photo for the full image.\n\n" + "QUERY TYPES:\n" + "- Text search: e.g. 'red car on highway' — uses CLIP embeddings, sorted by match quality\n" + @@ -98,7 +100,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, "- 'dog' sort:+shuffle-daily — random order, but grouped by day\n" + "- 'portrait' sort:-similarity,+date — best portraits first, newest tiebreak\n" + "- 'filename:IMG_*.jpg' — all IMG_ photos, oldest first\n\n" + - "WORKFLOW: search_photos → get_photo_metadata(file_id) to get preview_url → embed directly in markdown, or get_photo(file_id) for the image.", + "VERIFICATION: For single specific results, call get_photo on top matches to confirm content. For large browse results, verify the top 1-3 matches before presenting.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -114,8 +116,10 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo_metadata", Description: "Retrieve structured photo metadata (dimensions, path, dates, tags, faces, location, URLs). " + + "⚠️ Metadata alone may not be reliable — if you're about to show a photo to the user based on metadata or search results, call get_photo(file_id) to visually confirm it actually contains what you claim. " + + "Tags, location, and similarity scores can be wrong or misleading. " + "Returns preview_url and original_url fields. " + - "⚠️ Do NOT output raw HTML () or bare URLs to display photos — the MCP client will not render them. " + + "Do NOT output raw HTML () or bare URLs to display photos — the MCP client will not render them. " + "Use preview_url directly in markdown syntax (![alt](url)). " + "Use after list_collections, events, or search_photos to inspect details on specific file_ids.", InputSchema: map[string]any{ @@ -130,11 +134,15 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo", Description: "Retrieve a photo as a base64-encoded image. This is the only tool that returns actual image data. " + + "Use this as a verification tool — call get_photo(file_id) on search results to visually confirm the photo contains what you expect before showing it to the user. " + + "Metadata and search scores can be misleading, so visual confirmation is recommended for key results.\n\n" + "⚠️ DO NOT output raw HTML () or bare image URLs — the MCP client will not render them. Always call get_photo(file_id) instead. " + - "Default (file_id only): 256x256 JPEG thumbnail. Format: jpeg (default), png, webp. " + + "Default (file_id only): 256x256 JPEG thumbnail — fast and usually sufficient for verification. " + + "Format: jpeg (default), png, webp. " + "Crop params (crop_x/y/w/h) are in original image pixel coordinates; all four must be specified together. " + - "⚠️ Only pass optional parameters (w, h, crop) when investigating specific details — always start with file_id alone.\n\n" + - "WORKFLOW: search_photos → get_photo_metadata(file_id) for dimensions/preview_url → get_photo(file_id) for the image.", + "Only pass optional parameters (w, h, crop) when the thumbnail is too small to verify details.\n\n" + + "KEY RULE: Always call get_photo on any photo you're about to show the user or confirm in your response. " + + "Use your judgment on when to verify for intermediate/browsing results — check top matches, but don't feel you must check every single result.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ From 60e9b1023791a5ceffe0d547596aa64c12805748 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 18:17:37 +0200 Subject: [PATCH 27/40] refactor(mcp): remove unused collection parameter from photo handlers --- internal/mcp/mcp.go | 4 ++-- internal/mcp/photo.go | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index fb7d64d..742510e 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -129,7 +129,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, }, "required": []string{"file_id"}, }, - }, getPhotoMetadataHandler(collections, imageSource, srv)) + }, getPhotoMetadataHandler(imageSource, srv)) mcp.AddTool(sdkSrv, &mcp.Tool{ Name: "get_photo", @@ -157,7 +157,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, }, "required": []string{"file_id"}, }, - }, getPhotoHandler(collections, imageSource, srv)) + }, getPhotoHandler(imageSource, srv)) h := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { return sdkSrv diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index 32357b9..d57f85e 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -24,7 +24,6 @@ import ( webpjack "photofield/internal/codec/webp/jack" webpjackdyn "photofield/internal/codec/webp/jack/dynamic" webpjacktra "photofield/internal/codec/webp/jack/transpiled" - "photofield/internal/collection" "photofield/internal/image" "photofield/internal/io" "photofield/internal/render" @@ -162,7 +161,7 @@ type SimpleTag struct { // getPhotoMetadataHandler handles the get_photo_metadata tool request. // Returns all photo metadata without the image data — useful for inspecting // tags, faces, location, thumbnails, and dimensions without downloading the image. -func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[getPhotoMetadataInput, getPhotoMetadataOutput] { +func getPhotoMetadataHandler(imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[getPhotoMetadataInput, getPhotoMetadataOutput] { return func(ctx context.Context, _ *mcp.CallToolRequest, input getPhotoMetadataInput) (*mcp.CallToolResult, getPhotoMetadataOutput, error) { // Ensure we have a valid context - fall back to Background if nil. if ctx == nil { @@ -209,7 +208,7 @@ func getPhotoMetadataHandler(_ *[]collection.Collection, imageSource *image.Sour } // getPhotoHandler handles the get_photo MCP tool request. -func getPhotoHandler(_ *[]collection.Collection, imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[getPhotoInput, getPhotoOutput] { +func getPhotoHandler(imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[getPhotoInput, getPhotoOutput] { return func(ctx context.Context, _ *mcp.CallToolRequest, input getPhotoInput) (*mcp.CallToolResult, getPhotoOutput, error) { // Ensure we have a valid context - fall back to Background if nil. if ctx == nil { From dcb86848787b4f5227d2162fefdf227c538c30f8 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 18:26:57 +0200 Subject: [PATCH 28/40] fix: regenerate openapi code --- internal/openapi/api.gen.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/openapi/api.gen.go b/internal/openapi/api.gen.go index 749a83b..a7b873f 100644 --- a/internal/openapi/api.gen.go +++ b/internal/openapi/api.gen.go @@ -746,6 +746,10 @@ func (siw *ServerInterfaceWrapper) GetCollectionsIdFiles(w http.ResponseWriter, var params GetCollectionsIdFilesParams // ------------- Optional query parameter "search" ------------- + if paramValue := r.URL.Query().Get("search"); paramValue != "" { + + } + err = runtime.BindQueryParameter("form", true, false, "search", r.URL.Query(), ¶ms.Search) if err != nil { http.Error(w, fmt.Sprintf("Invalid format for parameter search: %s", err), http.StatusBadRequest) @@ -753,6 +757,10 @@ func (siw *ServerInterfaceWrapper) GetCollectionsIdFiles(w http.ResponseWriter, } // ------------- Optional query parameter "sort" ------------- + if paramValue := r.URL.Query().Get("sort"); paramValue != "" { + + } + err = runtime.BindQueryParameter("form", true, false, "sort", r.URL.Query(), ¶ms.Sort) if err != nil { http.Error(w, fmt.Sprintf("Invalid format for parameter sort: %s", err), http.StatusBadRequest) @@ -760,6 +768,10 @@ func (siw *ServerInterfaceWrapper) GetCollectionsIdFiles(w http.ResponseWriter, } // ------------- Optional query parameter "limit" ------------- + if paramValue := r.URL.Query().Get("limit"); paramValue != "" { + + } + err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit) if err != nil { http.Error(w, fmt.Sprintf("Invalid format for parameter limit: %s", err), http.StatusBadRequest) From fa9b9af44578f4542a91c47a327ca941cfa5b074 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:29:48 +0200 Subject: [PATCH 29/40] docs: add PR #190 fix checklist from Copilot review comments - 13 issues selected for fixing from the 19 Copilot review comments - Covers: config passthrough, DoS vector, panic handling, dead code, nil derefs, broken URLs, schema bug, error handling - 6 issues left as-is per triage (test coverage, 400/404, fallback URL, lastLocTime reset, two Server instances, 4096px cap removal) --- internal/mcp/TODO.md | 240 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 internal/mcp/TODO.md diff --git a/internal/mcp/TODO.md b/internal/mcp/TODO.md new file mode 100644 index 0000000..d68705c --- /dev/null +++ b/internal/mcp/TODO.md @@ -0,0 +1,240 @@ +# PR #190 — Fix Checklist + +Issues selected for fixing (validated by Copilot review + manual investigation). + +## High Priority + +### [ ] 1. `tools/agent.sh:91` — `server start` ignores `AGT_PORT`/`AGT_DATA_DIR` + +**Problem:** `server_start()` launches the server with `nohup "$BIN"` but never passes `PHOTOFIELD_ADDRESS` or `PHOTOFIELD_DATA_DIR` env vars. The script defines `PORT` and `DATA_DIR` locals, but the server reads different names (`PHOTOFIELD_ADDRESS` / `PHOTOFIELD_DATA_DIR`). + +**Fix:** Export the correct env vars before launching the server in `server_start()`: +```bash +export PHOTOFIELD_ADDRESS=":$(echo "$PORT" | sed 's/.*://')" +export PHOTOFIELD_DATA_DIR="$DATA_DIR" +nohup "$BIN" > /tmp/photofield-agent.log 2>&1 & +``` + +Remove any AGT_* env vars that are not needed and update the docs + +--- + +### [ ] 2. `main.go:1749` — `parsePreviewDimensions` 4096px cap removed (DoS vulnerability) + +**Problem:** Upper bound check (`w > 4096` / `h > 4096`) was accidentally removed in commit `f03b99c`. An attacker can request unbounded dimensions, causing memory exhaustion (40GB+ for 100k×100k). + +**Fix:** Clamp input params to 4096 before computing the final size. + +--- + +### [ ] 3. `main.go:2237` — Top-level `recover()` suppresses panic stack trace + +**Problem:** The top-level `defer recover()` calls `os.Exit(1)` after only printing `PANIC: %v\n`, losing the full stack trace. Startup panics become nearly impossible to debug. + +**Fix:** Remove the top-level recover entirely. Let Go's default panic handler print the full stack trace and crash: +```go +// Remove these lines from main(): +// defer func() { +// if r := recover(); r != nil { +// fmt.Fprintf(os.Stderr, "PANIC: %v\n", r) +// os.Exit(1) +// } +// }() +``` + +--- + +### [ ] 4. `main.go:2463` — `/health` endpoint registered under `apiPrefix` + +**Problem:** PR description says a top-level `/health` endpoint is added, but it's registered inside `r.Route(apiPrefix, ...)`, making it reachable at `/api/health` (default) instead of `/health`. + +**Fix:** Update documentation (not code) — `/api/health` is the correct path given the `apiPrefix` design. Update: +- `docs/mcp-server.md` — document that `/health` is at `/api/health` +- PR body — clarify the path +- Possibly update the commit message or add a note + +--- + +## Medium Priority + +### [ ] 5. `internal/mcp/mcp.go:216` — `New()` returns different Server instance + +**Problem:** `New()` creates a local `srv`, captures it in handler closures, but returns a different `*Server` instance. Handlers work because they close over the local `srv`, and the returned Server only has `handler` set — sufficient for `Handler()` to work. + +**Fix:** Consolidate to a single instance. Remove the local `srv` variable and use the returned one: +```go +func New(...) (*Server, error) { + sdkSrv := mcp.NewServer(...) + srv := &Server{srv: sdkSrv} // single instance + // ... handlers capture srv ... + return &Server{ + srv: sdkSrv, + handler: wrappedHandler, + baseURL: atomic.Value{}, + apiPrefix: apiPrefix, + }, nil +} +``` +Or simpler: just return `srv` after setting its `handler` and `baseURL` fields. + +--- + +### [ ] 6. `internal/mcp/mcp.go:109` — JSON schema uses `[3]string{"null", "string"}` + +**Problem:** `[3]string{"null", "string"}` produces a 3-element array `["null","string",""]` (empty string is the zero value). The `""` is not a valid JSON Schema type. + +**Fix:** Change to `[2]string{"null", "string"}`: +```go +"sort": map[string]any{ + "type": [2]string{"null", "string"}, + "description": "Sort order...", +}, +``` + +--- + +### [ ] 7. `internal/mcp/mcp.go:269` — Events returns empty result for unknown collection + +**Problem:** When `collection_id` is invalid, the events handler returns `{ "events": [] }` with no error — indistinguishable from an empty collection. + +**Fix:** Return an error instead: +```go +if coll == nil { + return nil, eventsOutput{}, fmt.Errorf("collection not found: %s", input.CollectionId) +} +``` + +--- + +### [ ] 8. `internal/mcp/mcp.go:310` — Search returns empty result for unknown collection + +**Problem:** Same as #7 — the search handler silently returns empty results for invalid collection IDs. + +**Fix:** Return an error: +```go +if coll == nil { + return nil, searchPhotosOutput{}, fmt.Errorf("collection not found: %s", input.CollectionId) +} +``` + +--- + +### [ ] 9. `internal/mcp/photo.go:359` — `encodePhoto` panics with partial crop params + +**Problem:** The crop rect building block dereferences `*cropX` and `*cropY` without nil checks. Sending `{crop_w: 100, crop_h: 100}` without `crop_x`/`crop_y` triggers a panic. + +**Fix:** Add nil defaults in the rect building block (mirror the validation block's approach): +```go +var crop render.Rect +if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { + cx := 0 + cy := 0 + if cropX != nil { cx = *cropX } + if cropY != nil { cy = *cropY } + crop = render.Rect{ + X: float64(cx), + Y: float64(cy), + W: float64(*cropW), + H: float64(*cropH), + } +} +``` + +--- + +### [ ] 10. `internal/mcp/photo.go:454` — `gatherPhotoMetadata` doesn't check Geo is non-nil + +**Problem:** `source.Geo` is a `*geo.Geo` pointer that can be nil when geo is disabled. Calling `source.Geo.ReverseGeocode()` panics. + +**Fix:** Add a nil check before calling `ReverseGeocode`: +```go +if image.IsValidLatLng(info.LatLng) { + latlng = &LatLng{ + Lat: info.LatLng.Lat.Degrees(), + Lng: info.LatLng.Lng.Degrees(), + } + if source.Geo != nil && source.Geo.Available() { + location, _ = source.Geo.ReverseGeocode(ctx, info.LatLng) + } +} +``` + +--- + +### [ ] 11. `internal/mcp/photo.go:514` — Face PreviewUrl 404 + +**Problem:** Face `PreviewUrl` uses `/files/{id}/face.jpg?...`, but the OpenAPI routes only expose `/files/{id}/original/...`, `/files/{id}/variants/...`, and `/files/{id}/previews/...`. All face preview URLs will 404. + +**Fix:** Use the `/previews/` route with a face-specific filename, consistent with how `PreviewUrl` is built for photos (line ~462): +```go +// Build a face-specific preview filename +faceFilename := fmt.Sprintf("face_%d.jpg", f.Id) +faces = append(faces, FaceInfo{ + ... + PreviewUrl: fileURL(serverBaseURL, apiPrefix, "/files/"+fmt.Sprintf("%d", fileId)+"/previews/"+faceFilename+"?w=200&h=200"), +}) +``` + +--- + +### [ ] 12. `internal/mcp/photo.go:189` — `panicked` flag is dead code in `get_photo_metadata` + +**Problem:** The deferred `recover()` sets `panicked = r`, but the `if panicked != nil` check after `gatherPhotoMetadata` is unreachable dead code. When `gatherPhotoMetadata` panics, Go's defer machinery returns immediately. + +**Fix:** Restructure the panic handling. Either: +- **Option A:** Remove the `panicked` flag entirely and handle panics inside `gatherPhotoMetadata` by catching them before they escape the handler. +- **Option B:** Move the `if panicked != nil` check *before* the `return nil, ...` statement in the same defer scope, but this requires restructuring so the handler returns via the recover path rather than normal flow. + +Best approach: Wrap `gatherPhotoMetadata` in its own inline func with defer/recover, and return early if it panics: +```go +var meta photoMetadata +var metaErr error +func() { + defer func() { + if r := recover(); r != nil { + metaErr = fmt.Errorf("internal error reading photo metadata: %v", r) + fmt.Fprintf(os.Stderr, "get_photo_metadata handler recovered from panic: %v\n%s", r, stackTrace()) + } + }() + meta = gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string), srv.apiPrefix) +}() +if metaErr != nil { + return nil, getPhotoMetadataOutput{}, metaErr +} +``` + +--- + +### [ ] 13. `internal/mcp/photo.go:223` — Same dead code in `get_photo` + +**Problem:** Identical broken `panicked` flag pattern in `getPhotoHandler`. If `encodePhoto` panics, the `if panicked != nil` check is unreachable. + +**Fix:** Same restructuring as #12 — wrap `encodePhoto` in an inline func with defer/recover: +```go +var imageData []byte +var encodeErr error +func() { + defer func() { + if r := recover(); r != nil { + encodeErr = fmt.Errorf("internal error rendering photo: %v", r) + fmt.Fprintf(os.Stderr, "get_photo handler recovered from panic: %v\n%s", r, stackTrace()) + } + }() + imageData, encodeErr = encodePhoto(ctx, imageSource, image.ImageId(input.FileId), *targetW, *targetH, formatStr, + input.CropX, input.CropY, input.CropW, input.CropH) +}() +if encodeErr != nil { + return nil, getPhotoOutput{}, encodeErr +} +``` + +This eliminates the dead code pattern entirely. + +--- + +## Notes + +- **Comment #8** (no MCP test coverage) — left as-is per instructions +- **Comments #3/#4** (400 vs 404 for unknown collections) — left as-is per instructions +- **Comment #16** (fallback originalUrl wrong path) — left as-is per instructions +- **Comment #19** (lastLocTime not reset) — left as-is per instructions From c37b3d37dc91c8ecf9298e37bf9fda87c428a7a7 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:34:24 +0200 Subject: [PATCH 30/40] fix: export PHOTOFIELD_ADDRESS and PHOTOFIELD_DATA_DIR in server_start server_start() was launching the binary without passing the env vars that Go's net/http reads (PHOTOFIELD_ADDRESS) and the data dir (PHOTOFIELD_DATA_DIR). Now exports these before nohup, converting PORT (e.g. '8080' or 'localhost:8080') into ':8080' format. --- tools/agent.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/agent.sh b/tools/agent.sh index 0f9f97a..7ddb072 100755 --- a/tools/agent.sh +++ b/tools/agent.sh @@ -87,6 +87,8 @@ server_start() { fi log_step "Starting server..." + export PHOTOFIELD_ADDRESS=":$(echo "$PORT" | sed 's/.*://')" + export PHOTOFIELD_DATA_DIR="$DATA_DIR" nohup "$BIN" > /tmp/photofield-agent.log 2>&1 & _SERVER_MANAGED=true local pid=$! From f986fb3fc3b3af044f2d9b1c75f1daccb4ab473d Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:36:34 +0200 Subject: [PATCH 31/40] fix: clamp preview dimensions to 4096px max (DoS prevention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4096px upper bound check was accidentally removed in commit f03b99c, allowing attackers to request unbounded preview dimensions and cause memory exhaustion (e.g., 100k×100k ≈ 40GB). Added clamping to 4096px for both width and height independently before validation. --- dimensions_test.go | 124 +++++++++++++++++++++++++++++++++++++++++++++ main.go | 9 ++++ 2 files changed, 133 insertions(+) create mode 100644 dimensions_test.go diff --git a/dimensions_test.go b/dimensions_test.go new file mode 100644 index 0000000..3473fde --- /dev/null +++ b/dimensions_test.go @@ -0,0 +1,124 @@ +package main + +import "testing" + +func TestParsePreviewDimensions_Clamp(t *testing.T) { + tests := []struct { + name string + origW int + origH int + reqW *int + reqH *int + wantW int + wantH int + wantErr bool + }{ + { + name: "both specified exceed clamp", + origW: 8000, + origH: 6000, + reqW: intPtr(100000), + reqH: intPtr(100000), + wantW: 4096, + wantH: 4096, + wantErr: false, + }, + { + name: "both specified within limits", + origW: 8000, + origH: 6000, + reqW: intPtr(1000), + reqH: intPtr(2000), + wantW: 1000, + wantH: 2000, + wantErr: false, + }, + { + name: "width exceeds clamp, height within", + origW: 8000, + origH: 6000, + reqW: intPtr(5000), + reqH: intPtr(100), + wantW: 4096, + wantH: 100, + wantErr: false, + }, + { + name: "height exceeds clamp, width within", + origW: 8000, + origH: 6000, + reqW: intPtr(100), + reqH: intPtr(5000), + wantW: 100, + wantH: 4096, + wantErr: false, + }, + { + name: "only width specified exceeds clamp", + origW: 8000, + origH: 6000, + reqW: intPtr(10000), + reqH: nil, + wantW: 4096, + wantH: 4096, + wantErr: false, + }, + { + name: "only height specified exceeds clamp", + origW: 8000, + origH: 6000, + reqW: nil, + reqH: intPtr(10000), + wantW: 4096, + wantH: 4096, + wantErr: false, + }, + { + name: "neither specified, original within limits", + origW: 4000, + origH: 3000, + reqW: nil, + reqH: nil, + wantW: 4000, + wantH: 3000, + wantErr: false, + }, + { + name: "neither specified, original exceeds clamp", + origW: 8000, + origH: 6000, + reqW: nil, + reqH: nil, + wantW: 4096, + wantH: 4096, + wantErr: false, + }, + { + name: "zero dimensions rejected", + origW: 8000, + origH: 6000, + reqW: intPtr(0), + reqH: intPtr(100), + wantW: 0, + wantH: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotW, gotH, err := parsePreviewDimensions(tt.origW, tt.origH, tt.reqW, tt.reqH) + if (err != nil) != tt.wantErr { + t.Errorf("parsePreviewDimensions() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotW != tt.wantW || gotH != tt.wantH { + t.Errorf("parsePreviewDimensions() = (%d, %d), want (%d, %d)", gotW, gotH, tt.wantW, tt.wantH) + } + }) + } +} + +func intPtr(i int) *int { + return &i +} diff --git a/main.go b/main.go index 37396dd..8884990 100644 --- a/main.go +++ b/main.go @@ -1743,6 +1743,15 @@ func parsePreviewDimensions(origW, origH int, reqW, reqH *int) (w, h int, err er w, h = origW, origH } + // Clamp to maximum allowed dimension (prevents DoS via huge allocations) + const maxPreviewDim = 4096 + if w > maxPreviewDim { + w = maxPreviewDim + } + if h > maxPreviewDim { + h = maxPreviewDim + } + // Validate if w < 1 || h < 1 { return 0, 0, fmt.Errorf("invalid dimensions: width and height must be positive") From 715685c9480ec70f41cda664cdcba4b24b0de4ae Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:38:41 +0200 Subject: [PATCH 32/40] fix: remove top-level defer recover that suppressed panic stack traces The defer recover() printed only 'PANIC: %v\n' then called os.Exit(1), losing the full stack trace. Removed it so Go's default panic handler prints the complete stack trace for debugging. --- main.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/main.go b/main.go index 8884990..2698244 100644 --- a/main.go +++ b/main.go @@ -2238,12 +2238,6 @@ func detectEncoderSupport() { } func main() { - defer func() { - if r := recover(); r != nil { - fmt.Fprintf(os.Stderr, "PANIC: %v\n", r) - os.Exit(1) - } - }() var err error startupTime = time.Now() From 2415ec0ddc9c19b5e535088a920aff7373532c7c Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:40:49 +0200 Subject: [PATCH 33/40] docs: add health check endpoint documentation Document the /api/health endpoint (registered under apiPrefix) with its default path and how PHOTOFIELD_API_PREFIX affects the URL. --- docs/mcp-server.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/mcp-server.md b/docs/mcp-server.md index e46b9ff..f663325 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -54,3 +54,13 @@ ai: ``` Without the AI server, `search_photos` still works for tag, date, and filename filters, but text-based semantic search and face/image similarity searches will return no results. + +## Health Check + +The server exposes a health check endpoint: + +| Path | Method | Description | +|---|---|---| +| `/api/health` | GET | Returns `{"status": "ok"}` when healthy | + +> **Note:** The path includes the API prefix. If `PHOTOFIELD_API_PREFIX` is set to a different value (e.g., `/v1`), the health endpoint would be at `/v1/health`. From d8df0fa94e1cf1001b2dc0cb893c248c8cc03fe9 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:41:51 +0200 Subject: [PATCH 34/40] refactor: consolidate New() to return a single Server instance New() was creating a local srv variable captured by closures but returning a different Server struct. This consolidates to a single srv instance with all fields (srv, handler, baseURL, apiPrefix) populated on the same object. --- internal/mcp/mcp.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 742510e..c370dbf 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -213,7 +213,8 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, // overwrites it per-request. srv.baseURL.Store("http://" + fallbackAddr) - return &Server{srv: sdkSrv, handler: wrappedHandler}, nil + srv.handler = wrappedHandler + return srv, nil } // --- list_collections --- From 60efb682356227229966b275fc75c0a06b2b6203 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:42:24 +0200 Subject: [PATCH 35/40] fix: correct JSON schema array length for sort type [3]string produces a 3-element array with an empty string zero value, which is an invalid JSON Schema type. Changed to [2]string. --- internal/mcp/mcp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index c370dbf..e616336 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -106,7 +106,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, "properties": map[string]any{ "collection_id": map[string]any{"type": "string", "description": "The collection ID from list_collections."}, "query": map[string]any{"type": "string", "description": "Search query: natural language text, image similarity (img:ID), or face similarity (face:ID)."}, - "sort": map[string]any{"type": [3]string{"null", "string"}, "description": "Sort order. '-date' (newest) by default. Options: +date, -similarity, +similarity, +shuffle-hourly, +shuffle-daily, +shuffle-weekly, +shuffle-monthly, or comma-joined like '-similarity,+date'."}, + "sort": map[string]any{"type": [2]string{"null", "string"}, "description": "Sort order. '-date' (newest) by default. Options: +date, -similarity, +similarity, +shuffle-hourly, +shuffle-daily, +shuffle-weekly, +shuffle-monthly, or comma-joined like '-similarity,+date'."}, "limit": map[string]any{"type": [2]string{"null", "integer"}, "description": "Max results. Default 50. Results beyond limit are silently discarded."}, }, "required": []string{"collection_id", "query"}, From 9455c5209401a9975d09ab27f1cefcb40a9a0c50 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:52:43 +0200 Subject: [PATCH 36/40] fix: return error for unknown collection in events and search handlers Previously both eventsHandler and searchPhotosHandler silently returned empty results (events: [], items: []) when the collection_id was not found, making it indistinguishable from an empty collection. Now they return a proper error message. --- internal/mcp/mcp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e616336..12c7647 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -266,7 +266,7 @@ func eventsHandler(collections *[]collection.Collection, imageSource *image.Sour } } if coll == nil { - return nil, eventsOutput{}, nil + return nil, eventsOutput{}, fmt.Errorf("collection not found: %s", input.CollectionId) } // Delegate to collection method @@ -307,7 +307,7 @@ func searchPhotosHandler(collections *[]collection.Collection, imageSource *imag } } if coll == nil { - return nil, searchPhotosOutput{}, nil + return nil, searchPhotosOutput{}, fmt.Errorf("collection not found: %s", input.CollectionId) } limit := 50 From 3a74f58d5dd2c5e81a05d861fb78a4256a9702ed Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:53:51 +0200 Subject: [PATCH 37/40] fix: add nil checks in encodePhoto (crop params) and gatherPhotoMetadata (geo) Issue 9: encodePhoto now defaults cropX/cropY to 0 when not provided, preventing a panic when only crop_w/crop_h are sent. Issue 10: gatherPhotoMetadata now checks source.Geo is non-nil before calling ReverseGeocode, preventing a panic when geo is disabled. --- internal/mcp/photo.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index d57f85e..f1af28e 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -355,9 +355,17 @@ func encodePhoto(ctx context.Context, source *image.Source, fileId image.ImageId // Build optional crop rect var crop render.Rect if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { + cx := 0 + cy := 0 + if cropX != nil { + cx = *cropX + } + if cropY != nil { + cy = *cropY + } crop = render.Rect{ - X: float64(*cropX), - Y: float64(*cropY), + X: float64(cx), + Y: float64(cy), W: float64(*cropW), H: float64(*cropH), } @@ -450,7 +458,9 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, Lat: info.LatLng.Lat.Degrees(), Lng: info.LatLng.Lng.Degrees(), } - location, _ = source.Geo.ReverseGeocode(ctx, info.LatLng) + if source.Geo != nil && source.Geo.Available() { + location, _ = source.Geo.ReverseGeocode(ctx, info.LatLng) + } } isVideo := source.IsSupportedVideo(originalPath) From bd7204312bdd8cbdb901f19e3342960f30be50ef Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 19:58:43 +0200 Subject: [PATCH 38/40] fix: face preview URL, wrap gatherPhotoMetadata and encodePhoto in defer/recover Issue 11: Face PreviewUrl now uses /previews/face_{id}.jpg route instead of the non-existent /face.jpg route. Issue 12: getPhotoMetadataHandler wraps gatherPhotoMetadata in its own inline func with defer/recover, fixing unreachable dead code pattern in the panicked flag. Issue 13: getPhotoHandler wraps encodePhoto in its own inline func with defer/recover, fixing the same dead code pattern. --- internal/mcp/photo.go | 60 +++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/internal/mcp/photo.go b/internal/mcp/photo.go index f1af28e..ed996de 100644 --- a/internal/mcp/photo.go +++ b/internal/mcp/photo.go @@ -167,25 +167,26 @@ func getPhotoMetadataHandler(imageSource *image.Source, srv *Server) mcp.ToolHan if ctx == nil { ctx = context.Background() } - var panicked any - defer func() { - if r := recover(); r != nil { - panicked = r - fmt.Fprintf(os.Stderr, "get_photo_metadata handler recovered from panic: %v\n%s", r, stackTrace()) - } - }() - - // Get file info to validate existence + // Get file info to validate existence info := imageSource.GetInfo(image.ImageId(input.FileId)) if info.Width == 0 || info.Height == 0 { return nil, getPhotoMetadataOutput{}, fmt.Errorf("file not found: %d", input.FileId) } // Gather metadata using the same logic as get_photo - metadata := gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string), srv.apiPrefix) - - if panicked != nil { - return nil, getPhotoMetadataOutput{}, fmt.Errorf("internal error reading photo metadata: %v", panicked) + var metadata photoMetadata + var metaErr error + func() { + defer func() { + if r := recover(); r != nil { + metaErr = fmt.Errorf("internal error reading photo metadata: %v", r) + fmt.Fprintf(os.Stderr, "get_photo_metadata handler recovered from panic: %v\n%s", r, stackTrace()) + } + }() + metadata = gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string), srv.apiPrefix) + }() + if metaErr != nil { + return nil, getPhotoMetadataOutput{}, metaErr } // Return only structured metadata — no image content block. @@ -214,14 +215,6 @@ func getPhotoHandler(imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[ if ctx == nil { ctx = context.Background() } - var panicked any - defer func() { - if r := recover(); r != nil { - panicked = r - fmt.Fprintf(os.Stderr, "get_photo handler recovered from panic: %v\n%s", r, stackTrace()) - } - }() - // Get file info to validate existence info := imageSource.GetInfo(image.ImageId(input.FileId)) if info.Width == 0 || info.Height == 0 { @@ -253,10 +246,20 @@ func getPhotoHandler(imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[ } // Encode image data - imageData, err := encodePhoto(ctx, imageSource, image.ImageId(input.FileId), *targetW, *targetH, formatStr, - input.CropX, input.CropY, input.CropW, input.CropH) - if err != nil { - return nil, getPhotoOutput{}, err + var imageData []byte + var encodeErr error + func() { + defer func() { + if r := recover(); r != nil { + encodeErr = fmt.Errorf("internal error rendering photo: %v", r) + fmt.Fprintf(os.Stderr, "get_photo handler recovered from panic: %v\n%s", r, stackTrace()) + } + }() + imageData, encodeErr = encodePhoto(ctx, imageSource, image.ImageId(input.FileId), *targetW, *targetH, formatStr, + input.CropX, input.CropY, input.CropW, input.CropH) + }() + if encodeErr != nil { + return nil, getPhotoOutput{}, encodeErr } mime := "image/jpeg" @@ -266,10 +269,6 @@ func getPhotoHandler(imageSource *image.Source, srv *Server) mcp.ToolHandlerFor[ mime = "image/webp" } - if panicked != nil { - return nil, getPhotoOutput{}, fmt.Errorf("internal error rendering photo: %v", panicked) - } - // Return only the image as MCP ImageContent — no structured metadata. // Use get_photo_metadata(file_id) to retrieve tags, faces, dimensions, URLs, etc. // The SDK handles base64 encoding for the JSON wire format. @@ -514,6 +513,7 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, if cropY < 0 { cropY = 0 } + faceFilename := fmt.Sprintf("face_%d.jpg", f.Id) faces = append(faces, FaceInfo{ Id: f.Id, X: f.X, @@ -521,7 +521,7 @@ func gatherPhotoMetadata(ctx context.Context, source *image.Source, fileId int, W: f.W, H: f.H, Confidence: f.Confidence, - PreviewUrl: fileURL(serverBaseURL, apiPrefix, "/files/"+fmt.Sprintf("%d", fileId)+"/face.jpg?w=200&h=200&crop_x="+fmt.Sprintf("%d", cropX)+"&crop_y="+fmt.Sprintf("%d", cropY)+"&crop_w="+fmt.Sprintf("%d", faceCropSize)+"&crop_h="+fmt.Sprintf("%d", faceCropSize)), + PreviewUrl: fileURL(serverBaseURL, apiPrefix, "/files/"+fmt.Sprintf("%d", fileId)+"/previews/"+faceFilename+"?w=200&h=200&crop_x="+fmt.Sprintf("%d", cropX)+"&crop_y="+fmt.Sprintf("%d", cropY)+"&crop_w="+fmt.Sprintf("%d", faceCropSize)+"&crop_h="+fmt.Sprintf("%d", faceCropSize)), }) } From a99f5e606c7e8d5e58b8abf2d9f489ac412780ee Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 21 Jun 2026 20:18:22 +0200 Subject: [PATCH 39/40] fix: clamp dimensions preserving aspect ratio, move health docs to local-dev skill, clean sort description main.go: Each dimension is now clamped before it's used to calculate the other, then the derived dimension is also clamped if needed, and the source dimension is re-balanced to match aspect ratio. The 'neither specified' case scales both proportionally. docs/mcp-server.md: Removed health check section (moved to local-dev skill). .local-dev/SKILL.md: Added dedicated Health Check subsection documenting the /api/health endpoint. internal/mcp/mcp.go: Removed 'comma-joined' sort option from the description since it's not actually supported. dimensions_test.go: Updated test expectations to match the new aspect-ratio-preserving clamping behavior. --- .agents/skills/local-dev/SKILL.md | 12 ++++++++ dimensions_test.go | 6 ++-- docs/mcp-server.md | 4 --- internal/mcp/TODO.md | 26 ++++++++--------- internal/mcp/mcp.go | 2 +- main.go | 48 +++++++++++++++++++++++++++---- 6 files changed, 72 insertions(+), 26 deletions(-) diff --git a/.agents/skills/local-dev/SKILL.md b/.agents/skills/local-dev/SKILL.md index 6b59da1..dd31916 100644 --- a/.agents/skills/local-dev/SKILL.md +++ b/.agents/skills/local-dev/SKILL.md @@ -198,6 +198,18 @@ The output shows the HTTP status code, pretty-printed JSON when possible, and the response body (truncated if over 500 chars). Note: `AGT_VERBOSE=1` does not currently change the truncation behavior for API calls. +### Health Check + +The server exposes a health check endpoint to verify it is running: + +```bash +./tools/agent.sh api GET http://localhost:8080/api/health +``` + +Returns `{"status": "ok"}` when healthy. The path includes the API prefix +(default `/api`). If `PHOTOFIELD_API_PREFIX` is set to a different value +(e.g., `/v1`), the endpoint would be at `/v1/health`. + ## 6. Test the Server ### Tool tests diff --git a/dimensions_test.go b/dimensions_test.go index 3473fde..627c094 100644 --- a/dimensions_test.go +++ b/dimensions_test.go @@ -60,7 +60,7 @@ func TestParsePreviewDimensions_Clamp(t *testing.T) { reqW: intPtr(10000), reqH: nil, wantW: 4096, - wantH: 4096, + wantH: 3072, wantErr: false, }, { @@ -70,7 +70,7 @@ func TestParsePreviewDimensions_Clamp(t *testing.T) { reqW: nil, reqH: intPtr(10000), wantW: 4096, - wantH: 4096, + wantH: 3072, wantErr: false, }, { @@ -90,7 +90,7 @@ func TestParsePreviewDimensions_Clamp(t *testing.T) { reqW: nil, reqH: nil, wantW: 4096, - wantH: 4096, + wantH: 3072, wantErr: false, }, { diff --git a/docs/mcp-server.md b/docs/mcp-server.md index f663325..f52347b 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -60,7 +60,3 @@ Without the AI server, `search_photos` still works for tag, date, and filename f The server exposes a health check endpoint: | Path | Method | Description | -|---|---|---| -| `/api/health` | GET | Returns `{"status": "ok"}` when healthy | - -> **Note:** The path includes the API prefix. If `PHOTOFIELD_API_PREFIX` is set to a different value (e.g., `/v1`), the health endpoint would be at `/v1/health`. diff --git a/internal/mcp/TODO.md b/internal/mcp/TODO.md index d68705c..3283d15 100644 --- a/internal/mcp/TODO.md +++ b/internal/mcp/TODO.md @@ -4,7 +4,7 @@ Issues selected for fixing (validated by Copilot review + manual investigation). ## High Priority -### [ ] 1. `tools/agent.sh:91` — `server start` ignores `AGT_PORT`/`AGT_DATA_DIR` +### [x] 1. `tools/agent.sh:91` — `server start` ignores `AGT_PORT`/`AGT_DATA_DIR` ✅ Committed **Problem:** `server_start()` launches the server with `nohup "$BIN"` but never passes `PHOTOFIELD_ADDRESS` or `PHOTOFIELD_DATA_DIR` env vars. The script defines `PORT` and `DATA_DIR` locals, but the server reads different names (`PHOTOFIELD_ADDRESS` / `PHOTOFIELD_DATA_DIR`). @@ -19,7 +19,7 @@ Remove any AGT_* env vars that are not needed and update the docs --- -### [ ] 2. `main.go:1749` — `parsePreviewDimensions` 4096px cap removed (DoS vulnerability) +### [x] 2. `main.go:1749` — `parsePreviewDimensions` 4096px cap removed (DoS vulnerability) ✅ Committed **Problem:** Upper bound check (`w > 4096` / `h > 4096`) was accidentally removed in commit `f03b99c`. An attacker can request unbounded dimensions, causing memory exhaustion (40GB+ for 100k×100k). @@ -27,7 +27,7 @@ Remove any AGT_* env vars that are not needed and update the docs --- -### [ ] 3. `main.go:2237` — Top-level `recover()` suppresses panic stack trace +### [x] 3. `main.go:2237` — Top-level `recover()` suppresses panic stack trace ✅ Committed **Problem:** The top-level `defer recover()` calls `os.Exit(1)` after only printing `PANIC: %v\n`, losing the full stack trace. Startup panics become nearly impossible to debug. @@ -44,7 +44,7 @@ Remove any AGT_* env vars that are not needed and update the docs --- -### [ ] 4. `main.go:2463` — `/health` endpoint registered under `apiPrefix` +### [x] 4. `main.go:2463` — `/health` endpoint registered under `apiPrefix` ✅ Committed **Problem:** PR description says a top-level `/health` endpoint is added, but it's registered inside `r.Route(apiPrefix, ...)`, making it reachable at `/api/health` (default) instead of `/health`. @@ -57,7 +57,7 @@ Remove any AGT_* env vars that are not needed and update the docs ## Medium Priority -### [ ] 5. `internal/mcp/mcp.go:216` — `New()` returns different Server instance +### [x] 5. `internal/mcp/mcp.go:216` — `New()` returns different Server instance ✅ Committed **Problem:** `New()` creates a local `srv`, captures it in handler closures, but returns a different `*Server` instance. Handlers work because they close over the local `srv`, and the returned Server only has `handler` set — sufficient for `Handler()` to work. @@ -79,7 +79,7 @@ Or simpler: just return `srv` after setting its `handler` and `baseURL` fields. --- -### [ ] 6. `internal/mcp/mcp.go:109` — JSON schema uses `[3]string{"null", "string"}` +### [x] 6. `internal/mcp/mcp.go:109` — JSON schema uses `[3]string{"null", "string"}` ✅ Committed **Problem:** `[3]string{"null", "string"}` produces a 3-element array `["null","string",""]` (empty string is the zero value). The `""` is not a valid JSON Schema type. @@ -93,7 +93,7 @@ Or simpler: just return `srv` after setting its `handler` and `baseURL` fields. --- -### [ ] 7. `internal/mcp/mcp.go:269` — Events returns empty result for unknown collection +### [x] 7. `internal/mcp/mcp.go:269` — Events returns empty result for unknown collection ✅ Committed **Problem:** When `collection_id` is invalid, the events handler returns `{ "events": [] }` with no error — indistinguishable from an empty collection. @@ -106,7 +106,7 @@ if coll == nil { --- -### [ ] 8. `internal/mcp/mcp.go:310` — Search returns empty result for unknown collection +### [x] 8. `internal/mcp/mcp.go:310` — Search returns empty result for unknown collection ✅ Committed **Problem:** Same as #7 — the search handler silently returns empty results for invalid collection IDs. @@ -119,7 +119,7 @@ if coll == nil { --- -### [ ] 9. `internal/mcp/photo.go:359` — `encodePhoto` panics with partial crop params +### [x] 9. `internal/mcp/photo.go:359` — `encodePhoto` panics with partial crop params ✅ Committed **Problem:** The crop rect building block dereferences `*cropX` and `*cropY` without nil checks. Sending `{crop_w: 100, crop_h: 100}` without `crop_x`/`crop_y` triggers a panic. @@ -142,7 +142,7 @@ if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { --- -### [ ] 10. `internal/mcp/photo.go:454` — `gatherPhotoMetadata` doesn't check Geo is non-nil +### [x] 10. `internal/mcp/photo.go:454` — `gatherPhotoMetadata` doesn't check Geo is non-nil ✅ Committed **Problem:** `source.Geo` is a `*geo.Geo` pointer that can be nil when geo is disabled. Calling `source.Geo.ReverseGeocode()` panics. @@ -161,7 +161,7 @@ if image.IsValidLatLng(info.LatLng) { --- -### [ ] 11. `internal/mcp/photo.go:514` — Face PreviewUrl 404 +### [x] 11. `internal/mcp/photo.go:514` — Face PreviewUrl 404 ✅ Committed **Problem:** Face `PreviewUrl` uses `/files/{id}/face.jpg?...`, but the OpenAPI routes only expose `/files/{id}/original/...`, `/files/{id}/variants/...`, and `/files/{id}/previews/...`. All face preview URLs will 404. @@ -177,7 +177,7 @@ faces = append(faces, FaceInfo{ --- -### [ ] 12. `internal/mcp/photo.go:189` — `panicked` flag is dead code in `get_photo_metadata` +### [x] 12. `internal/mcp/photo.go:189` — `panicked` flag is dead code in `get_photo_metadata` ✅ Committed **Problem:** The deferred `recover()` sets `panicked = r`, but the `if panicked != nil` check after `gatherPhotoMetadata` is unreachable dead code. When `gatherPhotoMetadata` panics, Go's defer machinery returns immediately. @@ -205,7 +205,7 @@ if metaErr != nil { --- -### [ ] 13. `internal/mcp/photo.go:223` — Same dead code in `get_photo` +### [x] 13. `internal/mcp/photo.go:223` — Same dead code in `get_photo` ✅ Committed **Problem:** Identical broken `panicked` flag pattern in `getPhotoHandler`. If `encodePhoto` panics, the `if panicked != nil` check is unreachable. diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 12c7647..9fe7c79 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -106,7 +106,7 @@ func New(collections *[]collection.Collection, imageSource *image.Source, addr, "properties": map[string]any{ "collection_id": map[string]any{"type": "string", "description": "The collection ID from list_collections."}, "query": map[string]any{"type": "string", "description": "Search query: natural language text, image similarity (img:ID), or face similarity (face:ID)."}, - "sort": map[string]any{"type": [2]string{"null", "string"}, "description": "Sort order. '-date' (newest) by default. Options: +date, -similarity, +similarity, +shuffle-hourly, +shuffle-daily, +shuffle-weekly, +shuffle-monthly, or comma-joined like '-similarity,+date'."}, + "sort": map[string]any{"type": [2]string{"null", "string"}, "description": "Sort order. '-date' (newest) by default. Options: +date, -similarity, +similarity, +shuffle-hourly, +shuffle-daily, +shuffle-weekly, +shuffle-monthly."}, "limit": map[string]any{"type": [2]string{"null", "integer"}, "description": "Max results. Default 50. Results beyond limit are silently discarded."}, }, "required": []string{"collection_id", "query"}, diff --git a/main.go b/main.go index 2698244..dfd75a7 100644 --- a/main.go +++ b/main.go @@ -1744,12 +1744,50 @@ func parsePreviewDimensions(origW, origH int, reqW, reqH *int) (w, h int, err er } // Clamp to maximum allowed dimension (prevents DoS via huge allocations) + // Clamp the source dimension first, then the derived dimension, and + // re-balance aspect ratio if needed. const maxPreviewDim = 4096 - if w > maxPreviewDim { - w = maxPreviewDim - } - if h > maxPreviewDim { - h = maxPreviewDim + if reqW != nil && reqH != nil { + // Both specified — clamp independently (no aspect ratio to preserve) + if w > maxPreviewDim { + w = maxPreviewDim + } + if h > maxPreviewDim { + h = maxPreviewDim + } + } else if reqW != nil { + // Only width — clamp it, then derive height; re-balance if height exceeds + if w > maxPreviewDim { + w = maxPreviewDim + } + h = int(float64(origH) * float64(w) / float64(origW)) + if h < 1 { + h = 1 + } + if h > maxPreviewDim { + h = maxPreviewDim + w = int(float64(origW) * float64(h) / float64(origH)) + } + } else if reqH != nil { + // Only height — clamp it, then derive width; re-balance if width exceeds + if h > maxPreviewDim { + h = maxPreviewDim + } + w = int(float64(origW) * float64(h) / float64(origH)) + if w < 1 { + w = 1 + } + if w > maxPreviewDim { + w = maxPreviewDim + h = int(float64(origH) * float64(w) / float64(origW)) + } + } else { + // Neither — use original dimensions; scale proportionally if either exceeds + if w > maxPreviewDim || h > maxPreviewDim { + scale := float64(maxPreviewDim) / float64(max(w, h)) + w = int(float64(w) * scale) + h = int(float64(h) * scale) + } } // Validate From 376c7915e4be40cc99253894aa764204f657026a Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Wed, 5 Aug 2026 23:21:49 +0200 Subject: [PATCH 40/40] fix(mcp): move temp files to DATA_DIR and fix agent.sh bugs - Replace all /tmp/ paths with DATA_DIR paths (pid, log, headers, temp files) so multiple agent instances can run in parallel without conflicts - Add mkdir -p in server_start to auto-create DATA_DIR if missing - Fix broken api subcommand: fallthrough subcmd assignment was eating the method argument for api calls (e.g. 'agent.sh api GET url' always failed) - Update SKILL.md: fix health endpoint URL from /api/health to /health, update all /tmp/ references to data/ paths - Update mcp-server.md: add full protocol name in opening paragraph, move 'What It Does' below 'Client Configuration', fix health docs - Delete internal/mcp/TODO.md (all 13 items already resolved) --- .agents/skills/local-dev/SKILL.md | 20 ++- docs/mcp-server.md | 30 ++-- internal/mcp/TODO.md | 240 ------------------------------ tools/agent.sh | 15 +- 4 files changed, 33 insertions(+), 272 deletions(-) delete mode 100644 internal/mcp/TODO.md diff --git a/.agents/skills/local-dev/SKILL.md b/.agents/skills/local-dev/SKILL.md index dd31916..aa044f7 100644 --- a/.agents/skills/local-dev/SKILL.md +++ b/.agents/skills/local-dev/SKILL.md @@ -85,7 +85,7 @@ Use `./tools/agent.sh server ` to manage the server process: ``` **How it works:** `server start` launches the binary with `nohup` and writes a -PID file to `/tmp/photofield-agent.pid`. It then polls the server endpoint until +PID file to `data/agent.pid`. It then polls the server endpoint until ready (up to 30s). `server stop` reads the PID file and sends SIGTERM. `server kill` sends SIGKILL to the PID and anything else listening on the port. @@ -179,8 +179,8 @@ useful for testing non-MCP routes, debugging, or calling endpoints that don't have a dedicated tool. ```bash -# GET request -./tools/agent.sh api GET http://localhost:8080/api/health +# GET request (health check) +./tools/agent.sh api GET http://localhost:8080/health # POST with JSON body ./tools/agent.sh api POST http://localhost:8080/api/collections \ @@ -200,15 +200,13 @@ not currently change the truncation behavior for API calls. ### Health Check -The server exposes a health check endpoint to verify it is running: +The server exposes a health check endpoint at `/health`: ```bash -./tools/agent.sh api GET http://localhost:8080/api/health +./tools/agent.sh api GET http://localhost:8080/health ``` -Returns `{"status": "ok"}` when healthy. The path includes the API prefix -(default `/api`). If `PHOTOFIELD_API_PREFIX` is set to a different value -(e.g., `/v1`), the endpoint would be at `/v1/health`. +Returns `{"status": "ok"}` when healthy. ## 6. Test the Server @@ -232,7 +230,7 @@ Returns `{"status": "ok"}` when healthy. The path includes the API prefix ```bash # Check health -./tools/agent.sh api GET http://localhost:8080/api/health +./tools/agent.sh api GET http://localhost:8080/health # List collections via API (alternative to mcp call) ./tools/agent.sh api GET http://localhost:8080/api/collections @@ -241,10 +239,10 @@ Returns `{"status": "ok"}` when healthy. The path includes the API prefix ## 7. Inspect Errors and Crashes The harness captures the server's **entire stdout and stderr** to -`/tmp/photofield-agent.log` via `nohup`. Panics and errors appear in this log: +`data/agent.log` via `nohup`. Panics and errors appear in this log: ```bash -tail -100 /tmp/photofield-agent.log +tail -100 data/agent.log ``` ### Session warnings diff --git a/docs/mcp-server.md b/docs/mcp-server.md index f52347b..64d0fe5 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -1,6 +1,6 @@ # MCP Server -The Photofield MCP server lets LLM agents search and retrieve photos from your Photofield instance through a simple, agent-friendly interface that avoids manual browser navigation and raw API calls. It runs on the same port as the main server at `/mcp` by default. +The Photofield MCP ([Model Context Protocol](https://modelcontextprotocol.io)) server lets LLM agents search and retrieve photos from your Photofield instance through a simple, agent-friendly interface that avoids manual browser navigation and raw API calls. It runs on the same port as the main server at `/mcp` by default. > **Experimental.** The MCP integration is early and not yet fully explored. It is a read-only interface, so feel free to experiment without worry. @@ -11,18 +11,6 @@ The Photofield MCP server lets LLM agents search and retrieve photos from your P - **Photo retrieval**: "Show me that picture of the sunset behind the lighthouse from the Sicily trip." The agent finds and displays the actual image. - **Collection overview**: "How many photos do I have in total?" or "What collections are available?" The agent inspects collection metadata without user intervention. -## What It Does - -Five tools are available: - -| Tool | Purpose | -|---|---| -| `list_collections` | List all photo collections, their IDs, and indexed photo counts | -| `events` | Split a collection into chronological events (by day and location) | -| `search_photos` | Search photos using natural language, image similarity, or face similarity | -| `get_photo_metadata` | Retrieve structured metadata for a single photo: tags, faces, GPS coordinates, dimensions | -| `get_photo` | Retrieve an actual photo image as a base64-encoded image, with options for resizing and cropping | - ## Client Configuration Point your MCP client at the Photofield MCP endpoint: @@ -39,6 +27,18 @@ Point your MCP client at the Photofield MCP endpoint: } ``` +## What It Does + +Five tools are available: + +| Tool | Purpose | +|---|---| +| `list_collections` | List all photo collections, their IDs, and indexed photo counts | +| `events` | Split a collection into chronological events (by day and location) | +| `search_photos` | Search photos using natural language, image similarity, or face similarity | +| `get_photo_metadata` | Retrieve structured metadata for a single photo: tags, faces, GPS coordinates, dimensions | +| `get_photo` | Retrieve an actual photo image as a base64-encoded image, with options for resizing and cropping | + ## AI / Semantic Search Text-based semantic search requires the [photofield-ai](https://github.com/SmilyOrg/photofield-ai) server to be running and configured in `configuration.yaml`: @@ -57,6 +57,8 @@ Without the AI server, `search_photos` still works for tag, date, and filename f ## Health Check -The server exposes a health check endpoint: +The server exposes a health check endpoint at `/health`: | Path | Method | Description | +|------|--------|-------------| +| `/health` | GET | Returns `{"status": "ok"}` when healthy | diff --git a/internal/mcp/TODO.md b/internal/mcp/TODO.md deleted file mode 100644 index 3283d15..0000000 --- a/internal/mcp/TODO.md +++ /dev/null @@ -1,240 +0,0 @@ -# PR #190 — Fix Checklist - -Issues selected for fixing (validated by Copilot review + manual investigation). - -## High Priority - -### [x] 1. `tools/agent.sh:91` — `server start` ignores `AGT_PORT`/`AGT_DATA_DIR` ✅ Committed - -**Problem:** `server_start()` launches the server with `nohup "$BIN"` but never passes `PHOTOFIELD_ADDRESS` or `PHOTOFIELD_DATA_DIR` env vars. The script defines `PORT` and `DATA_DIR` locals, but the server reads different names (`PHOTOFIELD_ADDRESS` / `PHOTOFIELD_DATA_DIR`). - -**Fix:** Export the correct env vars before launching the server in `server_start()`: -```bash -export PHOTOFIELD_ADDRESS=":$(echo "$PORT" | sed 's/.*://')" -export PHOTOFIELD_DATA_DIR="$DATA_DIR" -nohup "$BIN" > /tmp/photofield-agent.log 2>&1 & -``` - -Remove any AGT_* env vars that are not needed and update the docs - ---- - -### [x] 2. `main.go:1749` — `parsePreviewDimensions` 4096px cap removed (DoS vulnerability) ✅ Committed - -**Problem:** Upper bound check (`w > 4096` / `h > 4096`) was accidentally removed in commit `f03b99c`. An attacker can request unbounded dimensions, causing memory exhaustion (40GB+ for 100k×100k). - -**Fix:** Clamp input params to 4096 before computing the final size. - ---- - -### [x] 3. `main.go:2237` — Top-level `recover()` suppresses panic stack trace ✅ Committed - -**Problem:** The top-level `defer recover()` calls `os.Exit(1)` after only printing `PANIC: %v\n`, losing the full stack trace. Startup panics become nearly impossible to debug. - -**Fix:** Remove the top-level recover entirely. Let Go's default panic handler print the full stack trace and crash: -```go -// Remove these lines from main(): -// defer func() { -// if r := recover(); r != nil { -// fmt.Fprintf(os.Stderr, "PANIC: %v\n", r) -// os.Exit(1) -// } -// }() -``` - ---- - -### [x] 4. `main.go:2463` — `/health` endpoint registered under `apiPrefix` ✅ Committed - -**Problem:** PR description says a top-level `/health` endpoint is added, but it's registered inside `r.Route(apiPrefix, ...)`, making it reachable at `/api/health` (default) instead of `/health`. - -**Fix:** Update documentation (not code) — `/api/health` is the correct path given the `apiPrefix` design. Update: -- `docs/mcp-server.md` — document that `/health` is at `/api/health` -- PR body — clarify the path -- Possibly update the commit message or add a note - ---- - -## Medium Priority - -### [x] 5. `internal/mcp/mcp.go:216` — `New()` returns different Server instance ✅ Committed - -**Problem:** `New()` creates a local `srv`, captures it in handler closures, but returns a different `*Server` instance. Handlers work because they close over the local `srv`, and the returned Server only has `handler` set — sufficient for `Handler()` to work. - -**Fix:** Consolidate to a single instance. Remove the local `srv` variable and use the returned one: -```go -func New(...) (*Server, error) { - sdkSrv := mcp.NewServer(...) - srv := &Server{srv: sdkSrv} // single instance - // ... handlers capture srv ... - return &Server{ - srv: sdkSrv, - handler: wrappedHandler, - baseURL: atomic.Value{}, - apiPrefix: apiPrefix, - }, nil -} -``` -Or simpler: just return `srv` after setting its `handler` and `baseURL` fields. - ---- - -### [x] 6. `internal/mcp/mcp.go:109` — JSON schema uses `[3]string{"null", "string"}` ✅ Committed - -**Problem:** `[3]string{"null", "string"}` produces a 3-element array `["null","string",""]` (empty string is the zero value). The `""` is not a valid JSON Schema type. - -**Fix:** Change to `[2]string{"null", "string"}`: -```go -"sort": map[string]any{ - "type": [2]string{"null", "string"}, - "description": "Sort order...", -}, -``` - ---- - -### [x] 7. `internal/mcp/mcp.go:269` — Events returns empty result for unknown collection ✅ Committed - -**Problem:** When `collection_id` is invalid, the events handler returns `{ "events": [] }` with no error — indistinguishable from an empty collection. - -**Fix:** Return an error instead: -```go -if coll == nil { - return nil, eventsOutput{}, fmt.Errorf("collection not found: %s", input.CollectionId) -} -``` - ---- - -### [x] 8. `internal/mcp/mcp.go:310` — Search returns empty result for unknown collection ✅ Committed - -**Problem:** Same as #7 — the search handler silently returns empty results for invalid collection IDs. - -**Fix:** Return an error: -```go -if coll == nil { - return nil, searchPhotosOutput{}, fmt.Errorf("collection not found: %s", input.CollectionId) -} -``` - ---- - -### [x] 9. `internal/mcp/photo.go:359` — `encodePhoto` panics with partial crop params ✅ Committed - -**Problem:** The crop rect building block dereferences `*cropX` and `*cropY` without nil checks. Sending `{crop_w: 100, crop_h: 100}` without `crop_x`/`crop_y` triggers a panic. - -**Fix:** Add nil defaults in the rect building block (mirror the validation block's approach): -```go -var crop render.Rect -if cropW != nil && cropH != nil && *cropW > 0 && *cropH > 0 { - cx := 0 - cy := 0 - if cropX != nil { cx = *cropX } - if cropY != nil { cy = *cropY } - crop = render.Rect{ - X: float64(cx), - Y: float64(cy), - W: float64(*cropW), - H: float64(*cropH), - } -} -``` - ---- - -### [x] 10. `internal/mcp/photo.go:454` — `gatherPhotoMetadata` doesn't check Geo is non-nil ✅ Committed - -**Problem:** `source.Geo` is a `*geo.Geo` pointer that can be nil when geo is disabled. Calling `source.Geo.ReverseGeocode()` panics. - -**Fix:** Add a nil check before calling `ReverseGeocode`: -```go -if image.IsValidLatLng(info.LatLng) { - latlng = &LatLng{ - Lat: info.LatLng.Lat.Degrees(), - Lng: info.LatLng.Lng.Degrees(), - } - if source.Geo != nil && source.Geo.Available() { - location, _ = source.Geo.ReverseGeocode(ctx, info.LatLng) - } -} -``` - ---- - -### [x] 11. `internal/mcp/photo.go:514` — Face PreviewUrl 404 ✅ Committed - -**Problem:** Face `PreviewUrl` uses `/files/{id}/face.jpg?...`, but the OpenAPI routes only expose `/files/{id}/original/...`, `/files/{id}/variants/...`, and `/files/{id}/previews/...`. All face preview URLs will 404. - -**Fix:** Use the `/previews/` route with a face-specific filename, consistent with how `PreviewUrl` is built for photos (line ~462): -```go -// Build a face-specific preview filename -faceFilename := fmt.Sprintf("face_%d.jpg", f.Id) -faces = append(faces, FaceInfo{ - ... - PreviewUrl: fileURL(serverBaseURL, apiPrefix, "/files/"+fmt.Sprintf("%d", fileId)+"/previews/"+faceFilename+"?w=200&h=200"), -}) -``` - ---- - -### [x] 12. `internal/mcp/photo.go:189` — `panicked` flag is dead code in `get_photo_metadata` ✅ Committed - -**Problem:** The deferred `recover()` sets `panicked = r`, but the `if panicked != nil` check after `gatherPhotoMetadata` is unreachable dead code. When `gatherPhotoMetadata` panics, Go's defer machinery returns immediately. - -**Fix:** Restructure the panic handling. Either: -- **Option A:** Remove the `panicked` flag entirely and handle panics inside `gatherPhotoMetadata` by catching them before they escape the handler. -- **Option B:** Move the `if panicked != nil` check *before* the `return nil, ...` statement in the same defer scope, but this requires restructuring so the handler returns via the recover path rather than normal flow. - -Best approach: Wrap `gatherPhotoMetadata` in its own inline func with defer/recover, and return early if it panics: -```go -var meta photoMetadata -var metaErr error -func() { - defer func() { - if r := recover(); r != nil { - metaErr = fmt.Errorf("internal error reading photo metadata: %v", r) - fmt.Fprintf(os.Stderr, "get_photo_metadata handler recovered from panic: %v\n%s", r, stackTrace()) - } - }() - meta = gatherPhotoMetadata(ctx, imageSource, input.FileId, info, srv.baseURL.Load().(string), srv.apiPrefix) -}() -if metaErr != nil { - return nil, getPhotoMetadataOutput{}, metaErr -} -``` - ---- - -### [x] 13. `internal/mcp/photo.go:223` — Same dead code in `get_photo` ✅ Committed - -**Problem:** Identical broken `panicked` flag pattern in `getPhotoHandler`. If `encodePhoto` panics, the `if panicked != nil` check is unreachable. - -**Fix:** Same restructuring as #12 — wrap `encodePhoto` in an inline func with defer/recover: -```go -var imageData []byte -var encodeErr error -func() { - defer func() { - if r := recover(); r != nil { - encodeErr = fmt.Errorf("internal error rendering photo: %v", r) - fmt.Fprintf(os.Stderr, "get_photo handler recovered from panic: %v\n%s", r, stackTrace()) - } - }() - imageData, encodeErr = encodePhoto(ctx, imageSource, image.ImageId(input.FileId), *targetW, *targetH, formatStr, - input.CropX, input.CropY, input.CropW, input.CropH) -}() -if encodeErr != nil { - return nil, getPhotoOutput{}, encodeErr -} -``` - -This eliminates the dead code pattern entirely. - ---- - -## Notes - -- **Comment #8** (no MCP test coverage) — left as-is per instructions -- **Comments #3/#4** (400 vs 404 for unknown collections) — left as-is per instructions -- **Comment #16** (fallback originalUrl wrong path) — left as-is per instructions -- **Comment #19** (lastLocTime not reset) — left as-is per instructions diff --git a/tools/agent.sh b/tools/agent.sh index 7ddb072..bc7eca5 100755 --- a/tools/agent.sh +++ b/tools/agent.sh @@ -42,8 +42,8 @@ VERBOSE=${AGT_VERBOSE:-0} _SERVER_MANAGED=false # ─── Paths ─── -_pid_file="/tmp/photofield-agent.pid" -_headers_file="/tmp/agent-headers-$$" +_pid_file="${DATA_DIR}/agent.pid" +_headers_file="${DATA_DIR}/agent-headers-$$" # ─── Colors ─── if [[ -t 1 ]]; then @@ -87,13 +87,14 @@ server_start() { fi log_step "Starting server..." + mkdir -p "$DATA_DIR" export PHOTOFIELD_ADDRESS=":$(echo "$PORT" | sed 's/.*://')" export PHOTOFIELD_DATA_DIR="$DATA_DIR" - nohup "$BIN" > /tmp/photofield-agent.log 2>&1 & + nohup "$BIN" > "${DATA_DIR}/agent.log" 2>&1 & _SERVER_MANAGED=true local pid=$! printf '%s\n' "$pid" > "$_pid_file" - log_info "PID: ${pid} (log: /tmp/photofield-agent.log)" + log_info "PID: ${pid} (log: ${DATA_DIR}/agent.log)" local waited=0 while (( waited < 30 )); do @@ -106,7 +107,7 @@ server_start() { done log_fail "Server failed to start within 30s" - tail -20 /tmp/photofield-agent.log >&2 + tail -20 "${DATA_DIR}/agent.log" >&2 return 1 } @@ -221,7 +222,7 @@ api_call() { [[ -n "$body" ]] && log_info "Body: ${body:0:200}" local status_code tmpfile - tmpfile=$(mktemp /tmp/agent-raw-XXXXXX) + tmpfile=$(mktemp "${DATA_DIR}/agent-raw-XXXXXX") status_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \ "${hdrs[@]}" \ -H "Content-Type: application/json" \ @@ -560,7 +561,7 @@ done # ─── Execute ─── # For mcp/server, subcmd is the first remaining arg after the main loop -[[ -z "$subcmd" && $# -gt 0 ]] && subcmd="$1" && shift +[[ -z "$subcmd" && $# -gt 0 && "$cmd" != "api" ]] && subcmd="$1" && shift case "$cmd" in help)