2 Commits
Author SHA1 Message Date
gronod e7728cdbce Add integration verification suite: raw JSON-RPC harness (Phase 08)
Exercises the compiled binary end to end over stdio rather than
calling handlers in-process, so config loading, auth, transport and
envelope mapping are all covered. 32 contract tests run against a
mock Ombi (undocumented-field fixtures, auth enforcement, recorded
upstream calls) behind the integration build tag; live tests engage
when OMBI_* env vars are set. No handler or schema changes were
needed — findings recorded in 06-verification.md.
2026-09-18 19:14:17 +01:00
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
38 changed files with 16259 additions and 4 deletions
+3
View File
@@ -20,6 +20,7 @@ Remote repo: https://git.i3omb.com/gronod/ombi-mcp.git
- `internal/mcpserver/` — MCP server setup
- `internal/tools/` — MCP tool registry, argument, and result types
- `internal/translate/` — enum/terminology translation layer
- `internal/integration_test/` — build-tagged (`//go:build integration`) end-to-end suite: spawns the compiled binary and speaks raw MCP JSON-RPC over stdio against a mock Ombi, or a live instance when `OMBI_*` env vars are set
- `docs/` — schema and planning docs
- `AGENTS.md` — this file
@@ -39,6 +40,8 @@ Toolchain: Go (module `ombi-mcp`). Build with `CGO_ENABLED=0` — the server shi
- Build binary: `CGO_ENABLED=0 go build -o ombi-mcp ./cmd/ombi-mcp`
- Run dev server: `go run ./cmd/ombi-mcp`
- Run tests: `go test ./...`
- Run integration tests (mock Ombi, no credentials needed): `go test -tags integration ./internal/integration_test/`
- Run live integration tests: set `OMBI_*` vars (e.g. `set -a; . ./.env; set +a`) then the same command — live tests skip when `OMBI_URL` is unset
- Lint / typecheck: `go vet ./...` (and `gofmt -l .` for formatting)
## Conventions
+201
View File
@@ -0,0 +1,201 @@
# ombi-mcp
A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that exposes [Ombi](https://ombi.io/) — the self-hosted media request manager — to MCP clients such as AI assistants.
`ombi-mcp` is a distributable, statically linked Go binary that speaks MCP over stdio. It works against **any** self-hosted Ombi instance: point it at your instance with `OMBI_URL` and authenticate with either a JWT login or an API key. Nothing is instance-specific or hardcoded.
> **Status:** under active development. The tool catalogue, schemas and auth layer are implemented; see [Project status](#project-status) for details.
## Features
- **31 tools** covering search, discovery, requests, issues, votes, users, library, server status and administration — every one mapped from the documented Ombi API.
- **Bundle-based deployment policy**: expose only ordinary media workflows (`core`), or opt in to `moderation` and `administration` tool groups per deployment.
- **Effect-encoding tool names**: `read_*` tools are read-only, `write_*` tools mutate state. MCP tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) are declared per tool.
- **Strict, embedded schemas**: every tool advertises a complete JSON Schema 2020-12 input schema and a structured output envelope, generated from the design docs in [`docs/schema`](docs/schema/README.md) and embedded at build time.
- **Two upstream auth modes** with automatic JWT lifecycle management (login, caching, single-flight renewal, retry-on-401).
- **Predictable results**: every tool returns the same bounded envelope with `ok`, `data`/`error`, `warnings`, `truncated` and a `correlation_id` — never raw upstream dumps.
## Requirements
- Go 1.27+ (to build)
- A reachable Ombi instance and credentials (API key, or username/password for JWT mode)
## Building
```sh
CGO_ENABLED=0 go build -o ombi-mcp ./cmd/ombi-mcp
```
The server ships as a statically linked binary with no CGO dependencies.
## Configuration
All configuration is via environment variables:
| Variable | Required | Description |
|---|---|---|
| `OMBI_URL` | yes | Base URL of the Ombi instance. Must be a scheme + host with no credentials, query string or fragment. A reverse-proxy path prefix is preserved. |
| `OMBI_AUTH_MODE` | yes | Upstream credential mechanism: `jwt` (primary) or `api_key`. |
| `OMBI_USERNAME` | jwt mode | Ombi login username. |
| `OMBI_PASSWORD` | jwt mode | Ombi login password. |
| `OMBI_API_KEY` | api_key mode | Ombi API key, sent as the `ApiKey` header. |
| `OMBI_USER_NAME` | no | Optional Ombi username sent as the `UserName` header in `api_key` mode, to bind requests to a specific Ombi user. |
| `OMBI_BUNDLES` | no | Comma-separated tool bundles to enable: `core`, `moderation`, `administration`. Defaults to `core`. |
Credentials are read from the environment only — keep them in a local `.env` or your MCP client's per-server `env` block, and never commit them.
### Auth modes
- **`jwt`** (primary): logs in once via `POST /api/v1/Token` with `OMBI_USERNAME`/`OMBI_PASSWORD`, caches the bearer token, renews it proactively before expiry (expiry is taken from the response, the JWT `exp` claim, or a conservative fallback), and transparently retries once after a `401`.
- **`api_key`** (secondary): sends the `ApiKey` header on every request; optionally impersonates/binds a user with `UserName`.
## Running with an MCP client
`ombi-mcp` communicates over **stdio**, so it is launched as a subprocess by the MCP client. Example client configuration:
```json
{
"mcpServers": {
"ombi": {
"command": "/usr/local/bin/ombi-mcp",
"env": {
"OMBI_URL": "https://ombi.example.com",
"OMBI_AUTH_MODE": "api_key",
"OMBI_API_KEY": "your-api-key"
}
}
}
}
```
To expose moderation and administration tools as well:
```json
"env": {
"OMBI_URL": "https://ombi.example.com",
"OMBI_AUTH_MODE": "jwt",
"OMBI_USERNAME": "your-user",
"OMBI_PASSWORD": "your-password",
"OMBI_BUNDLES": "core,moderation,administration"
}
```
## Tool catalogue
Tools are grouped into **bundles** — deployment policy groups, not permission boundaries by themselves. Only tools in enabled bundles are advertised via `tools/list`, and bundle policy is re-checked at `tools/call` time.
### Core (18 tools) — enabled by default
| Tool | Description |
|---|---|
| `read_search` | Search media by text, with explicit movie refinements or a multi-search filter. |
| `read_discover` | Curated lists, similar movies, collections, credits, artist albums, advanced movie filters. |
| `read_media` | Media details by provider or request; ratings; streaming availability. |
| `read_reference` | Genres, languages, keywords, watch-provider catalogue, countries, issue categories. |
| `read_requests` | List/get/search requests, TV children, recent requests, privileged retry queue. |
| `read_request_stats` | Request counts, totals, per-media quota, user-has-requests. |
| `read_issues` | Issues, grouped summaries, comments and counts. |
| `read_votes` | Global vote list or votes on a request. |
| `read_users` | Self, authorized user lookup, claims, online users, preference read. |
| `read_library` | Recent additions, calendar, artwork. |
| `read_server` | Server status, version, features, news, stats, cron validation. |
| `read_integration` | Saved ARR options and authorized media-server metadata. |
| `write_request_create` | Create one media request or an explicit collection request. |
| `write_request_subscribe` | Subscribe/unsubscribe to a request. |
| `write_issue_create` | Report an issue. |
| `write_issue_comment` | Add a comment to an issue. |
| `write_vote` | Up/down vote on a request. |
| `write_user_preferences` | Language, streaming country, newsletter opt-out. |
### Moderation (5 tools) — opt-in
| Tool | Description |
|---|---|
| `write_request_moderate` | Approve, deny, mark available. |
| `write_request_delete` | Delete a single movie/album/TV-parent/TV-child request. |
| `write_request_options` | Advanced routing overrides and TV root folder/quality. |
| `write_request_reprocess` | Reprocess an existing request. |
| `write_issue_manage` | Change issue state, delete issues/comments, manage categories. |
### Administration (8 tools) — opt-in
| Tool | Description |
|---|---|
| `read_settings` | Safe (non-secret) configuration projection and revision. |
| `write_settings_patch` | Typed, non-secret settings patch or feature flag. |
| `write_user_manage` | Delete a user or send a welcome email. |
| `write_integration_test` | Test a saved integration profile (can send notifications). |
| `write_job_run` | Trigger permitted jobs / watchlist revalidation. |
| `write_notification_send` | Send email to explicit recipients. |
| `write_retry_remove` | Remove one retry-queue entry. |
| `read_logs` | Bounded, sanitized diagnostic log reads. |
## Result envelope
Every tool returns the same structured envelope as its `structuredContent` (plus a serialized text copy for clients that do not consume structured content):
```json
{
"ok": true,
"operation": "read_search",
"data": { "…family-specific result…" },
"warnings": [],
"truncated": false,
"correlation_id": "…"
}
```
- On success (`ok: true`) `data` is present and `error` is absent; on failure the reverse. This is enforced by the tool's JSON Schema output contract.
- `data` is always one of a small set of typed **families** (e.g. `media_page`, `request_page`, `mutation`, `metrics`) — never an arbitrary upstream payload.
- `truncated` signals that a bounded result was clipped; `warnings` carries non-fatal notes; `correlation_id` ties a result back to a specific call for diagnostics.
- Errors carry a stable `code` (e.g. `INVALID_ARGUMENT`, `INTERNAL_ERROR`), a human-readable message, a `retryable` flag and, where applicable, the upstream `http_status` and `retry_after_seconds`.
Enum-like upstream values (request types, issue statuses, notification agents/types) are translated to stable agent-facing labels (`movie`/`tv`/`album`, `pending`/`in_progress`/`resolved`/`closed`, …) in both directions by the `internal/translate` layer.
## Architecture
```
cmd/ombi-mcp/ entrypoint: config → auth → client → MCP server over stdio
internal/config/ environment/config loading and validation
internal/ombi/ Ombi API client, auth manager, wire types
internal/mcpserver/ MCP protocol wiring (tools/list, tools/call dispatch)
internal/tools/ tool registry, argument handling, result envelope,
embedded JSON schemas (generated)
internal/translate/ bidirectional enum label ↔ wire-constant translation
docs/schema/ design and audit docs; source of the tool contracts
tools/schemagen/ generator that extracts embedded schemas from docs
```
Design principles:
- **Single client module** — all Ombi API access goes through `internal/ombi`; tools never make their own HTTP calls.
- **Static, audited catalogue** — the 31-tool registry is fixed; input schemas are generated from the docs via `go generate` and embedded, and output schemas are composed per tool from a shared `$defs` library.
- **Bounded results** — list-shaped data is page-bounded and flagged with `truncated` rather than streamed unbounded.
- **No instance-specific defaults** — every URL and credential comes from the environment.
## Development
Requires Go 1.27+.
```sh
go mod tidy # install dependencies
CGO_ENABLED=0 go build ./... # build all packages
CGO_ENABLED=0 go build -o ombi-mcp ./cmd/ombi-mcp
go run ./cmd/ombi-mcp # run dev server (stdio)
go test ./... # run tests
go vet ./... && gofmt -l . # lint / formatting check
go generate ./internal/tools # regenerate embedded schemas
```
For integration testing against a real instance, put `OMBI_URL`, `OMBI_AUTH_MODE` and credentials in a local `.env` — **never commit secrets, `.env` files or generated artifacts**.
## Project status
- **Done:** schema design and endpoint-coverage audit (all 377 RAML operations accounted for in `docs/schema/`), the 31-tool catalogue with input/output JSON Schemas, JWT and API-key auth with token lifecycle management, the MCP stdio server with bundle filtering and tool annotations, and the enum translation layer.
- **In progress:** completing and verifying tool handlers against a live Ombi instance; see `docs/schema/06-verification.md` for tracked runtime assumptions and acceptance criteria.
## Links
- Ombi: <https://ombi.io/>
- Model Context Protocol: <https://modelcontextprotocol.io/>
- Design docs: [`docs/schema/README.md`](docs/schema/README.md)
+48 -1
View File
@@ -1,3 +1,50 @@
package main
func main() {}
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/modelcontextprotocol/go-sdk/mcp"
"ombi-mcp/internal/config"
"ombi-mcp/internal/mcpserver"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/tools"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "ombi-mcp:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
hc := &http.Client{Timeout: cfg.HTTPTimeout}
creds := ombi.Credentials{
Mode: ombi.AuthMode(cfg.AuthMode),
Username: cfg.Username,
Password: cfg.Password,
APIKey: cfg.APIKey,
UserName: cfg.UserName,
}
auth := ombi.NewAuthManager(creds, cfg.BaseURL.String(), hc)
env := &tools.Env{Upstream: ombi.NewClient(cfg.BaseURL.String(), auth, hc)}
srv, err := mcpserver.New(env, cfg.EnabledBundles)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
return srv.Run(ctx, &mcp.StdioTransport{})
}
+45
View File
@@ -252,3 +252,48 @@ Tool: `write_integration_test`.
- [MCP resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) supports optional bounded reference/artwork resources.
The protocol baseline is intentionally pinned; consult the applicable version when choosing a newer transport implementation. RAML corrections or live-server observations should be recorded as versioned adapter evidence rather than silently editing the meaning of this snapshot.
## Phase 08 integration verification (2026-09-18)
This section records the runtime verification of the compiled server, performed after Phases 00–07. It complements — does not replace — the design-time checks above.
### Harness
`internal/integration_test/` (whole package behind `//go:build integration`; standard `go test ./...` never compiles it). Run with `go test -tags integration ./internal/integration_test/`. The suite spawns the **compiled binary** as a subprocess and speaks raw newline-delimited JSON-RPC 2.0 over stdio (`initialize` → `notifications/initialized` → `tools/list`/`tools/call`), then decodes the `structuredContent` envelope. Two upstream targets:
- **Mock Ombi** (`httptest`): enforces both auth modes, records every upstream call for wire-shape assertions, serves realistic fixtures deliberately laced with undocumented fields. Always runs.
- **Live Ombi**: the same style of tests gated on `OMBI_URL`/`OMBI_AUTH_MODE`/`OMBI_USERNAME`/`OMBI_PASSWORD`/`OMBI_API_KEY`/`OMBI_USER_NAME` from the parent env (a sourced `.env`). Skips cleanly when unset. Pending credentials — see "Remaining live Verify items".
### Results: mock suite — 32/32 pass
| Contract area | Evidence |
|---|---|
| Protocol surface | `initialize` returns `serverInfo.name=ombi-mcp`; `tools/list` advertises exactly 31 tools (all bundles) / 18 (core only); a disabled-bundle tool is refused at call time, not just hidden from the list |
| Read/projection contract | `read_media` (movie + TV details), `read_requests` (movie/TV lists), `read_issues` project correctly despite fixtures carrying ~10 undocumented fields each (nested objects, arrays, scalars); allowlist projection confirmed — nothing undocumented passes through; `isError` mirrors `!ok`; text content equals the structured envelope |
| Enum translation (T1–T4) | `status:1`→`in_progress`, `requestType:1`→`movie` twins emitted with `*_code` preserved; unmapped `status:99` keeps code, empty label, and raises a `warnings[]` entry. Notification template patch translates `notification_type`/`agent` labels back to wire ints (T4 `issue`→1, T3 `discord`→1); snake_case keys never reach the wire |
| Expansion logic | `write_request_create` season mode: exactly one private `GET /api/v2/Search/tv/moviedb/{id}`, exactly one `POST /api/v2/Requests/tv`; posted `seasons[].episodes` equals the fixture's explicit episode lists (S1:7 eps, S2:4 eps); `requestAll`/`firstSeason`/`latestSeason` stay false. Flag modes (`all`/`first_season`/`latest_season`) set the matching wire flag with no private GET. `episodes` mode posts explicit picks to the v1 TVDB route with no details GET. Unknown season number → `INVALID_ARGUMENT` before any POST |
| Patch cycle | `read_settings` omits secret/excluded fields into `omitted_fields` only (values never projected) and emits a revision for writable sections only. `write_settings_patch` GETs before POSTing, SHA256 revision lock holds (stale → `CONFLICT`, no POST), null/excluded-field changes → `INVALID_ARGUMENT` before upstream traffic, no-op patch → `INVALID_ARGUMENT`, nested objects deep-merge (`customPage.content` preserved when `customPage.enabled` patched), secret fields preserved byte-for-byte in the POSTed document |
| Error boundaries | 401 → `AUTHENTICATION_FAILED` (bad ApiKey; rejected JWT login), 404 → `NOT_FOUND` (JSON and HTML bodies), 429 → `RATE_LIMITED` with `retry_after_seconds` parsed from `Retry-After`, 500 → retryable `UPSTREAM_REJECTED`, malformed upstream JSON → `UPSTREAM_SCHEMA_MISMATCH`, HTTP 200 with `isError:true` → `UPSTREAM_REJECTED` with sanitized message, transport failure → retryable `UPSTREAM_REJECTED` (read) / non-retryable `UNKNOWN_OUTCOME` (write). Every result is a mapped `ToolError` envelope; leak scan over the entire raw result confirms no `Bearer`/`Authorization`/`ApiKey`, no HTML, no stack traces — including when the upstream body actively contains them |
| Auth lifecycle | JWT login → cached token reuse → 401 triggers invalidate + single re-login + one retry (verified by rotating the accepted token). `api_key` mode sends `ApiKey` and never `Authorization`, never hits `/api/v1/Token` |
| Argument validation | `{}` rejected by write tools; unknown fields rejected (`additionalProperties:false` semantics); `album`+`unavailable` combination rejected — all before upstream calls |
### Live observations (unauthenticated/invalid-credential probes)
- `GET /api/v1/Status` returns HTTP 200 with body `200` and no credentials — the scalar-decode path handles it.
- `GET /api/v1/Settings/clientid` and `/api/v1/Settings/baseurl` are open unauthenticated.
- **Quirk:** sending an *invalid* `ApiKey` header makes even nominally-open routes return 401 — Ombi validates presented credentials regardless of endpoint auth requirements. Through the server this correctly surfaces as `AUTHENTICATION_FAILED` (verified against the real instance: `TestLiveBadCredentials` PASS).
- `POST`ing to a dead upstream confirmed read-vs-write error asymmetry (`UPSTREAM_REJECTED` vs `UNKNOWN_OUTCOME`).
### Handler changes required
**None.** No discrepancies between the RAML-derived contracts and exercised behaviour were found; no input/output schema change was needed. All defects found during the phase were in the test harness itself (mock body-drain on record, mock token recomputation), fixed there.
### Remaining live Verify items (pending credentials)
The gated live suite is committed and ready; run it with a sourced `.env`. It additionally performs the only state-mutating checks — `write_request_create` season expansion against a real show (then deletes the created parent) and a self-restoring `write_settings_patch` cycle — plus `read_requests`/`read_issues`/`read_media` on real payloads and real JWT renewal. Live 429 remains unverifiable by nature (cannot force upstream rate limiting); mock coverage stands in.
### Constraint check
- Input/output schema contracts in `docs/schema/` unchanged.
- `go test ./...` output is identical to before the phase (package fully behind the tag).
- No instance URL in committed files; `.env` guidance points to the gitignored file only.
+12 -1
View File
@@ -2,4 +2,15 @@ module ombi-mcp
go 1.27.1
require github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
require github.com/modelcontextprotocol/go-sdk v1.8.0
require (
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+22
View File
@@ -1,2 +1,24 @@
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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
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=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+947
View File
@@ -0,0 +1,947 @@
//go:build integration
package integration_test
import (
"encoding/json"
"strings"
"testing"
)
// --- protocol surface ---
func TestHandshakeAndToolList(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
names := c.listTools(t)
if len(names) != 31 {
t.Fatalf("tools/list returned %d tools, want 31", len(names))
}
seen := map[string]bool{}
for _, n := range names {
seen[n] = true
if !strings.HasPrefix(n, "read_") && !strings.HasPrefix(n, "write_") {
t.Errorf("tool %q lacks read_/write_ prefix", n)
}
}
for _, want := range []string{
"read_media", "read_requests", "read_issues", "read_settings",
"write_request_create", "write_settings_patch",
} {
if !seen[want] {
t.Errorf("missing tool %q", want)
}
}
}
func TestBundleRestriction(t *testing.T) {
mock := newMockOmbi(t, "jwt")
env := mock.env()
env["OMBI_BUNDLES"] = "core"
c := spawnServer(t, env)
c.handshake(t)
names := c.listTools(t)
if len(names) != 18 {
t.Fatalf("core bundle advertised %d tools, want 18", len(names))
}
for _, n := range names {
if n == "read_settings" || n == "write_settings_patch" {
t.Fatalf("administration tool %q advertised under core bundle", n)
}
}
// Call-time enforcement: a disabled tool must not dispatch even
// if the client knows its name.
res, err := c.request("tools/call", map[string]any{
"name": "read_settings", "arguments": map[string]any{"section": "ombi"},
})
if err == nil {
t.Fatalf("disabled tool dispatched: %s", res)
}
}
// --- 1. read/projection contract ---
func TestReadMediaDetailsProjectsUndocumentedFields(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 27205},
})
data := requireOK(t, out)
var page struct {
Kind string `json:"kind"`
Items []struct {
Media string `json:"media"`
Title string `json:"title"`
Year *int `json:"year"`
Genres []string `json:"genres"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
Ratings []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"ratings"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("media_page decode: %v\n%s", err, data)
}
if page.Kind != "media_page" || len(page.Items) != 1 {
t.Fatalf("bad media_page shape: %s", data)
}
it := page.Items[0]
if it.Media != "movie" || it.Title != "Inception" {
t.Fatalf("unexpected projection: %s", data)
}
if it.Year == nil || *it.Year != 2010 {
t.Errorf("year = %v, want 2010", it.Year)
}
if len(it.Genres) != 2 || it.Genres[0] != "Action" {
t.Errorf("genres = %v", it.Genres)
}
ids := map[string]string{}
for _, id := range it.Identifiers {
ids[id.Namespace] = id.Value
}
if ids["tmdb"] != "27205" || ids["imdb"] != "tt1375666" {
t.Errorf("identifiers = %v", ids)
}
// Undocumented upstream fields must not appear in the projection.
for _, leaked := range []string{
"productionCompanies", "undocumentedFuture", "runtimeNewField",
"watchProviders", "popularity",
} {
if strings.Contains(string(data), leaked) {
t.Errorf("projection passed through undocumented field %q", leaked)
}
}
assertNoLeak(t, out.Raw)
}
func TestReadRequestsListProjection(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "movie",
})
data := requireOK(t, out)
var page struct {
Kind string `json:"kind"`
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
Title string `json:"title"`
Approved *bool `json:"approved"`
Available *bool `json:"available"`
Is4K *bool `json:"is_4k"`
} `json:"items"`
Page struct {
Mode string `json:"mode"`
Total *int `json:"total"`
} `json:"page"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("request_page decode: %v\n%s", err, data)
}
if page.Kind != "request_page" || len(page.Items) != 1 {
t.Fatalf("bad request_page: %s", data)
}
it := page.Items[0]
if it.Target.Kind != "movie" || it.Target.ID != 10 || it.Title != "Inception" {
t.Fatalf("bad request projection: %s", data)
}
if page.Page.Mode != "upstream" || page.Page.Total == nil || *page.Page.Total != 1 {
t.Errorf("paging = %+v", page.Page)
}
if strings.Contains(string(data), "newHiddenField") {
t.Errorf("undocumented field leaked into projection")
}
}
func TestReadRequestsTVChildrenAreChildKind(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "tv",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
ParentRequestID *int `json:"parent_request_id"`
Seasons []struct {
SeasonNumber int `json:"season_number"`
Episodes []struct {
EpisodeNumber int `json:"episode_number"`
} `json:"episodes"`
} `json:"seasons"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v", err)
}
if len(page.Items) != 1 || page.Items[0].Target.Kind != "tv_child" {
t.Fatalf("v2 TV list must project tv_child targets: %s", data)
}
if page.Items[0].ParentRequestID == nil || *page.Items[0].ParentRequestID != 12 {
t.Errorf("parent_request_id = %v", page.Items[0].ParentRequestID)
}
if len(page.Items[0].Seasons) != 1 || len(page.Items[0].Seasons[0].Episodes) != 2 {
t.Errorf("season projection wrong: %s", data)
}
}
// --- enum translation twins (T1 request_type, T2 issue status) ---
func TestEnumTranslationTwins(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_issues", map[string]any{
"action": "list", "status": "pending",
})
data := requireOK(t, out)
var page struct {
Items []struct {
ID int `json:"id"`
Status string `json:"status"`
StatusCode *int `json:"status_code"`
RequestType string `json:"request_type"`
RequestTypeCode *int `json:"request_type_code"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v", err)
}
if len(page.Items) != 2 {
t.Fatalf("want 2 issues, got %s", data)
}
// status=1 → in_progress; requestType=1 → movie.
if page.Items[0].Status != "in_progress" || *page.Items[0].StatusCode != 1 {
t.Errorf("status twin = %q/%v", page.Items[0].Status, page.Items[0].StatusCode)
}
if page.Items[0].RequestType != "movie" || *page.Items[0].RequestTypeCode != 1 {
t.Errorf("request_type twin = %q/%v", page.Items[0].RequestType, page.Items[0].RequestTypeCode)
}
// status=99 is unmapped: code preserved, label empty, warning raised.
if page.Items[1].Status != "" || *page.Items[1].StatusCode != 99 {
t.Errorf("unmapped twin = %q/%v", page.Items[1].Status, page.Items[1].StatusCode)
}
foundWarn := false
for _, w := range out.Envelope.Warnings {
if strings.Contains(w, "unmapped upstream enum value 99") {
foundWarn = true
}
}
if !foundWarn {
t.Errorf("expected unmapped-enum warning, got %v", out.Envelope.Warnings)
}
}
// --- 2. expansion logic: write_request_create season mode ---
func TestSeasonExpansionWire(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv",
"provider": "tmdb",
"id": 1396,
"selection": map[string]any{
"mode": "season",
"season_numbers": []int{1, 2},
},
})
data := requireOK(t, out)
var mut struct {
Kind string `json:"kind"`
Outcome string `json:"outcome"`
RequestID *int `json:"request_id"`
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
}
if err := json.Unmarshal(data, &mut); err != nil {
t.Fatalf("mutation decode: %v", err)
}
if mut.Kind != "mutation" || mut.Outcome != "completed" {
t.Fatalf("bad mutation: %s", data)
}
if mut.RequestID == nil || *mut.RequestID != 555 {
t.Errorf("request_id = %v", mut.RequestID)
}
// The private GET must have fetched TMDB details exactly once.
if n := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/1396"); n != 1 {
t.Fatalf("private TV-details GET count = %d, want 1", n)
}
// Exactly one create POST.
if n := mock.countCalls("POST", "/api/v2/Requests/tv"); n != 1 {
t.Fatalf("create POST count = %d, want 1", n)
}
body := mock.lastBody(t, "POST", "/api/v2/Requests/tv")
if body["theMovieDbId"] != float64(1396) {
t.Errorf("theMovieDbId = %v", body["theMovieDbId"])
}
if body["requestAll"] != false || body["firstSeason"] != false || body["latestSeason"] != false {
t.Errorf("selection flags wrong: %v", body)
}
seasons, ok := body["seasons"].([]any)
if !ok || len(seasons) != 2 {
t.Fatalf("seasons = %v", body["seasons"])
}
s1 := seasons[0].(map[string]any)
if s1["seasonNumber"] != float64(1) {
t.Fatalf("season[0].seasonNumber = %v", s1["seasonNumber"])
}
eps1, _ := s1["episodes"].([]any)
if len(eps1) != 7 {
t.Fatalf("season 1 expanded to %d episodes, want 7", len(eps1))
}
for i, e := range eps1 {
em := e.(map[string]any)
if em["episodeNumber"] != float64(i+1) {
t.Fatalf("episode %d = %v", i, em)
}
}
s2 := seasons[1].(map[string]any)
eps2, _ := s2["episodes"].([]any)
if s2["seasonNumber"] != float64(2) || len(eps2) != 4 {
t.Fatalf("season 2 expansion wrong: %v", s2)
}
assertNoLeak(t, out.Raw)
}
func TestSeasonExpansionMissingSeason(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv",
"provider": "tmdb",
"id": 1396,
"selection": map[string]any{
"mode": "season",
"season_numbers": []int{99},
},
})
e := requireErr(t, out, "INVALID_ARGUMENT")
if e.Field != "selection.season_numbers" {
t.Errorf("error field = %q", e.Field)
}
// The private GET ran but no POST must have been sent.
if n := mock.countCalls("POST", "/api/v2/Requests/tv"); n != 0 {
t.Fatalf("create POST sent on invalid selection")
}
}
func TestEpisodesModeExpansion(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv",
"provider": "tvdb",
"id": 81189,
"selection": map[string]any{
"mode": "episodes",
"seasons": []any{
map[string]any{"season_number": 1, "episodes": []int{1, 3}},
},
},
})
requireOK(t, out)
// episodes mode must not hit the details route.
if n := mock.countCalls("GET", "/api/v2/Search/tv/"); n != 0 {
t.Errorf("episodes mode triggered a private details GET")
}
body := mock.lastBody(t, "POST", "/api/v1/Request/tv")
if body["tvDbId"] != float64(81189) {
t.Errorf("tvDbId = %v", body["tvDbId"])
}
seasons := body["seasons"].([]any)
s1 := seasons[0].(map[string]any)
eps := s1["episodes"].([]any)
if len(eps) != 2 {
t.Fatalf("episodes = %v", eps)
}
}
// --- 3. patch cycle ---
func readSettingsRevision(t *testing.T, c *rpcClient, section string) string {
t.Helper()
out := c.callTool(t, "read_settings", map[string]any{"section": section})
data := requireOK(t, out)
var s struct {
Kind string `json:"kind"`
Section string `json:"section"`
Revision string `json:"revision"`
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("settings decode: %v", err)
}
if s.Kind != "settings" || s.Section != section {
t.Fatalf("bad settings family: %s", data)
}
if s.Revision == "" {
t.Fatalf("writable section offered no revision")
}
return s.Revision
}
func TestSettingsReadProjection(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_settings", map[string]any{"section": "customization"})
data := requireOK(t, out)
var s struct {
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("decode: %v", err)
}
names := map[string]any{}
for _, v := range s.Values {
names[v.Name] = v.Value
}
if names["/applicationName"] != "Ombi" {
t.Errorf("applicationName = %v", names["/applicationName"])
}
// Secret-looking and excluded fields land in omitted_fields only.
joined := strings.Join(s.OmittedFields, ",")
for _, want := range []string{"/secretToken", "/apiKey", "/applicationUrl", "/logo", "/favicon"} {
if !strings.Contains(joined, want) {
t.Errorf("omitted_fields missing %q: %v", want, s.OmittedFields)
}
}
if strings.Contains(string(data), "tok-should-not-project") ||
strings.Contains(string(data), "key-should-not-project") ||
strings.Contains(string(data), "ombi.internal.example") {
t.Errorf("secret value leaked into settings projection: %s", data)
}
assertNoLeak(t, out.Raw)
}
func TestSettingsPatchCycle(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "customization")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{"applicationName": "OmbiPatched"},
})
data := requireOK(t, out)
var mut struct {
Kind string `json:"kind"`
Outcome string `json:"outcome"`
}
if err := json.Unmarshal(data, &mut); err != nil || mut.Outcome != "completed" {
t.Fatalf("bad mutation: %s", data)
}
// The POSTed document must carry the change AND preserve every
// untouched field — including secrets the read projection hid.
body := mock.lastBody(t, "POST", "/api/v1/Settings/customization")
if body["applicationName"] != "OmbiPatched" {
t.Errorf("applicationName = %v", body["applicationName"])
}
if body["secretToken"] != "tok-should-not-project" {
t.Errorf("secretToken not preserved: %v", body["secretToken"])
}
if body["apiKey"] != "key-should-not-project" {
t.Errorf("apiKey not preserved: %v", body["apiKey"])
}
if body["applicationUrl"] != "https://ombi.internal.example" {
t.Errorf("applicationUrl not preserved: %v", body["applicationUrl"])
}
if _, ok := body["customPage"].(map[string]any); !ok {
t.Errorf("nested customPage object lost: %v", body["customPage"])
}
// GET happened before POST (read-modify-write).
var sawGet bool
for _, r := range mock.requests() {
if r.Method == "GET" && r.Path == "/api/v1/Settings/customization" {
sawGet = true
}
if r.Method == "POST" && r.Path == "/api/v1/Settings/customization" && !sawGet {
t.Fatalf("settings POST without preceding GET")
}
}
assertNoLeak(t, out.Raw)
}
func TestSettingsPatchStaleRevision(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": "deadbeefdeadbeef",
"changes": map[string]any{"applicationName": "X"},
})
requireErr(t, out, "CONFLICT")
if n := mock.countCalls("POST", "/api/v1/Settings/customization"); n != 0 {
t.Fatalf("stale revision still POSTed")
}
}
func TestSettingsPatchNullAndExcludedRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "customization")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{"applicationName": nil},
})
e := requireErr(t, out, "INVALID_ARGUMENT")
if !strings.Contains(e.Message, "null") {
t.Errorf("null change error message = %q", e.Message)
}
out = c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{"apiKey": "new-key"},
})
requireErr(t, out, "INVALID_ARGUMENT")
if n := mock.countCalls("GET", "/api/v1/Settings/customization"); n != 1 {
// Both rejections happen before the private GET; only the
// read_settings GET above counts.
t.Fatalf("rejected patches triggered upstream GETs: %d", n)
}
}
func TestSettingsPatchReadOnlySection(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "base_url",
"revision": "x",
"changes": map[string]any{"a": 1},
})
requireErr(t, out, "INVALID_ARGUMENT")
}
// --- 4. error & security boundaries ---
func TestUnauthorized401(t *testing.T) {
mock := newMockOmbi(t, "api_key")
env := mock.env()
env["OMBI_API_KEY"] = "wrong-key"
c := spawnServer(t, env)
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
e := requireErr(t, out, "AUTHENTICATION_FAILED")
if e.HTTPStatus == nil || *e.HTTPStatus != 401 {
t.Errorf("http_status = %v", e.HTTPStatus)
}
if e.Retryable {
t.Errorf("401 must not be retryable")
}
}
func TestJWTLoginRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
env := mock.env()
env["OMBI_PASSWORD"] = "wrong-password"
c := spawnServer(t, env)
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
requireErr(t, out, "AUTHENTICATION_FAILED")
}
func TestJWTReLoginOn401(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// First call: login #1, request succeeds.
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
requireOK(t, out)
if n := mock.tokenCallCount(); n != 1 {
t.Fatalf("token calls = %d, want 1", n)
}
// Expire the accepted token: the next request 401s, the client
// must invalidate, re-login (call #2) and retry successfully.
mock.expireToken()
out = c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
requireOK(t, out)
if n := mock.tokenCallCount(); n != 2 {
t.Fatalf("token calls = %d, want 2 after renewal", n)
}
}
func TestNotFound404(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 88888888},
})
e := requireErr(t, out, "NOT_FOUND")
if e.HTTPStatus == nil || *e.HTTPStatus != 404 {
t.Errorf("http_status = %v", e.HTTPStatus)
}
if e.Retryable {
t.Errorf("404 must not be retryable")
}
}
func TestNotFoundHTMLBodySanitized(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "get",
"target": map[string]any{"kind": "movie", "id": 404000},
})
e := requireErr(t, out, "NOT_FOUND")
// The upstream HTML page (incl. filesystem path) must not surface.
if strings.Contains(e.Message, "inetpub") || strings.Contains(e.Message, "<") {
t.Errorf("HTML leaked into error message: %q", e.Message)
}
}
func TestRateLimited429(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 429000},
})
e := requireErr(t, out, "RATE_LIMITED")
if !e.Retryable {
t.Errorf("429 must be retryable")
}
if e.RetryAfterSeconds == nil || *e.RetryAfterSeconds != 30 {
t.Errorf("retry_after_seconds = %v, want 30", e.RetryAfterSeconds)
}
}
func TestServerError500HTML(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 500000},
})
e := requireErr(t, out, "UPSTREAM_REJECTED")
if !e.Retryable {
t.Errorf("5xx must be retryable")
}
if e.HTTPStatus == nil || *e.HTTPStatus != 500 {
t.Errorf("http_status = %v", e.HTTPStatus)
}
}
func TestTransportFailureReadVsWrite(t *testing.T) {
// A dead upstream: nothing listening on this port.
deadEnv := map[string]string{
"OMBI_URL": "http://127.0.0.1:1",
"OMBI_AUTH_MODE": "api_key",
"OMBI_API_KEY": "x",
"OMBI_BUNDLES": "core,moderation,administration",
}
c := spawnServer(t, deadEnv)
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
e := requireErr(t, out, "UPSTREAM_REJECTED")
if !e.Retryable {
t.Errorf("read transport failure must be retryable")
}
out = c.callTool(t, "write_issue_comment", map[string]any{
"issue_id": 1, "comment": "x",
})
e = requireErr(t, out, "UNKNOWN_OUTCOME")
if e.Retryable {
t.Errorf("write transport failure must not be retryable")
}
}
// --- argument validation ---
func TestEmptyArgsRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{})
requireErr(t, out, "INVALID_ARGUMENT")
// No upstream call must have been attempted.
if n := mock.countCalls("POST", "/api/v2/Requests"); n != 0 {
t.Fatalf("invalid args still reached upstream")
}
}
func TestUnknownFieldRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "movie", "bogus_field": 1,
})
requireErr(t, out, "INVALID_ARGUMENT")
}
func TestAlbumUnavailableComboRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "album", "status": "unavailable",
})
requireErr(t, out, "INVALID_ARGUMENT")
}
// --- api_key mode ---
func TestNotificationTemplateTranslation(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "notifications.discord")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "notifications.discord",
"revision": rev,
"changes": map[string]any{
"notificationTemplates": []any{
map[string]any{
"notification_type": "issue", // T4: issue → 1
"agent": "discord", // T3: discord → 1
"subject": "Issue", "message": "m",
},
},
},
})
requireOK(t, out)
body := mock.lastBody(t, "POST", "/api/v1/Settings/notifications/discord")
tmpls, ok := body["notificationTemplates"].([]any)
if !ok || len(tmpls) != 1 {
t.Fatalf("notificationTemplates = %v", body["notificationTemplates"])
}
t0 := tmpls[0].(map[string]any)
if t0["notificationType"] != float64(1) || t0["agent"] != float64(1) {
t.Errorf("label translation wrong: %v", t0)
}
if _, present := t0["notification_type"]; present {
t.Errorf("snake_case label key leaked onto the wire")
}
// Untouched fields preserved.
if body["webhookUrl"] != "https://discord.invalid/secret-wh" {
t.Errorf("webhookUrl not preserved: %v", body["webhookUrl"])
}
}
func TestTVSelectionModes(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
for _, tc := range []struct {
mode string
wantFlag string
}{
{"all", "requestAll"},
{"first_season", "firstSeason"},
{"latest_season", "latestSeason"},
} {
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv", "provider": "tmdb", "id": 1396,
"selection": map[string]any{"mode": tc.mode},
})
requireOK(t, out)
body := mock.lastBody(t, "POST", "/api/v2/Requests/tv")
if body[tc.wantFlag] != true {
t.Errorf("mode %s: %s = %v, want true", tc.mode, tc.wantFlag, body[tc.wantFlag])
}
// Flag modes carry no explicit season expansion and skip the
// private details GET.
if _, ok := body["seasons"]; ok && body["seasons"] != nil {
if arr, _ := body["seasons"].([]any); len(arr) > 0 {
t.Errorf("mode %s sent unexpected seasons: %v", tc.mode, body["seasons"])
}
}
}
if n := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/"); n != 0 {
t.Errorf("flag selection modes triggered %d private GETs, want 0", n)
}
}
func TestSettingsNestedMerge(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "customization")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{
"customPage": map[string]any{"enabled": true},
},
})
requireOK(t, out)
body := mock.lastBody(t, "POST", "/api/v1/Settings/customization")
cp, ok := body["customPage"].(map[string]any)
if !ok {
t.Fatalf("customPage = %v", body["customPage"])
}
if cp["enabled"] != true {
t.Errorf("customPage.enabled = %v", cp["enabled"])
}
// Sibling key inside the patched object must survive the merge.
if cp["content"] != "" {
t.Errorf("customPage.content lost in merge: %v", cp)
}
}
func TestSettingsPatchNoEffectiveChange(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "customization")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{"applicationName": "Ombi"}, // already the value
})
requireErr(t, out, "INVALID_ARGUMENT")
if n := mock.countCalls("POST", "/api/v1/Settings/customization"); n != 0 {
t.Fatalf("no-op patch still POSTed")
}
}
func TestUpstreamMalformedJSON(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "search", "media": "movie", "query": "anything",
})
e := requireErr(t, out, "UPSTREAM_SCHEMA_MISMATCH")
if e.Retryable {
t.Errorf("schema mismatch must not be retryable")
}
}
func TestEngineResultBusinessFailure(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// HTTP 200 with isError:true must surface as UPSTREAM_REJECTED,
// not a successful mutation.
out := c.callTool(t, "write_request_create", map[string]any{
"action": "movie", "tmdb_id": 666,
})
e := requireErr(t, out, "UPSTREAM_REJECTED")
if !strings.Contains(e.Message, "already been requested") {
t.Errorf("sanitized upstream message = %q", e.Message)
}
if e.Retryable {
t.Errorf("business rejection must not be retryable")
}
}
func TestAPIKeyMode(t *testing.T) {
mock := newMockOmbi(t, "api_key")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
data := requireOK(t, out)
var m struct {
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("decode: %v", err)
}
if len(m.Values) == 0 {
t.Fatalf("empty metrics: %s", data)
}
// The ApiKey header must have been sent; token endpoint unused.
if mock.countCalls("POST", "/api/v1/Token") != 0 {
t.Errorf("api_key mode hit the token endpoint")
}
reqs := mock.requests()
found := false
for _, r := range reqs {
if r.Header.Get("ApiKey") == "mock-api-key-123" {
found = true
}
if r.Header.Get("Authorization") != "" {
t.Errorf("api_key mode sent Authorization header")
}
}
if !found {
t.Errorf("ApiKey header never reached upstream")
}
}
+428
View File
@@ -0,0 +1,428 @@
//go:build integration
// Package integration_test exercises the compiled ombi-mcp binary end
// to end: it spawns the server as a subprocess and issues raw MCP
// JSON-RPC messages over stdio, against either a mock Ombi instance
// (httptest) or a live one selected by environment variables.
//
// The whole package is behind the "integration" build tag so standard
// CI (`go test ./...`) never compiles or runs it. Run explicitly with:
//
// go test -tags integration ./internal/integration_test/
package integration_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// serverBin is the compiled server under test, built once in TestMain.
var serverBin string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "ombi-mcp-itest")
if err != nil {
fmt.Fprintln(os.Stderr, "mktemp:", err)
os.Exit(1)
}
defer os.RemoveAll(dir)
bin := filepath.Join(dir, "ombi-mcp")
root, err := filepath.Abs("../..")
if err != nil {
fmt.Fprintln(os.Stderr, "resolve repo root:", err)
os.Exit(1)
}
build := exec.Command("go", "build", "-o", bin, "./cmd/ombi-mcp")
build.Dir = root
build.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := build.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "build server: %v\n%s\n", err, out)
os.Exit(1)
}
serverBin = bin
os.Exit(m.Run())
}
// serverEnv returns os.Environ with every OMBI_* variable replaced by
// the given overrides, so a spawned server sees exactly the intended
// configuration regardless of the parent environment.
func serverEnv(overrides map[string]string) []string {
env := []string{}
for _, e := range os.Environ() {
if strings.HasPrefix(e, "OMBI_") {
continue
}
env = append(env, e)
}
for k, v := range overrides {
env = append(env, k+"="+v)
}
return env
}
// --- raw JSON-RPC stdio client ---
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id,omitempty"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type rpcMessage struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
func (e *rpcError) Error() string {
return fmt.Sprintf("jsonrpc error %d: %s", e.Code, e.Message)
}
// syncBuf captures the server's stderr for diagnostics on failure.
type syncBuf struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuf) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.buf.Len() < 64<<10 {
return b.buf.Write(p)
}
return len(p), nil
}
func (b *syncBuf) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// rpcClient is a spawned ombi-mcp server spoken to over stdio with
// newline-delimited JSON-RPC 2.0. A single reader goroutine decodes
// all inbound messages onto a channel; request() matches responses
// by id and answers stray server-initiated requests with -32601.
type rpcClient struct {
t *testing.T
cmd *exec.Cmd
stdin io.WriteCloser
msgs chan *rpcMessage
nextID atomic.Int64
stderr *syncBuf
done chan struct{}
}
func spawnServer(t *testing.T, env map[string]string) *rpcClient {
t.Helper()
cmd := exec.Command(serverBin)
cmd.Env = serverEnv(env)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatalf("stdin pipe: %v", err)
}
stderr := &syncBuf{}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start server: %v", err)
}
c := &rpcClient{
t: t,
cmd: cmd,
stdin: stdin,
msgs: make(chan *rpcMessage, 64),
stderr: stderr,
done: make(chan struct{}),
}
go c.readLoop(stdout)
t.Cleanup(func() {
c.stdin.Close()
select {
case <-c.done:
case <-time.After(5 * time.Second):
c.cmd.Process.Kill()
<-c.done
}
})
return c
}
func (c *rpcClient) readLoop(r io.Reader) {
defer close(c.done)
defer close(c.msgs)
dec := json.NewDecoder(r)
for {
var m rpcMessage
if err := dec.Decode(&m); err != nil {
return
}
c.msgs <- &m
}
}
// request sends one JSON-RPC request and waits for its response.
// Stray server-initiated requests get a method-not-found reply;
// notifications and unrelated responses are skipped.
func (c *rpcClient) request(method string, params any) (json.RawMessage, error) {
id := c.nextID.Add(1)
body, err := json.Marshal(rpcRequest{
JSONRPC: "2.0", ID: id, Method: method, Params: params,
})
if err != nil {
return nil, err
}
if _, err := c.stdin.Write(append(body, '\n')); err != nil {
return nil, fmt.Errorf("write %s: %w", method, err)
}
timer := time.NewTimer(60 * time.Second)
defer timer.Stop()
for {
select {
case m, ok := <-c.msgs:
if !ok {
return nil, fmt.Errorf("server stdout closed (stderr: %s)", c.stderr)
}
if m.Method != "" && len(m.ID) > 0 {
// A server-initiated request: refuse politely and
// keep waiting for our own response.
c.replyError(m.ID, -32601, "client does not implement "+m.Method)
continue
}
var got int64
if err := json.Unmarshal(m.ID, &got); err != nil || got != id {
continue // notification or other response — skip
}
if m.Error != nil {
return nil, m.Error
}
return m.Result, nil
case <-timer.C:
return nil, fmt.Errorf("timeout waiting for %s response", method)
}
}
}
func (c *rpcClient) replyError(id json.RawMessage, code int, msg string) {
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{"code": code, "message": msg},
})
c.stdin.Write(append(body, '\n'))
}
func (c *rpcClient) notify(method string, params any) {
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "method": method, "params": params,
})
c.stdin.Write(append(body, '\n'))
}
// handshake performs initialize + notifications/initialized.
func (c *rpcClient) handshake(t *testing.T) {
t.Helper()
res, err := c.request("initialize", map[string]any{
"protocolVersion": "2025-06-18",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "integration-harness", "version": "0.1.0"},
})
if err != nil {
t.Fatalf("initialize: %v (stderr: %s)", err, c.stderr)
}
var init struct {
ServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"serverInfo"`
}
if err := json.Unmarshal(res, &init); err != nil {
t.Fatalf("initialize result decode: %v", err)
}
if init.ServerInfo.Name != "ombi-mcp" {
t.Fatalf("unexpected server identity %q", init.ServerInfo.Name)
}
c.notify("notifications/initialized", map[string]any{})
}
// --- tool call envelope ---
type toolError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
HTTPStatus *int `json:"http_status"`
Field string `json:"field"`
RetryAfterSeconds *int `json:"retry_after_seconds"`
}
type envelope struct {
OK bool `json:"ok"`
Operation string `json:"operation"`
Data json.RawMessage `json:"data"`
Error *toolError `json:"error"`
Warnings []string `json:"warnings"`
Truncated bool `json:"truncated"`
CorrelationID string `json:"correlation_id"`
}
// callOutcome carries the decoded tools/call result plus the raw
// result object for leak scanning.
type callOutcome struct {
Raw json.RawMessage
Envelope *envelope
IsError bool
Text string
}
// callTool issues a raw tools/call and decodes the result envelope.
// Protocol-level errors (JSON-RPC error) fail the test.
func (c *rpcClient) callTool(t *testing.T, name string, args any) *callOutcome {
t.Helper()
res, err := c.request("tools/call", map[string]any{
"name": name,
"arguments": args,
})
if err != nil {
t.Fatalf("tools/call %s: %v (stderr: %s)", name, err, c.stderr)
}
return decodeCallOutcome(t, res)
}
func decodeCallOutcome(t *testing.T, res json.RawMessage) *callOutcome {
t.Helper()
var parsed struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Structured *envelope `json:"structuredContent"`
IsError bool `json:"isError"`
Raw json.RawMessage `json:"-"`
}
if err := json.Unmarshal(res, &parsed); err != nil {
t.Fatalf("tools/call result not JSON: %v\n%s", err, res)
}
out := &callOutcome{Raw: res, IsError: parsed.IsError}
if parsed.Structured == nil {
t.Fatalf("tools/call result has no structuredContent: %s", res)
}
out.Envelope = parsed.Structured
for _, blk := range parsed.Content {
if blk.Type == "text" {
out.Text = blk.Text
}
}
return out
}
// --- shared assertions ---
// requireOK asserts the envelope is a success and returns its data.
func requireOK(t *testing.T, o *callOutcome) json.RawMessage {
t.Helper()
env := o.Envelope
if env.CorrelationID == "" {
t.Errorf("missing correlation_id")
}
if o.IsError {
t.Fatalf("isError=true with envelope: %s", o.Raw)
}
if !env.OK {
t.Fatalf("ok=false: %s", o.Raw)
}
if len(env.Data) == 0 || string(env.Data) == "null" {
t.Fatalf("ok=true but data missing: %s", o.Raw)
}
// Text fallback must carry the same bounded envelope.
var textEnv envelope
if err := json.Unmarshal([]byte(o.Text), &textEnv); err != nil {
t.Fatalf("text content is not the serialized envelope: %v", err)
}
if textEnv.OK != env.OK || textEnv.CorrelationID != env.CorrelationID {
t.Fatalf("text/structured envelopes disagree")
}
return env.Data
}
// requireErr asserts the envelope is a mapped ToolError with the
// expected code and runs the leak scan over the whole raw result.
func requireErr(t *testing.T, o *callOutcome, code string) *toolError {
t.Helper()
env := o.Envelope
if env.OK {
t.Fatalf("expected error %s, got ok=true: %s", code, o.Raw)
}
if !o.IsError {
t.Errorf("isError=false on error result")
}
if env.Error == nil {
t.Fatalf("ok=false but no error object: %s", o.Raw)
}
if env.Error.Code != code {
t.Fatalf("error code = %q, want %q: %s", env.Error.Code, code, o.Raw)
}
assertNoLeak(t, o.Raw)
return env.Error
}
// forbiddenLeak substrings that must never appear in tool output:
// upstream credentials, HTML error pages and stack-trace shapes.
func assertNoLeak(t *testing.T, raw json.RawMessage) {
t.Helper()
s := string(raw)
for _, bad := range []string{
"Bearer ", "Authorization", "<html", "<!DOCTYPE", "<HTML",
"ApiKey", "api_key", "System.Exception", "NullReferenceException",
" at ", "StackTrace", "traceback",
} {
if strings.Contains(s, bad) {
t.Errorf("tool output leaks %q:\n%s", bad, raw)
}
}
}
// listTools returns the advertised tool names.
func (c *rpcClient) listTools(t *testing.T) []string {
t.Helper()
res, err := c.request("tools/list", map[string]any{})
if err != nil {
t.Fatalf("tools/list: %v", err)
}
var out struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if err := json.Unmarshal(res, &out); err != nil {
t.Fatalf("tools/list decode: %v", err)
}
names := make([]string, 0, len(out.Tools))
for _, tl := range out.Tools {
names = append(names, tl.Name)
}
return names
}
+435
View File
@@ -0,0 +1,435 @@
//go:build integration
package integration_test
import (
"encoding/json"
"os"
"strings"
"testing"
)
// liveEnv resolves the live-instance configuration from the parent
// environment (sourced .env or exported vars). It skips the calling
// test when OMBI_URL is unset, so the suite degrades to mock-only
// coverage on machines without credentials.
func liveEnv(t *testing.T) map[string]string {
t.Helper()
url := os.Getenv("OMBI_URL")
if url == "" {
t.Skip("OMBI_URL not set — skipping live Ombi test")
}
env := map[string]string{
"OMBI_URL": url,
"OMBI_BUNDLES": "core,moderation,administration",
}
switch os.Getenv("OMBI_AUTH_MODE") {
case "jwt":
env["OMBI_AUTH_MODE"] = "jwt"
env["OMBI_USERNAME"] = os.Getenv("OMBI_USERNAME")
env["OMBI_PASSWORD"] = os.Getenv("OMBI_PASSWORD")
case "api_key":
env["OMBI_AUTH_MODE"] = "api_key"
env["OMBI_API_KEY"] = os.Getenv("OMBI_API_KEY")
if v := os.Getenv("OMBI_USER_NAME"); v != "" {
env["OMBI_USER_NAME"] = v
}
default:
t.Skip("OMBI_AUTH_MODE unset/invalid — skipping live Ombi test")
}
return env
}
func liveServer(t *testing.T) *rpcClient {
t.Helper()
c := spawnServer(t, liveEnv(t))
c.handshake(t)
return c
}
// --- open endpoint: exercises the whole upstream path unauthenticated-safe ---
func TestLiveServerStatus(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_server", map[string]any{"action": "status"})
data := requireOK(t, out)
var m struct {
Kind string `json:"kind"`
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("metrics decode: %v\n%s", err, data)
}
if m.Kind != "metrics" || len(m.Values) == 0 {
t.Fatalf("bad metrics family: %s", data)
}
t.Logf("live status: %s", data)
assertNoLeak(t, out.Raw)
}
// --- read/projection contract against real payloads ---
func TestLiveReadRequestsList(t *testing.T) {
c := liveServer(t)
for _, media := range []string{"movie", "tv", "album"} {
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": media,
"page": map[string]any{"offset": 0, "limit": 10},
})
data := requireOK(t, out)
var page struct {
Kind string `json:"kind"`
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
} `json:"items"`
Page struct {
Mode string `json:"mode"`
Returned int `json:"returned"`
} `json:"page"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("%s: request_page decode: %v\n%s", media, err, data)
}
if page.Kind != "request_page" {
t.Fatalf("%s: wrong family %q", media, page.Kind)
}
wantKind := media
if media == "tv" {
wantKind = "tv_child"
}
for _, it := range page.Items {
if it.Target.Kind != wantKind {
t.Errorf("%s: item target kind %q, want %q", media, it.Target.Kind, wantKind)
}
}
t.Logf("%s: %d items, paging=%s", media, page.Page.Returned, data)
assertNoLeak(t, out.Raw)
}
}
func TestLiveReadIssues(t *testing.T) {
c := liveServer(t)
for _, st := range []string{"pending", "in_progress", "resolved", "closed"} {
out := c.callTool(t, "read_issues", map[string]any{
"action": "list", "status": st,
"page": map[string]any{"offset": 0, "limit": 10},
})
data := requireOK(t, out)
var page struct {
Kind string `json:"kind"`
Items []struct {
Status string `json:"status"`
StatusCode *int `json:"status_code"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("%s: issue_page decode: %v\n%s", st, err, data)
}
if page.Kind != "issue_page" {
t.Fatalf("%s: wrong family %q", st, page.Kind)
}
for _, it := range page.Items {
if it.Status != st {
t.Errorf("issue status %q under %s filter (code=%v)",
it.Status, st, it.StatusCode)
}
}
}
}
func TestLiveSearchThenDetails(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_search", map[string]any{
"action": "text", "media": "tv", "query": "Breaking Bad",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("search decode: %v\n%s", err, data)
}
if len(page.Items) == 0 {
t.Skip("live search returned no TV results for 'Breaking Bad'")
}
var tmdb int
for _, id := range page.Items[0].Identifiers {
if id.Namespace == "tmdb" {
if err := json.Unmarshal([]byte(id.Value), &tmdb); err != nil {
t.Fatalf("tmdb identifier not numeric: %q", id.Value)
}
}
}
if tmdb == 0 {
t.Fatalf("first search hit has no tmdb identifier: %s", data)
}
t.Logf("resolved tmdb id %d for %q", tmdb, page.Items[0].Title)
out = c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "tv", "provider": "tmdb", "id": tmdb},
})
data = requireOK(t, out)
var det struct {
Items []struct {
Title string `json:"title"`
Seasons []struct {
SeasonNumber int `json:"season_number"`
Episodes []struct {
EpisodeNumber int `json:"episode_number"`
} `json:"episodes"`
} `json:"seasons"`
} `json:"items"`
}
if err := json.Unmarshal(data, &det); err != nil {
t.Fatalf("details decode: %v\n%s", err, data)
}
if len(det.Items) != 1 || len(det.Items[0].Seasons) == 0 {
t.Fatalf("TV details missing season structure: %s", data)
}
assertNoLeak(t, out.Raw)
}
// --- expansion logic live ---
// TestLiveSeasonExpansion creates a real TV request via season mode
// and then deletes the parent request it created. It is the only
// live test that mutates upstream state.
func TestLiveSeasonExpansion(t *testing.T) {
c := liveServer(t)
// Resolve a show to request via live search.
out := c.callTool(t, "read_search", map[string]any{
"action": "text", "media": "tv", "query": "Breaking Bad",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Requested *bool `json:"requested"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil || len(page.Items) == 0 {
t.Skip("no live TV search results to request")
}
var tmdb int
for _, id := range page.Items[0].Identifiers {
if id.Namespace == "tmdb" {
_ = json.Unmarshal([]byte(id.Value), &tmdb)
}
}
if tmdb == 0 {
t.Skip("first search hit has no tmdb id")
}
// Fetch details to learn which seasons exist.
out = c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "tv", "provider": "tmdb", "id": tmdb},
})
data = requireOK(t, out)
var det struct {
Items []struct {
Seasons []struct {
SeasonNumber int `json:"season_number"`
Episodes []struct {
EpisodeNumber int `json:"episode_number"`
} `json:"episodes"`
} `json:"seasons"`
} `json:"items"`
}
if err := json.Unmarshal(data, &det); err != nil {
t.Fatalf("details decode: %v", err)
}
var season int
var epCount int
for _, s := range det.Items[0].Seasons {
if s.SeasonNumber > 0 && len(s.Episodes) > 0 {
season, epCount = s.SeasonNumber, len(s.Episodes)
break
}
}
if season == 0 {
t.Skip("show exposes no requestable season")
}
t.Logf("requesting tmdb=%d season=%d (%d episodes)", tmdb, season, epCount)
out = c.callTool(t, "write_request_create", map[string]any{
"action": "tv",
"provider": "tmdb",
"id": tmdb,
"selection": map[string]any{
"mode": "season",
"season_numbers": []int{season},
},
})
data = requireOK(t, out)
var mut struct {
Outcome string `json:"outcome"`
RequestID *int `json:"request_id"`
Target *struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
}
if err := json.Unmarshal(data, &mut); err != nil {
t.Fatalf("mutation decode: %v\n%s", err, data)
}
if mut.Outcome != "completed" || mut.RequestID == nil {
t.Fatalf("live create did not complete: %s", data)
}
t.Logf("created live request id=%d", *mut.RequestID)
assertNoLeak(t, out.Raw)
// Clean up: delete the TV parent we just created.
parent := *mut.RequestID
if mut.Target != nil && mut.Target.Kind == "tv_parent" {
parent = mut.Target.ID
}
out = c.callTool(t, "write_request_delete", map[string]any{
"target": map[string]any{"kind": "tv_parent", "id": parent},
})
if !out.Envelope.OK {
t.Logf("cleanup: delete of tv_parent %d failed (manual cleanup needed): %s",
parent, out.Raw)
} else {
t.Logf("cleanup: deleted tv_parent %d", parent)
}
}
// --- patch cycle live ---
// TestLiveSettingsPatch performs a read-modify-write on the
// customization section, then restores the original value. It is
// mutating but self-healing.
func TestLiveSettingsPatch(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_settings", map[string]any{"section": "customization"})
data := requireOK(t, out)
var s struct {
Revision string `json:"revision"`
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("settings decode: %v", err)
}
if s.Revision == "" {
t.Skip("customization offered no revision — cannot patch")
}
// Pick the first scalar string field for a reversible change.
var field, orig string
for _, v := range s.Values {
if str, ok := v.Value.(string); ok && v.Name != "" {
field, orig = v.Name, str
break
}
}
if field == "" {
t.Skip("no string scalar found in customization projection")
}
key := strings.TrimPrefix(field, "/")
t.Logf("patching %s (was %q)", key, orig)
patch := map[string]any{key: orig + "-itest"}
out = c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch", "section": "customization",
"revision": s.Revision, "changes": patch,
})
data = requireOK(t, out)
assertNoLeak(t, out.Raw)
// Re-read: revision must have moved, value must reflect the patch.
out = c.callTool(t, "read_settings", map[string]any{"section": "customization"})
data = requireOK(t, out)
var s2 struct {
Revision string `json:"revision"`
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
}
if err := json.Unmarshal(data, &s2); err != nil {
t.Fatalf("re-read decode: %v", err)
}
if s2.Revision == s.Revision {
t.Errorf("revision unchanged after patch")
}
got := ""
for _, v := range s2.Values {
if v.Name == field {
got, _ = v.Value.(string)
}
}
if got != orig+"-itest" {
t.Errorf("patched value = %q, want %q", got, orig+"-itest")
}
// Restore.
out = c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch", "section": "customization",
"revision": s2.Revision, "changes": map[string]any{key: orig},
})
requireOK(t, out)
// Stale-revision check: replaying the first revision must conflict.
out = c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch", "section": "customization",
"revision": s.Revision, "changes": map[string]any{key: "stale"},
})
requireErr(t, out, "CONFLICT")
}
// --- live error boundaries ---
func TestLiveBadCredentials(t *testing.T) {
if os.Getenv("OMBI_URL") == "" {
t.Skip("OMBI_URL not set")
}
env := map[string]string{
"OMBI_URL": os.Getenv("OMBI_URL"),
"OMBI_AUTH_MODE": "api_key",
"OMBI_API_KEY": "definitely-invalid-key",
"OMBI_BUNDLES": "core,moderation,administration",
}
c := spawnServer(t, env)
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
e := requireErr(t, out, "AUTHENTICATION_FAILED")
if e.HTTPStatus == nil || *e.HTTPStatus != 401 {
t.Errorf("http_status = %v", e.HTTPStatus)
}
}
func TestLiveNotFound(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_users", map[string]any{
"action": "get", "user_id": "nonexistent-user-id-zzz",
})
e := requireErr(t, out, "NOT_FOUND")
if e.HTTPStatus == nil || *e.HTTPStatus != 404 {
t.Errorf("http_status = %v", e.HTTPStatus)
}
}
+618
View File
@@ -0,0 +1,618 @@
//go:build integration
package integration_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// recordedReq captures one upstream call for wire-shape assertions.
type recordedReq struct {
Method string
Path string
Body json.RawMessage
Header http.Header
}
// mockOmbi emulates the subset of the Ombi API the tools exercise,
// enforcing the configured auth mode and serving fixtures that carry
// realistic undocumented fields (the projection layer must tolerate
// them — it decodes into maps, never strict structs).
type mockOmbi struct {
*httptest.Server
mu sync.Mutex
reqs []recordedReq
mode string // "jwt" | "api_key"
apiKey string
userName string // expected UserName header (api_key mode)
user string
pass string
token string // currently accepted Bearer token (jwt mode)
tokenCalls int
settings map[string]map[string]any // live settings docs by path
}
func newMockOmbi(t *testing.T, mode string) *mockOmbi {
t.Helper()
m := &mockOmbi{
mode: mode,
apiKey: "mock-api-key-123",
user: "mockuser",
pass: "mockpass",
}
m.settings = map[string]map[string]any{
"/api/v1/Settings/customization": mockCustomization(),
"/api/v1/Settings/notifications/discord": mockDiscordSettings(),
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/Token", m.handleToken)
mux.HandleFunc("GET /api/v1/Status", m.wrap(m.fixed(`200`)))
mux.HandleFunc("GET /api/v1/Status/info", m.wrap(m.fixed(`"mock-status-info"`)))
mux.HandleFunc("GET /api/v1/Settings/about", m.wrap(m.json(mockAbout())))
mux.HandleFunc("GET /api/v2/Requests/movie/{amt}/{pos}/requestDate/{order}", m.wrap(m.movieList))
mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/requestDate/{order}", m.wrap(m.tvList))
mux.HandleFunc("GET /api/v2/Search/movie/{id}", m.wrap(m.movieDetails))
mux.HandleFunc("GET /api/v2/Search/tv/moviedb/{id}", m.wrap(m.tvDetailsTMDB))
mux.HandleFunc("GET /api/v2/Search/tv/{id}", m.wrap(m.tvDetailsTVDB))
mux.HandleFunc("GET /api/v1/Request/movie/info/{id}", m.wrap(m.movieInfo))
mux.HandleFunc("POST /api/v2/Requests/tv", m.wrap(m.createTV))
mux.HandleFunc("POST /api/v1/Request/tv", m.wrap(m.createTV))
mux.HandleFunc("POST /api/v1/Request/movie", m.wrap(m.createMovie))
mux.HandleFunc("GET /api/v1/Issues/count", m.wrap(m.fixed(`{"pending":2,"inProgress":1,"resolved":5,"extraCountField":9}`)))
mux.HandleFunc("GET /api/v1/Issues/{limit}/{offset}/{code}", m.wrap(m.issueList))
mux.HandleFunc("GET /api/v1/Issues/{id}", m.wrap(m.issueGet))
mux.HandleFunc("GET /api/v1/Issues/{id}/comments", m.wrap(m.issueComments))
mux.HandleFunc("GET /api/v1/Settings/customization", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/customization", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/notifications/discord", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/notifications/discord", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Request/movie/search/{q}", m.wrap(m.malformed))
mux.HandleFunc("GET /api/v1/Request/count", m.wrap(m.fixed(`{"pending":3,"approved":2,"available":5,"denied":1}`)))
mux.HandleFunc("GET /api/v1/Request/movie/total", m.wrap(m.fixed(`7`)))
mux.HandleFunc("GET /api/v1/Identity", m.wrap(m.json(mockUser("u-self", "mockuser"))))
mux.HandleFunc("GET /api/v1/Identity/Users", m.wrap(m.json([]any{
mockUser("u-1", "alice"), mockUser("u-2", "bob"),
})))
mux.HandleFunc("GET /api/v1/Identity/User/{id}", m.wrap(m.userGet))
mux.HandleFunc("/api/v1/", m.wrap(m.catchAll)) // fallthrough: 404
m.Server = httptest.NewServer(mux)
t.Cleanup(m.Server.Close)
return m
}
// --- auth plumbing ---
// mintToken issues and stores the next accepted Bearer token
// (jwt mode). Callers hold m.mu.
func (m *mockOmbi) mintToken() string {
m.tokenCalls++
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
exp := time.Now().Add(time.Hour).Unix()
pl := base64.RawURLEncoding.EncodeToString(
[]byte(fmt.Sprintf(`{"sub":"%s","exp":%d,"seq":%d}`, m.user, exp, m.tokenCalls)))
m.token = hdr + "." + pl + ".mocksig"
return m.token
}
// currentToken returns the currently accepted Bearer token.
func (m *mockOmbi) currentToken() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.token
}
// expireToken rotates the accepted token so the next authenticated
// request 401s, forcing the client to re-login.
func (m *mockOmbi) expireToken() {
m.mu.Lock()
m.token = "expired." + m.token
m.mu.Unlock()
}
func (m *mockOmbi) handleToken(w http.ResponseWriter, r *http.Request) {
m.record(r)
if m.mode != "jwt" {
http.Error(w, `{"errorMessage":"token endpoint disabled"}`, http.StatusNotFound)
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &body)
if body.Username != m.user || body.Password != m.pass {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"errorMessage":"Invalid credentials"}`))
return
}
m.mu.Lock()
tok := m.mintToken()
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":%q,"expiration":%q}`,
tok, time.Now().Add(time.Hour).UTC().Format(time.RFC3339))
}
// wrap records the request and enforces the configured auth mode.
func (m *mockOmbi) wrap(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m.record(r)
if m.mode == "jwt" {
if r.Header.Get("Authorization") != "Bearer "+m.currentToken() {
m.jsonErr(w, http.StatusUnauthorized, "Token is invalid or expired")
return
}
} else {
if r.Header.Get("ApiKey") != m.apiKey {
m.jsonErr(w, http.StatusUnauthorized, "Invalid API key")
return
}
if m.userName != "" && r.Header.Get("UserName") != m.userName {
m.jsonErr(w, http.StatusForbidden, "UserName binding mismatch")
return
}
}
h(w, r)
}
}
func (m *mockOmbi) record(r *http.Request) {
var body json.RawMessage
if r.Body != nil {
b, _ := io.ReadAll(r.Body)
// Restore so the handler sees the body too.
r.Body = io.NopCloser(bytes.NewReader(b))
if len(b) > 0 {
body = json.RawMessage(b)
}
}
m.mu.Lock()
m.reqs = append(m.reqs, recordedReq{
Method: r.Method, Path: r.URL.Path, Body: body, Header: r.Header.Clone(),
})
m.mu.Unlock()
}
func (m *mockOmbi) jsonErr(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"errorMessage":%q,"title":%q}`, msg, msg)
}
func (m *mockOmbi) fixed(body string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
}
}
func (m *mockOmbi) json(v any) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
}
func (m *mockOmbi) catchAll(w http.ResponseWriter, r *http.Request) {
m.jsonErr(w, http.StatusNotFound, "The resource was not found")
}
// requests returns the recorded upstream calls in order.
func (m *mockOmbi) requests() []recordedReq {
m.mu.Lock()
defer m.mu.Unlock()
return append([]recordedReq{}, m.reqs...)
}
// lastBody decodes the body of the most recent call matching
// method+pathPrefix, failing the test when absent.
func (m *mockOmbi) lastBody(t *testing.T, method, prefix string) map[string]any {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
for i := len(m.reqs) - 1; i >= 0; i-- {
r := m.reqs[i]
if r.Method == method && strings.HasPrefix(r.Path, prefix) {
var out map[string]any
if err := json.Unmarshal(r.Body, &out); err != nil {
t.Fatalf("%s %s body not JSON: %v", method, prefix, err)
}
return out
}
}
t.Fatalf("no recorded %s %s call", method, prefix)
return nil
}
func (m *mockOmbi) countCalls(method, prefix string) int {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, r := range m.reqs {
if r.Method == method && strings.HasPrefix(r.Path, prefix) {
n++
}
}
return n
}
func (m *mockOmbi) tokenCallCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.tokenCalls
}
// --- fixtures ---
func mockAbout() map[string]any {
return map[string]any{
"version": "4.43.5", "branch": "develop",
"frameworkDescription": ".NET 8.0.4",
"osDescription": "Linux 6.1", "osArchitecture": "X64",
"processArchitecture": "X64", "ombiDatabaseType": "SQLite",
"externalDatabaseType": "", "settingsDatabaseType": "SQLite",
"notSupported": false,
// Undocumented extras the projection must ignore.
"applicationUrl": "https://secret-internal.example",
"databaseConnectionString": "Data Source=/secret/ombi.db",
"extraFutureField": map[string]any{"a": 1},
}
}
func mockUser(id, name string) map[string]any {
return map[string]any{
"id": id, "userName": name, "alias": name + " alias",
"language": "en", "streamingCountry": "US",
"emailAddress": name + "@example.internal",
"passwordHash": "SUPERSECRETHASH-that-must-not-leak",
"claims": []any{}, "userType": 1, "userAccess": 3,
}
}
// mockMovieDetail: SearchMovieViewModel-ish with extra fields.
func mockMovieDetail() map[string]any {
return map[string]any{
"id": 27205, "theMovieDbId": 27205, "imdbId": "tt1375666",
"title": "Inception", "originalTitle": "Inception",
"overview": "A thief who steals corporate secrets.",
"releaseDate": "2010-07-15T00:00:00", "status": "Released",
"available": false, "requested": false, "approved": false,
"requestId": 0, "posterPath": "/9gk7adHYeDvHkCSEqAvQNLV5Uge.jpg",
"backdropPath": "/s3TBrRGB1iav7gFOCNx3H31MoES.jpg",
"voteAverage": 8.4, "voteCount": 35000, "popularity": 42.5,
"genres": []any{
map[string]any{"id": 28, "name": "Action"},
map[string]any{"id": 878, "name": "Science Fiction"},
},
"cast": []any{
map[string]any{"id": 6193, "person": "Leonardo DiCaprio", "character": "Cobb"},
},
"crew": []any{
map[string]any{"id": 525, "name": "Christopher Nolan", "department": "Directing", "job": "Director"},
},
"externalIds": map[string]any{"imdbId": "tt1375666"},
"belongsToCollection": nil,
// Undocumented upstream fields:
"productionCompanies": []any{map[string]any{"id": 1, "name": "Legendary"}},
"videos": map[string]any{"results": []any{}},
"watchProviders": map[string]any{"US": []any{}},
"undocumentedFuture": map[string]any{"nested": []any{1, 2, 3}},
"runtimeNewField": "some-value",
}
}
// mockTVDetail: SearchFullInfoTvShowViewModel-ish; seasonRequests feed
// both the seasons projection and the write expansion path.
func mockTVDetail() map[string]any {
return map[string]any{
"id": 1396, "theMovieDbId": 1396, "theTvDbId": 81189,
"title": "Breaking Bad", "name": "Breaking Bad",
"overview": "A chemistry teacher turns to cooking meth.",
"firstAired": "2008-01-20T00:00:00", "status": "Ended",
"available": false, "requested": false, "fullyAvailable": false,
"posterPath": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"backdropPath": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
"banner": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
"genres": []any{map[string]any{"id": 18, "name": "Drama"}},
"cast": []any{
map[string]any{"id": 17419, "person": "Bryan Cranston", "character": "Walter White"},
},
"externalIds": map[string]any{"imdbId": "tt0903747", "tvdbId": 81189},
"seasonRequests": []any{
map[string]any{
"seasonNumber": 0,
"episodes": []any{map[string]any{"episodeNumber": 1, "title": "Special"}},
},
map[string]any{
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true, "requested": true},
map[string]any{"episodeNumber": 2, "title": "Cat's in the Bag...", "available": true},
map[string]any{"episodeNumber": 3, "title": "...And the Bag's in the River"},
map[string]any{"episodeNumber": 4, "title": "Cancer Man"},
map[string]any{"episodeNumber": 5, "title": "Gray Matter"},
map[string]any{"episodeNumber": 6, "title": "Crazy Handful of Nothin'"},
map[string]any{"episodeNumber": 7, "title": "A No-Rough-Stuff-Type Deal"},
},
},
map[string]any{
"seasonNumber": 2,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Seven Thirty-Seven"},
map[string]any{"episodeNumber": 2, "title": "Grilled"},
map[string]any{"episodeNumber": 3, "title": "Bit by a Dead Bee"},
map[string]any{"episodeNumber": 4, "title": "Down"},
},
},
},
"networks": []any{map[string]any{"name": "AMC"}},
"createdBy": []any{map[string]any{"name": "Vince Gilligan"}},
"episodeRunTime": []any{49},
"undocumentedTVField": []any{"x", "y"},
"futureObject": map[string]any{"deeply": map[string]any{"nested": true}},
}
}
func mockMovieRequest() map[string]any {
return map[string]any{
"id": 10, "requestId": 10, "theMovieDbId": 27205, "imdbId": "tt1375666",
"title": "Inception", "approved": true, "available": true,
"denied": false, "is4kRequest": false, "approved4K": false,
"available4K": false, "denied4K": false, "subscribed": true,
"canApprove": false, "requestStatus": "Common.UI.Available",
"requestedDate": "2026-09-01T10:00:00", "requestedUserId": "u-self",
"markedAsApproved": "2026-09-01T10:05:00",
// Undocumented extras:
"qualityOverrideTitle": "HD-1080p", "rootFolder": "/movies",
"issues": []any{}, "requestSource": 0,
"newHiddenField": "should-be-ignored",
}
}
func mockTVChild() map[string]any {
return map[string]any{
"id": 77, "parentRequestId": 12, "title": "Breaking Bad",
"approved": true, "available": false, "denied": false,
"requestedDate": "2026-09-05T12:00:00", "requestedUserId": "u-1",
"requestStatus": "Common.UI.Pending", "subscribed": false,
"canApprove": true, "seriesType": 0, "issueId": nil,
"seasonRequests": []any{
map[string]any{
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true},
map[string]any{"episodeNumber": 2, "title": "Cat's in the Bag...", "requested": true},
},
},
},
"tvDbId": 81189, "externalProviderId": 1396, "imdbId": "tt0903747",
"childFieldFuture": []any{1, 2},
}
}
func mockIssue(id int, status int) map[string]any {
return map[string]any{
"id": id, "subject": "Playback stops at 12 minutes",
"title": "Inception", "description": "Transcode failure.",
"issueCategoryId": 2, "status": status, "requestType": 1,
"requestId": 10, "providerId": "27205",
"createdDate": "2026-09-10T08:30:00",
"resovledDate": nil, // upstream typo preserved in fixture
"userReportedId": "u-2",
"comments": []any{}, "issueCategory": map[string]any{"id": 2, "value": "Playback"},
"undocumentedIssueField": "x",
}
}
func mockCustomization() map[string]any {
return map[string]any{
"applicationName": "Ombi", "logo": "/img/logo.png",
"favicon": "/img/favicon.ico", "preset": "",
"peopleToFollow": []any{}, "themes": []any{},
"customPage": map[string]any{"enabled": false, "content": ""},
// Fields the projection must omit and the patch must preserve:
"applicationUrl": "https://ombi.internal.example",
"secretToken": "tok-should-not-project",
"apiKey": "key-should-not-project",
}
}
// mockDiscordSettings carries notificationTemplates with upstream
// integer agent/type codes — the patch path must translate labels
// back to ints on write (T3/T4).
func mockDiscordSettings() map[string]any {
return map[string]any{
"enabled": false, "botName": "Ombi", "webhookUrl": "https://discord.invalid/secret-wh",
"username": "ombi-bot", "avatar": "",
"notificationTemplates": []any{
map[string]any{
"notificationType": 0, "agent": 1,
"subject": "New request", "message": "{RequestedBy} added {Title}",
},
},
}
}
// --- handlers ---
func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"collection": []any{mockMovieRequest()},
"total": 1,
// Undocumented page-wrapper fields:
"extraPageField": true, "facets": map[string]any{},
})
}
func (m *mockOmbi) tvList(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"collection": []any{mockTVChild()}, "total": 1, "newField": []any{},
})
}
func (m *mockOmbi) movieDetails(w http.ResponseWriter, r *http.Request) {
switch r.PathValue("id") {
case "27205":
m.json(mockMovieDetail())(w, r)
case "429000":
w.Header().Set("Retry-After", "30")
m.jsonErr(w, http.StatusTooManyRequests, "Rate limit exceeded")
case "500000":
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
"System.NullReferenceException at Ombi.Core.Engine.MovieRequestEngine" +
"Authorization: Bearer should-never-appear</body></html>"))
default:
m.jsonErr(w, http.StatusNotFound, "Movie not found")
}
}
func (m *mockOmbi) movieInfo(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") == "404000" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("<html><head><title>IIS 404</title></head>" +
"<body>Physical path C:\\inetpub\\ombi\\secret</body></html>"))
return
}
m.json(mockMovieRequest())(w, r)
}
func (m *mockOmbi) tvDetailsTMDB(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "1396" {
m.jsonErr(w, http.StatusNotFound, "TV show not found")
return
}
m.json(mockTVDetail())(w, r)
}
func (m *mockOmbi) tvDetailsTVDB(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "81189" {
m.jsonErr(w, http.StatusNotFound, "TV show not found")
return
}
m.json(mockTVDetail())(w, r)
}
func (m *mockOmbi) createTV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"result": true, "requestId": 555, "isError": false,
"message": "Request for Breaking Bad has been added successfully",
"errorMessage": "", "errorCode": 0,
"newEngineField": "undocumented",
})
}
func (m *mockOmbi) createMovie(w http.ResponseWriter, r *http.Request) {
var body map[string]any
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &body)
w.Header().Set("Content-Type", "application/json")
if body["theMovieDbId"] == float64(666) {
// HTTP 200 carrying a business failure.
json.NewEncoder(w).Encode(map[string]any{
"result": false, "isError": true, "requestId": nil,
"errorMessage": "Movie has already been requested",
"errorCode": 12, "message": "",
})
return
}
json.NewEncoder(w).Encode(map[string]any{
"result": true, "requestId": 42, "isError": false,
"message": "Request for Inception has been added successfully",
})
}
func (m *mockOmbi) issueList(w http.ResponseWriter, r *http.Request) {
m.json([]any{mockIssue(7, 1), mockIssue(8, 99)})(w, r) // 99 = unmapped enum
}
func (m *mockOmbi) issueGet(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "7" {
m.jsonErr(w, http.StatusNotFound, "Issue not found")
return
}
m.json(mockIssue(7, 2))(w, r)
}
func (m *mockOmbi) issueComments(w http.ResponseWriter, r *http.Request) {
m.json([]any{
map[string]any{"id": 3, "issuesId": 7, "comment": "Still broken",
"userId": "u-2", "date": "2026-09-11T09:00:00", "extra": 1},
})(w, r)
}
func (m *mockOmbi) settingsGet(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
doc := m.settings[r.URL.Path]
m.mu.Unlock()
m.json(doc)(w, r)
}
func (m *mockOmbi) settingsPost(w http.ResponseWriter, r *http.Request) {
var body map[string]any
b, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(b, &body); err != nil {
m.jsonErr(w, http.StatusBadRequest, "invalid settings body")
return
}
m.mu.Lock()
m.settings[r.URL.Path] = body
m.mu.Unlock()
m.fixed(`true`)(w, r)
}
// malformed returns a non-JSON body to exercise the
// UPSTREAM_SCHEMA_MISMATCH path on an otherwise-200 response.
func (m *mockOmbi) malformed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`<<<not json at all`))
}
func (m *mockOmbi) userGet(w http.ResponseWriter, r *http.Request) {
switch r.PathValue("id") {
case "u-1":
m.json(mockUser("u-1", "alice"))(w, r)
case "u-2":
m.json(mockUser("u-2", "bob"))(w, r)
default:
m.jsonErr(w, http.StatusNotFound, "User not found")
}
}
// mockEnv returns the server env pointing at this mock in the
// requested mode, plus all three bundles.
func (m *mockOmbi) env() map[string]string {
env := map[string]string{
"OMBI_URL": m.URL,
"OMBI_BUNDLES": "core,moderation,administration",
}
if m.mode == "jwt" {
env["OMBI_AUTH_MODE"] = "jwt"
env["OMBI_USERNAME"] = m.user
env["OMBI_PASSWORD"] = m.pass
} else {
env["OMBI_AUTH_MODE"] = "api_key"
env["OMBI_API_KEY"] = m.apiKey
if m.userName != "" {
env["OMBI_USER_NAME"] = m.userName
}
}
return env
}
+95
View File
@@ -1 +1,96 @@
// Package mcpserver wires the tool registry onto the MCP protocol
// transport: tools/list advertises the bundle-filtered catalogue and
// tools/call dispatches to handler closures.
package mcpserver
import (
"context"
"encoding/json"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"ombi-mcp/internal/tools"
)
// Version is stamped into the server implementation identity.
const Version = "0.1.0"
// New builds an MCP server advertising every tool whose bundle is
// enabled. Bundle policy is enforced again at call time so a stale
// tools/list snapshot cannot reach a disabled handler.
func New(env *tools.Env, bundles map[string]bool) (*mcp.Server, error) {
srv := mcp.NewServer(&mcp.Implementation{
Name: "ombi-mcp",
Version: Version,
}, &mcp.ServerOptions{
// The tool list is static for a process lifetime, so no
// listChanged notification is ever sent; declaring the bare
// capability also drops the SDK's default logging capability,
// which this server does not implement.
Capabilities: &mcp.ServerCapabilities{Tools: &mcp.ToolCapabilities{}},
})
registered := map[string]bool{}
for _, def := range tools.Registry() {
if !def.Enabled(bundles) {
continue
}
if registered[def.Name] {
return nil, fmt.Errorf("duplicate tool registration %q", def.Name)
}
registered[def.Name] = true
inSchema, err := def.InputSchema()
if err != nil {
return nil, fmt.Errorf("tool %q: %w", def.Name, err)
}
outSchema, err := def.OutputSchema()
if err != nil {
return nil, fmt.Errorf("tool %q: %w", def.Name, err)
}
t := &mcp.Tool{
Name: def.Name,
Description: def.Description,
InputSchema: inSchema,
OutputSchema: outSchema,
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: def.ReadOnly,
DestructiveHint: boolPtr(def.Destructive),
IdempotentHint: def.Idempotent,
OpenWorldHint: boolPtr(def.OpenWorld),
},
}
srv.AddTool(t, callHandler(env, bundles, def))
}
return srv, nil
}
// callHandler adapts a tools.Handler to the SDK's ToolHandler. The
// ToolResult envelope maps to structuredContent plus a serialized
// text copy; isError mirrors ok.
func callHandler(env *tools.Env, bundles map[string]bool, def tools.ToolDef) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if !def.Enabled(bundles) {
return nil, fmt.Errorf("tool %q is not enabled", def.Name)
}
res := def.Handler(ctx, env, req.Params.Arguments)
if res == nil {
return nil, fmt.Errorf("tool %q returned no result", def.Name)
}
// Serialize the same bounded object into a text block for
// clients that do not consume structured content.
text, err := json.Marshal(res)
if err != nil {
return nil, fmt.Errorf("marshal result: %w", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(text)}},
StructuredContent: res,
IsError: !res.OK,
}, nil
}
}
func boolPtr(b bool) *bool { return &b }
+85 -2
View File
@@ -1,14 +1,22 @@
package ombi
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// ErrAuthFailed is returned when the upstream rejects login credentials
// or returns an unusable token response.
var ErrAuthFailed = errors.New("upstream authentication failed")
// AuthMode is the selected upstream credential mechanism.
type AuthMode string
@@ -42,6 +50,13 @@ type AuthManager struct {
const expirySkew = 60 * time.Second
const fallbackTokenTTL = time.Hour
// NewAuthManager builds an AuthManager for the given credentials.
// base is the configured Ombi base URL (any path prefix preserved);
// hc is the shared HTTP client used for token requests.
func NewAuthManager(creds Credentials, base string, hc *http.Client) *AuthManager {
return &AuthManager{creds: creds, base: base, http: hc}
}
// Apply sets the authentication headers on an outgoing upstream request.
// In api_key mode it sets ApiKey (+ optional UserName) and returns.
// In jwt mode it blocks until a valid Bearer token is available.
@@ -97,8 +112,76 @@ func (a *AuthManager) getToken(ctx context.Context) (string, error) {
return a.login(ctx)
}
// login performs a full POST /api/v1/Token login, caches the token and
// resolves its expiry: RFC3339 expiration field, then the JWT exp claim
// (decode only — not signature validation), else a conservative TTL.
func (a *AuthManager) login(ctx context.Context) (string, error) {
return "", errors.New("implemented by phase 02 executor: POST /api/v1/Token")
body, err := json.Marshal(map[string]string{
"username": a.creds.Username,
"password": a.creds.Password,
})
if err != nil {
return "", err
}
u := strings.TrimSuffix(a.base, "/") + "/api/v1/Token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return "", fmt.Errorf("%w: login rejected with HTTP %d", ErrAuthFailed, resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return "", fmt.Errorf("token endpoint returned HTTP %d", resp.StatusCode)
}
var tok struct {
AccessToken string `json:"access_token"`
Expiration string `json:"expiration"`
}
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return "", fmt.Errorf("token response decode: %w", err)
}
if tok.AccessToken == "" {
return "", fmt.Errorf("%w: token response missing access_token", ErrAuthFailed)
}
expiresAt := resolveExpiry(tok.AccessToken, tok.Expiration)
a.mu.Lock()
a.token = tok.AccessToken
a.expiresAt = expiresAt
a.mu.Unlock()
return tok.AccessToken, nil
}
var _ = fmt.Sprintf // placeholder import guard — remove on implementation
func resolveExpiry(token, expiration string) time.Time {
if t, err := time.Parse(time.RFC3339, expiration); err == nil {
return t
}
if exp, ok := jwtExpClaim(token); ok {
return time.Unix(exp, 0)
}
return time.Now().Add(fallbackTokenTTL)
}
func jwtExpClaim(token string) (int64, bool) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return 0, false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return 0, false
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp == 0 {
return 0, false
}
return claims.Exp, true
}
+7
View File
@@ -23,6 +23,13 @@ type Client struct {
http *http.Client
}
// NewClient builds a Client against the configured base URL. The base
// may carry a reverse-proxy path prefix; it is preserved when joining
// API paths. hc is the shared upstream HTTP client.
func NewClient(base string, auth *AuthManager, hc *http.Client) *Client {
return &Client{base: base, auth: auth, http: hc}
}
// Do issues a single authenticated request. Callers pass method, the
// API path already joined onto the configured base (prefix preserved),
// optional query values, and an optional JSON body. Do returns
+248
View File
@@ -1 +1,249 @@
package ombi
// Wire types for upstream request bodies and small typed responses.
// Field names match the RAML wire spellings exactly — including
// upstream quirks such as is4kRequest vs is4K. Optional fields use
// pointers/omitempty so omitted values are never sent.
// --- Request creation bodies ---
// MovieRequestViewModel — POST /api/v1/Request/movie
type MovieRequestViewModel struct {
TheMovieDbID int `json:"theMovieDbId"`
LanguageCode string `json:"languageCode,omitempty"`
Is4KRequest bool `json:"is4kRequest"`
RequestOnBehalf string `json:"requestOnBehalf,omitempty"`
RootFolderOverride *int `json:"rootFolderOverride,omitempty"`
QualityPathOverride *int `json:"qualityPathOverride,omitempty"`
}
// TvRequestViewModel — POST /api/v1/Request/tv (TVDB provider)
type TvRequestViewModel struct {
TvDbID int `json:"tvDbId"`
RequestAll bool `json:"requestAll"`
LatestSeason bool `json:"latestSeason"`
FirstSeason bool `json:"firstSeason"`
Seasons []SeasonsViewModel `json:"seasons,omitempty"`
LanguageProfile *int `json:"languageProfile,omitempty"`
RequestOnBehalf string `json:"requestOnBehalf,omitempty"`
RootFolderOverride *int `json:"rootFolderOverride,omitempty"`
QualityPathOverride *int `json:"qualityPathOverride,omitempty"`
}
// TvRequestViewModelV2 — POST /api/v2/Requests/tv (TMDB provider)
type TvRequestViewModelV2 struct {
TheMovieDbID int `json:"theMovieDbId"`
LanguageCode string `json:"languageCode,omitempty"`
RequestAll bool `json:"requestAll"`
LatestSeason bool `json:"latestSeason"`
FirstSeason bool `json:"firstSeason"`
Seasons []SeasonsViewModel `json:"seasons,omitempty"`
LanguageProfile *int `json:"languageProfile,omitempty"`
RequestOnBehalf string `json:"requestOnBehalf,omitempty"`
RootFolderOverride *int `json:"rootFolderOverride,omitempty"`
QualityPathOverride *int `json:"qualityPathOverride,omitempty"`
}
type SeasonsViewModel struct {
SeasonNumber int `json:"seasonNumber"`
Episodes []EpisodesViewModel `json:"episodes"`
}
type EpisodesViewModel struct {
EpisodeNumber int `json:"episodeNumber"`
}
// MusicAlbumRequestViewModel — POST /api/v1/request/music
type MusicAlbumRequestViewModel struct {
ForeignAlbumID string `json:"foreignAlbumId"`
RequestedByAlias string `json:"requestedByAlias,omitempty"`
}
// --- Moderation bodies ---
// MovieUpdateModel — POST Request/movie/{approve|available|unavailable}
type MovieUpdateModel struct {
ID int `json:"id"`
Is4K bool `json:"is4K"`
}
// TvUpdateModel / AlbumUpdateModel — POST Request/tv|request/music/*
type UpdateModelID struct {
ID int `json:"id"`
}
// DenyMovieModel — PUT Request/movie/deny
type DenyMovieModel struct {
Reason string `json:"reason"`
ID int `json:"id"`
Is4K bool `json:"is4K"`
}
// DenyTvModel / DenyAlbumModel — PUT Request/tv|request/music/deny
type DenyModel struct {
Reason string `json:"reason"`
ID int `json:"id"`
}
// MediaAdvancedOptions — POST /api/v2/Requests/{movie|tv}/advancedoptions
type MediaAdvancedOptions struct {
RequestID int `json:"requestId"`
RootPathOverride *int `json:"rootPathOverride,omitempty"`
QualityOverride *int `json:"qualityOverride,omitempty"`
LanguageProfile *int `json:"languageProfile,omitempty"`
}
// --- Issue bodies ---
// IssueCreateBody — POST /api/v1/Issues (minimal editable projection)
type IssueCreateBody struct {
Title string `json:"title"`
Subject string `json:"subject,omitempty"`
Description string `json:"description"`
IssueCategoryID int `json:"issueCategoryId"`
RequestType int `json:"requestType"`
RequestID *int `json:"requestId,omitempty"`
ProviderID string `json:"providerId,omitempty"`
}
// NewIssueCommentViewModel — POST /api/v1/Issues/comments
type NewIssueCommentViewModel struct {
Comment string `json:"comment"`
IssueID int `json:"issueId"`
}
// IssueStateViewModel — POST /api/v1/Issues/status
type IssueStateViewModel struct {
IssueID int `json:"issueId"`
Status int `json:"status"`
}
// IssueCategoryBody — POST /api/v1/Issues/categories
type IssueCategoryBody struct {
Value string `json:"value"`
}
// --- Search / discovery bodies ---
// MultiSearchFilter — POST /api/v2/Search/multi/{searchTerm}
type MultiSearchFilter struct {
Movies bool `json:"movies"`
TvShows bool `json:"tvShows"`
Music bool `json:"music"`
People bool `json:"people"`
}
// SearchMovieRefineModel — POST /api/v1/Search/movie
type SearchMovieRefineModel struct {
SearchTerm string `json:"searchTerm"`
Year *int `json:"year,omitempty"`
LanguageCode string `json:"languageCode,omitempty"`
}
// SearchActorModel — POST /api/v1/Search/movie/actor
type SearchActorModel struct {
SearchTerm string `json:"searchTerm"`
LanguageCode string `json:"languageCode,omitempty"`
}
// SimilarMoviesRefineModel — POST /api/v2/Search/movie/similar
type SimilarMoviesRefineModel struct {
TheMovieDbID int `json:"theMovieDbId"`
LanguageCode string `json:"languageCode,omitempty"`
}
// SearchMovieExtraInfoRefineModel — POST /api/v1/Search/movie/info
type SearchMovieExtraInfoRefineModel struct {
TheMovieDbID int `json:"theMovieDbId"`
LanguageCode string `json:"languageCode"`
}
// DiscoverModel — POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}
// The optional upstream `type` field is intentionally omitted until
// its semantics are verified.
type DiscoverModel struct {
ReleaseYear *int `json:"releaseYear,omitempty"`
Decade *int `json:"decade,omitempty"`
GenreIDs []int `json:"genreIds,omitempty"`
KeywordIDs []int `json:"keywordIds,omitempty"`
WatchProviders []int `json:"watchProviders,omitempty"`
Companies []int `json:"companies,omitempty"`
}
// --- User / admin bodies ---
// UserLanguage — POST /api/v1/Identity/language
type UserLanguage struct {
Lang string `json:"lang"`
}
// CountryStreamingPreference — POST /api/v1/Identity/streamingcountry
type CountryStreamingPreference struct {
Code string `json:"code"`
}
// CronViewModelBody — POST /api/v1/Settings/testcron
type CronViewModelBody struct {
Expression string `json:"expression"`
}
// FeatureEnablement — POST /api/v2/Features/{enable|disable}
type FeatureEnablement struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
// MassEmailModel — POST /api/v1/Notifications/massemail
type MassEmailModel struct {
Subject string `json:"subject"`
Body string `json:"body"`
Bcc bool `json:"bcc"`
Users []MassEmailUser `json:"users"`
}
// MassEmailUser is the minimal verified recipient entity resolved
// internally from a saved user record — never model-supplied JSON.
type MassEmailUser struct {
ID string `json:"id"`
UserName string `json:"userName,omitempty"`
Email string `json:"email,omitempty"`
}
// --- Typed small responses ---
// EngineResult is the shared shape of RequestEngineResult and
// VoteEngineResult upstream responses.
type EngineResult struct {
Result *bool `json:"result"`
Message string `json:"message"`
IsError *bool `json:"isError"`
ErrorMessage string `json:"errorMessage"`
ErrorCode *int `json:"errorCode"`
RequestID *int `json:"requestId"`
}
// IdentityResult — DELETE Identity/{userId} and other identity writes
type IdentityResult struct {
Errors []string `json:"errors"`
Successful *bool `json:"successful"`
}
// TesterResultModel — Tester/{radarr|sonarr|lidarr} responses
type TesterResultModel struct {
IsValid *bool `json:"isValid"`
Version string `json:"version"`
ExpectedSubDir string `json:"expectedSubDir"`
AdditionalInformation string `json:"additionalInformation"`
}
// CronTestModel — Settings/testcron response
type CronTestModel struct {
Success *bool `json:"success"`
Message string `json:"message"`
}
// JobSettingsViewModel — Settings/jobs POST response
type JobSettingsViewModel struct {
Result *bool `json:"result"`
Message string `json:"message"`
}
+226
View File
@@ -0,0 +1,226 @@
package tools
import "encoding/json"
// Argument structs for tools not covered by the Phase 06 baseline in
// args.go. Field shapes mirror docs/schema/03-input-schemas.md;
// decoding is strict (unknown properties rejected) in op.args.
// read_search
type SearchArgs struct {
Action string `json:"action"` // text|multi|movie_refine|actor
Media string `json:"media,omitempty"`
Query string `json:"query,omitempty"`
Include []string `json:"include,omitempty"`
Year *int `json:"year,omitempty"`
Language string `json:"language,omitempty"`
Page *Page `json:"page,omitempty"`
}
// read_discover
type DiscoverArgs struct {
Action string `json:"action"` // browse|similar|collection|credits|artist_albums|advanced_movie
Media string `json:"media,omitempty"`
Category string `json:"category,omitempty"`
TmdbID *int `json:"tmdb_id,omitempty"`
Language string `json:"language,omitempty"`
CollectionID *int `json:"collection_id,omitempty"`
PersonID *int `json:"person_id,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
Filters *DiscoverFilters `json:"filters,omitempty"`
Page *Page `json:"page,omitempty"`
}
type DiscoverFilters struct {
ReleaseYear *int `json:"release_year,omitempty"`
Decade *int `json:"decade,omitempty"`
GenreIDs []int `json:"genre_ids,omitempty"`
KeywordIDs []int `json:"keyword_ids,omitempty"`
WatchProviderIDs []int `json:"watch_provider_ids,omitempty"`
CompanyIDs []int `json:"company_ids,omitempty"`
}
// read_media details target — media/provider/id where id may be an
// integer (tmdb|tvdb) or a string (imdb|musicbrainz). This shape is
// distinct from args.go Target (kind/id request targets).
type ExternalTarget struct {
Media string `json:"media"`
Provider string `json:"provider"`
ID json.RawMessage `json:"id"`
}
// mediaCallArgs decodes read_media: MediaArgs fields plus the
// correctly-shaped external details target. The outer Target field
// shadows the embedded request-shaped Target for the "target" key.
type mediaCallArgs struct {
MediaArgs
Target *ExternalTarget `json:"target,omitempty"`
}
// read_reference
type ReferenceArgs struct {
Action string `json:"action"` // genres|languages|keywords|keyword|watch_providers|streaming_countries|issue_categories
Media string `json:"media,omitempty"`
Query string `json:"query,omitempty"`
KeywordID *int `json:"keyword_id,omitempty"`
}
// read_request_stats
type RequestStatsArgs struct {
Action string `json:"action"` // counts|total|quota|has_requests
Media string `json:"media,omitempty"`
UserID string `json:"user_id,omitempty"`
}
// read_issues
type IssuesArgs struct {
Action string `json:"action"` // list|summary|get|by_request|by_provider|provider_summary|comments|counts
Status string `json:"status,omitempty"`
IssueID *int `json:"issue_id,omitempty"`
RequestID *int `json:"request_id,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
Page *Page `json:"page,omitempty"`
}
// read_votes
type VotesArgs struct {
Action string `json:"action"` // list|get
Media string `json:"media,omitempty"`
RequestID *int `json:"request_id,omitempty"`
Page *Page `json:"page,omitempty"`
}
// read_users
type UsersArgs struct {
Action string `json:"action"` // self|list|dropdown|get|claims|online|notification_preferences
UserID string `json:"user_id,omitempty"`
Page *Page `json:"page,omitempty"`
}
// read_library
type LibraryArgs struct {
Action string `json:"action"` // recent|calendar|image|tv_images|album_art|default_poster|random_background|background_info
Media string `json:"media,omitempty"`
Provider string `json:"provider,omitempty"`
Kind string `json:"kind,omitempty"`
ID *int `json:"id,omitempty"`
TvdbID *int `json:"tvdb_id,omitempty"`
MusicBrainzID string `json:"musicbrainz_id,omitempty"`
Grouped *bool `json:"grouped,omitempty"`
Page *Page `json:"page,omitempty"`
}
// read_server
type ServerArgs struct {
Action string `json:"action"` // status|status_info|about|update_info|update_check|news|landing|features|stats|cron_validate
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Expression string `json:"expression,omitempty"`
}
// read_integration
type IntegrationArgs struct {
Action string `json:"action"` // options|plex|plex_libraries|media_server
Service string `json:"service,omitempty"`
Resource string `json:"resource,omitempty"`
Variant string `json:"variant,omitempty"`
MachineID string `json:"machine_id,omitempty"`
ServerID string `json:"server_id,omitempty"`
}
// write_request_subscribe
type SubscribeArgs struct {
Action string `json:"action"` // subscribe|unsubscribe
Media string `json:"media"`
RequestID int `json:"request_id"`
}
// write_issue_comment
type IssueCommentArgs struct {
IssueID int `json:"issue_id"`
Comment string `json:"comment"`
}
// write_vote
type VoteArgs struct {
Media string `json:"media"`
RequestID int `json:"request_id"`
Direction string `json:"direction"` // up|down
}
// write_user_preferences
type UserPrefsArgs struct {
Action string `json:"action"` // language|streaming_country|unsubscribe_newsletter
Language string `json:"language,omitempty"`
CountryCode string `json:"country_code,omitempty"`
UserID string `json:"user_id,omitempty"`
}
// write_request_delete
type DeleteArgs struct {
Target *Target `json:"target"` // kind movie|tv_parent|tv_child|album
}
// write_issue_manage
type IssueManageArgs struct {
Action string `json:"action"` // set_status|delete|delete_comment|create_category|delete_category
IssueID *int `json:"issue_id,omitempty"`
CommentID *int `json:"comment_id,omitempty"`
CategoryID *int `json:"category_id,omitempty"`
Status string `json:"status,omitempty"`
Value string `json:"value,omitempty"`
}
// read_settings
type SettingsReadArgs struct {
Section string `json:"section"`
}
// write_settings_patch
type SettingsPatchArgs struct {
Action string `json:"action"` // patch|feature
Section string `json:"section,omitempty"`
Revision string `json:"revision,omitempty"`
Changes map[string]any `json:"changes,omitempty"`
Name string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// write_user_manage
type UserManageArgs struct {
Action string `json:"action"` // delete|welcome_email
UserID string `json:"user_id"`
}
// write_integration_test
type IntegrationTestArgs struct {
Service string `json:"service"`
ProfileID string `json:"profile_id"`
}
// write_job_run
type JobRunArgs struct {
Action string `json:"action"` // run|revalidate_watchlist
Job string `json:"job,omitempty"`
}
// write_notification_send
type NotificationSendArgs struct {
Subject string `json:"subject"`
Body string `json:"body"`
Bcc bool `json:"bcc"`
UserIDs []string `json:"user_ids"`
}
// write_retry_remove
type RetryRemoveArgs struct {
QueueID int `json:"queue_id"`
}
// read_logs
type LogsArgs struct {
Action string `json:"action"` // list|read
FileID string `json:"file_id,omitempty"`
Offset *int `json:"offset,omitempty"`
Limit *int `json:"limit,omitempty"`
}
+259
View File
@@ -0,0 +1,259 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"ombi-mcp/internal/ombi"
)
// maxUpstreamBody bounds how much of one upstream response is read
// before projection; oversized data is refused rather than parsed
// into an unbounded entity graph.
const maxUpstreamBody = 8 << 20 // 8 MiB
// maxSanitizedMsg bounds upstream-derived error text.
const maxSanitizedMsg = 300
// seg percent-encodes one path segment value.
func seg(v string) string { return url.PathEscape(v) }
func segInt(v int) string { return strconv.Itoa(v) }
// args strictly decodes the MCP arguments object into v. Unknown
// properties are rejected (additionalProperties:false semantics).
// Returns a finished error envelope, or nil when decoding succeeds.
func (o *op) args(raw json.RawMessage, v any) *ToolResult {
if len(raw) == 0 {
return o.invalid("", "arguments object is required")
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return o.invalid("", "invalid arguments: %s", sanitizeErr(err))
}
return nil
}
// call performs one upstream request. On HTTP 2xx it returns the raw
// body; otherwise it returns a finished error envelope. The caller
// must return the envelope immediately when non-nil.
func (o *op) call(method, path string, query map[string]string, body any) ([]byte, *ToolResult) {
resp, err := o.env.Upstream.Do(o.ctx, method, path, query, body)
if err != nil {
return nil, o.transportErr(err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamBody+1))
if err != nil {
return nil, o.transportErr(err)
}
if len(raw) > maxUpstreamBody {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"upstream response exceeded the read budget", false)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, o.httpErr(resp, raw)
}
return raw, nil
}
// transportErr maps client/transport failures onto ToolError codes.
// A timed-out or disconnected write has unknown outcome and is never
// marked retryable.
func (o *op) transportErr(err error) *ToolResult {
switch {
case errors.Is(err, ombi.ErrUnauthorized), errors.Is(err, ombi.ErrAuthFailed):
st := http.StatusUnauthorized
return o.failErr(&ToolError{
Code: "AUTHENTICATION_FAILED",
Message: "upstream authentication failed",
Retryable: false,
HTTPStatus: &st,
})
case errors.Is(err, context.DeadlineExceeded) || isNetTimeout(err):
if o.write {
return o.fail("UNKNOWN_OUTCOME",
"upstream timed out after the request may have executed", false)
}
return o.fail("TIMEOUT", "upstream request timed out", true)
default:
if o.write {
return o.fail("UNKNOWN_OUTCOME",
"upstream transport failure; outcome is unknown", false)
}
return o.fail("UPSTREAM_REJECTED",
fmt.Sprintf("upstream transport failure: %s", sanitizeErr(err)), true)
}
}
func isNetTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
// httpErr maps an upstream non-2xx status to a sanitized ToolError.
// Raw bodies are never forwarded; a short sanitized message is kept
// only when the body parses as JSON.
func (o *op) httpErr(resp *http.Response, raw []byte) *ToolResult {
st := resp.StatusCode
e := &ToolError{HTTPStatus: &st}
switch st {
case http.StatusBadRequest, http.StatusUnprocessableEntity:
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
"upstream rejected the request"+sanitizedDetail(raw), false
case http.StatusUnauthorized:
e.Code, e.Message, e.Retryable = "AUTHENTICATION_FAILED",
"upstream authentication failed", false
case http.StatusForbidden:
e.Code, e.Message, e.Retryable = "FORBIDDEN",
"upstream denied the request", false
case http.StatusNotFound:
e.Code, e.Message, e.Retryable = "NOT_FOUND",
"upstream resource not found"+sanitizedDetail(raw), false
case http.StatusConflict:
e.Code, e.Message, e.Retryable = "CONFLICT",
"upstream conflict"+sanitizedDetail(raw), false
case http.StatusTooManyRequests:
e.Code, e.Message, e.Retryable = "RATE_LIMITED",
"upstream rate limit exceeded", true
if s := parseRetryAfter(resp.Header.Get("Retry-After")); s != nil {
e.RetryAfterSeconds = s
}
default:
if st >= 500 {
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
fmt.Sprintf("upstream error (HTTP %d)", st), true
} else {
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
fmt.Sprintf("upstream returned HTTP %d", st), false
}
}
return o.failErr(e)
}
// sanitizedDetail extracts a short human message from an upstream
// error body. It returns "" for empty/HTML/oversized/unparseable
// bodies — never forwarding stack traces, HTML pages or raw payloads.
func sanitizedDetail(raw []byte) string {
trim := bytes.TrimSpace(raw)
if len(trim) == 0 || trim[0] == '<' {
return ""
}
var m map[string]any
if err := json.Unmarshal(trim, &m); err != nil {
return ""
}
for _, k := range []string{"title", "errorMessage", "message", "Message", "detail", "error"} {
if s, ok := m[k].(string); ok {
if s = sanitizeText(s, maxSanitizedMsg); s != "" {
return ": " + s
}
}
}
return ""
}
// sanitizeText trims a string to n chars, strips control characters
// and collapses whitespace so nothing multiline or HTML-like leaks.
func sanitizeText(s string, n int) string {
s = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return ' '
}
return r
}, s)
s = strings.Join(strings.Fields(s), " ")
if len(s) > n {
s = s[:n] + "…"
}
return s
}
// sanitizeErr renders a Go error without exposing internals beyond a
// bounded single-line message.
func sanitizeErr(err error) string { return sanitizeText(err.Error(), maxSanitizedMsg) }
func parseRetryAfter(v string) *int {
if v == "" {
return nil
}
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
return &n
}
return nil
}
// decodeJSON unmarshals an upstream body into v. Decode failures map
// to UPSTREAM_SCHEMA_MISMATCH rather than fabricated defaults.
func (o *op) decodeJSON(raw []byte, v any) *ToolResult {
if err := json.Unmarshal(raw, v); err != nil {
return o.fail("UPSTREAM_SCHEMA_MISMATCH",
fmt.Sprintf("upstream response did not match the expected shape: %s", sanitizeErr(err)),
false)
}
return nil
}
// decodeObject unmarshals an upstream body expecting a JSON object.
func (o *op) decodeObject(raw []byte) (map[string]any, *ToolResult) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
fmt.Sprintf("upstream response was not a JSON object: %s", sanitizeErr(err)), false)
}
if m == nil {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH", "upstream response was null", false)
}
return m, nil
}
// decodeArray unmarshals an upstream body expecting a JSON array.
func (o *op) decodeArray(raw []byte) ([]map[string]any, *ToolResult) {
var arr []map[string]any
if err := json.Unmarshal(raw, &arr); err != nil {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
fmt.Sprintf("upstream response was not a JSON array: %s", sanitizeErr(err)), false)
}
if arr == nil {
arr = []map[string]any{}
}
return arr, nil
}
// decodeScalar unmarshals an upstream body that is a bare JSON scalar
// (bool, number or string). Unquoted plain text is tolerated as a
// string for upstream routes that return raw text.
func (o *op) decodeScalar(raw []byte) (any, *ToolResult) {
trim := bytes.TrimSpace(raw)
if len(trim) == 0 {
return nil, nil
}
var v any
if err := json.Unmarshal(trim, &v); err == nil {
return v, nil
}
if len(trim) > 1<<20 {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH", "upstream scalar body too large", false)
}
return string(trim), nil
}
// decodeBool unmarshals an upstream body expecting a JSON boolean.
func (o *op) decodeBool(raw []byte) (bool, *ToolResult) {
var b bool
if err := json.Unmarshal(bytes.TrimSpace(raw), &b); err != nil {
return false, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"upstream response was not a boolean", false)
}
return b, nil
}
+267
View File
@@ -0,0 +1,267 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"ombi-mcp/internal/ombi"
)
// read_discover — curated browse lists, similar movies, collections,
// actor credits, artist albums and advanced movie discovery.
// Family: media_page. Browse and advanced_movie use server-paged v2
// routes ({currentPosition}/{amountToLoad}); the rest page locally.
func handleDiscover(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_discover", false)
var a DiscoverArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "browse":
return o.discoverBrowse(&a)
case "similar":
return o.discoverSimilar(&a)
case "collection":
return o.discoverCollection(&a)
case "credits":
return o.discoverCredits(&a)
case "artist_albums":
return o.discoverArtistAlbums(&a)
case "advanced_movie":
return o.discoverAdvanced(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
var movieBrowseRoutes = map[string]string{
"popular": "popular",
"now_playing": "nowplaying",
"top_rated": "toprated",
"upcoming": "upcoming",
"seasonal": "seasonal",
"requested": "requested",
}
var tvBrowseRoutes = map[string]string{
"popular": "popular",
"anticipated": "anticipated",
"most_watched": "mostwatched",
"trending": "trending",
"requested": "requested",
}
func (o *op) discoverBrowse(a *DiscoverArgs) *ToolResult {
var routes map[string]string
var mediaSeg, media string
switch a.Media {
case "movie":
routes, mediaSeg, media = movieBrowseRoutes, "movie", "movie"
case "tv":
routes, mediaSeg, media = tvBrowseRoutes, "tv", "tv"
default:
return o.invalid("media", "media must be movie or tv")
}
segName, ok := routes[a.Category]
if !ok {
return o.invalid("category", "unsupported %s category %q", a.Media, a.Category)
}
pos, amt := bounds(a.Page)
path := fmt.Sprintf("/api/v2/Search/%s/%s/%d/%d", mediaSeg, segName, pos, amt)
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, media))
}
items = capItems(o, items)
pg := upstreamPage(o, a.Page, len(items), nil, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}
func (o *op) discoverSimilar(a *DiscoverArgs) *ToolResult {
if a.TmdbID == nil || *a.TmdbID < 1 {
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
}
body := ombi.SimilarMoviesRefineModel{TheMovieDbID: *a.TmdbID,
LanguageCode: a.Language}
raw, fail := o.call("POST", "/api/v2/Search/movie/similar", nil, body)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, "movie"))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) discoverCollection(a *DiscoverArgs) *ToolResult {
if a.CollectionID == nil || *a.CollectionID < 1 {
return o.invalid("collection_id", "collection_id must be a positive integer")
}
raw, fail := o.call("GET",
"/api/v2/Search/movie/collection/"+segInt(*a.CollectionID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
items := []Media{}
// The collection keeps its own identity first, then its members.
coll := Media{Media: "collection", Identifiers: []Identifier{},
Title: jstr(m, "name"), Overview: jstr(m, "overview")}
coll.Identifiers = addID(coll.Identifiers, "tmdb", m["id"])
if u := jstr(m, "posterPath", "backdropPath"); u != "" {
coll.ArtworkURIs = []string{u}
}
items = append(items, coll)
for _, mem := range jarr(m, "collection") {
if mm, ok := mem.(map[string]any); ok {
items = append(items, projectSearchMedia(mm, "movie"))
}
}
items = capItems(o, items)
pg := singlePage(len(items), "media")
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}
func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult {
if a.PersonID == nil || *a.PersonID < 1 {
return o.invalid("person_id", "person_id must be a positive integer")
}
var media string
switch a.Media {
case "movie", "tv":
media = a.Media
default:
return o.invalid("media", "media must be movie or tv")
}
raw, fail := o.call("GET",
fmt.Sprintf("/api/v2/Search/actor/%d/%s", *a.PersonID, media), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
// ActorCredits.cast/crew entries are credited works; emit each as a
// media record keeping the TMDB id and the person's role as a credit.
items := []Media{}
seen := map[int]bool{}
add := func(arr []any, isCrew bool) {
for _, v := range arr {
cm, ok := v.(map[string]any)
if !ok {
continue
}
id, _ := toInt(cm["id"])
if id > 0 && seen[id] {
continue
}
if id > 0 {
seen[id] = true
}
it := Media{Media: media, Identifiers: []Identifier{},
Title: jstr(cm, "title", "original_title", "name"),
Overview: jstr(cm, "overview")}
it.Identifiers = addID(it.Identifiers, "tmdb", cm["id"])
if y := yearOf(jstr(cm, "release_date", "first_air_date")); y != nil {
it.Year = y
}
if u := jstr(cm, "poster_path"); u != "" {
it.ArtworkURIs = []string{u}
}
cr := Credit{PersonID: a.PersonID}
if isCrew {
cr.Role = jstr(cm, "job")
cr.Department = jstr(cm, "department")
} else {
cr.Role = jstr(cm, "character")
}
if cr.Role != "" || cr.Department != "" {
it.Credits = []Credit{cr}
}
items = append(items, it)
}
}
add(jarr(m, "cast"), false)
add(jarr(m, "crew"), true)
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) discoverArtistAlbums(a *DiscoverArgs) *ToolResult {
if !nonempty(a.ArtistID) {
return o.invalid("artist_id", "artist_id must be a nonempty provider string")
}
raw, fail := o.call("GET",
"/api/v1/Search/music/artist/album/"+seg(a.ArtistID), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, "album"))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) discoverAdvanced(a *DiscoverArgs) *ToolResult {
f := a.Filters
if f == nil {
return o.invalid("filters", "filters object is required")
}
if f.ReleaseYear != nil && f.Decade != nil {
if *f.ReleaseYear < *f.Decade || *f.ReleaseYear > *f.Decade+9 {
return o.invalid("filters",
"release_year %d does not fall inside decade %d", *f.ReleaseYear, *f.Decade)
}
}
body := ombi.DiscoverModel{
ReleaseYear: f.ReleaseYear,
Decade: f.Decade,
GenreIDs: f.GenreIDs,
KeywordIDs: f.KeywordIDs,
WatchProviders: f.WatchProviderIDs,
Companies: f.CompanyIDs,
}
pos, amt := bounds(a.Page)
raw, fail := o.call("POST",
fmt.Sprintf("/api/v2/Search/advancedSearch/movie/%d/%d", pos, amt), nil, body)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, "movie"))
}
items = capItems(o, items)
pg := upstreamPage(o, a.Page, len(items), nil, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}
+104
View File
@@ -0,0 +1,104 @@
package tools
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"ombi-mcp/internal/ombi"
)
// Env carries the dependencies a handler needs at call time.
type Env struct {
// Upstream performs authenticated calls against the configured
// Ombi instance.
Upstream *ombi.Client
// settingsMu serializes settings patch read-modify-write cycles
// so concurrent local saves cannot interleave between the private
// GET and the POST (07-settings-types: serialize local saves).
settingsMu sync.Mutex
}
// Handler executes one tool call. raw is the MCP arguments object.
// It never returns nil; failures are expressed in the ToolResult
// envelope, not as Go errors.
type Handler func(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult
// op is the per-call working context: it accumulates warnings and
// truncation state and stamps the shared envelope fields.
type op struct {
ctx context.Context
env *Env
tool string
corr string
warn []string
truncated bool
write bool // mutating call — changes transport-error mapping
}
func newOp(ctx context.Context, env *Env, tool string, write bool) *op {
return &op{
ctx: ctx,
env: env,
tool: tool,
corr: newCorrelationID(),
warn: []string{},
write: write,
}
}
func newCorrelationID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return "corr-unknown"
}
return hex.EncodeToString(b[:])
}
// warnf appends a degradation note to warnings[].
func (o *op) warnf(format string, args ...any) {
o.warn = append(o.warn, fmt.Sprintf(format, args...))
}
// ok wraps a successful data family object in the envelope.
func (o *op) ok(data any) *ToolResult {
return &ToolResult{
OK: true,
Operation: o.tool,
Data: data,
Warnings: o.warn,
Truncated: o.truncated,
CorrelationID: o.corr,
}
}
// failErr wraps a ToolError in the envelope (ok=false).
func (o *op) failErr(e *ToolError) *ToolResult {
return &ToolResult{
OK: false,
Operation: o.tool,
Error: e,
Warnings: o.warn,
Truncated: o.truncated,
CorrelationID: o.corr,
}
}
// fail builds and wraps a ToolError.
func (o *op) fail(code, msg string, retryable bool) *ToolResult {
return o.failErr(&ToolError{Code: code, Message: msg, Retryable: retryable})
}
// invalid reports a client-argument problem (INVALID_ARGUMENT).
func (o *op) invalid(field, format string, args ...any) *ToolResult {
return o.failErr(&ToolError{
Code: "INVALID_ARGUMENT",
Message: fmt.Sprintf(format, args...),
Retryable: false,
Field: field,
})
}
+306
View File
@@ -0,0 +1,306 @@
package tools
// Output family structs — the domain projections defined by
// docs/schema/04-results.md ($defs library). Every field set is a
// closed allowlist: optional members use omitempty so missing
// upstream values stay absent (never fabricated as false/zero).
// Nullable contract members use pointers without omitempty so they
// serialize as explicit JSON null.
type OutTarget struct {
Kind string `json:"kind"` // movie|tv_parent|tv_child|album
ID int `json:"id"`
}
type Identifier struct {
Namespace string `json:"namespace"` // tmdb|tvdb|imdb|musicbrainz|ombi_*|provider_unknown
Value string `json:"value"`
}
type Reference struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Value any `json:"value,omitempty"`
Category string `json:"category,omitempty"`
}
type Credit struct {
Name string `json:"name"`
PersonID *int `json:"person_id,omitempty"`
Role string `json:"role,omitempty"`
Department string `json:"department,omitempty"`
}
type Provider struct {
Name string `json:"name"`
Country string `json:"country,omitempty"`
Access string `json:"access,omitempty"`
URL string `json:"url,omitempty"`
}
type EpisodeOut struct {
EpisodeNumber int `json:"episode_number"`
Title string `json:"title,omitempty"`
Requested *bool `json:"requested,omitempty"`
Available *bool `json:"available,omitempty"`
}
type SeasonOut struct {
SeasonNumber int `json:"season_number"`
Episodes []EpisodeOut `json:"episodes"`
Truncated bool `json:"truncated"`
}
type Media struct {
Media string `json:"media"` // movie|tv|artist|album|person|collection|unknown
Identifiers []Identifier `json:"identifiers"`
Title string `json:"title,omitempty"`
Overview string `json:"overview,omitempty"`
Year *int `json:"year,omitempty"`
Available *bool `json:"available,omitempty"`
Requested *bool `json:"requested,omitempty"`
RequestTargets []OutTarget `json:"request_targets,omitempty"`
Genres []string `json:"genres,omitempty"`
Credits []Credit `json:"credits,omitempty"`
Providers []Provider `json:"providers,omitempty"`
Ratings []Reference `json:"ratings,omitempty"`
Seasons []SeasonOut `json:"seasons,omitempty"`
ArtworkURIs []string `json:"artwork_uris,omitempty"`
}
type Request struct {
Target OutTarget `json:"target"`
ParentRequestID *int `json:"parent_request_id,omitempty"`
Title string `json:"title,omitempty"`
Identifiers []Identifier `json:"identifiers,omitempty"`
Approved *bool `json:"approved,omitempty"`
Available *bool `json:"available,omitempty"`
Denied *bool `json:"denied,omitempty"`
StatusText string `json:"status_text,omitempty"`
RequestedDate string `json:"requested_date,omitempty"`
RequestedUserID string `json:"requested_user_id,omitempty"`
DeniedReason string `json:"denied_reason,omitempty"`
Is4K *bool `json:"is_4k,omitempty"`
Approved4K *bool `json:"approved_4k,omitempty"`
Available4K *bool `json:"available_4k,omitempty"`
Denied4K *bool `json:"denied_4k,omitempty"`
Subscribed *bool `json:"subscribed,omitempty"`
CanApprove *bool `json:"can_approve,omitempty"`
Seasons []SeasonOut `json:"seasons,omitempty"`
}
type Issue struct {
ID int `json:"id"`
Title string `json:"title,omitempty"`
Subject string `json:"subject,omitempty"`
Description string `json:"description,omitempty"`
CategoryID *int `json:"category_id,omitempty"`
Status string `json:"status,omitempty"`
StatusCode *int `json:"status_code,omitempty"`
RequestType string `json:"request_type,omitempty"`
RequestTypeCode *int `json:"request_type_code,omitempty"`
RequestID *int `json:"request_id,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
CreatedDate string `json:"created_date,omitempty"`
ResolvedDate string `json:"resolved_date,omitempty"`
ReportedByUserID string `json:"reported_by_user_id,omitempty"`
}
type Comment struct {
ID *int `json:"id,omitempty"`
IssueID *int `json:"issue_id,omitempty"`
Comment string `json:"comment"`
AuthorID string `json:"author_id,omitempty"`
CreatedDate string `json:"created_date,omitempty"`
}
type Group struct {
ProviderID string `json:"provider_id,omitempty"`
Title string `json:"title,omitempty"`
Count *int `json:"count,omitempty"`
Issues []Issue `json:"issues,omitempty"`
}
type Vote struct {
RequestID *int `json:"request_id,omitempty"`
Media string `json:"media,omitempty"`
UserID string `json:"user_id,omitempty"`
VoteCode *int `json:"vote_code,omitempty"`
Up *int `json:"up,omitempty"`
Down *int `json:"down,omitempty"`
}
type User struct {
ID string `json:"id"`
UserName string `json:"user_name,omitempty"`
Alias string `json:"alias,omitempty"`
Language string `json:"language,omitempty"`
StreamingCountry string `json:"streaming_country,omitempty"`
Online *bool `json:"online,omitempty"`
}
type CalendarEntry struct {
Title string `json:"title,omitempty"`
Date string `json:"date,omitempty"`
Media string `json:"media,omitempty"` // movie|tv|unknown
Identifiers []Identifier `json:"identifiers,omitempty"`
SeasonNumber *int `json:"season_number,omitempty"`
EpisodeNumber *int `json:"episode_number,omitempty"`
Available *bool `json:"available,omitempty"`
}
type Artwork struct {
URI string `json:"uri"`
MimeType string `json:"mime_type,omitempty"`
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
}
type RetryEntry struct {
QueueID int `json:"queue_id"`
RequestID *int `json:"request_id,omitempty"`
RequestType string `json:"request_type,omitempty"`
RequestTypeCode *int `json:"request_type_code,omitempty"`
Title string `json:"title,omitempty"`
Reason string `json:"reason,omitempty"`
}
type Paging struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
Returned int `json:"returned"`
Total *int `json:"total"`
HasMore *bool `json:"has_more"`
NextOffset *int `json:"next_offset"`
Mode string `json:"mode"` // upstream|local|none
Unit string `json:"unit"` // media|requests|issues|provider_groups|comments|users|references|votes|calendar_entries|artwork|queue_entries
}
type MediaPage struct {
Kind string `json:"kind"` // const media_page
Items []Media `json:"items"`
Page *Paging `json:"page"`
}
type RequestPage struct {
Kind string `json:"kind"`
Items []Request `json:"items"`
Page *Paging `json:"page"`
}
type IssuePage struct {
Kind string `json:"kind"`
Items []Issue `json:"items"`
Page *Paging `json:"page"`
}
type CommentPage struct {
Kind string `json:"kind"`
Items []Comment `json:"items"`
Page *Paging `json:"page"`
}
type GroupPage struct {
Kind string `json:"kind"`
Items []Group `json:"items"`
Page *Paging `json:"page"`
}
type VotePage struct {
Kind string `json:"kind"`
Items []Vote `json:"items"`
Page *Paging `json:"page"`
}
type UserPage struct {
Kind string `json:"kind"`
Items []User `json:"items"`
Page *Paging `json:"page"`
}
type CalendarPage struct {
Kind string `json:"kind"`
Items []CalendarEntry `json:"items"`
Page *Paging `json:"page"`
}
type ArtworkPage struct {
Kind string `json:"kind"`
Items []Artwork `json:"items"`
Page *Paging `json:"page"`
}
type RetryPage struct {
Kind string `json:"kind"`
Items []RetryEntry `json:"items"`
Page *Paging `json:"page"`
}
type ReferencePage struct {
Kind string `json:"kind"`
Items []Reference `json:"items"`
Page *Paging `json:"page"`
}
type Metric struct {
Name string `json:"name"`
Value any `json:"value"`
Scope string `json:"scope"` // instance|principal|selected_user|unknown
Unit string `json:"unit,omitempty"`
}
type Metrics struct {
Kind string `json:"kind"` // const metrics
Values []Metric `json:"values"`
}
type Change struct {
Name string `json:"name"`
Value any `json:"value"`
}
type Settings struct {
Kind string `json:"kind"` // const settings
Section string `json:"section"`
Revision string `json:"revision,omitempty"`
Values []Change `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
type ItemResult struct {
Identifier string `json:"identifier"`
Outcome string `json:"outcome"` // completed|accepted|unknown|rejected
Message string `json:"message,omitempty"`
}
type Mutation struct {
Kind string `json:"kind"` // const mutation
Outcome string `json:"outcome"` // completed|accepted|partial|unknown|rejected
Target *OutTarget `json:"target,omitempty"`
RequestID *int `json:"request_id,omitempty"`
IssueID *int `json:"issue_id,omitempty"`
CommentID *int `json:"comment_id,omitempty"`
CategoryID *int `json:"category_id,omitempty"`
UserID string `json:"user_id,omitempty"`
QueueID *int `json:"queue_id,omitempty"`
Message string `json:"message,omitempty"`
UpstreamResult *bool `json:"upstream_result,omitempty"`
UpstreamIsError *bool `json:"upstream_is_error,omitempty"`
UpstreamErrorCode *int `json:"upstream_error_code,omitempty"`
AffectedCount *int `json:"affected_count,omitempty"`
ItemResults []ItemResult `json:"item_results,omitempty"`
}
type LogFile struct {
FileID string `json:"file_id"`
Name string `json:"name"`
}
type Logs struct {
Kind string `json:"kind"` // const logs
Files []LogFile `json:"files,omitempty"`
Lines []string `json:"lines,omitempty"`
Offset *int `json:"offset,omitempty"`
NextOffset *int `json:"next_offset"`
Truncated bool `json:"truncated"`
}
+411
View File
@@ -0,0 +1,411 @@
package tools
import (
"context"
"encoding/json"
"fmt"
)
// read_integration — saved ARR options and authorized media-server
// metadata. No credentials or connection overrides are accepted.
// Families: reference_page, user_page.
func handleIntegration(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_integration", false)
var a IntegrationArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "options":
return o.integrationOptions(&a)
case "plex":
return o.integrationPlex(&a)
case "plex_libraries":
return o.integrationPlexLibraries(&a)
case "media_server":
return o.integrationMediaServer(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
// integrationOptions covers the saved-settings option reads. GET
// routes are preferred; POST routes with privately constructed saved
// settings exist only where the ledger marks them D.
func (o *op) integrationOptions(a *IntegrationArgs) *ToolResult {
var path, method, cat string
var privateSettingsPath string
switch a.Service {
case "radarr":
switch a.Resource {
case "profiles":
cat = "profile"
path = "/api/v1/Radarr/Profiles"
if a.Variant == "4k" {
path += "/4k"
} else if a.Variant != "" && a.Variant != "standard" {
return o.invalid("variant", "variant must be standard or 4k")
}
case "root_folders":
cat = "root_folder"
path = "/api/v1/Radarr/RootFolders"
if a.Variant == "4k" {
path += "/4k"
} else if a.Variant != "" && a.Variant != "standard" {
return o.invalid("variant", "variant must be standard or 4k")
}
case "tags":
cat, path = "tag", "/api/v1/Radarr/tags"
case "enabled":
cat, path = "enabled", "/api/v1/Radarr/enabled"
default:
return o.invalid("resource", "unsupported radarr resource %q", a.Resource)
}
case "sonarr":
switch a.Resource {
case "profiles":
cat, path = "profile", "/api/v1/Sonarr/Profiles"
case "root_folders":
cat, path = "root_folder", "/api/v1/Sonarr/RootFolders"
case "tags":
cat, path = "tag", "/api/v1/Sonarr/tags"
case "language_profiles":
cat, path = "language_profile", "/api/v1/Sonarr/v3/LanguageProfiles"
case "enabled":
cat, path = "enabled", "/api/v1/Sonarr/enabled"
case "version":
cat, path = "version", "/api/v1/Sonarr/version"
default:
return o.invalid("resource", "unsupported sonarr resource %q", a.Resource)
}
case "lidarr":
switch a.Resource {
case "profiles":
cat, path = "profile", "/api/v1/Lidarr/Profiles"
case "root_folders":
cat, path = "root_folder", "/api/v1/Lidarr/RootFolders"
case "metadata":
// POST-only route; saved settings are loaded privately.
cat, path = "metadata_profile", "/api/v1/Lidarr/Metadata"
method, privateSettingsPath = "POST", "/api/v1/Settings/lidarr"
case "enabled":
cat, path = "enabled", "/api/v1/Lidarr/enabled"
default:
return o.invalid("resource", "unsupported lidarr resource %q", a.Resource)
}
case "couchpotato":
if a.Resource != "profiles" {
return o.invalid("resource", "unsupported couchpotato resource %q", a.Resource)
}
// Singular profile route; POST-only with saved settings.
cat, path = "profile", "/api/v1/CouchPotato/profile"
method, privateSettingsPath = "POST", "/api/v1/Settings/CouchPotato"
default:
return o.invalid("service", "unsupported service %q", a.Service)
}
var body any
if method == "POST" {
raw, fail := o.call("GET", privateSettingsPath, nil, nil)
if fail != nil {
return fail
}
settings, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
body = settings
} else {
method = "GET"
}
raw, fail := o.call(method, path, nil, body)
if fail != nil {
return fail
}
return o.refsOrScalar(raw, cat)
}
// refsOrScalar projects an option response: arrays become reference
// items; scalars/objects become single records.
func (o *op) refsOrScalar(raw []byte, cat string) *ToolResult {
if items, fail := o.refArray(raw,
[]string{"id", "value", "key"}, []string{"name", "label", "value"}, cat); fail == nil {
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
// Non-array bodies: scalar or single object → one reference.
if v, fail := o.decodeScalar(raw); fail == nil && v != nil {
r := Reference{Name: cat, Category: cat}
switch t := v.(type) {
case string, float64, bool:
r.Value = t
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")})
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
// Nested containers (e.g. CouchPotatoProfiles.list) project
// their first array member list.
for _, v := range m {
if arr, ok := v.([]any); ok {
items := []Reference{}
for _, e := range arr {
if em, ok := e.(map[string]any); ok {
items = append(items, refOf(em,
[]string{"_id", "id", "value"}, []string{"label", "name"}, nil, cat))
}
}
items = capItems(o, items)
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
}
r := refOf(m, []string{"id"}, []string{"name"}, nil, cat)
return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")})
}
func (o *op) integrationPlex(a *IntegrationArgs) *ToolResult {
var path, cat string
switch a.Resource {
case "servers":
path, cat = "/api/v1/Plex/servers", "plex_server"
case "friends":
path, cat = "/api/v1/Plex/friends", "plex_friend"
case "watchlist_users":
path, cat = "/api/v1/Plex/WatchlistUsers", "watchlist_user"
default:
return o.invalid("resource", "unsupported plex resource %q", a.Resource)
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
return o.refsOrUsers(raw, cat)
}
// refsOrUsers projects a response that may be an array of user-like
// objects or reference-like objects.
func (o *op) refsOrUsers(raw []byte, cat string) *ToolResult {
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
// User-shaped records (id+userName/username/email) → user_page.
users := []User{}
refs := []Reference{}
for _, m := range arr {
if jstr(m, "userName", "username", "email") != "" {
users = append(users, projectUser(m))
} else {
refs = append(refs, refOf(m,
[]string{"id", "machineIdentifier", "key"}, []string{"name", "title"}, nil, cat))
}
}
if len(users) > 0 && len(refs) == 0 {
users = capItems(o, users)
return o.ok(&UserPage{Kind: "user_page",
Items: users, Page: singlePage(len(users), "users")})
}
refs = capItems(o, refs)
return o.ok(&ReferencePage{Kind: "reference_page",
Items: refs, Page: singlePage(len(refs), "references")})
}
func (o *op) integrationPlexLibraries(a *IntegrationArgs) *ToolResult {
if !nonempty(a.MachineID) {
return o.invalid("machine_id", "machine_id is required")
}
raw, fail := o.call("GET",
"/api/v1/Plex/Libraries/"+seg(a.MachineID), nil, nil)
if fail != nil {
return fail
}
return o.refsOrScalar(raw, "plex_library")
}
// integrationMediaServer resolves a saved server privately by
// server_id — never accepts an arbitrary server object.
func (o *op) integrationMediaServer(a *IntegrationArgs) *ToolResult {
var svc, svcSeg, settingsPath string
switch a.Service {
case "emby":
svc, svcSeg, settingsPath = "emby", "Emby", "/api/v1/Settings/emby"
case "jellyfin":
svc, svcSeg, settingsPath = "jellyfin", "Jellyfin", "/api/v1/Settings/jellyfin"
default:
return o.invalid("service", "service must be emby or jellyfin")
}
switch a.Resource {
case "users":
raw, fail := o.call("GET",
fmt.Sprintf("/api/v1/%s/users", svcSeg), nil, nil)
if fail != nil {
return fail
}
return o.refsOrUsers(raw, svc+"_user")
case "info", "libraries":
if !nonempty(a.ServerID) {
return o.invalid("server_id", "server_id is required")
}
server, fail := o.savedServer(settingsPath, a.ServerID)
if fail != nil {
return fail
}
path := fmt.Sprintf("/api/v1/%s/info", svcSeg)
if a.Resource == "libraries" {
path = fmt.Sprintf("/api/v1/%s/Library", svcSeg)
}
raw, fail := o.call("POST", path, nil, server)
if fail != nil {
return fail
}
return o.refsOrScalar(raw, svc+"_"+a.Resource)
default:
return o.invalid("resource", "resource must be users|info|libraries")
}
}
// savedServer loads a media-server settings section privately and
// returns the server entry matching id — identity match only, no
// passthrough object construction.
func (o *op) savedServer(settingsPath, serverID string) (map[string]any, *ToolResult) {
raw, fail := o.call("GET", settingsPath, nil, nil)
if fail != nil {
return nil, fail
}
settings, fail := o.decodeObject(raw)
if fail != nil {
return nil, fail
}
for _, v := range jarr(settings, "servers") {
if sm, ok := v.(map[string]any); ok {
if s, _ := toStr(sm["id"]); s == serverID {
return sm, nil
}
if s, _ := toStr(sm["serverId"]); s == serverID {
return sm, nil
}
}
}
return nil, o.invalid("server_id", "no saved server matches server_id")
}
// write_integration_test — POST Tester/{service} with a saved
// profile resolved privately by profile_id. Some tests really send
// messages; completion means the tester returned.
func handleIntegrationTest(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_integration_test", true)
var a IntegrationTestArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
settingsPath, ok := testerSettingsPath(a.Service)
if !ok {
return o.fail("UNSUPPORTED_CAPABILITY",
fmt.Sprintf("service %q has no saved-settings profile route", a.Service), false)
}
if !nonempty(a.ProfileID) {
return o.invalid("profile_id", "profile_id is required")
}
raw, fail := o.call("GET", settingsPath, nil, nil)
if fail != nil {
return fail
}
settings, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
body, fail := o.resolveProfile(settings, a.ProfileID)
if fail != nil {
return fail
}
raw2, fail := o.call("POST", "/api/v1/Tester/"+a.Service, nil, body)
if fail != nil {
return fail
}
// Tester responses are a boolean or TesterResultModel.
if b, fail2 := o.decodeBool(raw2); fail2 == nil {
if !b {
return o.fail("UPSTREAM_REJECTED", "integration test failed", false)
}
return o.ok(&Mutation{Kind: "mutation", Outcome: "completed",
UpstreamResult: &b})
}
m, fail := o.decodeObject(raw2)
if fail != nil {
return fail
}
out := &Mutation{Kind: "mutation", Outcome: "completed"}
if v := jbool(m, "isValid"); v != nil {
out.UpstreamResult = v
if !*v {
return o.fail("UPSTREAM_REJECTED",
sanitizeText(jstr(m, "additionalInformation", "message"),
maxSanitizedMsg), false)
}
}
if s := jstr(m, "version", "expectedSubDir", "additionalInformation"); s != "" {
out.Message = sanitizeText(s, maxSanitizedMsg)
}
return o.ok(out)
}
// testerSettingsPath maps a tester service to its saved-settings GET.
// whatsapp has no complete saved-settings route per the registry.
func testerSettingsPath(service string) (string, bool) {
switch service {
case "radarr":
return "/api/v1/Settings/radarr", true
case "sonarr":
return "/api/v1/Settings/sonarr", true
case "lidarr":
return "/api/v1/Settings/lidarr", true
case "couchpotato":
return "/api/v1/Settings/CouchPotato", true
case "sickrage":
return "/api/v1/Settings/SickRage", true
case "plex":
return "/api/v1/Settings/plex", true
case "emby":
return "/api/v1/Settings/emby", true
case "jellyfin":
return "/api/v1/Settings/jellyfin", true
case "email", "discord", "telegram", "pushbullet", "pushover",
"slack", "mattermost", "twilio", "mobile", "gotify", "ntfy",
"webhook", "newsletter":
return "/api/v1/Settings/notifications/" + service, true
default:
return "", false
}
}
// resolveProfile selects the entity matching profile_id from a saved
// settings object. Array-backed sections match element identities;
// single-object sections match their id field when present.
func (o *op) resolveProfile(settings map[string]any, profileID string) (map[string]any, *ToolResult) {
for _, key := range []string{"servers", "profiles"} {
for _, v := range jarr(settings, key) {
if sm, ok := v.(map[string]any); ok {
for _, ik := range []string{"id", "serverId", "machineIdentifier", "name"} {
if s, _ := toStr(sm[ik]); s == profileID {
return sm, nil
}
}
}
}
}
if id, ok := toStr(settings["id"]); ok && id != "" {
if id != profileID {
return nil, o.invalid("profile_id",
"profile_id does not match the saved profile")
}
return settings, nil
}
// Single-entity settings without an identity field: the saved
// object itself is the administrator-provisioned profile.
return settings, nil
}
+424
View File
@@ -0,0 +1,424 @@
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)
}
+177
View File
@@ -0,0 +1,177 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"ombi-mcp/internal/ombi"
)
// permittedJobs is the closed enum of POST Job routes — names match
// the ledger exactly, including case-sensitive arrAvailability.
var permittedJobs = map[string]bool{
"update": true, "plexuserimporter": true, "plexwatchlist": true,
"embyuserimporter": true, "jellyfinuserimporter": true,
"plexcontentcacher": true, "clearmediaserverdata": true,
"plexrecentlyadded": true, "embycontentcacher": true,
"embyrecentlyadded": true, "jellyfincontentcacher": true,
"arrAvailability": true, "autodeleterequests": true,
"newsletter": true,
}
// write_job_run — trigger a permitted job or revalidate watchlist
// users. Accepted does not mean completed.
func handleJobRun(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_job_run", true)
var a JobRunArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "run":
if !permittedJobs[a.Job] {
return o.invalid("job", "unsupported job %q", a.Job)
}
raw, fail := o.call("POST", "/api/v1/Job/"+a.Job, nil, nil)
if fail != nil {
return fail
}
return o.jobResult(raw, a.Job)
case "revalidate_watchlist":
raw, fail := o.call("POST",
"/api/v1/Plex/WatchlistUsers/revalidate", nil, nil)
if fail != nil {
return fail
}
return o.jobResult(raw, "revalidate_watchlist")
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) jobResult(raw []byte, name string) *ToolResult {
m := &Mutation{Kind: "mutation", Outcome: "accepted"}
if b, fail := o.decodeBool(raw); fail == nil {
m.UpstreamResult = &b
if !b {
return o.fail("UPSTREAM_REJECTED",
fmt.Sprintf("job %q was not accepted", name), false)
}
} else if obj, fail2 := o.decodeObject(raw); fail2 == nil {
if s := jstr(obj, "message"); s != "" {
m.Message = sanitizeText(s, maxSanitizedMsg)
}
}
return o.ok(m)
}
// write_notification_send — mass email to explicit resolved user
// IDs. Recipients are resolved privately; partial resolution failure
// is reported per item rather than silently dropped.
func handleNotificationSend(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_notification_send", true)
var a NotificationSendArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if !nonempty(a.Subject) {
return o.invalid("subject", "subject must contain non-whitespace text")
}
if !nonempty(a.Body) {
return o.invalid("body", "body must contain non-whitespace text")
}
if len(a.UserIDs) == 0 {
return o.invalid("user_ids", "user_ids must be a nonempty list")
}
if len(a.UserIDs) > 100 {
return o.invalid("user_ids", "user_ids exceeds the 100-recipient cap")
}
seen := map[string]bool{}
users := []ombi.MassEmailUser{}
var itemResults []ItemResult
failed := 0
for _, id := range a.UserIDs {
if !nonempty(id) {
return o.invalid("user_ids", "user ids must be nonempty")
}
if seen[id] {
return o.invalid("user_ids", "duplicate user id %q", id)
}
seen[id] = true
raw, fail := o.call("GET", "/api/v1/Identity/User/"+seg(id), nil, nil)
if fail != nil {
failed++
itemResults = append(itemResults,
ItemResult{Identifier: id, Outcome: "rejected",
Message: "user lookup failed"})
continue
}
u, fail := o.decodeObject(raw)
if fail != nil {
failed++
itemResults = append(itemResults,
ItemResult{Identifier: id, Outcome: "rejected",
Message: "user record unreadable"})
continue
}
users = append(users, ombi.MassEmailUser{
ID: id,
UserName: jstr(u, "userName", "username"),
Email: jstr(u, "emailAddress", "email"),
})
itemResults = append(itemResults,
ItemResult{Identifier: id, Outcome: "accepted"})
}
if len(users) == 0 {
res := o.fail("PARTIAL_FAILURE",
"no recipients could be resolved", false)
return res
}
if failed > 0 {
o.warnf("%d of %d recipients could not be resolved", failed, len(a.UserIDs))
}
body := ombi.MassEmailModel{
Subject: a.Subject,
Body: a.Body,
Bcc: a.Bcc,
Users: users,
}
raw, fail := o.call("POST", "/api/v1/Notifications/massemail", nil, body)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, nil)
if fail != nil {
return fail
}
if failed > 0 {
m.Outcome = "partial"
m.ItemResults = itemResults
}
n := len(users)
m.AffectedCount = &n
return o.ok(m)
}
// write_retry_remove — DELETE the queue ID only; never calls a
// nonexistent POST RequestRetry and does not touch the underlying
// request.
func handleRetryRemove(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_retry_remove", true)
var a RetryRemoveArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if a.QueueID < 1 {
return o.invalid("queue_id", "queue_id must be a positive integer")
}
raw, fail := o.call("DELETE",
"/api/v1/RequestRetry/"+segInt(a.QueueID), nil, nil)
if fail != nil {
return fail
}
m := emptyMutation("completed", nil)
m.QueueID = &a.QueueID
return o.ok(m)
}
+216
View File
@@ -0,0 +1,216 @@
package tools
import (
"context"
"encoding/json"
"strings"
)
// read_library — recent additions, calendar and artwork references.
// Families: media_page, calendar_page, artwork_page.
func handleLibrary(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_library", false)
var a LibraryArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "recent":
return o.libraryRecent(&a)
case "calendar":
return o.libraryCalendar(&a)
case "image":
return o.libraryImage(&a)
case "tv_images":
return o.libraryTVImages(&a)
case "album_art":
return o.libraryAlbumArt(&a)
case "default_poster":
return o.artworkScalar("/api/v1/Images/poster", "default_poster")
case "random_background":
return o.artworkScalar("/api/v1/Images/background", "random_background")
case "background_info":
return o.libraryBackgroundInfo()
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) libraryRecent(a *LibraryArgs) *ToolResult {
var path, media string
switch a.Media {
case "movie":
path, media = "/api/v1/RecentlyAdded/movies", "movie"
case "tv":
if a.Grouped == nil {
return o.invalid("grouped", "grouped is required for tv")
}
media = "tv"
path = "/api/v1/RecentlyAdded/tv"
if *a.Grouped {
path += "/grouped"
}
default:
return o.invalid("media", "media must be movie or tv")
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Media{}
for _, m := range arr {
items = append(items, projectSearchMedia(m, media))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) libraryCalendar(a *LibraryArgs) *ToolResult {
raw, fail := o.call("GET", "/api/v2/Calendar", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []CalendarEntry{}
for _, m := range arr {
items = append(items, o.projectCalendarEntry(m))
}
win, pg := localWindow(o, items, a.Page, "calendar_entries")
return o.ok(&CalendarPage{Kind: "calendar_page", Items: win, Page: pg})
}
// libraryImage routes movie/tv image requests to the exact Images
// routes; responses are verified as URI strings before emission.
func (o *op) libraryImage(a *LibraryArgs) *ToolResult {
if a.ID == nil || *a.ID < 1 {
return o.invalid("id", "id must be a positive integer")
}
var path string
switch a.Media {
case "movie":
if a.Provider != "tmdb" {
return o.invalid("provider", "movie images use provider tmdb")
}
switch a.Kind {
case "poster", "background", "banner":
path = "/api/v1/Images/" + a.Kind + "/movie/" + segInt(*a.ID)
default:
return o.invalid("kind", "movie kind must be poster|background|banner")
}
case "tv":
switch a.Kind {
case "poster", "background":
default:
return o.invalid("kind", "tv kind must be poster|background")
}
switch a.Provider {
case "tvdb":
path = "/api/v1/Images/" + a.Kind + "/tv/" + segInt(*a.ID)
case "tmdb":
path = "/api/v1/Images/" + a.Kind + "/tv/tmdb/" + segInt(*a.ID)
default:
return o.invalid("provider", "tv provider must be tmdb or tvdb")
}
default:
return o.invalid("media", "media must be movie or tv")
}
return o.artworkScalar(path, a.Kind)
}
func (o *op) libraryTVImages(a *LibraryArgs) *ToolResult {
if a.TvdbID == nil || *a.TvdbID < 1 {
return o.invalid("tvdb_id", "tvdb_id must be a positive integer")
}
return o.artworkScalar("/api/v1/Images/tv/"+segInt(*a.TvdbID), "tv_images")
}
func (o *op) libraryAlbumArt(a *LibraryArgs) *ToolResult {
if !nonempty(a.MusicBrainzID) {
return o.invalid("musicbrainz_id", "musicbrainz_id must be nonempty")
}
raw, fail := o.call("GET",
"/api/v2/Search/releasegroupart/"+seg(a.MusicBrainzID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
it := Artwork{Name: a.MusicBrainzID, Kind: "album_art"}
if u := jstr(m, "image", "url", "uri"); u != "" && safeURI(u) {
it.URI = u
} else if u != "" {
o.warnf("upstream artwork reference was not a safe URI")
}
if mt := jstr(m, "mimeType", "contentType"); mt != "" {
it.MimeType = mt
}
return o.ok(&ArtworkPage{Kind: "artwork_page",
Items: []Artwork{it}, Page: singlePage(1, "artwork")})
}
// artworkScalar fetches an image route whose 200 body is a string
// representation; only safe URI strings are emitted as artwork URIs.
func (o *op) artworkScalar(path, kind string) *ToolResult {
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
v, fail := o.decodeScalar(raw)
if fail != nil {
return fail
}
s, _ := v.(string)
it := Artwork{Name: kind, Kind: kind}
if s != "" && safeURI(s) {
it.URI = s
} else if s != "" {
o.warnf("upstream returned a non-URI image representation")
}
return o.ok(&ArtworkPage{Kind: "artwork_page",
Items: []Artwork{it}, Page: singlePage(1, "artwork")})
}
// safeURI accepts only http(s) URLs or root-relative paths — never
// credential-bearing query strings or scriptable content.
func safeURI(s string) bool {
s = strings.TrimSpace(s)
if s == "" || strings.HasPrefix(s, "<") {
return false
}
if strings.Contains(s, "apikey=") || strings.Contains(s, "apiKey=") ||
strings.Contains(s, "ApiKey=") {
return false
}
low := strings.ToLower(s)
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") ||
strings.HasPrefix(s, "/")
}
func (o *op) libraryBackgroundInfo() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Images/background/info", nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
it := Artwork{Name: "background_info", Kind: "background_info"}
if u := jstr(m, "url", "uri", "image"); u != "" && safeURI(u) {
it.URI = u
}
if t := jstr(m, "title", "name"); t != "" {
it.Name = t
}
return o.ok(&ArtworkPage{Kind: "artwork_page",
Items: []Artwork{it}, Page: singlePage(1, "artwork")})
}
+140
View File
@@ -0,0 +1,140 @@
package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strings"
)
const (
maxLogBodyBytes = 512 * 1024
defaultLogLimit = 100
maxLogLimit = 500
)
// logFileID is the opaque identifier exposed for a log file name;
// it can only map back to a name that appears in a live listing.
func logFileID(name string) string {
sum := sha256.Sum256([]byte(name))
return hex.EncodeToString(sum[:16])
}
// logFileName extracts the upstream file name from a listing entry.
func logFileName(m map[string]any) string {
return jstr(m, "fileName", "filename", "name")
}
// read_logs — list sanitized log file IDs or read a bounded,
// sanitized slice of a single log file.
func handleLogs(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_logs", false)
var a LogsArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "list":
return o.logsList()
case "read":
return o.logsRead(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) logsList() *ToolResult {
raw, fail := o.call("GET", "/api/v2/System/logs", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
names := []string{}
for _, m := range arr {
if s := logFileName(m); s != "" {
names = append(names, s)
}
}
sort.Strings(names)
out := &Logs{Kind: "logs", Files: []LogFile{}}
for _, n := range names {
out.Files = append(out.Files, LogFile{FileID: logFileID(n), Name: n})
}
if len(out.Files) > 100 {
out.Files = out.Files[:100]
o.truncated = true
out.Truncated = true
}
return o.ok(out)
}
func (o *op) logsRead(a *LogsArgs) *ToolResult {
if !nonempty(a.FileID) {
return o.invalid("file_id", "file_id is required")
}
limit := defaultLogLimit
if a.Limit != nil {
limit = *a.Limit
}
if limit < 1 || limit > maxLogLimit {
return o.invalid("limit", "limit must be between 1 and %d", maxLogLimit)
}
offset := 0
if a.Offset != nil {
offset = *a.Offset
}
if offset < 0 {
return o.invalid("offset", "offset must be a non-negative integer")
}
// Resolve the opaque ID to a live listing name — a file_id that
// does not match any current file is rejected, never traversed.
raw, fail := o.call("GET", "/api/v2/System/logs", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
name := ""
for _, m := range arr {
if n := logFileName(m); n != "" && logFileID(n) == a.FileID {
name = n
break
}
}
if name == "" {
return o.fail("NOT_FOUND", "no log file matches the supplied file_id", false)
}
raw, fail = o.call("GET", "/api/v2/System/logs/"+seg(name), nil, nil)
if fail != nil {
return fail
}
if len(raw) > maxLogBodyBytes {
raw = raw[:maxLogBodyBytes]
o.truncated = true
}
text := sanitizeText(string(raw), maxLogBodyBytes)
lines := strings.Split(text, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
out := &Logs{Kind: "logs", Lines: []string{}, Offset: &offset}
if offset < len(lines) {
end := offset + limit
if end > len(lines) {
end = len(lines)
}
out.Lines = lines[offset:end]
if end < len(lines) {
next := end
out.NextOffset = &next
}
}
return o.ok(out)
}
+250
View File
@@ -0,0 +1,250 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"ombi-mcp/internal/ombi"
)
// read_media — details by explicit provider/request, localized movie
// info, ratings and streaming availability. Family: media_page.
func handleMedia(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_media", false)
var a mediaCallArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "details":
return o.mediaDetails(&a)
case "by_request":
return o.mediaByRequest(&a)
case "movie_localized":
return o.mediaLocalized(&a)
case "ratings":
return o.mediaRatings(&a)
case "streaming":
return o.mediaStreaming(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
// targetID decodes the external target id — an integer for
// tmdb/tvdb, a string for imdb/musicbrainz.
func (t *ExternalTarget) idInt() (int, bool) {
var i int
if err := json.Unmarshal(t.ID, &i); err == nil && i >= 1 {
return i, true
}
return 0, false
}
func (t *ExternalTarget) idString() (string, bool) {
var s string
if err := json.Unmarshal(t.ID, &s); err == nil && s != "" {
return s, true
}
return "", false
}
func (o *op) mediaDetails(a *mediaCallArgs) *ToolResult {
t := a.Target
if t == nil {
return o.invalid("target", "target is required")
}
var path string
var project func(map[string]any) Media
switch t.Media {
case "movie":
project = projectFullMovie
switch t.Provider {
case "tmdb":
id, ok := t.idInt()
if !ok {
return o.invalid("target.id", "tmdb id must be a positive integer")
}
path = "/api/v2/Search/movie/" + segInt(id)
case "imdb":
s, ok := t.idString()
if !ok || len(s) < 3 || s[:2] != "tt" {
return o.invalid("target.id", "imdb id must match ^tt[0-9]+$")
}
path = "/api/v2/Search/movie/imdb/" + seg(s)
default:
return o.invalid("target.provider", "movie provider must be tmdb or imdb")
}
case "tv":
project = o.projectFullTV
id, ok := t.idInt()
if !ok {
return o.invalid("target.id", "tv provider id must be a positive integer")
}
switch t.Provider {
case "tvdb":
path = "/api/v2/Search/tv/" + segInt(id)
case "tmdb":
path = "/api/v2/Search/tv/moviedb/" + segInt(id)
default:
return o.invalid("target.provider", "tv provider must be tmdb or tvdb")
}
case "artist":
if t.Provider != "musicbrainz" {
return o.invalid("target.provider", "artist provider must be musicbrainz")
}
s, ok := t.idString()
if !ok {
return o.invalid("target.id", "musicbrainz id must be a nonempty string")
}
project = func(m map[string]any) Media { return projectSearchMedia(m, "artist") }
path = "/api/v2/Search/artist/" + seg(s)
case "album":
if t.Provider != "musicbrainz" {
return o.invalid("target.provider", "album provider must be musicbrainz")
}
s, ok := t.idString()
if !ok {
return o.invalid("target.id", "musicbrainz id must be a nonempty string")
}
project = func(m map[string]any) Media { return projectSearchMedia(m, "album") }
path = "/api/v2/Search/artist/album/" + seg(s)
default:
return o.invalid("target.media", "media must be movie|tv|artist|album")
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
return o.ok(&MediaPage{Kind: "media_page",
Items: []Media{project(m)}, Page: singlePage(1, "media")})
}
func (o *op) mediaByRequest(a *mediaCallArgs) *ToolResult {
if a.RequestID == nil || *a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
var path string
var project func(map[string]any) Media
switch a.Media {
case "movie":
path = "/api/v2/Search/movie/request/"
project = projectFullMovie
case "tv":
// The TV request-ID namespace of this route requires adapter
// verification; surface results as TV media projections.
path = "/api/v2/Search/tv/request/"
project = o.projectFullTV
case "album":
// MCP media=album maps to the literal artist route segment.
path = "/api/v2/Search/artist/request/"
project = func(m map[string]any) Media { return projectSearchMedia(m, "album") }
default:
return o.invalid("media", "media must be movie|tv|album")
}
raw, fail := o.call("GET", path+segInt(*a.RequestID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
return o.ok(&MediaPage{Kind: "media_page",
Items: []Media{project(m)}, Page: singlePage(1, "media")})
}
func (o *op) mediaLocalized(a *mediaCallArgs) *ToolResult {
if a.TmdbID == nil || *a.TmdbID < 1 {
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
}
if !nonempty(a.Language) {
return o.invalid("language", "language is required")
}
body := ombi.SearchMovieExtraInfoRefineModel{
TheMovieDbID: *a.TmdbID, LanguageCode: a.Language}
raw, fail := o.call("POST", "/api/v1/Search/movie/info", nil, body)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
return o.ok(&MediaPage{Kind: "media_page",
Items: []Media{projectFullMovie(m)}, Page: singlePage(1, "media")})
}
func (o *op) mediaRatings(a *mediaCallArgs) *ToolResult {
var media string
switch a.Media {
case "movie", "tv":
media = a.Media
default:
return o.invalid("media", "media must be movie or tv")
}
if !nonempty(a.Name) {
return o.invalid("name", "name must contain non-whitespace text")
}
if a.Year == nil || *a.Year < 1870 || *a.Year > 9999 {
return o.invalid("year", "year must be within 1870..9999")
}
raw, fail := o.call("GET",
fmt.Sprintf("/api/v2/Search/ratings/%s/%s/%d", media, seg(a.Name), *a.Year),
nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
it := Media{Media: media, Identifiers: []Identifier{}, Title: a.Name, Year: a.Year}
for k, v := range m {
if i, ok := toInt(v); ok {
it.Ratings = append(it.Ratings, Reference{Name: k, Value: i})
} else if s, ok := v.(string); ok && s != "" {
it.Ratings = append(it.Ratings, Reference{Name: k, Value: s})
}
}
return o.ok(&MediaPage{Kind: "media_page",
Items: []Media{it}, Page: singlePage(1, "media")})
}
func (o *op) mediaStreaming(a *mediaCallArgs) *ToolResult {
var media string
switch a.Media {
case "movie", "tv":
media = a.Media
default:
return o.invalid("media", "media must be movie or tv")
}
if a.TmdbID == nil || *a.TmdbID < 1 {
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
}
// Streaming lookup uses the TMDB namespace even for TV.
raw, fail := o.call("GET",
fmt.Sprintf("/api/v2/Search/stream/%s/%d", media, *a.TmdbID), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
it := Media{Media: media, Identifiers: []Identifier{}}
it.Identifiers = addID(it.Identifiers, "tmdb", *a.TmdbID)
for _, sm := range arr {
p := Provider{Name: jstr(sm, "streamingProvider")}
if p.Name != "" {
it.Providers = append(it.Providers, p)
}
}
return o.ok(&MediaPage{Kind: "media_page",
Items: []Media{it}, Page: singlePage(1, "media")})
}
+787
View File
@@ -0,0 +1,787 @@
package tools
import (
"bytes"
"encoding/json"
"strconv"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate"
)
// Projection layer: bounded allowlisted projections of upstream
// entities into the docs/schema/04-results.md domain families.
// Upstream entities decode into map[string]any; helpers below never
// pass through nested objects wholesale (allowlist, not passthrough).
// --- generic accessors ---
func jstr(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok {
return s
}
}
}
return ""
}
func jint(m map[string]any, keys ...string) *int {
for _, k := range keys {
if v, ok := m[k]; ok {
if i, ok := toInt(v); ok {
return &i
}
}
}
return nil
}
func jbool(m map[string]any, keys ...string) *bool {
for _, k := range keys {
if v, ok := m[k]; ok {
if b, ok := v.(bool); ok {
return &b
}
}
}
return nil
}
func jarr(m map[string]any, key string) []any {
if v, ok := m[key]; ok {
if a, ok := v.([]any); ok {
return a
}
}
return nil
}
func jobj(m map[string]any, key string) map[string]any {
if v, ok := m[key]; ok {
if o, ok := v.(map[string]any); ok {
return o
}
}
return nil
}
func toInt(v any) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
case json.Number:
i, err := n.Int64()
if err == nil {
return int(i), true
}
case string:
i, err := strconv.Atoi(n)
if err == nil {
return i, true
}
}
return 0, false
}
func toStr(v any) (string, bool) {
switch s := v.(type) {
case string:
return s, true
case float64:
return strconv.FormatFloat(s, 'f', -1, 64), true
case bool:
return strconv.FormatBool(s), true
}
return "", false
}
func yearOf(s string) *int {
if len(s) < 4 {
return nil
}
if y, err := strconv.Atoi(s[:4]); err == nil && y > 0 {
return &y
}
return nil
}
// --- enum twins (Phase 04 label maps; unmapped → code + warning) ---
// enumPair emits the raw int into *code and the mapped label; an
// unmapped upstream int keeps the code only and warns.
func (o *op) enumPair(code *int, label string, ok bool, field string) (*int, string) {
if code == nil {
return nil, ""
}
if !ok {
o.warnf("unmapped upstream enum value %d for %s", *code, field)
return code, ""
}
return code, label
}
func (o *op) requestTypeTwin(v any) (*int, string) {
c, ok := toInt(v)
if !ok {
return nil, ""
}
l, mapped := translate.RequestTypeFromWire(c)
return o.enumPair(&c, l, mapped, "request_type")
}
func (o *op) issueStatusTwin(v any) (*int, string) {
c, ok := toInt(v)
if !ok {
return nil, ""
}
l, mapped := translate.IssueStatusFromWire(c)
return o.enumPair(&c, l, mapped, "status")
}
func (o *op) agentTwin(v any) (*int, string) {
c, ok := toInt(v)
if !ok {
return nil, ""
}
l, mapped := translate.AgentFromWire(c)
return o.enumPair(&c, l, mapped, "agent")
}
// --- identifiers ---
func addID(ids []Identifier, ns string, v any) []Identifier {
s, ok := toStr(v)
if !ok || s == "" || s == "0" {
return ids
}
return append(ids, Identifier{Namespace: ns, Value: s})
}
// --- media projections ---
// projectSearchMedia projects the shared tail of SearchMovieViewModel,
// SearchTvShowViewModel, SearchAlbumViewModel, ReleaseGroup and
// RecentlyAddedMovieModel — they share the same trailing fields.
func projectSearchMedia(m map[string]any, media string) Media {
out := Media{Media: media, Identifiers: []Identifier{}}
out.Title = jstr(m, "title", "artistName", "name")
out.Overview = jstr(m, "overview")
if y := yearOf(jstr(m, "releaseDate", "firstAired", "releaseYear")); y != nil {
out.Year = y
}
out.Available = jbool(m, "available", "fullyAvailable")
out.Requested = jbool(m, "requested")
out.Genres = strList(jarr(m, "genre"))
switch media {
case "movie":
out.Identifiers = addID(out.Identifiers, "tmdb", m["theMovieDbId"])
out.Identifiers = addID(out.Identifiers, "imdb", m["imdbId"])
if rid, ok := toInt(m["requestId"]); ok && rid > 0 {
out.RequestTargets = []OutTarget{{Kind: "movie", ID: rid}}
}
case "tv":
out.Identifiers = addID(out.Identifiers, "tmdb", m["theMovieDbId"])
out.Identifiers = addID(out.Identifiers, "tvdb", m["theTvDbId"])
out.Identifiers = addID(out.Identifiers, "imdb", m["imdbId"])
if rid, ok := toInt(m["requestId"]); ok && rid > 0 {
out.RequestTargets = []OutTarget{{Kind: "tv_parent", ID: rid}}
}
case "album":
out.Identifiers = addID(out.Identifiers, "musicbrainz", m["foreignAlbumId"])
if rid, ok := toInt(m["requestId"]); ok && rid > 0 {
out.RequestTargets = []OutTarget{{Kind: "album", ID: rid}}
}
case "artist":
for _, k := range []string{"forignArtistId", "foreignArtistId", "id"} {
if v, ok := m[k]; ok {
out.Identifiers = addID(out.Identifiers, "musicbrainz", v)
break
}
}
}
if u := jstr(m, "posterPath", "poster", "cover"); u != "" {
out.ArtworkURIs = append(out.ArtworkURIs, u)
}
return out
}
func strList(a []any) []string {
var out []string
for _, v := range a {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
// projectFullMovie enriches a movie projection with credits, genres,
// ratings and artwork from MovieFullInfoViewModel.
func projectFullMovie(m map[string]any) Media {
out := projectSearchMedia(m, "movie")
out.Genres = namesOf(jarr(m, "genres"))
out.Credits = creditsOf(m)
if v := jobj(m, "externalIds"); v != nil {
out.Identifiers = addID(out.Identifiers, "imdb", v["imdbId"])
}
if v := m["voteAverage"]; v != nil {
if f, ok := v.(float64); ok {
out.Ratings = append(out.Ratings, Reference{Name: "vote_average", Value: f})
}
}
if v := m["voteCount"]; v != nil {
if i, ok := toInt(v); ok {
out.Ratings = append(out.Ratings, Reference{Name: "vote_count", Value: i})
}
}
if c := jobj(m, "belongsToCollection"); c != nil {
if id, ok := toInt(c["id"]); ok {
out.Identifiers = addID(out.Identifiers, "tmdb", id)
}
}
for _, k := range []string{"backdropPath", "posterPath"} {
if u := jstr(m, k); u != "" {
out.ArtworkURIs = appendIfMissing(out.ArtworkURIs, u)
}
}
return out
}
// projectFullTV enriches a TV projection with credits, genres and
// season/episode structure from SearchFullInfoTvShowViewModel.
func (o *op) projectFullTV(m map[string]any) Media {
out := projectSearchMedia(m, "tv")
out.Genres = namesOf(jarr(m, "genres"))
out.Credits = creditsOf(m)
if v := jobj(m, "externalIds"); v != nil {
out.Identifiers = addID(out.Identifiers, "imdb", v["imdbId"])
out.Identifiers = addID(out.Identifiers, "tvdb", v["tvdbId"])
}
if u := jstr(m, "backdropPath", "banner"); u != "" {
out.ArtworkURIs = appendIfMissing(out.ArtworkURIs, u)
}
out.Seasons = o.seasonsOf(jarr(m, "seasonRequests"))
return out
}
func creditsOf(m map[string]any) []Credit {
var out []Credit
for _, c := range jarr(m, "cast") {
if cm, ok := c.(map[string]any); ok {
out = append(out, Credit{
Name: jstr(cm, "person", "name"),
PersonID: jint(cm, "id"),
Role: jstr(cm, "character"),
})
}
}
for _, c := range jarr(m, "crew") {
if cm, ok := c.(map[string]any); ok {
out = append(out, Credit{
Name: jstr(cm, "name"),
PersonID: jint(cm, "id"),
Department: jstr(cm, "department", "job"),
})
}
}
return out
}
func namesOf(a []any) []string {
var out []string
for _, v := range a {
if vm, ok := v.(map[string]any); ok {
if s := jstr(vm, "name"); s != "" {
out = append(out, s)
}
}
}
return out
}
// seasonsOf projects upstream SeasonRequests (seasonNumber +
// episodes[{episodeNumber,title,available,requested}]) into the
// bounded season family, capping nested episodes at 100.
func (o *op) seasonsOf(a []any) []SeasonOut {
if len(a) == 0 {
return nil
}
var out []SeasonOut
for _, s := range a {
sm, ok := s.(map[string]any)
if !ok {
continue
}
sn, ok := toInt(sm["seasonNumber"])
if !ok {
continue
}
eps := jarr(sm, "episodes")
trunc := false
if len(eps) > 100 {
eps = eps[:100]
trunc = true
o.warnf("season %d episode list truncated at 100", sn)
}
so := SeasonOut{SeasonNumber: sn, Episodes: []EpisodeOut{}, Truncated: trunc}
for _, e := range eps {
em, ok := e.(map[string]any)
if !ok {
continue
}
en, ok := toInt(em["episodeNumber"])
if !ok {
continue
}
so.Episodes = append(so.Episodes, EpisodeOut{
EpisodeNumber: en,
Title: jstr(em, "title"),
Requested: jbool(em, "requested"),
Available: jbool(em, "available"),
})
}
out = append(out, so)
}
return out
}
func appendIfMissing(s []string, v string) []string {
for _, x := range s {
if x == v {
return s
}
}
return append(s, v)
}
// --- request projections ---
// projectRequest projects MovieRequests / TvRequests / ChildRequests /
// AlbumRequest / RecentlyRequestedModel into the request family.
func (o *op) projectRequest(m map[string]any, kind string) Request {
r := Request{Target: OutTarget{Kind: kind}}
if id, ok := toInt(m["id"]); ok {
r.Target.ID = id
}
if id, ok := toInt(m["requestId"]); ok && r.Target.ID == 0 {
r.Target.ID = id
}
r.Title = jstr(m, "title", "artistName")
r.ParentRequestID = jint(m, "parentRequestId")
r.Approved = jbool(m, "approved")
r.Available = jbool(m, "available")
r.Denied = jbool(m, "denied")
r.StatusText = jstr(m, "requestStatus")
r.RequestedDate = jstr(m, "requestedDate", "requestDate")
r.RequestedUserID = jstr(m, "requestedUserId")
r.DeniedReason = jstr(m, "deniedReason")
r.Subscribed = jbool(m, "subscribed")
r.CanApprove = jbool(m, "canApprove")
r.Identifiers = []Identifier{}
switch kind {
case "movie":
r.Identifiers = addID(r.Identifiers, "tmdb", m["theMovieDbId"])
r.Identifiers = addID(r.Identifiers, "imdb", m["imdbId"])
r.Is4K = jbool(m, "is4kRequest")
r.Approved4K = jbool(m, "approved4K")
r.Available4K = jbool(m, "available4K")
r.Denied4K = jbool(m, "denied4K")
case "tv_parent", "tv_child":
r.Identifiers = addID(r.Identifiers, "tvdb", m["tvDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", m["externalProviderId"])
r.Identifiers = addID(r.Identifiers, "imdb", m["imdbId"])
case "album":
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["foreignAlbumId"])
}
if srs := jarr(m, "seasonRequests"); len(srs) > 0 {
r.Seasons = o.seasonsOf(srs)
}
return r
}
// projectRetryEntry projects FailedRequestViewModel — queue_id is
// strictly distinct from the underlying request_id.
func (o *op) projectRetryEntry(m map[string]any) RetryEntry {
r := RetryEntry{Title: jstr(m, "title"), Reason: jstr(m, "error")}
if id, ok := toInt(m["failedId"]); ok {
r.QueueID = id
}
r.RequestID = jint(m, "requestId")
r.RequestTypeCode, r.RequestType = o.requestTypeTwin(m["type"])
return r
}
// --- issue projections ---
func (o *op) projectIssue(m map[string]any) Issue {
i := Issue{}
if id, ok := toInt(m["id"]); ok {
i.ID = id
}
i.Title = jstr(m, "title")
i.Subject = jstr(m, "subject")
i.Description = jstr(m, "description")
i.CategoryID = jint(m, "issueCategoryId")
i.StatusCode, i.Status = o.issueStatusTwin(m["status"])
i.RequestTypeCode, i.RequestType = o.requestTypeTwin(m["requestType"])
i.RequestID = jint(m, "requestId")
i.ProviderID = jstr(m, "providerId")
i.CreatedDate = jstr(m, "createdDate")
// Upstream field is spelled "resovledDate"; map to resolved_date
// without adopting the typo.
i.ResolvedDate = jstr(m, "resovledDate", "resolvedDate")
i.ReportedByUserID = jstr(m, "userReportedId")
return i
}
// projectGroup projects IssuesSummaryModel into the provider-group
// family, truncating nested issues at 100 with a warning.
func (o *op) projectGroup(m map[string]any) Group {
g := Group{
ProviderID: jstr(m, "providerId"),
Title: jstr(m, "title"),
Count: jint(m, "count"),
}
arr := jarr(m, "issues")
if len(arr) > 100 {
arr = arr[:100]
o.warnf("nested issue list truncated at 100 for provider group %q", g.ProviderID)
o.truncated = true
}
if len(arr) > 0 {
g.Issues = []Issue{}
for _, v := range arr {
if im, ok := v.(map[string]any); ok {
g.Issues = append(g.Issues, o.projectIssue(im))
}
}
}
return g
}
func projectComment(m map[string]any) Comment {
return Comment{
ID: jint(m, "id"),
IssueID: jint(m, "issuesId", "issueId"),
Comment: jstr(m, "comment"),
AuthorID: jstr(m, "userId", "username"),
CreatedDate: jstr(m, "date", "createdDate"),
}
}
// --- vote projections ---
// projectVoteSummary projects VoteViewModel (global list entries).
func (o *op) projectVoteSummary(m map[string]any) Vote {
v := Vote{
RequestID: jint(m, "requestId"),
Up: jint(m, "upvotes"),
Down: jint(m, "downvotes"),
}
_, v.Media = o.requestTypeTwin(m["requestType"])
return v
}
// projectVoteRecord projects a Votes entity (per-request votes).
// VoteType has no published label map — preserve the raw code.
func (o *op) projectVoteRecord(m map[string]any) Vote {
v := Vote{
RequestID: jint(m, "requestId"),
UserID: jstr(m, "userId"),
}
_, v.Media = o.requestTypeTwin(m["requestType"])
if c, ok := toInt(m["voteType"]); ok {
v.VoteCode = &c
if c != 0 && c != 1 {
o.warnf("unmapped upstream enum value %d for vote_type", c)
}
}
return v
}
// --- user projections ---
func projectUser(m map[string]any) User {
u := User{
ID: jstr(m, "id", "userId"),
UserName: jstr(m, "userName", "username", "displayName"),
Alias: jstr(m, "alias"),
Language: jstr(m, "language"),
StreamingCountry: jstr(m, "streamingCountry"),
}
return u
}
// --- calendar projection ---
func (o *op) projectCalendarEntry(m map[string]any) CalendarEntry {
c := CalendarEntry{
Title: jstr(m, "title"),
Date: jstr(m, "start"),
Identifiers: []Identifier{},
}
if code, ok := toInt(m["type"]); ok {
switch code {
case 1:
c.Media = "movie"
case 0:
c.Media = "tv"
default:
c.Media = "unknown"
}
}
for _, ep := range jarr(m, "extraParams") {
em, ok := ep.(map[string]any)
if !ok {
continue
}
if pid, ok := toInt(em["providerId"]); ok && pid > 0 {
c.Identifiers = addID(c.Identifiers, "provider_unknown", pid)
}
}
return c
}
// --- generic reference projection ---
// refOf projects one upstream object into a reference record using
// the first present key from each candidate list.
func refOf(m map[string]any, idKeys, nameKeys []string, val any, category string) Reference {
r := Reference{Category: category}
for _, k := range idKeys {
if s, ok := toStr(m[k]); ok && s != "" {
r.ID = s
break
}
}
for _, k := range nameKeys {
if s, ok := m[k].(string); ok && s != "" {
r.Name = s
break
}
}
if val == nil {
r.Value = firstScalar(m)
} else {
r.Value = val
}
return r
}
// firstScalar returns the first scalar property value in a map for
// fallback reference values; iteration order makes this best-effort,
// so callers should prefer explicit keys where known.
func firstScalar(m map[string]any) any {
for _, v := range m {
switch v.(type) {
case string, float64, bool:
return v
}
}
return nil
}
// --- paging ---
const (
defaultLimit = 25
maxLimit = 100
maxRecords = 100
)
// bounds resolves the server-side page defaults: offset 0, limit 25,
// maximum limit 100.
func bounds(p *Page) (offset, limit int) {
if p == nil {
return 0, defaultLimit
}
offset, limit = p.Offset, p.Limit
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = defaultLimit
}
if limit > maxLimit {
limit = maxLimit
}
return offset, limit
}
// capItems applies the 100-record response budget.
func capItems[T any](o *op, items []T) []T {
if len(items) > maxRecords {
o.warnf("result capped at %d records", maxRecords)
o.truncated = true
return items[:maxRecords]
}
return items
}
// localWindow slices a complete upstream array locally and reports
// truthful totals (total is known only because the full authorized
// collection was received).
func localWindow[T any](o *op, items []T, p *Page, unit string) ([]T, *Paging) {
offset, limit := bounds(p)
total := len(items)
var win []T
if offset >= total {
win = []T{}
} else {
end := offset + limit
if end > total {
end = total
}
win = items[offset:end]
}
win = capItems(o, win)
hasMore := offset+len(win) < total
pg := &Paging{
Offset: offset,
Limit: limit,
Returned: len(win),
Total: &total,
HasMore: &hasMore,
Mode: "local",
Unit: unit,
}
if hasMore {
next := offset + len(win)
pg.NextOffset = &next
}
return win, pg
}
// upstreamPage builds paging metadata for a server-paged route.
// total is nil when the upstream response carries no reliable total;
// next_offset is offered only when a further page plausibly exists.
func upstreamPage(o *op, p *Page, returned int, total *int, unit string) *Paging {
offset, limit := bounds(p)
pg := &Paging{
Offset: offset,
Limit: limit,
Returned: returned,
Total: total,
Mode: "upstream",
Unit: unit,
}
if total != nil {
more := offset+returned < *total
pg.HasMore = &more
if more {
next := offset + returned
pg.NextOffset = &next
}
return pg
}
if returned < limit {
f := false
pg.HasMore = &f
return pg
}
// A full page was returned: a continuation plausibly exists but
// upstream pagination has no snapshot guarantee.
next := offset + returned
pg.NextOffset = &next
o.warnf("upstream has no paging snapshot; concurrent changes may shift offsets")
return pg
}
// singlePage builds mode=none metadata for a fixed-size result.
func singlePage(n int, unit string) *Paging {
f := false
return &Paging{
Offset: 0,
Limit: n,
Returned: n,
Total: &n,
HasMore: &f,
Mode: "none",
Unit: unit,
}
}
// engineMutation interprets an EngineResult upstream body per the
// output contract: isError/result=false is a tool failure; true is a
// completed mutation; missing/conflicting fields are unknown.
func (o *op) engineMutation(er *ombi.EngineResult, target *OutTarget) (*Mutation, *ToolResult) {
if er == nil {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH", "empty upstream result body", false)
}
m := &Mutation{Kind: "mutation", Target: target, RequestID: er.RequestID}
m.UpstreamResult = er.Result
m.UpstreamIsError = er.IsError
m.UpstreamErrorCode = er.ErrorCode
if er.IsError != nil && *er.IsError {
msg := sanitizeText(er.ErrorMessage, maxSanitizedMsg)
if msg == "" {
msg = "upstream reported an error"
}
return nil, o.fail("UPSTREAM_REJECTED", msg, false)
}
if er.Result != nil && !*er.Result {
msg := sanitizeText(er.ErrorMessage, maxSanitizedMsg)
if msg == "" {
msg = "upstream rejected the request"
}
return nil, o.fail("UPSTREAM_REJECTED", msg, false)
}
if er.Result == nil {
return nil, o.fail("UNKNOWN_OUTCOME",
"upstream result carried no success indicator", false)
}
m.Outcome = "completed"
m.Message = sanitizeText(er.Message, maxSanitizedMsg)
return m, nil
}
// boolMutation interprets an upstream boolean write result.
func (o *op) boolMutation(raw []byte, target *OutTarget) (*Mutation, *ToolResult) {
b, fail := o.decodeBool(raw)
if fail != nil {
return nil, fail
}
if !b {
return nil, o.fail("UPSTREAM_REJECTED", "upstream returned a failure result", false)
}
return &Mutation{Kind: "mutation", Outcome: "completed", Target: target,
UpstreamResult: &b}, nil
}
// emptyMutation reports an HTTP success with no response body —
// accepted, without inventing IDs or affected counts.
func emptyMutation(outcome string, target *OutTarget) *Mutation {
if outcome == "" {
outcome = "accepted"
}
return &Mutation{Kind: "mutation", Outcome: outcome, Target: target}
}
// mutationPage wraps a mutation as the data family.
func mutationResult(m *Mutation) any { return m }
// writeResult interprets a write response whose body shape is
// unspecified: an EngineResult object, a bare boolean, or an empty
// body. A false result or isError is a tool failure; an absent body
// completes without inventing IDs or counts.
func (o *op) writeResult(raw []byte, target *OutTarget) (*Mutation, *ToolResult) {
trim := bytes.TrimSpace(raw)
switch {
case len(trim) == 0:
return emptyMutation("completed", target), nil
case trim[0] == '{':
var er ombi.EngineResult
if fail := o.decodeJSON(trim, &er); fail != nil {
return nil, fail
}
return o.engineMutation(&er, target)
default:
return o.boolMutation(trim, target)
}
}
+146
View File
@@ -0,0 +1,146 @@
package tools
import (
"context"
"encoding/json"
)
// read_reference — genres, languages, keyword/provider lookups,
// streaming countries and issue categories. Family: reference_page.
func handleReference(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_reference", false)
var a ReferenceArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "genres":
return o.refGenres(&a)
case "languages":
return o.refList("/api/v2/Search/Languages",
[]string{"iso_639_1", "name"}, []string{"english_name", "name"}, "language")
case "keywords":
return o.refKeywords(&a)
case "keyword":
return o.refKeyword(&a)
case "watch_providers":
return o.refWatchProviders(&a)
case "streaming_countries":
return o.refList("/api/v1/Identity/streamingcountry",
[]string{"code", "isoCode", "id"}, []string{"name", "code"}, "country")
case "issue_categories":
return o.refList("/api/v1/Issues/categories",
[]string{"id"}, []string{"value", "name"}, "issue_category")
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
// refList fetches an upstream array and projects each element into a
// bounded reference record.
func (o *op) refList(path string, idKeys, nameKeys []string, cat string) *ToolResult {
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
items, fail := o.refArray(raw, idKeys, nameKeys, cat)
if fail != nil {
return fail
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
func (o *op) refArray(raw []byte, idKeys, nameKeys []string, cat string) ([]Reference, *ToolResult) {
arr, fail := o.decodeArray(raw)
if fail != nil {
// Some legacy routes return scalars (e.g. a bare string list).
var strs []string
if err := json.Unmarshal(raw, &strs); err == nil && strs != nil {
items := make([]Reference, 0, len(strs))
for _, s := range strs {
items = append(items, Reference{Name: s, Category: cat})
}
return capItems(o, items), nil
}
return nil, fail
}
items := make([]Reference, 0, len(arr))
for _, m := range arr {
items = append(items, refOf(m, idKeys, nameKeys, nil, cat))
}
return capItems(o, items), nil
}
func (o *op) refGenres(a *ReferenceArgs) *ToolResult {
var media string
switch a.Media {
case "movie", "tv":
media = a.Media
default:
return o.invalid("media", "media must be movie or tv")
}
return o.refList("/api/v2/Search/Genres/"+media,
[]string{"id"}, []string{"name"}, "genre")
}
func (o *op) refKeywords(a *ReferenceArgs) *ToolResult {
if !nonempty(a.Query) {
return o.invalid("query", "query must contain non-whitespace text")
}
raw, fail := o.call("GET", "/api/v1/TheMovieDb/Keywords",
map[string]string{"searchTerm": a.Query}, nil)
if fail != nil {
return fail
}
items, fail := o.refArray(raw, []string{"id"}, []string{"name"}, "keyword")
if fail != nil {
return fail
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
func (o *op) refKeyword(a *ReferenceArgs) *ToolResult {
if a.KeywordID == nil || *a.KeywordID < 1 {
return o.invalid("keyword_id", "keyword_id must be a positive integer")
}
raw, fail := o.call("GET",
"/api/v1/TheMovieDb/Keywords/"+segInt(*a.KeywordID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
r := refOf(m, []string{"id"}, []string{"name"}, nil, "keyword")
return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")})
}
func (o *op) refWatchProviders(a *ReferenceArgs) *ToolResult {
var media string
switch a.Media {
case "movie", "tv":
media = a.Media
default:
return o.invalid("media", "media must be movie or tv")
}
q := map[string]string{}
if nonempty(a.Query) {
q["searchTerm"] = a.Query
}
raw, fail := o.call("GET", "/api/v1/TheMovieDb/WatchProviders/"+media, q, nil)
if fail != nil {
return fail
}
items, fail := o.refArray(raw,
[]string{"id", "provider_id", "providerId"}, []string{"name", "provider_name", "providerName"},
"watch_provider")
if fail != nil {
return fail
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
+184
View File
@@ -1 +1,185 @@
package tools
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
)
//go:generate go run ../../tools/schemagen
//go:embed schemas.json
var schemasJSON []byte
// schemas is the embedded contract extracted from docs/schema by
// tools/schemagen: complete input schemas per tool plus the shared
// output $defs library.
var schemas struct {
Input map[string]json.RawMessage `json:"input"`
Defs map[string]json.RawMessage `json:"defs"`
}
func init() {
if err := json.Unmarshal(schemasJSON, &schemas); err != nil {
panic("tools: embedded schemas.json is invalid: " + err.Error())
}
}
// ToolDef is one registered tool: its MCP name, bundle, advisory
// annotations, allowed result families and handler.
type ToolDef struct {
Name string
Bundle string // core|moderation|administration
Description string
ReadOnly bool
Destructive bool
Idempotent bool
OpenWorld bool
Families []string // allowed `data` families for outputSchema
Handler Handler
}
// registry is the fixed 31-tool catalogue from the routing ledger.
var registry = []ToolDef{
{"read_search", "core", "Search media by text, with explicit movie refinements or a multi-search filter.", true, false, true, true, []string{"media_page"}, handleSearch},
{"read_discover", "core", "Curated lists, similar movies, collection, credits, artist albums, advanced movie filters.", true, false, true, true, []string{"media_page"}, handleDiscover},
{"read_media", "core", "Details by explicit provider or request; ratings; streaming.", true, false, true, true, []string{"media_page"}, handleMedia},
{"read_reference", "core", "Genres, languages, keywords, watch-provider catalogue, countries, issue categories.", true, false, true, true, []string{"reference_page"}, handleReference},
{"read_requests", "core", "List/get/search, TV children, recent requests, privileged retry queue.", true, false, true, true, []string{"request_page", "retry_page"}, handleRequests},
{"read_request_stats", "core", "Counts, totals, per-media quota, user-has-requests.", true, false, true, true, []string{"metrics"}, handleRequestStats},
{"read_issues", "core", "Issues, grouped summaries, comments and counts.", true, false, true, true, []string{"issue_page", "comment_page", "group_page", "metrics"}, handleIssues},
{"read_votes", "core", "Global vote list or votes on a request.", true, false, true, true, []string{"vote_page"}, handleVotes},
{"read_users", "core", "Self, authorized user lookup, claims, online users, preference read.", true, false, true, true, []string{"user_page", "reference_page"}, handleUsers},
{"read_library", "core", "Recent additions, calendar, artwork.", true, false, true, true, []string{"media_page", "calendar_page", "artwork_page"}, handleLibrary},
{"read_server", "core", "Status, version, features, news, stats, cron validation.", true, false, true, true, []string{"metrics", "reference_page"}, handleServer},
{"read_integration", "core", "Saved ARR options and authorized media-server metadata.", true, false, true, true, []string{"reference_page", "user_page"}, handleIntegration},
{"write_request_create", "core", "One media request or explicit collection request.", false, false, false, true, []string{"mutation"}, handleRequestCreate},
{"write_request_subscribe", "core", "Subscribe/unsubscribe.", false, false, false, true, []string{"mutation"}, handleRequestSubscribe},
{"write_issue_create", "core", "Report an issue.", false, false, false, true, []string{"mutation"}, handleIssueCreate},
{"write_issue_comment", "core", "Add a comment.", false, false, false, true, []string{"mutation"}, handleIssueComment},
{"write_vote", "core", "Up/down vote.", false, false, false, true, []string{"mutation"}, handleVote},
{"write_user_preferences", "core", "Language, streaming country, newsletter opt-out.", false, false, false, true, []string{"mutation"}, handleUserPrefs},
{"write_request_moderate", "moderation", "Approval, denial, availability.", false, true, false, true, []string{"mutation"}, handleRequestModerate},
{"write_request_delete", "moderation", "Explicit single movie/album/TV-parent/TV-child deletion.", false, true, false, true, []string{"mutation"}, handleRequestDelete},
{"write_request_options", "moderation", "Advanced routing overrides and TV root/quality.", false, true, false, true, []string{"mutation"}, handleRequestOptions},
{"write_request_reprocess", "moderation", "Reprocess an existing request.", false, true, false, true, []string{"mutation"}, handleRequestReprocess},
{"write_issue_manage", "moderation", "State, deletes, category management.", false, true, false, true, []string{"mutation"}, handleIssueManage},
{"read_settings", "administration", "Safe configuration projection and revision.", true, false, true, true, []string{"settings"}, handleSettingsRead},
{"write_settings_patch", "administration", "Typed non-secret patch or feature flag.", false, true, false, true, []string{"mutation"}, handleSettingsPatch},
{"write_user_manage", "administration", "Delete user or send welcome email.", false, true, false, true, []string{"mutation"}, handleUserManage},
{"write_integration_test", "administration", "Test a saved profile; can send notifications.", false, false, false, true, []string{"mutation"}, handleIntegrationTest},
{"write_job_run", "administration", "Trigger permitted jobs / watchlist revalidation.", false, true, false, true, []string{"mutation"}, handleJobRun},
{"write_notification_send", "administration", "Email explicit recipients.", false, false, false, true, []string{"mutation"}, handleNotificationSend},
{"write_retry_remove", "administration", "Remove one queue entry.", false, true, false, true, []string{"mutation"}, handleRetryRemove},
{"read_logs", "administration", "Bounded, sanitized diagnostic reads.", true, false, true, true, []string{"logs"}, handleLogs},
}
// Registry returns the full tool catalogue.
func Registry() []ToolDef { return registry }
// Enabled reports whether the tool's bundle is enabled.
func (d ToolDef) Enabled(bundles map[string]bool) bool { return bundles[d.Bundle] }
// InputSchema returns the tool's complete JSON Schema object.
func (d ToolDef) InputSchema() (any, error) {
raw, ok := schemas.Input[d.Name]
if !ok {
return nil, fmt.Errorf("no embedded input schema for %q", d.Name)
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
return v, nil
}
// OutputSchema composes the per-tool envelope: operation const, the
// tool's allowed `data` families, and the transitive $defs closure
// (error plus every def reachable through $ref).
func (d ToolDef) OutputSchema() (any, error) {
oneOf := make([]any, 0, len(d.Families))
roots := append([]string{"error"}, d.Families...)
for _, f := range d.Families {
oneOf = append(oneOf, map[string]any{"$ref": "#/$defs/" + f})
}
defs, err := defsClosure(roots)
if err != nil {
return nil, err
}
return map[string]any{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": map[string]any{
"ok": map[string]any{"type": "boolean"},
"operation": map[string]any{"const": d.Name},
"data": map[string]any{"oneOf": oneOf},
"error": map[string]any{"$ref": "#/$defs/error"},
"warnings": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"maxItems": 100,
},
"truncated": map[string]any{"type": "boolean"},
"correlation_id": map[string]any{"type": "string"},
},
"required": []any{"ok", "operation", "warnings", "truncated", "correlation_id"},
"additionalProperties": false,
"allOf": []any{map[string]any{
"if": map[string]any{"properties": map[string]any{"ok": map[string]any{"const": true}}},
"then": map[string]any{"required": []any{"data"}, "not": map[string]any{"required": []any{"error"}}},
"else": map[string]any{"required": []any{"error"}},
}},
"$defs": defs,
}, nil
}
// defsClosure BFS-expands the given root def names through every
// `#/$defs/<name>` reference found inside their schemas.
func defsClosure(roots []string) (map[string]any, error) {
out := map[string]any{}
queue := append([]string{}, roots...)
for len(queue) > 0 {
name := queue[0]
queue = queue[1:]
if _, seen := out[name]; seen {
continue
}
raw, ok := schemas.Defs[name]
if !ok {
return nil, fmt.Errorf("missing shared def %q", name)
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
out[name] = v
for _, ref := range findRefs(v) {
if _, seen := out[ref]; !seen {
queue = append(queue, ref)
}
}
}
return out, nil
}
// findRefs walks a decoded JSON value collecting `#/$defs/` names.
func findRefs(v any) []string {
var out []string
switch t := v.(type) {
case map[string]any:
for k, e := range t {
if k == "$ref" {
if s, ok := e.(string); ok && strings.HasPrefix(s, "#/$defs/") {
out = append(out, strings.TrimPrefix(s, "#/$defs/"))
}
continue
}
out = append(out, findRefs(e)...)
}
case []any:
for _, e := range t {
out = append(out, findRefs(e)...)
}
}
return out
}
+112
View File
@@ -0,0 +1,112 @@
package tools
import (
"encoding/json"
"testing"
)
func TestRegistryHas31UniqueTools(t *testing.T) {
reg := Registry()
if len(reg) != 31 {
t.Fatalf("registry has %d tools, want 31", len(reg))
}
seen := map[string]bool{}
for _, d := range reg {
if seen[d.Name] {
t.Errorf("duplicate tool %q", d.Name)
}
seen[d.Name] = true
if d.Handler == nil {
t.Errorf("tool %q has no handler", d.Name)
}
if d.Bundle != "core" && d.Bundle != "moderation" && d.Bundle != "administration" {
t.Errorf("tool %q has unknown bundle %q", d.Name, d.Bundle)
}
}
}
func TestBundleCounts(t *testing.T) {
counts := map[string]int{}
for _, d := range Registry() {
counts[d.Bundle]++
}
if counts["core"] != 18 || counts["moderation"] != 5 || counts["administration"] != 8 {
t.Errorf("bundle counts = %v, want core=18 moderation=5 administration=8", counts)
}
}
func TestEveryToolHasInputSchema(t *testing.T) {
for _, d := range Registry() {
s, err := d.InputSchema()
if err != nil {
t.Errorf("%s: %v", d.Name, err)
continue
}
m, ok := s.(map[string]any)
if !ok || m["type"] != "object" {
t.Errorf("%s: input schema is not a JSON object schema", d.Name)
}
}
}
func TestEveryToolHasComposedOutputSchema(t *testing.T) {
for _, d := range Registry() {
s, err := d.OutputSchema()
if err != nil {
t.Errorf("%s: %v", d.Name, err)
continue
}
m := s.(map[string]any)
props := m["properties"].(map[string]any)
if props["operation"].(map[string]any)["const"] != d.Name {
t.Errorf("%s: operation const mismatch", d.Name)
}
defs := m["$defs"].(map[string]any)
if _, ok := defs["error"]; !ok {
t.Errorf("%s: output schema lacks error def", d.Name)
}
for _, f := range d.Families {
if _, ok := defs[f]; !ok {
t.Errorf("%s: output schema lacks family def %q", d.Name, f)
}
}
// Every #/$defs ref inside the composed schema must resolve.
for _, ref := range findRefs(m) {
if _, ok := defs[ref]; !ok {
t.Errorf("%s: unresolved $defs ref %q", d.Name, ref)
}
}
}
}
func TestEnabledFiltering(t *testing.T) {
coreOnly := map[string]bool{"core": true}
n := 0
for _, d := range Registry() {
if d.Enabled(coreOnly) {
n++
}
}
if n != 18 {
t.Errorf("core-only registry = %d tools, want 18", n)
}
}
// TestToolResultEnvelopeJSON verifies the wire field names.
func TestToolResultEnvelopeJSON(t *testing.T) {
res := &ToolResult{OK: true, Operation: "read_search",
Warnings: []string{}, CorrelationID: "abc"}
raw, err := json.Marshal(res)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, k := range []string{"ok", "operation", "warnings", "truncated", "correlation_id"} {
if _, ok := m[k]; !ok {
t.Errorf("envelope missing key %q", k)
}
}
}
+360
View File
@@ -0,0 +1,360 @@
package tools
import (
"context"
"encoding/json"
"fmt"
)
// read_requests — v2 list/status routes, single gets, TV children,
// search, recent requests and the privileged retry queue.
// Families: request_page, retry_page.
func handleRequests(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_requests", false)
var a RequestsListArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "list":
return o.requestsList(&a)
case "get":
return o.requestsGet(&a)
case "children":
return o.requestsChildren(&a)
case "search":
return o.requestsSearch(&a)
case "recent":
return o.requestsRecent(&a)
case "retry_queue":
return o.requestsRetryQueue(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
// list maps media+status onto the v2 paged routes. `all` uses the
// base route, never an /all/ segment. Sort is always requestDate.
func (o *op) requestsList(a *RequestsListArgs) *ToolResult {
var mediaSeg, kind string
switch a.Media {
case "movie":
mediaSeg, kind = "movie", "movie"
case "tv":
mediaSeg, kind = "tv", "tv_child" // v2 TV pages are child requests
case "album":
mediaSeg, kind = "album", "album"
default:
return o.invalid("media", "media must be movie|tv|album")
}
status := a.Status
if status == "" {
status = "all"
}
switch status {
case "all", "pending", "processing", "available", "denied":
case "unavailable":
if a.Media == "album" {
return o.invalid("status", "album has no unavailable-status route")
}
default:
return o.invalid("status", "unsupported status %q", status)
}
order := a.SortDirection
if order == "" {
order = "desc"
}
if order != "asc" && order != "desc" {
return o.invalid("sort_direction", "sort_direction must be asc or desc")
}
pos, amt := bounds(a.Page)
path := fmt.Sprintf("/api/v2/Requests/%s", mediaSeg)
if status != "all" {
path += "/" + status
}
path += fmt.Sprintf("/%d/%d/requestDate/%s", amt, pos, order)
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
vm, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
items := []Request{}
for _, v := range jarr(vm, "collection") {
if m, ok := v.(map[string]any); ok {
items = append(items, o.projectRequest(m, kind))
}
}
items = capItems(o, items)
pg := upstreamPage(o, a.Page, len(items), jint(vm, "total"), "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: items, Page: pg})
}
func (o *op) requestsGet(a *RequestsListArgs) *ToolResult {
if a.Target == nil {
return o.invalid("target", "target is required")
}
var path, kind string
switch a.Target.Kind {
case "movie":
path = "/api/v1/Request/movie/info/"
kind = "movie"
case "tv_parent":
path = "/api/v1/Request/tv/"
kind = "tv_parent"
default:
return o.invalid("target.kind", "target kind must be movie or tv_parent")
}
if a.Target.ID < 1 {
return o.invalid("target.id", "target id must be a positive integer")
}
raw, fail := o.call("GET", path+segInt(a.Target.ID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
r := o.projectRequest(m, kind)
return o.ok(&RequestPage{Kind: "request_page",
Items: []Request{r}, Page: singlePage(1, "requests")})
}
func (o *op) requestsChildren(a *RequestsListArgs) *ToolResult {
if a.ParentRequestID == nil || *a.ParentRequestID < 1 {
return o.invalid("parent_request_id", "parent_request_id must be a positive integer")
}
raw, fail := o.call("GET",
fmt.Sprintf("/api/v1/Request/tv/%d/child", *a.ParentRequestID), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Request{}
for _, m := range arr {
r := o.projectRequest(m, "tv_child")
r.ParentRequestID = a.ParentRequestID
items = append(items, r)
}
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
func (o *op) requestsSearch(a *RequestsListArgs) *ToolResult {
if !nonempty(a.Query) {
return o.invalid("query", "query must contain non-whitespace text")
}
var path, kind string
switch a.Media {
case "movie":
path, kind = "/api/v1/Request/movie/search/", "movie"
case "tv":
// v1 search returns TV parent records.
path, kind = "/api/v1/Request/tv/search/", "tv_parent"
case "album":
path, kind = "/api/v1/request/music/search/", "album"
default:
return o.invalid("media", "media must be movie|tv|album")
}
raw, fail := o.call("GET", path+seg(a.Query), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Request{}
for _, m := range arr {
items = append(items, o.projectRequest(m, kind))
}
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
func (o *op) requestsRecent(a *RequestsListArgs) *ToolResult {
raw, fail := o.call("GET", "/api/v2/Requests/recentlyRequested", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Request{}
for _, m := range arr {
code, label := o.requestTypeTwin(m["type"])
kind := "movie"
switch label {
case "tv":
kind = "tv_parent" // show-level recent-request record
case "album":
kind = "album"
case "":
if code != nil {
o.warnf("unmapped request type %d on recent item", *code)
}
kind = "movie"
}
r := o.projectRequest(m, kind)
if id, ok := toInt(m["requestId"]); ok {
r.Target.ID = id
}
r.RequestedUserID = jstr(m, "userId")
items = append(items, r)
}
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
func (o *op) requestsRetryQueue(a *RequestsListArgs) *ToolResult {
raw, fail := o.call("GET", "/api/v1/RequestRetry", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []RetryEntry{}
for _, m := range arr {
items = append(items, o.projectRetryEntry(m))
}
win, pg := localWindow(o, items, a.Page, "queue_entries")
return o.ok(&RetryPage{Kind: "retry_page", Items: win, Page: pg})
}
// read_request_stats — counts, totals, quotas and has_requests.
// Family: metrics with explicit scopes.
func handleRequestStats(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_request_stats", false)
var a RequestStatsArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "counts":
return o.statsCounts()
case "total":
return o.statsTotal(&a)
case "quota":
return o.statsQuota(&a)
case "has_requests":
return o.statsHasRequests(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func metric(name string, value any, scope, unit string) Metric {
return Metric{Name: name, Value: value, Scope: scope, Unit: unit}
}
func (o *op) statsCounts() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Request/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", "approved", "available", "denied"} {
if v, ok := m[k]; ok {
if i, ok := toInt(v); ok {
vals = append(vals, metric(k, i, "instance", "requests"))
}
}
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
func (o *op) statsTotal(a *RequestStatsArgs) *ToolResult {
var path string
switch a.Media {
case "movie":
path = "/api/v1/Request/movie/total"
case "tv":
path = "/api/v1/Request/tv/total"
case "album":
path = "/api/v1/request/music/total"
default:
return o.invalid("media", "media must be movie|tv|album")
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
v, fail := o.decodeScalar(raw)
if fail != nil {
return fail
}
n, ok := toInt(v)
if !ok {
return o.fail("UPSTREAM_SCHEMA_MISMATCH", "upstream total was not an integer", false)
}
return o.ok(&Metrics{Kind: "metrics",
Values: []Metric{metric("total", n, "instance", "requests")}})
}
func (o *op) statsQuota(a *RequestStatsArgs) *ToolResult {
var path string
switch a.Media {
case "movie":
path = "/api/v1/Request/movie/remaining"
case "tv":
path = "/api/v1/Request/tv/remaining"
case "album":
path = "/api/v1/request/music/remaining"
default:
return o.invalid("media", "media must be movie|tv|album")
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
vals := []Metric{}
if v, ok := m["hasLimit"]; ok {
if b, ok := v.(bool); ok {
vals = append(vals, metric("has_limit", b, "principal", ""))
}
}
if v := jint(m, "limit"); v != nil {
vals = append(vals, metric("limit", *v, "principal", "requests"))
}
if v := jint(m, "remaining"); v != nil {
vals = append(vals, metric("remaining", *v, "principal", "requests"))
}
if s := jstr(m, "nextRequest"); s != "" {
vals = append(vals, metric("next_request", s, "principal", ""))
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
func (o *op) statsHasRequests(a *RequestStatsArgs) *ToolResult {
if !nonempty(a.UserID) {
return o.invalid("user_id", "user_id is required")
}
raw, fail := o.call("GET", "/api/v1/Request/userhasrequest",
map[string]string{"userId": a.UserID}, nil)
if fail != nil {
return fail
}
b, fail := o.decodeBool(raw)
if fail != nil {
return fail
}
return o.ok(&Metrics{Kind: "metrics",
Values: []Metric{metric("has_requests", b, "selected_user", "")}})
}
+662
View File
@@ -0,0 +1,662 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate"
)
// write_request_create — movie, TV (tmdb/tvdb), album or explicit
// collection request. TV selections expand to explicit episode lists.
func handleRequestCreate(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_create", true)
var a RequestCreateArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "movie":
return o.createMovie(&a)
case "tv":
return o.createTV(&a)
case "album":
return o.createAlbum(&a)
case "collection":
return o.createCollection(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
// onBehalf resolves an MCP user id to the username upstream
// requestOnBehalf expects (Verify: id vs username — resolved
// internally and explicitly).
func (o *op) onBehalf(userID string) (string, *ToolResult) {
if !nonempty(userID) {
return "", nil
}
raw, fail := o.call("GET", "/api/v1/Identity/User/"+seg(userID), nil, nil)
if fail != nil {
return "", fail
}
u, fail := o.decodeObject(raw)
if fail != nil {
return "", fail
}
name := jstr(u, "userName", "username")
if name == "" {
return "", o.invalid("on_behalf_user_id",
"could not resolve a username for on_behalf_user_id")
}
return name, nil
}
func (o *op) createMovie(a *RequestCreateArgs) *ToolResult {
if a.TmdbID == nil || *a.TmdbID < 1 {
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
}
body := ombi.MovieRequestViewModel{
TheMovieDbID: *a.TmdbID,
LanguageCode: a.Language,
Is4KRequest: a.Is4K, // explicit false when omitted
}
if a.Overrides != nil {
body.RootFolderOverride = a.Overrides.RootFolderID
body.QualityPathOverride = a.Overrides.QualityProfileID
}
name, fail := o.onBehalf(a.OnBehalfUserID)
if fail != nil {
return fail
}
body.RequestOnBehalf = name
raw, fail := o.call("POST", "/api/v1/Request/movie", nil, body)
if fail != nil {
return fail
}
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
m, fail := o.engineMutation(&er, &OutTarget{Kind: "movie", ID: deref(er.RequestID)})
if fail != nil {
return fail
}
return o.ok(m)
}
func deref(p *int) int {
if p == nil {
return 0
}
return *p
}
// createTV builds the selection expansion shared by the TMDB (v2)
// and TVDB (v1) create routes.
func (o *op) createTV(a *RequestCreateArgs) *ToolResult {
if a.ID == nil || *a.ID < 1 {
return o.invalid("id", "id must be a positive integer")
}
if a.Selection == nil {
return o.invalid("selection", "selection is required")
}
var provider string
switch a.Provider {
case "tmdb", "tvdb":
provider = a.Provider
default:
return o.invalid("provider", "provider must be tmdb or tvdb")
}
sel, fail := o.expandTVSelection(a, provider)
if fail != nil {
return fail
}
name, fail := o.onBehalf(a.OnBehalfUserID)
if fail != nil {
return fail
}
if provider == "tmdb" {
body := ombi.TvRequestViewModelV2{
TheMovieDbID: *a.ID,
LanguageCode: a.Language,
RequestAll: sel.all,
FirstSeason: sel.first,
LatestSeason: sel.latest,
Seasons: sel.seasons,
LanguageProfile: a.LanguageProfileID,
RequestOnBehalf: name,
}
if a.Overrides != nil {
body.RootFolderOverride = a.Overrides.RootFolderID
body.QualityPathOverride = a.Overrides.QualityProfileID
}
return o.postEngineResult("/api/v2/Requests/tv", body, "tv_parent")
}
body := ombi.TvRequestViewModel{
TvDbID: *a.ID,
RequestAll: sel.all,
FirstSeason: sel.first,
LatestSeason: sel.latest,
Seasons: sel.seasons,
LanguageProfile: a.LanguageProfileID,
RequestOnBehalf: name,
}
if a.Overrides != nil {
body.RootFolderOverride = a.Overrides.RootFolderID
body.QualityPathOverride = a.Overrides.QualityProfileID
}
return o.postEngineResult("/api/v1/Request/tv", body, "tv_parent")
}
type tvSelection struct {
all, first, latest bool
seasons []ombi.SeasonsViewModel
}
// expandTVSelection validates the selection and expands season modes
// into explicit seasons[].episodes structures. `season` mode reads
// TV details privately and expands each listed season's full episode
// list; expansion is the default construction, never an
// empty-means-all assumption.
func (o *op) expandTVSelection(a *RequestCreateArgs, provider string) (*tvSelection, *ToolResult) {
s := a.Selection
out := &tvSelection{}
switch s.Mode {
case "all":
out.all = true
case "first_season":
out.first = true
case "latest_season":
out.latest = true
case "episodes":
seasons, fail := o.explicitSeasons(s)
if fail != nil {
return nil, fail
}
out.seasons = seasons
case "season":
seasons, fail := o.expandSeasonNumbers(a, provider)
if fail != nil {
return nil, fail
}
out.seasons = seasons
default:
return nil, o.invalid("selection.mode", "unsupported mode %q", s.Mode)
}
return out, nil
}
// explicitSeasons validates the caller's explicit season/episode
// picks: unique season numbers, nonempty unique episode lists and a
// 2000-episode budget per call.
func (o *op) explicitSeasons(s *TVSelect) ([]ombi.SeasonsViewModel, *ToolResult) {
if len(s.Seasons) == 0 {
return nil, o.invalid("selection.seasons", "seasons must be a nonempty array")
}
seen := map[int]bool{}
total := 0
out := []ombi.SeasonsViewModel{}
for _, se := range s.Seasons {
if se.SeasonNumber < 0 {
return nil, o.invalid("selection.seasons", "season_number must be >= 0")
}
if seen[se.SeasonNumber] {
return nil, o.invalid("selection.seasons",
"duplicate season_number %d", se.SeasonNumber)
}
seen[se.SeasonNumber] = true
if len(se.Episodes) == 0 {
return nil, o.invalid("selection.seasons",
"season %d has an empty episode list", se.SeasonNumber)
}
epSeen := map[int]bool{}
eps := []ombi.EpisodesViewModel{}
for _, ep := range se.Episodes {
if ep < 1 {
return nil, o.invalid("selection.seasons",
"episode numbers must be >= 1")
}
if epSeen[ep] {
return nil, o.invalid("selection.seasons",
"duplicate episode %d in season %d", ep, se.SeasonNumber)
}
epSeen[ep] = true
total++
eps = append(eps, ombi.EpisodesViewModel{EpisodeNumber: ep})
}
out = append(out, ombi.SeasonsViewModel{
SeasonNumber: se.SeasonNumber, Episodes: eps})
}
if total > 2000 {
return nil, o.invalid("selection.seasons",
"selection exceeds the 2000-episode budget")
}
return out, nil
}
// expandSeasonNumbers resolves each requested season number into its
// full episode list by reading TV details privately.
func (o *op) expandSeasonNumbers(a *RequestCreateArgs, provider string) ([]ombi.SeasonsViewModel, *ToolResult) {
s := a.Selection
if len(s.SeasonNumbers) == 0 {
return nil, o.invalid("selection.season_numbers",
"season_numbers must be a nonempty array")
}
seen := map[int]bool{}
for _, n := range s.SeasonNumbers {
if n < 0 {
return nil, o.invalid("selection.season_numbers",
"season numbers must be >= 0")
}
if seen[n] {
return nil, o.invalid("selection.season_numbers",
"duplicate season number %d", n)
}
seen[n] = true
}
var detailsPath string
if provider == "tmdb" {
detailsPath = "/api/v2/Search/tv/moviedb/" + segInt(*a.ID)
} else {
detailsPath = "/api/v2/Search/tv/" + segInt(*a.ID)
}
raw, fail := o.call("GET", detailsPath, nil, nil)
if fail != nil {
return nil, fail
}
details, fail := o.decodeObject(raw)
if fail != nil {
return nil, fail
}
byNum := map[int][]ombi.EpisodesViewModel{}
for _, v := range jarr(details, "seasonRequests") {
sm, ok := v.(map[string]any)
if !ok {
continue
}
sn, ok := toInt(sm["seasonNumber"])
if !ok {
continue
}
eps := []ombi.EpisodesViewModel{}
for _, e := range jarr(sm, "episodes") {
if em, ok := e.(map[string]any); ok {
if en, ok := toInt(em["episodeNumber"]); ok {
eps = append(eps, ombi.EpisodesViewModel{EpisodeNumber: en})
}
}
}
byNum[sn] = eps
}
out := []ombi.SeasonsViewModel{}
total := 0
for _, n := range s.SeasonNumbers {
eps, ok := byNum[n]
if !ok {
return nil, o.invalid("selection.season_numbers",
"season %d was not found in the show details", n)
}
if len(eps) == 0 {
return nil, o.invalid("selection.season_numbers",
"season %d has no expandable episodes", n)
}
total += len(eps)
out = append(out, ombi.SeasonsViewModel{SeasonNumber: n, Episodes: eps})
}
if total > 2000 {
return nil, o.invalid("selection.season_numbers",
"selection exceeds the 2000-episode budget")
}
return out, nil
}
func (o *op) createAlbum(a *RequestCreateArgs) *ToolResult {
if !nonempty(a.MusicBrainzID) {
return o.invalid("musicbrainz_id", "musicbrainz_id must be a nonempty provider string")
}
body := ombi.MusicAlbumRequestViewModel{
ForeignAlbumID: a.MusicBrainzID,
RequestedByAlias: a.RequestedByAlias,
}
return o.postEngineResult("/api/v1/request/music", body, "album")
}
// createCollection posts to the documented collection route — never
// simulated with multiple movie POSTs. RAML documents a single
// RequestEngineResult; partial effects are reported, not claimed.
func (o *op) createCollection(a *RequestCreateArgs) *ToolResult {
if a.CollectionID == nil || *a.CollectionID < 1 {
return o.invalid("collection_id", "collection_id must be a positive integer")
}
raw, fail := o.call("POST",
"/api/v2/Requests/movie/collection/"+segInt(*a.CollectionID), nil, nil)
if fail != nil {
return fail
}
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
m, fail := o.engineMutation(&er, nil)
if fail != nil {
return fail
}
if er.RequestID == nil {
o.warnf("collection request returned no per-item outcomes")
}
return o.ok(m)
}
// postEngineResult posts a body and interprets the EngineResult.
func (o *op) postEngineResult(path string, body any, kind string) *ToolResult {
raw, fail := o.call("POST", path, nil, body)
if fail != nil {
return fail
}
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
var target *OutTarget
if er.RequestID != nil && kind != "" {
target = &OutTarget{Kind: kind, ID: *er.RequestID}
}
m, fail := o.engineMutation(&er, target)
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_subscribe — movie/TV subscribe and unsubscribe.
func handleRequestSubscribe(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_subscribe", true)
var a SubscribeArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
var media string
switch a.Media {
case "movie", "tv":
media = a.Media
default:
return o.invalid("media", "media must be movie or tv")
}
if a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
var verb string
switch a.Action {
case "subscribe", "unsubscribe":
verb = a.Action
default:
return o.invalid("action", "action must be subscribe or unsubscribe")
}
raw, fail := o.call("POST",
fmt.Sprintf("/api/v1/Request/%s/%s/%d", media, verb, a.RequestID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, &OutTarget{Kind: media, ID: a.RequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_moderate — approve/deny/availability across media.
// Movie uses {id,is4K} (+reason on deny); TV and album use {id}
// (+reason on deny). TV ids are child IDs per the contract.
func handleRequestModerate(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_moderate", true)
var a ModerateArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
var segName string
var isMovie bool
switch a.Media {
case "movie":
segName, isMovie = "movie", true
case "tv":
segName = "tv"
case "album":
segName = "request/music"
default:
return o.invalid("media", "media must be movie|tv|album")
}
switch a.Action {
case "approve", "mark_available", "mark_unavailable":
var route string
switch a.Action {
case "approve":
route = "approve"
case "mark_available":
route = "available"
case "mark_unavailable":
route = "unavailable"
}
var body any
if isMovie {
body = ombi.MovieUpdateModel{ID: a.RequestID, Is4K: a.Is4K}
} else {
body = ombi.UpdateModelID{ID: a.RequestID}
}
return o.moderatePost(fmt.Sprintf("/api/v1/Request/%s/%s", segName, route),
body, &a)
case "deny":
if !nonempty(a.Reason) {
return o.invalid("reason", "deny requires a nonempty reason")
}
var body any
if isMovie {
body = ombi.DenyMovieModel{Reason: a.Reason, ID: a.RequestID, Is4K: a.Is4K}
} else {
body = ombi.DenyModel{Reason: a.Reason, ID: a.RequestID}
}
raw, fail := o.call("PUT",
fmt.Sprintf("/api/v1/Request/%s/deny", segName), nil, body)
if fail != nil {
return fail
}
return o.finishModeration(raw, &a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) moderatePost(path string, body any, a *ModerateArgs) *ToolResult {
raw, fail := o.call("POST", path, nil, body)
if fail != nil {
return fail
}
return o.finishModeration(raw, a)
}
func (o *op) finishModeration(raw []byte, a *ModerateArgs) *ToolResult {
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
kind := a.Media
if kind == "tv" {
kind = "tv_child"
}
m, fail := o.engineMutation(&er, &OutTarget{Kind: kind, ID: a.RequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_delete — explicit single deletion distinguishing
// movie, album, TV parent and TV child. Parent deletion may affect
// all associated children.
func handleRequestDelete(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_delete", true)
var a DeleteArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if a.Target == nil {
return o.invalid("target", "target is required")
}
if a.Target.ID < 1 {
return o.invalid("target.id", "target id must be a positive integer")
}
var path string
switch a.Target.Kind {
case "movie":
path = "/api/v1/Request/movie/" + segInt(a.Target.ID)
case "tv_parent":
path = "/api/v1/Request/tv/" + segInt(a.Target.ID)
o.warnf("deleting a TV parent may delete all associated children")
case "tv_child":
path = "/api/v1/Request/tv/child/" + segInt(a.Target.ID)
case "album":
path = "/api/v1/request/music/" + segInt(a.Target.ID)
default:
return o.invalid("target.kind",
"target kind must be movie|tv_parent|tv_child|album")
}
raw, fail := o.call("DELETE", path, nil, nil)
if fail != nil {
return fail
}
// Some delete routes return RequestEngineResult, others no body.
m, fail := o.writeResult(raw, &OutTarget{Kind: a.Target.Kind, ID: a.Target.ID})
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_options — typed routing/profile overrides.
func handleRequestOptions(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_options", true)
var a OptionsArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "advanced":
return o.optionsAdvanced(&a)
case "tv_root":
return o.optionsTVRoot(&a)
case "tv_quality":
return o.optionsTVQuality(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) optionsAdvanced(a *OptionsArgs) *ToolResult {
var media string
switch a.Media {
case "movie", "tv":
media = a.Media
default:
return o.invalid("media", "media must be movie or tv")
}
if a.RequestID == nil || *a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
if a.Options == nil {
return o.invalid("options", "options object is required")
}
if a.Options.RootFolderID == nil && a.Options.QualityProfileID == nil &&
a.Options.LanguageProfileID == nil {
return o.invalid("options", "options must set at least one field")
}
body := ombi.MediaAdvancedOptions{
RequestID: *a.RequestID,
RootPathOverride: a.Options.RootFolderID,
QualityOverride: a.Options.QualityProfileID,
LanguageProfile: a.Options.LanguageProfileID,
}
kind := media
if kind == "tv" {
kind = "tv_child"
}
return o.postEngineResult(
fmt.Sprintf("/api/v2/Requests/%s/advancedoptions", media), body, kind)
}
func (o *op) optionsTVRoot(a *OptionsArgs) *ToolResult {
if a.ParentRequestID == nil || *a.ParentRequestID < 1 {
return o.invalid("parent_request_id", "parent_request_id must be a positive integer")
}
if a.RootFolderID == nil || *a.RootFolderID < 1 {
return o.invalid("root_folder_id", "root_folder_id must be a positive integer")
}
// Both IDs are in the URL; there is no body.
raw, fail := o.call("PUT",
fmt.Sprintf("/api/v1/Request/tv/root/%d/%d", *a.ParentRequestID, *a.RootFolderID),
nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, &OutTarget{Kind: "tv_parent", ID: *a.ParentRequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
func (o *op) optionsTVQuality(a *OptionsArgs) *ToolResult {
if a.ParentRequestID == nil || *a.ParentRequestID < 1 {
return o.invalid("parent_request_id", "parent_request_id must be a positive integer")
}
if a.QualityProfileID == nil || *a.QualityProfileID < 1 {
return o.invalid("quality_profile_id", "quality_profile_id must be a positive integer")
}
raw, fail := o.call("PUT",
fmt.Sprintf("/api/v1/Request/tv/quality/%d/%d", *a.ParentRequestID, *a.QualityProfileID),
nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, &OutTarget{Kind: "tv_parent", ID: *a.ParentRequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_reprocess — POST reprocess/{type}/{requestId}/{is4K}
// with the numeric RequestType enum; is_4k is an explicit Boolean.
func handleRequestReprocess(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_reprocess", true)
var a ReprocessArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
rt, err := translate.RequestTypeToWire(a.RequestType)
if err != nil {
return o.invalid("request_type", "unsupported request_type %q", a.RequestType)
}
if a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
raw, fail := o.call("POST",
fmt.Sprintf("/api/v2/Requests/reprocess/%d/%d/%t", rt, a.RequestID, a.Is4K),
nil, nil)
if fail != nil {
return fail
}
kind := a.RequestType
if kind == "tv" {
kind = "tv_child"
}
m, fail := o.writeResult(raw, &OutTarget{Kind: kind, ID: a.RequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
package tools
import (
"context"
"encoding/json"
"strings"
"ombi-mcp/internal/ombi"
)
// read_search — text, multi, movie refinement and actor search.
// Family: media_page. All branches return bounded arrays; local
// paging slices a bounded fetched response (never forwarded upstream).
func handleSearch(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_search", false)
var a SearchArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "text":
return o.searchText(&a)
case "multi":
return o.searchMulti(&a)
case "movie_refine":
return o.searchMovieRefine(&a)
case "actor":
return o.searchActor(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func nonempty(s string) bool { return strings.TrimSpace(s) != "" }
func (o *op) searchText(a *SearchArgs) *ToolResult {
if !nonempty(a.Query) {
return o.invalid("query", "query must contain non-whitespace text")
}
var path, media string
switch a.Media {
case "movie":
path, media = "/api/v1/Search/movie/", "movie"
case "tv":
path, media = "/api/v1/Search/tv/", "tv"
case "artist":
path, media = "/api/v1/Search/music/artist/", "artist"
case "album":
path, media = "/api/v1/Search/music/album/", "album"
default:
return o.invalid("media", "media must be one of movie|tv|artist|album")
}
raw, fail := o.call("GET", path+seg(a.Query), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, media))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) searchMulti(a *SearchArgs) *ToolResult {
if !nonempty(a.Query) {
return o.invalid("query", "query must contain non-whitespace text")
}
if len(a.Include) == 0 {
return o.invalid("include", "include must name at least one category")
}
var f ombi.MultiSearchFilter
seen := map[string]bool{}
for _, inc := range a.Include {
if seen[inc] {
return o.invalid("include", "duplicate include category %q", inc)
}
seen[inc] = true
switch inc {
case "movies":
f.Movies = true
case "tv_shows":
f.TvShows = true
case "music":
f.Music = true
case "people":
f.People = true
default:
return o.invalid("include", "unsupported include category %q", inc)
}
}
raw, fail := o.call("POST", "/api/v2/Search/multi/"+seg(a.Query), nil, f)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, o.projectMultiResult(m))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
// projectMultiResult maps MultiSearchResult {id, mediaType, title,
// poster, overview} — the id retains its source/provider namespace.
func (o *op) projectMultiResult(m map[string]any) Media {
mt := jstr(m, "mediaType")
out := Media{Identifiers: []Identifier{},
Title: jstr(m, "title"), Overview: jstr(m, "overview")}
switch mt {
case "movie":
out.Media = "movie"
out.Identifiers = addID(out.Identifiers, "tmdb", m["id"])
case "tv":
out.Media = "tv"
out.Identifiers = addID(out.Identifiers, "tmdb", m["id"])
case "artist", "album", "music":
out.Media = mt
if out.Media == "music" {
out.Media = "album"
}
out.Identifiers = addID(out.Identifiers, "musicbrainz", m["id"])
case "person":
out.Media = "person"
out.Identifiers = addID(out.Identifiers, "tmdb", m["id"])
default:
out.Media = "unknown"
out.Identifiers = addID(out.Identifiers, "provider_unknown", m["id"])
if mt != "" {
o.warnf("unmapped multi-search mediaType %q", mt)
}
}
if u := jstr(m, "poster"); u != "" {
out.ArtworkURIs = []string{u}
}
return out
}
func (o *op) searchMovieRefine(a *SearchArgs) *ToolResult {
if !nonempty(a.Query) {
return o.invalid("query", "query must contain non-whitespace text")
}
body := ombi.SearchMovieRefineModel{SearchTerm: a.Query,
Year: a.Year, LanguageCode: a.Language}
raw, fail := o.call("POST", "/api/v1/Search/movie", nil, body)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, "movie"))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) searchActor(a *SearchArgs) *ToolResult {
if !nonempty(a.Query) {
return o.invalid("query", "query must contain non-whitespace text")
}
body := ombi.SearchActorModel{SearchTerm: a.Query, LanguageCode: a.Language}
raw, fail := o.call("POST", "/api/v1/Search/movie/actor", nil, body)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, "movie"))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
+260
View File
@@ -0,0 +1,260 @@
package tools
import (
"context"
"encoding/json"
"time"
"ombi-mcp/internal/ombi"
)
// read_server — status, version, features, news, stats and the
// administrator-gated cron validation. Families: metrics,
// reference_page.
func handleServer(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_server", false)
var a ServerArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "status":
return o.serverStatus()
case "status_info":
return o.serverStatusInfo()
case "about":
return o.serverAbout()
case "update_info":
return o.serverUpdateInfo()
case "update_check":
return o.serverUpdateCheck()
case "news":
return o.serverNews()
case "landing":
return o.serverLanding()
case "features":
return o.serverFeatures()
case "stats":
return o.serverStats(&a)
case "cron_validate":
return o.serverCronValidate(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) serverStatus() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Status", nil, nil)
if fail != nil {
return fail
}
v, fail := o.decodeScalar(raw)
if fail != nil {
return fail
}
val, ok := toInt(v)
if !ok {
if s, ok2 := v.(string); ok2 {
// Status may serialize as a named status string.
return o.ok(&Metrics{Kind: "metrics",
Values: []Metric{metric("status", s, "instance", "")}})
}
return o.fail("UPSTREAM_SCHEMA_MISMATCH", "upstream status was not a scalar", false)
}
return o.ok(&Metrics{Kind: "metrics",
Values: []Metric{metric("status", val, "instance", "")}})
}
func (o *op) serverStatusInfo() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Status/info", nil, nil)
if fail != nil {
return fail
}
v, fail := o.decodeScalar(raw)
if fail != nil {
return fail
}
return o.ok(&Metrics{Kind: "metrics",
Values: []Metric{metric("status_info", v, "instance", "")}})
}
// serverAbout projects AboutViewModel excluding connection strings.
func (o *op) serverAbout() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Settings/about", nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
vals := []Metric{}
// Connection-string fields are never projected.
for _, k := range []string{"version", "branch", "frameworkDescription",
"osArchitecture", "osDescription", "processArchitecture",
"ombiDatabaseType", "externalDatabaseType", "settingsDatabaseType",
"notSupported"} {
if v, ok := m[k]; ok {
switch t := v.(type) {
case string, bool, float64:
vals = append(vals, metric(k, t, "instance", ""))
}
}
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
func (o *op) serverUpdateInfo() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Update", nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
vals := []Metric{}
for k, v := range m {
switch t := v.(type) {
case string, bool, float64:
vals = append(vals, metric(k, t, "instance", ""))
}
}
vals = capItems(o, vals)
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
func (o *op) serverUpdateCheck() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Job/update", nil, nil)
if fail != nil {
return fail
}
v, fail := o.decodeScalar(raw)
if fail != nil {
return fail
}
return o.ok(&Metrics{Kind: "metrics",
Values: []Metric{metric("update_available", v, "instance", "")}})
}
func (o *op) serverNews() *ToolResult {
raw, fail := o.call("GET", "/api/v2/System/news", nil, nil)
if fail != nil {
return fail
}
items, fail := o.refArray(raw,
[]string{"id"}, []string{"title", "name", "headline"}, "news")
if fail != nil {
return fail
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
func (o *op) serverLanding() *ToolResult {
raw, fail := o.call("GET", "/api/v1/LandingPage", nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
vals := []Metric{}
for k, v := range m {
switch t := v.(type) {
case string, bool, float64:
vals = append(vals, metric(k, t, "instance", ""))
}
}
vals = capItems(o, vals)
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
func (o *op) serverFeatures() *ToolResult {
raw, fail := o.call("GET", "/api/v2/Features", nil, nil)
if fail != nil {
return fail
}
items, fail := o.refArray(raw,
[]string{"name"}, []string{"name"}, "feature")
if fail != nil {
return fail
}
// Feature records carry enabled as their value.
var arr []map[string]any
_ = json.Unmarshal(raw, &arr)
for i, m := range arr {
if i < len(items) {
if v, ok := m["enabled"]; ok {
items[i].Value = v
}
}
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
func (o *op) serverStats(a *ServerArgs) *ToolResult {
// from <= to must be enforced by the server per the contract.
if a.From != "" && a.To != "" {
f, ferr := time.Parse(time.RFC3339, a.From)
t, terr := time.Parse(time.RFC3339, a.To)
if ferr != nil || terr != nil {
return o.invalid("from", "from/to must be RFC3339 date-time values")
}
if f.After(t) {
return o.invalid("from", "from must be on or before to")
}
}
q := map[string]string{}
if a.From != "" {
q["from"] = a.From
}
if a.To != "" {
q["to"] = a.To
}
raw, fail := o.call("GET", "/api/v1/Stats", q, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
// Only the seven numeric totals; nested user objects are dropped.
vals := []Metric{}
for _, k := range []string{"totalRequests", "totalMovieRequests",
"totalTvRequests", "totalIssues", "completedRequestsMovies",
"completedRequestsTv", "completedRequests"} {
if i, ok := toInt(m[k]); ok {
vals = append(vals, metric(k, i, "instance", "requests"))
}
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
func (o *op) serverCronValidate(a *ServerArgs) *ToolResult {
if !nonempty(a.Expression) {
return o.invalid("expression", "expression must contain non-whitespace text")
}
raw, fail := o.call("POST", "/api/v1/Settings/testcron", nil,
ombi.CronViewModelBody{Expression: a.Expression})
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
vals := []Metric{}
if v, ok := m["success"]; ok {
if b, ok2 := v.(bool); ok2 {
vals = append(vals, metric("valid", b, "instance", ""))
}
}
if s := jstr(m, "message"); s != "" {
vals = append(vals, metric("message", s, "instance", ""))
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
+417
View File
@@ -0,0 +1,417 @@
package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate"
)
// Section → upstream GET/POST paths from docs/schema/07-settings-types.md.
var settingsSections = map[string]string{
"custom_page": "/api/v1/CustomPage",
"ombi": "/api/v1/Settings/ombi",
"plex": "/api/v1/Settings/plex",
"emby": "/api/v1/Settings/emby",
"jellyfin": "/api/v1/Settings/jellyfin",
"landingpage": "/api/v1/Settings/landingpage",
"customization": "/api/v1/Settings/customization",
"sonarr": "/api/v1/Settings/sonarr",
"radarr": "/api/v1/Settings/radarr",
"lidarr": "/api/v1/Settings/lidarr",
"authentication": "/api/v1/Settings/authentication",
"update": "/api/v1/Settings/Update",
"user_management": "/api/v1/Settings/UserManagement",
"couchpotato": "/api/v1/Settings/CouchPotato",
"dognzb": "/api/v1/Settings/DogNzb",
"sickrage": "/api/v1/Settings/SickRage",
"jobs": "/api/v1/Settings/jobs",
"issues": "/api/v1/Settings/Issues",
"vote": "/api/v1/Settings/vote",
"themoviedb": "/api/v1/Settings/themoviedb",
"notifications.email": "/api/v1/Settings/notifications/email",
"notifications.discord": "/api/v1/Settings/notifications/discord",
"notifications.telegram": "/api/v1/Settings/notifications/telegram",
"notifications.pushbullet": "/api/v1/Settings/notifications/pushbullet",
"notifications.pushover": "/api/v1/Settings/notifications/pushover",
"notifications.slack": "/api/v1/Settings/notifications/slack",
"notifications.mattermost": "/api/v1/Settings/notifications/mattermost",
"notifications.twilio": "/api/v1/Settings/notifications/twilio",
"notifications.mobile": "/api/v1/Settings/notifications/mobile",
"notifications.gotify": "/api/v1/Settings/notifications/gotify",
"notifications.ntfy": "/api/v1/Settings/notifications/ntfy",
"notifications.webhook": "/api/v1/Settings/notifications/webhook",
"notifications.newsletter": "/api/v1/Settings/notifications/newsletter",
// Read-only sections.
"base_url": "/api/v1/Settings/baseurl",
"client_id": "/api/v1/Settings/clientid",
"default_language": "/api/v1/Settings/defaultlanguage",
"themes": "/api/v1/Settings/themes",
"lidarrenabled": "/api/v1/Settings/lidarrenabled",
"issuesenabled": "/api/v1/Settings/issuesenabled",
"voteenabled": "/api/v1/Settings/voteenabled",
"notifications.email.enabled": "/api/v1/Settings/notifications/email/enabled",
}
// readOnlySections have no PATCH contract; they are never writable.
var readOnlySections = map[string]bool{
"base_url": true,
"client_id": true,
"default_language": true,
"themes": true,
"lidarrenabled": true,
"issuesenabled": true,
"voteenabled": true,
"notifications.email.enabled": true,
}
// excludedSettingsFields are removed recursively from projections and
// rejected in patches (07-settings-types.md).
var excludedSettingsFields = map[string]bool{
"accessToken": true, "accountSid": true, "administratorId": true,
"apiKey": true, "applicationToken": true, "applicationUrl": true,
"authToken": true, "authorizationHeader": true, "baseUrl": true,
"botApi": true, "customDonationUrl": true,
"disableCertificateChecking": true, "disableTLS": true,
"favicon": true, "hasMigratedOldTvDbData": true, "host": true,
"iconUrl": true, "id": true, "installId": true, "ip": true,
"logo": true, "machineIdentifier": true, "password": true,
"plexAuthToken": true, "port": true, "processName": true,
"scriptLocation": true, "serverHostname": true, "serverId": true,
"set": true, "ssl": true, "subDir": true, "useScript": true,
"userToken": true, "webhookUrl": true, "windowsService": true,
"windowsServiceName": true, "wizard": true,
}
// secretish reports additional credential-looking names excluded
// from read projections (allowlist caution beyond the patch list).
func secretish(name string) bool {
l := strings.ToLower(name)
for _, p := range []string{"token", "secret", "password", "apikey",
"api_key", "connectionstring", "privatekey", "credential"} {
if strings.Contains(l, p) {
return true
}
}
return false
}
// read_settings — allowlisted section projection with an opaque
// revision digest for safe patching.
func handleSettingsRead(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_settings", false)
var a SettingsReadArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
path, ok := settingsSections[a.Section]
if !ok {
return o.invalid("section", "unsupported section %q", a.Section)
}
raw2, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
var doc any
if fail := o.decodeJSON(raw2, &doc); fail != nil {
return fail
}
out := &Settings{Kind: "settings", Section: a.Section,
Values: []Change{}, OmittedFields: []string{}}
var omitted []string
flattenSettings(doc, "", &out.Values, &omitted)
out.OmittedFields = omitted
// Revision is offered only for sections with a safe patch route.
if !readOnlySections[a.Section] {
out.Revision = revisionOf(raw2)
}
return o.ok(out)
}
// revisionOf digests the raw section body — an opaque, section-bound
// compare token for the patch path.
func revisionOf(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:16])
}
// flattenSettings walks a decoded settings document emitting one
// scalar leaf per Change with escaped-JSON-Pointer-style names.
// Excluded and secret-looking fields land in omitted, never values.
func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
child := path + "/" + escapePointer(k)
if excludedSettingsFields[k] || secretish(k) {
*omitted = append(*omitted, child)
continue
}
flattenSettings(t[k], child, out, omitted)
}
case []any:
for i, e := range t {
flattenSettings(e, fmt.Sprintf("%s/%d", path, i), out, omitted)
}
case string, float64, bool:
name := path
if name == "" {
name = "/"
}
*out = append(*out, Change{Name: name, Value: t})
case nil:
// Absent/null leaves are not projected.
}
if len(*out) > 100 {
*out = (*out)[:100]
}
}
func escapePointer(s string) string {
s = strings.ReplaceAll(s, "~", "~0")
return strings.ReplaceAll(s, "/", "~1")
}
// write_settings_patch — private GET, revision verify, typed merge
// preserving omitted/secret fields, full-model POST.
func handleSettingsPatch(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_settings_patch", true)
var a SettingsPatchArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "patch":
return o.settingsPatch(&a)
case "feature":
return o.settingsFeature(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) settingsPatch(a *SettingsPatchArgs) *ToolResult {
path, ok := settingsSections[a.Section]
if !ok {
return o.invalid("section", "unsupported section %q", a.Section)
}
if readOnlySections[a.Section] {
return o.invalid("section", "section %q is read-only", a.Section)
}
if !nonempty(a.Revision) {
return o.invalid("revision", "revision is required")
}
if len(a.Changes) == 0 {
return o.invalid("changes", "changes must contain at least one property")
}
if bad := findExcluded(a.Changes); bad != "" {
return o.invalid("changes", "field %q is not patchable", bad)
}
if bad := findNull(a.Changes); bad != "" {
return o.invalid("changes", "field %q may not be null", bad)
}
// Serialize read-modify-write cycles per instance.
o.env.settingsMu.Lock()
defer o.env.settingsMu.Unlock()
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
// An incomplete original means save is unsupported.
var orig map[string]any
if err := json.Unmarshal(raw, &orig); err != nil || orig == nil {
return o.fail("UNSUPPORTED_CAPABILITY",
"the saved section is not a complete object; patch is unsupported", false)
}
if revisionOf(raw) != a.Revision {
return o.fail("CONFLICT",
"settings revision is stale; re-read the section before patching", false)
}
changes := translateNotificationChanges(a.Changes)
merged, fail := mergeSettings(orig, changes)
if fail != nil {
return fail
}
if reflect.DeepEqual(orig, merged) {
return o.invalid("changes", "patch makes no effective change")
}
raw2, fail := o.call("POST", path, nil, merged)
if fail != nil {
return fail
}
// Most section POSTs return a boolean; jobs returns a result object.
if b, fail2 := o.decodeBool(raw2); fail2 == nil {
if !b {
return o.fail("UPSTREAM_REJECTED", "upstream rejected the settings save", false)
}
return o.ok(&Mutation{Kind: "mutation", Outcome: "completed",
UpstreamResult: &b})
}
m, fail := o.decodeObject(raw2)
if fail != nil {
return fail
}
out := &Mutation{Kind: "mutation", Outcome: "completed"}
if v := jbool(m, "result"); v != nil {
out.UpstreamResult = v
if !*v {
return o.fail("UPSTREAM_REJECTED",
sanitizeText(jstr(m, "message"), maxSanitizedMsg), false)
}
}
return o.ok(out)
}
// findExcluded walks patch keys recursively for excluded names.
func findExcluded(m map[string]any) string {
for k, v := range m {
if excludedSettingsFields[k] || secretish(k) {
return k
}
if sub, ok := v.(map[string]any); ok {
if bad := findExcluded(sub); bad != "" {
return bad
}
}
if arr, ok := v.([]any); ok {
for _, e := range arr {
if em, ok := e.(map[string]any); ok {
if bad := findExcluded(em); bad != "" {
return bad
}
}
}
}
}
return ""
}
// findNull walks patch values recursively rejecting JSON null.
func findNull(m map[string]any) string {
for k, v := range m {
if v == nil {
return k
}
if sub, ok := v.(map[string]any); ok {
if bad := findNull(sub); bad != "" {
return bad
}
}
}
return ""
}
// translateNotificationChanges rewrites notification_type/agent
// labels inside notificationTemplates elements into upstream
// integers before merging (07-settings-types.md).
func translateNotificationChanges(changes map[string]any) map[string]any {
out := make(map[string]any, len(changes))
for k, v := range changes {
if k == "notificationTemplates" {
if arr, ok := v.([]any); ok {
newArr := make([]any, 0, len(arr))
for _, e := range arr {
if em, ok := e.(map[string]any); ok {
newArr = append(newArr, translateTemplateLabels(em))
continue
}
newArr = append(newArr, e)
}
out[k] = newArr
continue
}
}
out[k] = v
}
return out
}
func translateTemplateLabels(el map[string]any) map[string]any {
out := make(map[string]any, len(el))
for k, v := range el {
switch k {
case "notification_type":
if s, ok := v.(string); ok {
if n, err := translate.NotifTypeToWire(s); err == nil {
out["notificationType"] = n
continue
}
}
out[k] = v
case "agent":
if s, ok := v.(string); ok {
if n, err := translate.AgentToWire(s); err == nil {
out["agent"] = n
continue
}
}
out[k] = v
default:
out[k] = v
}
}
return out
}
// mergeSettings deep-merges a validated patch into the original
// document: nested objects merge by property, arrays replace whole,
// scalars overwrite. Omitted fields are preserved automatically.
func mergeSettings(orig map[string]any, changes map[string]any) (map[string]any, *ToolResult) {
merged := make(map[string]any, len(orig))
for k, v := range orig {
merged[k] = v
}
for k, v := range changes {
if sub, ok := v.(map[string]any); ok {
if len(sub) == 0 {
// An empty nested object is a no-op, not a deletion.
continue
}
base, _ := merged[k].(map[string]any)
if base == nil {
base = map[string]any{}
}
m, err := mergeSettings(base, sub)
if err != nil {
return nil, err
}
merged[k] = m
continue
}
merged[k] = v // scalars overwrite; arrays replace whole
}
return merged, nil
}
// settingsFeature — POST {name, enabled} to the enable/disable
// feature route selected by the boolean.
func (o *op) settingsFeature(a *SettingsPatchArgs) *ToolResult {
if !nonempty(a.Name) {
return o.invalid("name", "name is required")
}
if a.Enabled == nil {
return o.invalid("enabled", "enabled is required")
}
route := "enable"
if !*a.Enabled {
route = "disable"
}
_, fail := o.call("POST", "/api/v2/Features/"+route, nil,
ombi.FeatureEnablement{Name: a.Name, Enabled: *a.Enabled})
if fail != nil {
return fail
}
return o.ok(&Mutation{Kind: "mutation", Outcome: "completed"})
}
+254
View File
@@ -0,0 +1,254 @@
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)
}
}
+114
View File
@@ -0,0 +1,114 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"ombi-mcp/internal/ombi"
)
// read_votes — global vote list or per-request votes.
// Family: vote_page.
func handleVotes(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_votes", false)
var a VotesArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "list":
return o.votesList(&a)
case "get":
return o.votesGet(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) votesList(a *VotesArgs) *ToolResult {
raw, fail := o.call("GET", "/api/v1/Vote", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Vote{}
for _, m := range arr {
items = append(items, o.projectVoteSummary(m))
}
win, pg := localWindow(o, items, a.Page, "votes")
return o.ok(&VotePage{Kind: "vote_page", Items: win, Page: pg})
}
func (o *op) votesGet(a *VotesArgs) *ToolResult {
var segName string
switch a.Media {
case "movie":
segName = "movie"
case "tv":
segName = "tv"
case "album":
segName = "music" // album votes read via the music segment
default:
return o.invalid("media", "media must be movie|tv|album")
}
if a.RequestID == nil || *a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
raw, fail := o.call("GET",
fmt.Sprintf("/api/v1/Vote/%s/%d", segName, *a.RequestID), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Vote{}
for _, m := range arr {
items = append(items, o.projectVoteRecord(m))
}
win, pg := localWindow(o, items, a.Page, "votes")
return o.ok(&VotePage{Kind: "vote_page", Items: win, Page: pg})
}
// write_vote — POST Vote/{up|down}/{movie|tv|album}/{requestId};
// album uses the literal album segment on writes.
func handleVote(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_vote", true)
var a VoteArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
var segName string
switch a.Media {
case "movie", "tv", "album":
segName = a.Media
default:
return o.invalid("media", "media must be movie|tv|album")
}
if a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
if a.Direction != "up" && a.Direction != "down" {
return o.invalid("direction", "direction must be up or down")
}
raw, fail := o.call("POST",
fmt.Sprintf("/api/v1/Vote/%s/%s/%d", a.Direction, segName, a.RequestID),
nil, nil)
if fail != nil {
return fail
}
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
m, fail := o.engineMutation(&er, nil)
if fail != nil {
return fail
}
m.RequestID = &a.RequestID
return o.ok(m)
}
+153
View File
@@ -0,0 +1,153 @@
// Command schemagen extracts the finalized JSON Schema contracts from
// docs/schema into internal/tools/schemas.json, which is embedded in
// the binary so tools/list can advertise complete input and output
// schemas without parsing markdown at runtime.
//
// Run from the repository root via `go generate ./internal/tools`.
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
)
const (
inputDoc = "docs/schema/03-input-schemas.md"
defsDoc = "docs/schema/04-results.md"
outputDoc = "internal/tools/schemas.json"
)
var sectionRe = regexp.MustCompile(`^## ([a-z_]+)\s*$`)
// firstJSONBlock returns the first ```json fenced block following a
// line index, or nil if the section has none.
func firstJSONBlock(lines []string, start, end int) []byte {
inBlock := false
var buf bytes.Buffer
for i := start; i < end; i++ {
l := lines[i]
switch {
case !inBlock && l == "```json":
inBlock = true
case inBlock && l == "```":
return buf.Bytes()
case inBlock:
buf.WriteString(l)
buf.WriteByte('\n')
}
}
return nil
}
func splitLines(b []byte) []string {
var out []string
start := 0
for i := 0; i < len(b); i++ {
if b[i] == '\n' {
out = append(out, string(b[start:i]))
start = i + 1
}
}
if start < len(b) {
out = append(out, string(b[start:]))
}
return out
}
func mustJSON(raw []byte, ctx string) json.RawMessage {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
fatal("%s: invalid JSON: %v", ctx, err)
}
return json.RawMessage(raw)
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "schemagen: "+format+"\n", args...)
os.Exit(1)
}
func main() {
root, err := os.Getwd()
if err != nil {
fatal("cwd: %v", err)
}
// --- input schemas: one ```json block per `## tool` section ---
inSrc, err := os.ReadFile(filepath.Join(root, inputDoc))
if err != nil {
fatal("read %s: %v", inputDoc, err)
}
lines := splitLines(inSrc)
input := map[string]json.RawMessage{}
for i, l := range lines {
m := sectionRe.FindStringSubmatch(l)
if m == nil {
continue
}
name := m[1]
end := len(lines)
for j := i + 1; j < len(lines); j++ {
if sectionRe.MatchString(lines[j]) || len(lines[j]) > 1 && lines[j][0] == '#' {
end = j
break
}
}
block := firstJSONBlock(lines, i+1, end)
if block == nil {
fatal("%s: no json schema block for section %q", inputDoc, name)
}
input[name] = mustJSON(block, inputDoc+"/"+name)
}
if len(input) != 31 {
fatal("expected 31 tool schemas, extracted %d", len(input))
}
// --- shared $defs library: first ```json block of 04-results.md ---
defSrc, err := os.ReadFile(filepath.Join(root, defsDoc))
if err != nil {
fatal("read %s: %v", defsDoc, err)
}
dlines := splitLines(defSrc)
defBlock := firstJSONBlock(dlines, 0, len(dlines))
if defBlock == nil {
fatal("%s: no json block found", defsDoc)
}
var defDoc struct {
Defs map[string]json.RawMessage `json:"$defs"`
}
if err := json.Unmarshal(defBlock, &defDoc); err != nil {
fatal("%s: invalid defs JSON: %v", defsDoc, err)
}
if len(defDoc.Defs) == 0 {
fatal("%s: empty $defs", defsDoc)
}
out := struct {
Input map[string]json.RawMessage `json:"input"`
Defs map[string]json.RawMessage `json:"defs"`
}{Input: input, Defs: defDoc.Defs}
raw, err := json.Marshal(out)
if err != nil {
fatal("marshal: %v", err)
}
var pretty bytes.Buffer
if err := json.Indent(&pretty, raw, "", " "); err != nil {
fatal("indent: %v", err)
}
path := filepath.Join(root, outputDoc)
if err := os.WriteFile(path, append(pretty.Bytes(), '\n'), 0o644); err != nil {
fatal("write %s: %v", outputDoc, err)
}
names := make([]string, 0, len(input))
for n := range input {
names = append(names, n)
}
sort.Strings(names)
fmt.Printf("wrote %s: %d input schemas, %d defs\n", outputDoc, len(input), len(defDoc.Defs))
}