Compare commits
2
Commits
4022d30fe6
...
e7728cdbce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7728cdbce | ||
|
|
eab16991ac |
@@ -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
|
||||
|
||||
@@ -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
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "")}})
|
||||
}
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user