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.
255 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|