diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index e354e1819..29fbe3235 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -19,6 +19,8 @@ import ( docs "github.com/sysadminsmedia/homebox/backend/app/api/static/docs" "github.com/sysadminsmedia/homebox/backend/internal/data/ent/authroles" "github.com/sysadminsmedia/homebox/backend/internal/data/repo" + hbmcp "github.com/sysadminsmedia/homebox/backend/internal/mcp" + _ "github.com/sysadminsmedia/homebox/backend/internal/mcp/tools" // register tools via init() ) const prefix = "/api" @@ -230,6 +232,24 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR // Reporting Services r.Get("/reporting/bill-of-materials", chain.ToHandlerFunc(v1Ctrl.HandleBillOfMaterialsExport(), userMW...)) + // MCP (Model Context Protocol) endpoint — disabled by default. + // Mounted behind userMW so each tool call inherits the calling user's + // services.Context (UID/GID); tool handlers must scope their queries + // using that context, never trust group/user IDs from tool input. + if a.conf.Mcp.Enabled { + mcpHandler := hbmcp.NewHandler(hbmcp.Deps{ + Services: a.services, + Repos: a.repos, + }) + mcpHandlerFunc := chain.ToHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + mcpHandler.ServeHTTP(w, r) + return nil + }, userMW...) + r.Get("/mcp", mcpHandlerFunc) + r.Post("/mcp", mcpHandlerFunc) + r.Delete("/mcp", mcpHandlerFunc) + } + // OpenTelemetry proxy endpoint for frontend telemetry (requires auth) if a.otel != nil && a.otel.IsEnabled() && a.conf.Otel.ProxyEnabled { r.Post("/telemetry", chain.ToHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { diff --git a/backend/go.mod b/backend/go.mod index 0961c7a84..a0a4f630f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -21,6 +21,7 @@ require ( github.com/gorilla/schema v1.4.1 github.com/hay-kot/httpkit v0.0.11 github.com/jackc/pgx/v5 v5.9.2 + github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/olahol/melody v1.4.0 github.com/pkg/errors v0.9.1 github.com/pressly/goose/v3 v3.27.1 @@ -150,8 +151,9 @@ require ( github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/wire v0.7.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect @@ -191,6 +193,8 @@ require ( github.com/rabbitmq/amqp091-go v1.11.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/swaggo/files/v2 v2.0.2 // indirect @@ -199,6 +203,7 @@ require ( github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index bb664ea62..15b68c0ec 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -245,8 +245,8 @@ github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ= github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= @@ -263,6 +263,8 @@ github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQE github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -348,6 +350,8 @@ github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= +github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= github.com/nats-io/nats-server/v2 v2.11.12 h1:jGDXTkcjqQ5fCRstwIxvv1K0RHfftFUoSCT/iIZcqOc= @@ -405,6 +409,10 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= @@ -451,6 +459,8 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ= github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= +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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= diff --git a/backend/internal/mcp/context.go b/backend/internal/mcp/context.go new file mode 100644 index 000000000..68fb698ba --- /dev/null +++ b/backend/internal/mcp/context.go @@ -0,0 +1,63 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/sysadminsmedia/homebox/backend/internal/core/services" +) + +// ErrNoAuthContext is returned when a tool runs without the auth/tenant +// middleware having populated the request context. The MCP route is mounted +// behind userMW, so this should only fire if a tool is invoked outside the +// HTTP transport (e.g. a misuse from tests). +var ErrNoAuthContext = errors.New("mcp: request has no authenticated user/tenant context") + +// ErrGroupNotMember is returned when a tool requests a group_id the calling +// user is not a member of. This is the safety check that lets tools accept +// an optional group_id input — the request is rejected if the user has no +// access to the named group. +var ErrGroupNotMember = errors.New("mcp: user is not a member of the requested group") + +// ServiceCtx returns the services.Context (UID, GID, User) for the calling +// MCP request. The GID is whatever the X-Tenant header resolved to (or the +// user's default group when the header is absent), so single-group users +// get the right scope automatically. +// +// Tools that accept an optional group_id input should use ResolveGroup +// instead, which validates membership before switching scope. +func ServiceCtx(ctx context.Context) (services.Context, error) { + sctx := services.NewContext(ctx) + if sctx.UID == uuid.Nil || sctx.GID == uuid.Nil { + return services.Context{}, ErrNoAuthContext + } + return sctx, nil +} + +// ResolveGroup returns a services.Context scoped to the requested group. +// If requested is uuid.Nil the user's tenant/default group is used. Otherwise +// the user must be a member of the requested group; ResolveGroup returns +// ErrGroupNotMember if not. This mirrors the membership check mwTenant +// performs for HTTP requests, but lets MCP tools take group_id as input so +// the LLM can pivot among the user's groups within a single session. +func ResolveGroup(ctx context.Context, requested uuid.UUID) (services.Context, error) { + sctx, err := ServiceCtx(ctx) + if err != nil { + return services.Context{}, err + } + if requested == uuid.Nil { + return sctx, nil + } + if sctx.User == nil { + return services.Context{}, ErrNoAuthContext + } + for _, gid := range sctx.User.GroupIDs { + if gid == requested { + sctx.GID = requested + return sctx, nil + } + } + return services.Context{}, fmt.Errorf("%w: %s", ErrGroupNotMember, requested) +} diff --git a/backend/internal/mcp/deps.go b/backend/internal/mcp/deps.go new file mode 100644 index 000000000..8077ca251 --- /dev/null +++ b/backend/internal/mcp/deps.go @@ -0,0 +1,22 @@ +// Package mcp wires the optional Model Context Protocol server that lets +// MCP-aware clients (for example Claude Desktop) query the user's inventory +// over HTTP. It is mounted at /api/v1/mcp behind the standard auth/tenant +// middleware, so tool invocations inherit the calling user's group scope. +// +// Tools live under internal/mcp/tools and self-register at init time via +// Register. Adding a new tool is purely additive: drop a new file in tools/ +// that imports this package and calls Register from its init function. +package mcp + +import ( + "github.com/sysadminsmedia/homebox/backend/internal/core/services" + "github.com/sysadminsmedia/homebox/backend/internal/data/repo" +) + +// Deps is the dependency bundle handed to each tool registration when the +// MCP server is constructed. Tool handlers close over this and pull the +// per-request user/group from context — never from tool input. +type Deps struct { + Services *services.AllServices + Repos *repo.AllRepos +} diff --git a/backend/internal/mcp/registry.go b/backend/internal/mcp/registry.go new file mode 100644 index 000000000..29587ed7f --- /dev/null +++ b/backend/internal/mcp/registry.go @@ -0,0 +1,25 @@ +package mcp + +import ( + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Registrar binds a single tool to a server using the supplied dependencies. +// It exists so tool files can stay strongly typed (each tool can declare its +// own input/output structs and call mcpsdk.AddTool[In,Out] directly) while +// still being collected into a uniform registry. +type Registrar func(s *mcpsdk.Server, d Deps) + +var registry []Registrar + +// Register adds a tool to the global registry. Call this from a tool file's +// init() so the tool is wired in for every server instance built by NewHandler. +func Register(r Registrar) { + registry = append(registry, r) +} + +func registerAll(s *mcpsdk.Server, d Deps) { + for _, r := range registry { + r(s, d) + } +} diff --git a/backend/internal/mcp/schema.go b/backend/internal/mcp/schema.go new file mode 100644 index 000000000..a6179416b --- /dev/null +++ b/backend/internal/mcp/schema.go @@ -0,0 +1,38 @@ +package mcp + +import ( + "fmt" + "reflect" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/google/uuid" +) + +// uuidStringSchema is the correct JSON schema for uuid.UUID: a plain string in +// UUID format. Without this override the jsonschema-go library infers the +// wrong schema — [16]byte becomes {type:array,items:{type:integer},minItems:16, +// maxItems:16} — because it operates on the underlying Go type and is unaware +// of uuid.UUID's custom JSON marshaller, which always encodes as a string. +var uuidStringSchema = &jsonschema.Schema{Type: "string", Format: "uuid"} + +// uuidTypeSchemas is passed to jsonschema.ForOptions.TypeSchemas so that every +// uuid.UUID field (and *uuid.UUID field, after pointer dereferencing) is +// rendered as {type:"string",format:"uuid"} in the generated schema. +var uuidTypeSchemas = map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[uuid.UUID](): uuidStringSchema, +} + +// MustSchema generates a JSON schema for T with uuid.UUID fields correctly +// represented as {type:"string",format:"uuid"} rather than byte arrays. +// It panics if schema inference fails, which indicates a programming error +// (e.g. an unsupported field type) that should be caught at startup. +func MustSchema[T any]() *jsonschema.Schema { + s, err := jsonschema.For[T](&jsonschema.ForOptions{ + TypeSchemas: uuidTypeSchemas, + }) + if err != nil { + var zero T + panic(fmt.Sprintf("mcp.MustSchema[%T]: %v", zero, err)) + } + return s +} diff --git a/backend/internal/mcp/server.go b/backend/internal/mcp/server.go new file mode 100644 index 000000000..b93aed9e6 --- /dev/null +++ b/backend/internal/mcp/server.go @@ -0,0 +1,38 @@ +package mcp + +import ( + "net/http" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ServerName and ServerVersion identify this MCP server to connecting clients. +// The version is intentionally tied to the MCP integration shape, not the +// Homebox release version — bump it when tools are added or their schemas +// change in a breaking way. +const ( + ServerName = "homebox" + ServerVersion = "0.1.0" +) + +// NewHandler builds an http.Handler that speaks the MCP Streamable HTTP +// transport. The returned handler should be mounted behind the standard +// auth/tenant middleware so each tool invocation carries the calling user's +// services.Context. +// +// One *mcpsdk.Server is built up-front with every registered tool. The same +// server is reused across requests; per-request data flows through the +// request context, which the handler propagates into each tool call. +func NewHandler(d Deps) http.Handler { + server := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: ServerName, + Version: ServerVersion, + }, nil) + + registerAll(server, d) + + return mcpsdk.NewStreamableHTTPHandler( + func(*http.Request) *mcpsdk.Server { return server }, + nil, + ) +} diff --git a/backend/internal/mcp/tools/doc.go b/backend/internal/mcp/tools/doc.go new file mode 100644 index 000000000..a3397177f --- /dev/null +++ b/backend/internal/mcp/tools/doc.go @@ -0,0 +1,11 @@ +// Package tools holds the read tools exposed by the Homebox MCP server. +// +// Each tool lives in its own file and self-registers via init() by calling +// mcp.Register. To add a new tool: create a new file here, define typed +// input/output structs, and register the tool with mcpsdk.AddTool inside a +// Registrar callback. Avoid accepting a user_id / group_id in tool input — +// always derive them from mcp.ServiceCtx(ctx). +// +// Importing this package (typically as a blank import from the route wiring) +// fires every file's init() and populates the registry. +package tools diff --git a/backend/internal/mcp/tools/get_entity_path.go b/backend/internal/mcp/tools/get_entity_path.go new file mode 100644 index 000000000..8015bfb70 --- /dev/null +++ b/backend/internal/mcp/tools/get_entity_path.go @@ -0,0 +1,60 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sysadminsmedia/homebox/backend/internal/mcp" +) + +type getEntityPathInput struct { + ID uuid.UUID `json:"id" jsonschema:"the entity ID to resolve"` + GroupID uuid.UUID `json:"group_id,omitempty" jsonschema:"optional group to query; omit to use your default group; call list_my_groups to see available groups"` +} + +type getEntityPathSegment struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type getEntityPathOutput struct { + Path []getEntityPathSegment `json:"path"` +} + +func init() { + mcp.Register(func(s *mcpsdk.Server, d mcp.Deps) { + mcpsdk.AddTool(s, + &mcpsdk.Tool{ + Name: "get_entity_path", + Description: "Return the full breadcrumb path from the root location down to the given entity. Useful for answering 'where is X?' — the last element of the path is the entity itself; everything before it is its containing chain.", + InputSchema: mcp.MustSchema[getEntityPathInput](), + OutputSchema: mcp.MustSchema[getEntityPathOutput](), + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getEntityPathInput) (*mcpsdk.CallToolResult, getEntityPathOutput, error) { + sctx, err := mcp.ResolveGroup(ctx, in.GroupID) + if err != nil { + return nil, getEntityPathOutput{}, err + } + + path, err := d.Repos.Entities.PathForEntity(ctx, sctx.GID, in.ID) + if err != nil { + return nil, getEntityPathOutput{}, fmt.Errorf("get entity path: %w", err) + } + + out := getEntityPathOutput{ + Path: make([]getEntityPathSegment, 0, len(path)), + } + for _, p := range path { + out.Path = append(out.Path, getEntityPathSegment{ + ID: p.ID, + Name: p.Name, + Type: string(p.Type), + }) + } + return nil, out, nil + }) + }) +} diff --git a/backend/internal/mcp/tools/get_group_statistics.go b/backend/internal/mcp/tools/get_group_statistics.go new file mode 100644 index 000000000..782ae0036 --- /dev/null +++ b/backend/internal/mcp/tools/get_group_statistics.go @@ -0,0 +1,56 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sysadminsmedia/homebox/backend/internal/mcp" +) + +type getGroupStatisticsInput struct { + GroupID uuid.UUID `json:"group_id,omitempty" jsonschema:"optional group to query; omit to use your default group; call list_my_groups to see available groups"` +} + +type getGroupStatisticsOutput struct { + TotalUsers int `json:"total_users"` + TotalItems int `json:"total_items"` + TotalLocations int `json:"total_locations"` + TotalTags int `json:"total_tags"` + TotalItemPrice float64 `json:"total_item_price"` + TotalWithWarranty int `json:"total_with_warranty"` +} + +func init() { + mcp.Register(func(s *mcpsdk.Server, d mcp.Deps) { + mcpsdk.AddTool(s, + &mcpsdk.Tool{ + Name: "get_group_statistics", + Description: "Return high-level inventory statistics for the calling user's group: counts of items, locations, labels, total purchase value, etc.", + InputSchema: mcp.MustSchema[getGroupStatisticsInput](), + OutputSchema: mcp.MustSchema[getGroupStatisticsOutput](), + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getGroupStatisticsInput) (*mcpsdk.CallToolResult, getGroupStatisticsOutput, error) { + sctx, err := mcp.ResolveGroup(ctx, in.GroupID) + if err != nil { + return nil, getGroupStatisticsOutput{}, err + } + + stats, err := d.Repos.Groups.StatsGroup(ctx, sctx.GID) + if err != nil { + return nil, getGroupStatisticsOutput{}, fmt.Errorf("get group statistics: %w", err) + } + + out := getGroupStatisticsOutput{ + TotalUsers: stats.TotalUsers, + TotalItems: stats.TotalItems, + TotalLocations: stats.TotalLocations, + TotalTags: stats.TotalTags, + TotalItemPrice: stats.TotalItemPrice, + TotalWithWarranty: stats.TotalWithWarranty, + } + return nil, out, nil + }) + }) +} diff --git a/backend/internal/mcp/tools/get_item.go b/backend/internal/mcp/tools/get_item.go new file mode 100644 index 000000000..6a4a789eb --- /dev/null +++ b/backend/internal/mcp/tools/get_item.go @@ -0,0 +1,161 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sysadminsmedia/homebox/backend/internal/mcp" +) + +type getItemInput struct { + ID uuid.UUID `json:"id" jsonschema:"UUID of the inventory entity (item or location) to fetch"` + GroupID uuid.UUID `json:"group_id,omitempty" jsonschema:"optional group to query; omit to use your default group; call list_my_groups to see available groups"` +} + +type getItemField struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + TextValue string `json:"text_value,omitempty"` + NumberValue int `json:"number_value,omitempty"` + BooleanValue bool `json:"boolean_value,omitempty"` +} + +type getItemAttachment struct { + ID uuid.UUID `json:"id"` + Title string `json:"title,omitempty"` +} + +type getItemOutput struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Quantity float64 `json:"quantity,omitempty"` + Archived bool `json:"archived,omitempty"` + AssetID string `json:"asset_id,omitempty"` + ParentName string `json:"parent_name,omitempty"` + ParentID uuid.UUID `json:"parent_id,omitempty"` + EntityType string `json:"entity_type,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + ModelNumber string `json:"model_number,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + LifetimeWarranty bool `json:"lifetime_warranty,omitempty"` + WarrantyExpires string `json:"warranty_expires,omitempty"` + WarrantyDetails string `json:"warranty_details,omitempty"` + PurchaseDate string `json:"purchase_date,omitempty"` + PurchaseFrom string `json:"purchase_from,omitempty"` + PurchasePrice float64 `json:"purchase_price,omitempty"` + SoldDate string `json:"sold_date,omitempty"` + SoldTo string `json:"sold_to,omitempty"` + SoldPrice float64 `json:"sold_price,omitempty"` + SoldNotes string `json:"sold_notes,omitempty"` + Notes string `json:"notes,omitempty"` + Tags []string `json:"tags,omitempty"` + Fields []getItemField `json:"fields,omitempty"` + Attachments []getItemAttachment `json:"attachments,omitempty"` + ChildrenCount int `json:"children_count,omitempty"` + TotalPrice float64 `json:"total_price,omitempty"` +} + +func init() { + mcp.Register(func(s *mcpsdk.Server, d mcp.Deps) { + mcpsdk.AddTool(s, + &mcpsdk.Tool{ + Name: "get_item", + Description: "Fetch full details of a single inventory entity (item or location) by its UUID. Returns parent location, entity type, tags, custom fields, attachments, warranty, purchase, and sold info.", + InputSchema: mcp.MustSchema[getItemInput](), + OutputSchema: mcp.MustSchema[getItemOutput](), + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getItemInput) (*mcpsdk.CallToolResult, getItemOutput, error) { + sctx, err := mcp.ResolveGroup(ctx, in.GroupID) + if err != nil { + return nil, getItemOutput{}, err + } + + e, err := d.Repos.Entities.GetOneByGroup(ctx, sctx.GID, in.ID) + if err != nil { + return nil, getItemOutput{}, fmt.Errorf("get item: %w", err) + } + + out := getItemOutput{ + ID: e.ID, + Name: e.Name, + Description: e.Description, + Quantity: e.Quantity, + Archived: e.Archived, + SerialNumber: e.SerialNumber, + ModelNumber: e.ModelNumber, + Manufacturer: e.Manufacturer, + LifetimeWarranty: e.LifetimeWarranty, + WarrantyDetails: e.WarrantyDetails, + PurchaseFrom: e.PurchaseFrom, + PurchasePrice: e.PurchasePrice, + SoldTo: e.SoldTo, + SoldPrice: e.SoldPrice, + SoldNotes: e.SoldNotes, + Notes: e.Notes, + TotalPrice: e.TotalPrice, + ChildrenCount: len(e.Children), + } + + if !e.AssetID.Nil() { + out.AssetID = e.AssetID.String() + } + + if e.Parent != nil { + out.ParentName = e.Parent.Name + out.ParentID = e.Parent.ID + } + + if e.EntityType != nil { + out.EntityType = e.EntityType.Name + } + + if s := e.WarrantyExpires.String(); s != "" { + out.WarrantyExpires = s + } + if s := e.PurchaseDate.String(); s != "" { + out.PurchaseDate = s + } + if s := e.SoldDate.String(); s != "" { + out.SoldDate = s + } + + if len(e.Tags) > 0 { + tags := make([]string, 0, len(e.Tags)) + for _, t := range e.Tags { + tags = append(tags, t.Name) + } + out.Tags = tags + } + + if len(e.Fields) > 0 { + fields := make([]getItemField, 0, len(e.Fields)) + for _, f := range e.Fields { + fields = append(fields, getItemField{ + Name: f.Name, + Type: f.Type, + TextValue: f.TextValue, + NumberValue: f.NumberValue, + BooleanValue: f.BooleanValue, + }) + } + out.Fields = fields + } + + if len(e.Attachments) > 0 { + attachments := make([]getItemAttachment, 0, len(e.Attachments)) + for _, a := range e.Attachments { + attachments = append(attachments, getItemAttachment{ + ID: a.ID, + Title: a.Title, + }) + } + out.Attachments = attachments + } + + return nil, out, nil + }) + }) +} diff --git a/backend/internal/mcp/tools/get_maintenance_schedule.go b/backend/internal/mcp/tools/get_maintenance_schedule.go new file mode 100644 index 000000000..6680f0caa --- /dev/null +++ b/backend/internal/mcp/tools/get_maintenance_schedule.go @@ -0,0 +1,108 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sysadminsmedia/homebox/backend/internal/data/repo" + "github.com/sysadminsmedia/homebox/backend/internal/mcp" +) + +const ( + getMaintenanceScheduleDefaultLimit = 50 + getMaintenanceScheduleMaxLimit = 200 +) + +type getMaintenanceScheduleInput struct { + Status string `json:"status,omitempty" jsonschema:"scheduled | completed | both; defaults to scheduled"` + Limit int `json:"limit,omitempty" jsonschema:"max number of entries to return; defaults to 50; capped at 200"` + GroupID uuid.UUID `json:"group_id,omitempty" jsonschema:"optional group to query; omit to use your default group; call list_my_groups to see available groups"` +} + +type getMaintenanceScheduleItem struct { + ID uuid.UUID `json:"id"` + ItemID uuid.UUID `json:"item_id"` + ItemName string `json:"item_name,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ScheduledDate string `json:"scheduled_date,omitempty"` + CompletedDate string `json:"completed_date,omitempty"` + Cost float64 `json:"cost,omitempty"` +} + +type getMaintenanceScheduleOutput struct { + Status string `json:"status"` + Total int `json:"total"` + Results []getMaintenanceScheduleItem `json:"results"` +} + +func init() { + mcp.Register(func(s *mcpsdk.Server, d mcp.Deps) { + mcpsdk.AddTool(s, + &mcpsdk.Tool{ + Name: "get_maintenance_schedule", + Description: "List maintenance entries across the calling user's group. Filter by status (scheduled, completed, or both) to find upcoming work or review history.", + InputSchema: mcp.MustSchema[getMaintenanceScheduleInput](), + OutputSchema: mcp.MustSchema[getMaintenanceScheduleOutput](), + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getMaintenanceScheduleInput) (*mcpsdk.CallToolResult, getMaintenanceScheduleOutput, error) { + sctx, err := mcp.ResolveGroup(ctx, in.GroupID) + if err != nil { + return nil, getMaintenanceScheduleOutput{}, err + } + + var status repo.MaintenanceFilterStatus + switch in.Status { + case "", string(repo.MaintenanceFilterStatusScheduled): + status = repo.MaintenanceFilterStatusScheduled + case string(repo.MaintenanceFilterStatusCompleted): + status = repo.MaintenanceFilterStatusCompleted + case string(repo.MaintenanceFilterStatusBoth): + status = repo.MaintenanceFilterStatusBoth + default: + return nil, getMaintenanceScheduleOutput{}, fmt.Errorf("invalid status %q: must be one of scheduled, completed, both", in.Status) + } + + limit := in.Limit + if limit <= 0 { + limit = getMaintenanceScheduleDefaultLimit + } + if limit > getMaintenanceScheduleMaxLimit { + limit = getMaintenanceScheduleMaxLimit + } + + entries, err := d.Repos.MaintEntry.GetAllMaintenance(ctx, sctx.GID, repo.MaintenanceFilters{ + Status: status, + }) + if err != nil { + return nil, getMaintenanceScheduleOutput{}, fmt.Errorf("get maintenance schedule: %w", err) + } + + if len(entries) > limit { + entries = entries[:limit] + } + + out := getMaintenanceScheduleOutput{ + Status: string(status), + Total: len(entries), + Results: make([]getMaintenanceScheduleItem, 0, len(entries)), + } + for _, e := range entries { + item := getMaintenanceScheduleItem{ + ID: e.ID, + ItemID: e.ItemID, + ItemName: e.ItemName, + Name: e.Name, + Description: e.Description, + ScheduledDate: e.ScheduledDate.String(), + CompletedDate: e.CompletedDate.String(), + Cost: e.Cost, + } + out.Results = append(out.Results, item) + } + return nil, out, nil + }) + }) +} diff --git a/backend/internal/mcp/tools/list_entity_types.go b/backend/internal/mcp/tools/list_entity_types.go new file mode 100644 index 000000000..94f66dddf --- /dev/null +++ b/backend/internal/mcp/tools/list_entity_types.go @@ -0,0 +1,77 @@ +package tools + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sysadminsmedia/homebox/backend/internal/mcp" +) + +type listEntityTypesInput struct { + GroupID uuid.UUID `json:"group_id,omitempty" jsonschema:"optional group to query; omit to use your default group; call list_my_groups to see available groups"` +} + +type listEntityTypesItem struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + IsLocation bool `json:"is_location"` + Icon string `json:"icon,omitempty"` + DefaultTemplateID *uuid.UUID `json:"default_template_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type listEntityTypesOutput struct { + ItemTypes []listEntityTypesItem `json:"item_types"` + LocationTypes []listEntityTypesItem `json:"location_types"` +} + +func init() { + mcp.Register(func(s *mcpsdk.Server, d mcp.Deps) { + mcpsdk.AddTool(s, + &mcpsdk.Tool{ + Name: "list_entity_types", + Description: "List all entity types (categories) defined in the calling user's group. Each type indicates whether entities of that type are locations or items.", + InputSchema: mcp.MustSchema[listEntityTypesInput](), + OutputSchema: mcp.MustSchema[listEntityTypesOutput](), + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listEntityTypesInput) (*mcpsdk.CallToolResult, listEntityTypesOutput, error) { + sctx, err := mcp.ResolveGroup(ctx, in.GroupID) + if err != nil { + return nil, listEntityTypesOutput{}, err + } + + types, err := d.Repos.EntityTypes.GetAll(ctx, sctx.GID) + if err != nil { + return nil, listEntityTypesOutput{}, fmt.Errorf("list entity types: %w", err) + } + + out := listEntityTypesOutput{ + ItemTypes: make([]listEntityTypesItem, 0, len(types)), + LocationTypes: make([]listEntityTypesItem, 0, len(types)), + } + for _, et := range types { + item := listEntityTypesItem{ + ID: et.ID, + Name: et.Name, + Description: et.Description, + IsLocation: et.IsLocation, + Icon: et.Icon, + DefaultTemplateID: et.DefaultTemplateID, + CreatedAt: et.CreatedAt, + UpdatedAt: et.UpdatedAt, + } + if et.IsLocation { + out.LocationTypes = append(out.LocationTypes, item) + } else { + out.ItemTypes = append(out.ItemTypes, item) + } + } + return nil, out, nil + }) + }) +} diff --git a/backend/internal/mcp/tools/list_labels.go b/backend/internal/mcp/tools/list_labels.go new file mode 100644 index 000000000..91573bc3e --- /dev/null +++ b/backend/internal/mcp/tools/list_labels.go @@ -0,0 +1,63 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sysadminsmedia/homebox/backend/internal/mcp" +) + +type listLabelsInput struct { + GroupID uuid.UUID `json:"group_id,omitempty" jsonschema:"optional group to query; omit to use your default group; call list_my_groups to see available groups"` +} + +type listLabelsItem struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Color string `json:"color,omitempty"` +} + +type listLabelsOutput struct { + Total int `json:"total"` + Results []listLabelsItem `json:"results"` +} + +func init() { + mcp.Register(func(s *mcpsdk.Server, d mcp.Deps) { + mcpsdk.AddTool(s, + &mcpsdk.Tool{ + Name: "list_labels", + Description: "List all labels (tags) defined in the calling user's group. Use the returned label IDs to filter `search_items` results.", + InputSchema: mcp.MustSchema[listLabelsInput](), + OutputSchema: mcp.MustSchema[listLabelsOutput](), + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listLabelsInput) (*mcpsdk.CallToolResult, listLabelsOutput, error) { + sctx, err := mcp.ResolveGroup(ctx, in.GroupID) + if err != nil { + return nil, listLabelsOutput{}, err + } + + tags, err := d.Repos.Tags.GetAll(ctx, sctx.GID) + if err != nil { + return nil, listLabelsOutput{}, fmt.Errorf("list labels: %w", err) + } + + out := listLabelsOutput{ + Total: len(tags), + Results: make([]listLabelsItem, 0, len(tags)), + } + for _, t := range tags { + out.Results = append(out.Results, listLabelsItem{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + Color: t.Color, + }) + } + return nil, out, nil + }) + }) +} diff --git a/backend/internal/mcp/tools/list_my_groups.go b/backend/internal/mcp/tools/list_my_groups.go new file mode 100644 index 000000000..056d46d04 --- /dev/null +++ b/backend/internal/mcp/tools/list_my_groups.go @@ -0,0 +1,60 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sysadminsmedia/homebox/backend/internal/mcp" +) + +type listMyGroupsInput struct{} + +type listMyGroupsItem struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Currency string `json:"currency,omitempty"` + IsDefault bool `json:"is_default,omitempty"` +} + +type listMyGroupsOutput struct { + Groups []listMyGroupsItem `json:"groups"` +} + +func init() { + mcp.Register(func(s *mcpsdk.Server, d mcp.Deps) { + mcpsdk.AddTool(s, + &mcpsdk.Tool{ + Name: "list_my_groups", + Description: "List the groups the calling user is a member of. The returned IDs can be passed as the optional `group_id` argument on other tools to scope queries to a specific group; omit `group_id` to use the user's default group.", + InputSchema: mcp.MustSchema[listMyGroupsInput](), + OutputSchema: mcp.MustSchema[listMyGroupsOutput](), + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ listMyGroupsInput) (*mcpsdk.CallToolResult, listMyGroupsOutput, error) { + sctx, err := mcp.ServiceCtx(ctx) + if err != nil { + return nil, listMyGroupsOutput{}, err + } + + groups, err := d.Repos.Groups.GetAllGroups(ctx, sctx.UID) + if err != nil { + return nil, listMyGroupsOutput{}, fmt.Errorf("list groups: %w", err) + } + + out := listMyGroupsOutput{ + Groups: make([]listMyGroupsItem, 0, len(groups)), + } + defaultGID := sctx.User.DefaultGroupID + for _, g := range groups { + out.Groups = append(out.Groups, listMyGroupsItem{ + ID: g.ID, + Name: g.Name, + Currency: g.Currency, + IsDefault: g.ID == defaultGID, + }) + } + return nil, out, nil + }) + }) +} diff --git a/backend/internal/mcp/tools/search_items.go b/backend/internal/mcp/tools/search_items.go new file mode 100644 index 000000000..d9f7f03d7 --- /dev/null +++ b/backend/internal/mcp/tools/search_items.go @@ -0,0 +1,101 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sysadminsmedia/homebox/backend/internal/data/repo" + "github.com/sysadminsmedia/homebox/backend/internal/mcp" +) + +const searchItemsDefaultPageSize = 25 + +type searchItemsInput struct { + Query string `json:"query,omitempty" jsonschema:"text matched against item names and descriptions; omit for an unfiltered list"` + Page int `json:"page,omitempty" jsonschema:"1-indexed page number; defaults to 1"` + PageSize int `json:"page_size,omitempty" jsonschema:"items per page; defaults to 25; max useful value is around 100"` + IsLocation *bool `json:"is_location,omitempty" jsonschema:"true returns only locations, false returns only items, omit returns both"` + GroupID uuid.UUID `json:"group_id,omitempty" jsonschema:"optional group to query; omit to use your default group; call list_my_groups to see available groups"` +} + +type searchItemHit struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Quantity float64 `json:"quantity,omitempty"` + Archived bool `json:"archived,omitempty"` + PurchasePrice float64 `json:"purchase_price,omitempty"` + ParentName string `json:"parent_name,omitempty"` + EntityType string `json:"entity_type,omitempty"` +} + +type searchItemsOutput struct { + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Results []searchItemHit `json:"results"` +} + +func init() { + mcp.Register(func(s *mcpsdk.Server, d mcp.Deps) { + mcpsdk.AddTool(s, + &mcpsdk.Tool{ + Name: "search_items", + Description: "Search the inventory for items (and optionally locations) belonging to the calling user's group. Returns a paginated list of matches with parent location and entity type included.", + InputSchema: mcp.MustSchema[searchItemsInput](), + OutputSchema: mcp.MustSchema[searchItemsOutput](), + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in searchItemsInput) (*mcpsdk.CallToolResult, searchItemsOutput, error) { + sctx, err := mcp.ResolveGroup(ctx, in.GroupID) + if err != nil { + return nil, searchItemsOutput{}, err + } + + page := in.Page + if page <= 0 { + page = 1 + } + pageSize := in.PageSize + if pageSize <= 0 { + pageSize = searchItemsDefaultPageSize + } + + res, err := d.Repos.Entities.QueryByGroup(ctx, sctx.GID, repo.EntityQuery{ + Search: in.Query, + Page: page, + PageSize: pageSize, + IsLocation: in.IsLocation, + }) + if err != nil { + return nil, searchItemsOutput{}, fmt.Errorf("search items: %w", err) + } + + out := searchItemsOutput{ + Total: res.Total, + Page: res.Page, + PageSize: res.PageSize, + Results: make([]searchItemHit, 0, len(res.Items)), + } + for _, it := range res.Items { + hit := searchItemHit{ + ID: it.ID, + Name: it.Name, + Description: it.Description, + Quantity: it.Quantity, + Archived: it.Archived, + PurchasePrice: it.PurchasePrice, + } + if it.Parent != nil { + hit.ParentName = it.Parent.Name + } + if it.EntityType != nil { + hit.EntityType = it.EntityType.Name + } + out.Results = append(out.Results, hit) + } + return nil, out, nil + }) + }) +} diff --git a/backend/internal/sys/config/conf.go b/backend/internal/sys/config/conf.go index 8b75035d9..a8da80c7a 100644 --- a/backend/internal/sys/config/conf.go +++ b/backend/internal/sys/config/conf.go @@ -34,6 +34,14 @@ type Config struct { Otel OTelConfig `yaml:"otel"` Auth AuthConfig `yaml:"auth"` Notifier NotifierConf `yaml:"notifier"` + Mcp MCPConfig `yaml:"mcp"` +} + +// MCPConfig configures the optional Model Context Protocol server mounted at +// /api/v1/mcp. Disabled by default; when enabled it exposes read tools that +// query the inventory using the same auth/tenant scope as the calling user. +type MCPConfig struct { + Enabled bool `yaml:"enabled" conf:"default:false"` } type Options struct {