Files
ombi-mcp/internal/tools/users.go
gronod eab16991ac Implement the runnable MCP server: 31 tools, auth, projections (Phase 07)
Lands the executable half of the Phase 01-06 contracts: stdio MCP
wiring with bundle policy enforced at call time, JWT/api_key upstream
auth with single-flight renewal and 401 retry, strict argument
decoding, allowlisted result projections with enum label twins, TV
season expansion, settings read-modify-write under a revision lock,
and a sanitized ToolError envelope that never forwards raw upstream
bodies.
2026-09-18 19:14:10 +01:00

255 lines
6.7 KiB
Go

package tools
import (
"context"
"encoding/json"
"ombi-mcp/internal/ombi"
)
// read_users — self, authorized list/dropdown/get, claims, online
// users and notification preferences. Families: user_page,
// reference_page.
func handleUsers(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_users", false)
var a UsersArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "self":
return o.usersSelf()
case "list":
return o.usersList("/api/v1/Identity/Users", a.Page)
case "dropdown":
return o.usersList("/api/v1/Identity/dropdown/Users", a.Page)
case "get":
return o.usersGet(&a)
case "claims":
return o.usersClaims()
case "online":
return o.usersOnline()
case "notification_preferences":
return o.usersNotifPrefs(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) usersSelf() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Identity", nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
return o.ok(&UserPage{Kind: "user_page",
Items: []User{projectUser(m)}, Page: singlePage(1, "users")})
}
func (o *op) usersList(path string, page *Page) *ToolResult {
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []User{}
for _, m := range arr {
items = append(items, projectUser(m))
}
win, pg := localWindow(o, items, page, "users")
return o.ok(&UserPage{Kind: "user_page", Items: win, Page: pg})
}
func (o *op) usersGet(a *UsersArgs) *ToolResult {
if !nonempty(a.UserID) {
return o.invalid("user_id", "user_id must be nonempty")
}
raw, fail := o.call("GET", "/api/v1/Identity/User/"+seg(a.UserID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
return o.ok(&UserPage{Kind: "user_page",
Items: []User{projectUser(m)}, Page: singlePage(1, "users")})
}
func (o *op) usersClaims() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Identity/claims", nil, nil)
if fail != nil {
return fail
}
items, fail := o.refArray(raw,
[]string{"value"}, []string{"description", "name", "value"}, "claim")
if fail != nil {
return fail
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
func (o *op) usersOnline() *ToolResult {
raw, fail := o.call("GET", "/api/v2/Hub/Users", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
online := true
items := []User{}
for _, m := range arr {
u := projectUser(m)
u.Online = &online
items = append(items, u)
}
items = capItems(o, items)
return o.ok(&UserPage{Kind: "user_page",
Items: items, Page: singlePage(len(items), "users")})
}
// usersNotifPrefs projects preference records: enabled agent
// labels/codes only — never the delivery-token `value` strings.
func (o *op) usersNotifPrefs(a *UsersArgs) *ToolResult {
path := "/api/v1/Identity/notificationpreferences"
if nonempty(a.UserID) {
path += "/" + seg(a.UserID)
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Reference{}
for _, m := range arr {
r := Reference{Category: "notification_preference"}
code, label := o.agentTwin(m["agent"])
r.ID = ""
if code != nil {
r.ID = segInt(*code)
}
r.Name = label
if v, ok := m["enabled"]; ok {
r.Value = v
}
items = append(items, r)
}
items = capItems(o, items)
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
// write_user_preferences — language, streaming country and the
// mutating newsletter unsubscribe GET.
func handleUserPrefs(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_user_preferences", true)
var a UserPrefsArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "language":
if !nonempty(a.Language) {
return o.invalid("language", "language is required")
}
_, fail := o.call("POST", "/api/v1/Identity/language", nil,
ombi.UserLanguage{Lang: a.Language})
if fail != nil {
return fail
}
return o.ok(emptyMutation("completed", nil))
case "streaming_country":
if len(a.CountryCode) != 2 {
return o.invalid("country_code", "country_code must be a 2-letter code")
}
_, fail := o.call("POST", "/api/v1/Identity/streamingcountry", nil,
ombi.CountryStreamingPreference{Code: a.CountryCode})
if fail != nil {
return fail
}
return o.ok(emptyMutation("completed", nil))
case "unsubscribe_newsletter":
if !nonempty(a.UserID) {
return o.invalid("user_id", "user_id is required")
}
// Mutating GET per the ledger — always a write tool.
_, fail := o.call("GET",
"/api/v1/Identity/newsletter/unsubscribe/"+seg(a.UserID), nil, nil)
if fail != nil {
return fail
}
m := emptyMutation("completed", nil)
m.UserID = a.UserID
return o.ok(m)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
// write_user_manage — delete a user or send a welcome email.
// welcome_email resolves the user view-model internally; the model
// never constructs a UserViewModel.
func handleUserManage(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_user_manage", true)
var a UserManageArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if !nonempty(a.UserID) {
return o.invalid("user_id", "user_id is required")
}
switch a.Action {
case "delete":
raw, fail := o.call("DELETE", "/api/v1/Identity/"+seg(a.UserID), nil, nil)
if fail != nil {
return fail
}
var ir ombi.IdentityResult
if fail := o.decodeJSON(raw, &ir); fail != nil {
return fail
}
m := &Mutation{Kind: "mutation", UserID: a.UserID}
if ir.Successful != nil && *ir.Successful {
m.Outcome = "completed"
return o.ok(m)
}
if len(ir.Errors) > 0 {
return o.fail("UPSTREAM_REJECTED",
sanitizeText(ir.Errors[0], maxSanitizedMsg), false)
}
return o.fail("UNKNOWN_OUTCOME",
"upstream identity result carried no success indicator", false)
case "welcome_email":
// Resolve the existing user's view model privately.
raw, fail := o.call("GET", "/api/v1/Identity/User/"+seg(a.UserID), nil, nil)
if fail != nil {
return fail
}
user, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
_, fail = o.call("POST", "/api/v1/Identity/welcomeEmail", nil, user)
if fail != nil {
return fail
}
m := emptyMutation("completed", nil)
m.UserID = a.UserID
return o.ok(m)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}