Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/app/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

`Check OSV/GitHub advisories for:

  • github.com/modelcontextprotocol/go-sdk v1.5.0
  • github.com/google/jsonschema-go v0.4.2
    Return unresolved HIGH/CRITICAL findings and affected version ranges.`

💡 Result:

github.com/modelcontextprotocol/go-sdk v1.5.0: Unresolved HIGH/CRITICAL findings (as of 2026-05-10): - GO-2026-4569 (GHSA-wvj2-96wp-fq3f, CVE-2026-27896): Improper Handling of Case Sensitivity. HIGH severity. Affected: >=1.3.1 (no fixed version listed; review_status: UNREVIEWED). v1.5.0 affected since introduced through 1.3.1 and no explicit fix beyond.[1][2][3] Other HIGH advisories listed on GitHub security page, published Feb-Mar 2026 (e.g., GHSA-q382-vc8q-7jhj null Unicode, GHSA-89xv-2j6f-qhc8 Cross-Site Tool Execution <=1.4.0, GHSA-xw59-hvm2-8pj6 DNS Rebinding <=1.4.0), but GO-2026-4569 explicitly covers >=1.3.1 range including v1.5.0.[2][4][5] github.com/google/jsonschema-go v0.4.2: No HIGH/CRITICAL unresolved findings in OSV/GitHub advisories. No published security advisories on GitHub.[6][7] Note: Unrelated vulns in other jsonschema packages (e.g., npm json-schema).[8]

Citations:


Do not merge with unresolved HIGH severity security advisories in go-sdk dependency.

The MCP dependency chain contains an unresolved HIGH severity advisory (GO-2026-4569/CVE-2026-27896: Improper Handling of Case Sensitivity) affecting go-sdk v1.5.0. Additional HIGH advisories (GHSA-q382-vc8q-7jhj, GHSA-89xv-2j6f-qhc8, GHSA-xw59-hvm2-8pj6) also affect this version with no published fixes available. jsonschema-go v0.4.2 is clear. Either upgrade go-sdk to a patched version or defer this PR pending security resolution.

Also applies to: 156-156

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/go.mod` at line 24, The go.mod entry pinning
github.com/modelcontextprotocol/go-sdk v1.5.0 contains unresolved HIGH severity
advisories; update the dependency to a patched release (replace the version
string for github.com/modelcontextprotocol/go-sdk in go.mod and run `go get`/`go
mod tidy` to lock the fixed version) or remove/replace the module if no patch
exists and defer merging this PR until a safe version is available; ensure
jsonschema-go remains at v0.4.2 or higher and re-run `go list -m -u all` /
security scanning to confirm advisories are cleared before merging.

github.com/olahol/melody v1.4.0
github.com/pkg/errors v0.9.1
github.com/pressly/goose/v3 v3.27.1
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
63 changes: 63 additions & 0 deletions backend/internal/mcp/context.go
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 22 additions & 0 deletions backend/internal/mcp/deps.go
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 25 additions & 0 deletions backend/internal/mcp/registry.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +17 to +19
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against nil registrars to prevent panic at registration replay.

If Register(nil) ever happens, Line 23 panics when registerAll runs. A defensive guard here is cheap and avoids brittle startup failures.

Proposed fix
 func Register(r Registrar) {
+	if r == nil {
+		panic("mcp.Register: nil registrar")
+	}
 	registry = append(registry, r)
 }

Also applies to: 22-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/mcp/registry.go` around lines 17 - 19, Add a nil-check in
the Register function to avoid appending nil registrars: ensure the function
(Register) returns early if the passed Registrar is nil, so registry only
contains non-nil entries and registerAll won’t panic when replaying
registrations; reference the Registrar type, the Register function, and the
registry slice to locate where to insert the guard.


