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.
425 lines
12 KiB
Go
425 lines
12 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"ombi-mcp/internal/ombi"
|
|
"ombi-mcp/internal/translate"
|
|
)
|
|
|
|
// read_issues — paged v1 lists, v2 provider-grouped summaries, single
|
|
// records, request/provider lookups, comments and counts.
|
|
// Families: issue_page, comment_page, group_page, metrics.
|
|
func handleIssues(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
|
o := newOp(ctx, env, "read_issues", false)
|
|
var a IssuesArgs
|
|
if fail := o.args(raw, &a); fail != nil {
|
|
return fail
|
|
}
|
|
switch a.Action {
|
|
case "list":
|
|
return o.issuesList(&a)
|
|
case "summary":
|
|
return o.issuesSummary(&a)
|
|
case "get":
|
|
return o.issuesGet(&a)
|
|
case "by_request":
|
|
return o.issuesByRequest(&a)
|
|
case "by_provider":
|
|
return o.issuesByProvider(&a)
|
|
case "provider_summary":
|
|
return o.issuesProviderSummary(&a)
|
|
case "comments":
|
|
return o.issuesComments(&a)
|
|
case "counts":
|
|
return o.issuesCounts()
|
|
default:
|
|
return o.invalid("action", "unsupported action %q", a.Action)
|
|
}
|
|
}
|
|
|
|
func (o *op) issueStatusCode(a *IssuesArgs) (int, *ToolResult) {
|
|
c, err := translate.IssueStatusToWire(a.Status)
|
|
if err != nil {
|
|
return 0, o.invalid("status", "unsupported status %q", a.Status)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// issuesList uses v1 paged issues: take then skip (do not reverse).
|
|
func (o *op) issuesList(a *IssuesArgs) *ToolResult {
|
|
code, fail := o.issueStatusCode(a)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
offset, limit := bounds(a.Page)
|
|
raw, fail := o.call("GET",
|
|
pathJoin("/api/v1/Issues", limit, offset, code), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
arr, fail := o.decodeArray(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
items := []Issue{}
|
|
for _, m := range arr {
|
|
items = append(items, o.projectIssue(m))
|
|
}
|
|
items = capItems(o, items)
|
|
pg := upstreamPage(o, a.Page, len(items), nil, "issues")
|
|
return o.ok(&IssuePage{Kind: "issue_page", Items: items, Page: pg})
|
|
}
|
|
|
|
// issuesSummary uses v2 provider groups: position then take.
|
|
func (o *op) issuesSummary(a *IssuesArgs) *ToolResult {
|
|
code, fail := o.issueStatusCode(a)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
offset, limit := bounds(a.Page)
|
|
raw, fail := o.call("GET",
|
|
pathJoin("/api/v2/Issues", offset, limit, code), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
arr, fail := o.decodeArray(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
items := []Group{}
|
|
for _, m := range arr {
|
|
items = append(items, o.projectGroup(m))
|
|
}
|
|
items = capItems(o, items)
|
|
pg := upstreamPage(o, a.Page, len(items), nil, "provider_groups")
|
|
return o.ok(&GroupPage{Kind: "group_page", Items: items, Page: pg})
|
|
}
|
|
|
|
func (o *op) issuesGet(a *IssuesArgs) *ToolResult {
|
|
if a.IssueID == nil || *a.IssueID < 1 {
|
|
return o.invalid("issue_id", "issue_id must be a positive integer")
|
|
}
|
|
raw, fail := o.call("GET", "/api/v1/Issues/"+segInt(*a.IssueID), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.ok(&IssuePage{Kind: "issue_page",
|
|
Items: []Issue{o.projectIssue(m)}, Page: singlePage(1, "issues")})
|
|
}
|
|
|
|
// issuesByArray projects an unspecified-body route that returns
|
|
// either a single issue object or an array of issues.
|
|
func (o *op) issuesByArray(path string) *ToolResult {
|
|
raw, fail := o.call("GET", path, nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
if arr, err := o.decodeArray(raw); err == nil {
|
|
items := []Issue{}
|
|
for _, m := range arr {
|
|
items = append(items, o.projectIssue(m))
|
|
}
|
|
items = capItems(o, items)
|
|
return o.ok(&IssuePage{Kind: "issue_page",
|
|
Items: items, Page: singlePage(len(items), "issues")})
|
|
}
|
|
m, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.ok(&IssuePage{Kind: "issue_page",
|
|
Items: []Issue{o.projectIssue(m)}, Page: singlePage(1, "issues")})
|
|
}
|
|
|
|
func (o *op) issuesByRequest(a *IssuesArgs) *ToolResult {
|
|
if a.RequestID == nil || *a.RequestID < 1 {
|
|
return o.invalid("request_id", "request_id must be a positive integer")
|
|
}
|
|
return o.issuesByArray("/api/v1/Issues/request/" + segInt(*a.RequestID))
|
|
}
|
|
|
|
func (o *op) issuesByProvider(a *IssuesArgs) *ToolResult {
|
|
if !nonempty(a.ProviderID) {
|
|
return o.invalid("provider_id", "provider_id must be nonempty")
|
|
}
|
|
return o.issuesByArray("/api/v1/Issues/provider/" + seg(a.ProviderID))
|
|
}
|
|
|
|
func (o *op) issuesProviderSummary(a *IssuesArgs) *ToolResult {
|
|
if !nonempty(a.ProviderID) {
|
|
return o.invalid("provider_id", "provider_id must be nonempty")
|
|
}
|
|
raw, fail := o.call("GET",
|
|
"/api/v2/Issues/details/"+seg(a.ProviderID), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
g := o.projectGroup(m)
|
|
return o.ok(&GroupPage{Kind: "group_page",
|
|
Items: []Group{g}, Page: singlePage(1, "provider_groups")})
|
|
}
|
|
|
|
func (o *op) issuesComments(a *IssuesArgs) *ToolResult {
|
|
if a.IssueID == nil || *a.IssueID < 1 {
|
|
return o.invalid("issue_id", "issue_id must be a positive integer")
|
|
}
|
|
raw, fail := o.call("GET",
|
|
"/api/v1/Issues/"+segInt(*a.IssueID)+"/comments", nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
arr, fail := o.decodeArray(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
items := []Comment{}
|
|
for _, m := range arr {
|
|
items = append(items, projectComment(m))
|
|
}
|
|
win, pg := localWindow(o, items, a.Page, "comments")
|
|
return o.ok(&CommentPage{Kind: "comment_page", Items: win, Page: pg})
|
|
}
|
|
|
|
func (o *op) issuesCounts() *ToolResult {
|
|
raw, fail := o.call("GET", "/api/v1/Issues/count", nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
vals := []Metric{}
|
|
for _, k := range []string{"pending", "inProgress", "resolved"} {
|
|
if i, ok := toInt(m[k]); ok {
|
|
name := k
|
|
if k == "inProgress" {
|
|
name = "in_progress"
|
|
}
|
|
vals = append(vals, metric(name, i, "instance", "issues"))
|
|
}
|
|
}
|
|
return o.ok(&Metrics{Kind: "metrics", Values: vals})
|
|
}
|
|
|
|
// pathJoin appends integer path segments to base in order.
|
|
func pathJoin(base string, segs ...int) string {
|
|
out := base
|
|
for _, s := range segs {
|
|
out += "/" + segInt(s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// write_issue_create — minimal editable body; server supplies author,
|
|
// timestamps and state. At least one association is required by
|
|
// design (request_id or provider_id).
|
|
func handleIssueCreate(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
|
o := newOp(ctx, env, "write_issue_create", true)
|
|
var a IssueCreateArgs
|
|
if fail := o.args(raw, &a); fail != nil {
|
|
return fail
|
|
}
|
|
if !nonempty(a.Title) {
|
|
return o.invalid("title", "title must contain non-whitespace text")
|
|
}
|
|
if !nonempty(a.Description) {
|
|
return o.invalid("description", "description must contain non-whitespace text")
|
|
}
|
|
if a.CategoryID < 1 {
|
|
return o.invalid("category_id", "category_id must be a positive integer")
|
|
}
|
|
rt, err := translate.RequestTypeToWire(a.RequestType)
|
|
if err != nil {
|
|
return o.invalid("request_type", "unsupported request_type %q", a.RequestType)
|
|
}
|
|
if a.RequestID == nil && !nonempty(a.ProviderID) {
|
|
return o.invalid("request_id",
|
|
"at least one association is required: request_id or provider_id")
|
|
}
|
|
if a.RequestID != nil && *a.RequestID < 1 {
|
|
return o.invalid("request_id", "request_id must be a positive integer")
|
|
}
|
|
body := ombi.IssueCreateBody{
|
|
Title: a.Title,
|
|
Subject: a.Subject,
|
|
Description: a.Description,
|
|
IssueCategoryID: a.CategoryID,
|
|
RequestType: rt,
|
|
RequestID: a.RequestID,
|
|
ProviderID: a.ProviderID,
|
|
}
|
|
raw2, fail := o.call("POST", "/api/v1/Issues", nil, body)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
v, fail := o.decodeScalar(raw2)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m := &Mutation{Kind: "mutation", Outcome: "completed"}
|
|
if id, ok := toInt(v); ok && id > 0 {
|
|
m.IssueID = &id
|
|
} else {
|
|
o.warnf("upstream returned no issue id")
|
|
m.Outcome = "accepted"
|
|
}
|
|
return o.ok(m)
|
|
}
|
|
|
|
// write_issue_comment — POST {comment, issueId}; returns the created
|
|
// comment entity.
|
|
func handleIssueComment(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
|
o := newOp(ctx, env, "write_issue_comment", true)
|
|
var a IssueCommentArgs
|
|
if fail := o.args(raw, &a); fail != nil {
|
|
return fail
|
|
}
|
|
if a.IssueID < 1 {
|
|
return o.invalid("issue_id", "issue_id must be a positive integer")
|
|
}
|
|
if !nonempty(a.Comment) {
|
|
return o.invalid("comment", "comment must contain non-whitespace text")
|
|
}
|
|
raw2, fail := o.call("POST", "/api/v1/Issues/comments", nil,
|
|
ombi.NewIssueCommentViewModel{Comment: a.Comment, IssueID: a.IssueID})
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m := &Mutation{Kind: "mutation", Outcome: "completed", IssueID: &a.IssueID}
|
|
if obj, err := o.decodeObject(raw2); err == nil {
|
|
if id := jint(obj, "id"); id != nil {
|
|
m.CommentID = id
|
|
}
|
|
} else {
|
|
m.Outcome = "accepted"
|
|
o.warnf("upstream returned no comment entity")
|
|
}
|
|
return o.ok(m)
|
|
}
|
|
|
|
// write_issue_manage — status changes, deletes and category admin.
|
|
func handleIssueManage(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
|
o := newOp(ctx, env, "write_issue_manage", true)
|
|
var a IssueManageArgs
|
|
if fail := o.args(raw, &a); fail != nil {
|
|
return fail
|
|
}
|
|
switch a.Action {
|
|
case "set_status":
|
|
return o.issueSetStatus(&a)
|
|
case "delete":
|
|
return o.issueDelete(&a)
|
|
case "delete_comment":
|
|
return o.issueDeleteComment(&a)
|
|
case "create_category":
|
|
return o.issueCreateCategory(&a)
|
|
case "delete_category":
|
|
return o.issueDeleteCategory(&a)
|
|
default:
|
|
return o.invalid("action", "unsupported action %q", a.Action)
|
|
}
|
|
}
|
|
|
|
func (o *op) issueSetStatus(a *IssueManageArgs) *ToolResult {
|
|
if a.IssueID == nil || *a.IssueID < 1 {
|
|
return o.invalid("issue_id", "issue_id must be a positive integer")
|
|
}
|
|
st, err := translate.IssueStatusToWire(a.Status)
|
|
if err != nil {
|
|
return o.invalid("status", "unsupported status %q", a.Status)
|
|
}
|
|
raw, fail := o.call("POST", "/api/v1/Issues/status", nil,
|
|
ombi.IssueStateViewModel{IssueID: *a.IssueID, Status: st})
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.boolMutation(raw, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m.IssueID = a.IssueID
|
|
return o.ok(m)
|
|
}
|
|
|
|
func (o *op) issueDelete(a *IssueManageArgs) *ToolResult {
|
|
if a.IssueID == nil || *a.IssueID < 1 {
|
|
return o.invalid("issue_id", "issue_id must be a positive integer")
|
|
}
|
|
raw, fail := o.call("DELETE", "/api/v1/Issues/"+segInt(*a.IssueID), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.boolMutation(raw, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m.IssueID = a.IssueID
|
|
return o.ok(m)
|
|
}
|
|
|
|
func (o *op) issueDeleteComment(a *IssueManageArgs) *ToolResult {
|
|
if a.CommentID == nil || *a.CommentID < 1 {
|
|
return o.invalid("comment_id", "comment_id must be a positive integer")
|
|
}
|
|
raw, fail := o.call("DELETE",
|
|
"/api/v1/Issues/comments/"+segInt(*a.CommentID), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.boolMutation(raw, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m.CommentID = a.CommentID
|
|
return o.ok(m)
|
|
}
|
|
|
|
func (o *op) issueCreateCategory(a *IssueManageArgs) *ToolResult {
|
|
if !nonempty(a.Value) {
|
|
return o.invalid("value", "value must contain non-whitespace text")
|
|
}
|
|
raw, fail := o.call("POST", "/api/v1/Issues/categories", nil,
|
|
ombi.IssueCategoryBody{Value: a.Value})
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.boolMutationOK(raw)
|
|
}
|
|
|
|
func (o *op) issueDeleteCategory(a *IssueManageArgs) *ToolResult {
|
|
if a.CategoryID == nil || *a.CategoryID < 1 {
|
|
return o.invalid("category_id", "category_id must be a positive integer")
|
|
}
|
|
raw, fail := o.call("DELETE",
|
|
"/api/v1/Issues/categories/"+segInt(*a.CategoryID), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.boolMutation(raw, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m.CategoryID = a.CategoryID
|
|
return o.ok(m)
|
|
}
|
|
|
|
// boolMutationOK wraps a boolean write result with no extra fields.
|
|
func (o *op) boolMutationOK(raw []byte) *ToolResult {
|
|
m, fail := o.boolMutation(raw, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.ok(m)
|
|
}
|