photoprism/internal/mcp/data.go
Michael Mayer b04233f2f4 Backend: Use strings.SplitSeq for split-and-iterate loops #5599
Replace strings.Split with the lazy strings.SplitSeq iterator (Go 1.23+)
in loops that only range over the tokens, avoiding the intermediate
slice allocation. The yielded substrings are identical to Split, so
behavior is unchanged. Applies the suggestion from #5599 to its three
sites (CIDR parsing, WebDAV hidden-path, overlay path checks) and
extends it to two more split-and-iterate loops in the server compression
negotiator and MCP config-options builder for consistency. The slice
returning splitPath helper is intentionally left on strings.Split.
2026-05-30 09:49:47 +00:00

157 lines
4.4 KiB
Go

package mcp
import (
"sort"
"strings"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/form"
)
// Resource URIs advertised to MCP clients. Each one maps to a JSON
// payload built from internal PhotoPrism reference data and registered
// in resources.go.
const (
// configOptionsURI is the URI of the "config-options" resource,
// which mirrors the `photoprism show config-options` CLI report.
configOptionsURI = "photoprism://config-options"
// searchFiltersURI is the URI of the "search-filters" resource,
// which mirrors the `photoprism show search-filters` CLI report.
searchFiltersURI = "photoprism://search-filters"
)
// Dataset caches the static MCP data returned by resources and tools.
type Dataset struct {
CurrentEdition string
ConfigOptions []ConfigOption
SearchFilters []SearchFilter
}
// ConfigOption represents a single config option report row with section metadata.
type ConfigOption struct {
Section string `json:"section"`
Environment string `json:"environment"`
CLIFlag string `json:"cli_flag"`
Default string `json:"default"`
Description string `json:"description"`
Tags []string `json:"-"`
}
// SearchFilter represents a single search filter report row.
type SearchFilter struct {
Filter string `json:"filter"`
Type string `json:"type"`
Examples string `json:"examples"`
Notes string `json:"notes"`
}
// ConfigOptionsResource contains the JSON payload for the config-options resource.
type ConfigOptionsResource struct {
Edition string `json:"edition"`
Items []ConfigOption `json:"items"`
}
// SearchFiltersResource contains the JSON payload for the search-filters resource.
type SearchFiltersResource struct {
Edition string `json:"edition"`
Items []SearchFilter `json:"items"`
}
// NewDataset builds the static MCP dataset for the current build.
func NewDataset(currentEdition string) *Dataset {
return &Dataset{
CurrentEdition: normalizeEdition(currentEdition),
ConfigOptions: buildConfigOptions(),
SearchFilters: buildSearchFilters(),
}
}
// buildConfigOptions walks config.Flags and returns one ConfigOption per
// non-hidden flag, with the originating section title attached. Section
// titles come from config.OptionsReportSections; each section declares one
// or more "start" env vars that mark where the section begins in the flag
// list. Flags encountered before the first known start env var inherit an
// empty section, matching the layout of the `photoprism show config-*`
// CLI reports.
func buildConfigOptions() []ConfigOption {
// Index section start env vars by the exact env var name for O(1)
// lookup while iterating flags below.
sectionStarts := make(map[string]string)
for _, section := range config.OptionsReportSections {
for key := range strings.SplitSeq(section.Start, ", ") {
sectionStarts[key] = section.Title
}
}
items := make([]ConfigOption, 0, len(config.Flags))
currentSection := ""
for _, flag := range config.Flags {
if flag.Hidden() {
continue
}
envVar := flag.EnvVar()
// Update the section title whenever we cross a section boundary.
if title, ok := sectionStarts[envVar]; ok {
currentSection = title
}
items = append(items, ConfigOption{
Section: currentSection,
Environment: envVar,
CLIFlag: flag.CommandFlag(),
Default: flag.Default(),
Description: flag.Usage(),
Tags: flag.Tags,
})
}
return items
}
// buildSearchFilters runs the existing search-filter report against
// form.SearchPhotos and returns the rows sorted by (type, filter) for
// deterministic MCP output. Rows with fewer than four columns are
// dropped defensively in case the report shape ever changes.
func buildSearchFilters() []SearchFilter {
rows, _ := form.Report(&form.SearchPhotos{})
sort.Slice(rows, func(i, j int) bool {
if rows[i][1] == rows[j][1] {
return rows[i][0] < rows[j][0]
}
return rows[i][1] < rows[j][1]
})
items := make([]SearchFilter, 0, len(rows))
for _, row := range rows {
if len(row) < 4 {
continue
}
items = append(items, SearchFilter{
Filter: row[0],
Type: row[1],
Examples: row[2],
Notes: row[3],
})
}
return items
}
// normalizeEdition returns a lowercase edition identifier suitable for MCP output.
func normalizeEdition(edition string) string {
edition = strings.TrimSpace(strings.ToLower(edition))
if edition == "" {
return "unknown"
}
return edition
}