-
-
Notifications
You must be signed in to change notification settings - Fork 409
feat: mcp service #1485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: mcp service #1485
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } |
| 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 | ||
| } |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against nil registrars to prevent panic at registration replay. If 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 |
||
|
|
||
| func registerAll(s *mcpsdk.Server, d Deps) { | ||
| for _, r := range registry { | ||
| r(s, d) | ||
| } | ||
| } | ||
| 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 | ||
| } |
| 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 }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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 -60Repository: sysadminsmedia/homebox Length of output: 1495 The shared The web search confirms that Mitigation options:
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 |
||
| nil, | ||
| ) | ||
| } | ||
| 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 |
| 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() { | ||
| 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 | ||
| }) | ||
| }) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
`Check OSV/GitHub advisories for:
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