func registerAll(s *mcpsdk.Server, d Deps) {
for _, r := range registry {
r(s, d)
}
}
38 changes: 38 additions & 0 deletions backend/internal/mcp/schema.go
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions backend/internal/mcp/server.go
Original file line number Diff line number Diff line change
@@ -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 },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In github.com/modelcontextprotocol/go-sdk/mcp v1.5.0, is *mcp.Server documented as safe for concurrent use across multiple simultaneous HTTP requests when reused as a singleton?

💡 Result:

No. In v1.5.0, the code/docs do not document mcp.Server as “safe for concurrent use” across multiple simultaneous HTTP requests when reused as a singleton. What the authoritative docs do say instead: - The Go SDK’s protocol documentation explicitly says MCP has no concurrency guarantees at the spec level, and the Go SDK only implements certain heuristics (e.g., async handling of tools/calls) rather than stating the underlying server object is concurrency-safe for reuse across concurrent HTTP requests. [1] What’s visible from the server implementation (indirectly relevant to the question): - mcp.NewServer builds a *Server with internal mutable fields like maps (e.g., pendingNotifications, resourceSubscriptions) that are not documented here as protected for concurrent access. [2] Practical implication for your scenario (singleton across concurrent HTTP requests): - Because there is no explicit “safe for concurrent use” guarantee for the singleton *mcp.Server object in v1.5.0, you should not assume it is safe. Instead, assume that per-request/per-session state must be handled by the session/transport layers and your handlers must be concurrency-safe themselves. The SDK doc indicates calls are handled asynchronously relative to each other, which increases the likelihood of shared-state issues if you rely on a singleton server object without synchronization. [1] If you want, share the specific HTTP entrypoint/transport you are using in v1.5.0 (e.g., streamable HTTP handler variant and how you store/route *mcp.Server), and I can check the exact transport/session code paths to determine where concurrency boundaries are enforced.

Citations:


🏁 Script executed:

cat -n backend/internal/mcp/server.go | head -60

Repository: sysadminsmedia/homebox

Length of output: 1495


The shared *mcpsdk.Server instance is unsafe for concurrent use in v1.5.0—implement per-request server instances or add synchronization.

The web search confirms that mcp.Server v1.5.0 lacks documented concurrency guarantees and contains mutable internal fields (e.g., pendingNotifications, resourceSubscriptions) that are not protected for concurrent access. Reusing a singleton server across simultaneous HTTP requests creates a data race risk—concurrent tool invocations or protocol state updates may corrupt shared maps or introduce inconsistent state.

Mitigation options:

  • Create a new *mcpsdk.Server per HTTP request instead of reusing a singleton, or
  • Add mutex synchronization to protect all shared state within tool handlers

Security note: Data races in protocol handlers could allow users to access each other's context or tool results, particularly in multi-tenant scenarios where requests carry different user/tenant information through context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/mcp/server.go` at line 35, The code returns a shared
mcpsdk.Server instance (the server variable) for every HTTP request, but
mcpsdk.Server v1.5.0 has mutable fields (e.g., pendingNotifications,
resourceSubscriptions) that are not concurrency-safe; replace the singleton
usage by constructing a fresh *mcpsdk.Server per request inside the handler
factory (or otherwise clone/initialize a new server instance for each request)
or wrap all accesses to the shared server state with a mutex protecting
pendingNotifications and resourceSubscriptions and any other mutable fields used
by methods invoked from the HTTP request; update the function that currently
returns server to instead create and return a request-scoped *mcpsdk.Server (or
ensure all handler calls acquire the mutex) so concurrent requests cannot mutate
the same internal maps.

nil,
)
}
11 changes: 11 additions & 0 deletions backend/internal/mcp/tools/doc.go
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions backend/internal/mcp/tools/get_entity_path.go
Original file line number Diff line number Diff line change
@@ -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() {

Check failure on line 27 in backend/internal/mcp/tools/get_entity_path.go

View workflow job for this annotation

GitHub Actions / Backend Server Tests / Go

don't use `init` function (gochecknoinits)
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
})
})
}
Loading
Loading