Files
ombi-mcp/docs/schema/02-tool-mapping.md
gronod 80e01253a2
Build and publish / Test and build (linux) (pull_request) Canceled after 0s
Build and publish / Test and build (windows) (pull_request) Canceled after 0s
Build and publish / Build and publish Docker image (pull_request) Canceled after 0s
Build and publish / Test and build (darwin) (pull_request) Canceled after 40s
Build and publish / Test and build (darwin) (push) Successful in 2m9s
Build and publish / Test and build (linux) (push) Successful in 2m34s
Build and publish / Test and build (windows) (push) Successful in 3m11s
Build and publish / Build and publish Docker image (push) Successful in 1m55s
Fix #10, #27 and #28 from the read-tools sweep
v2 request lists sent the RAML example sort field requestDate; Ombi
looks up RequestedDate and NullReferenceException'd every non-empty
page. Browse now streams TV popular/most-watched payloads and skips
the hydrated seasonRequests graph that blew the 8 MiB read budget.
provider_summary treats an empty upstream body as an empty group_page.
2026-09-19 20:59:57 +01:00

240 lines
32 KiB
Markdown

# Tool catalogue and routing
## Grouping strategy
There are **31 defined tools: 18 core, 5 moderation, 8 administration**. Core is the ordinary media workflow; it is not a guarantee that every core branch is authorized for every Ombi user. User listing, retry queue inspection, saved integration server/user details, cron validation and another user's preferences require additional branch authorization. The administration and moderation bundles are opt-in. A read-only deployment can advertise just the 12 core read tools, with privileged branches removed.
Grouping follows a task and side-effect boundary: searching, browsing, reading details, submitting, moderating, deleting and subscribing are distinct. Movie/TV/album variants belong together when their intention is the same. Settings sections share one schema-driven administration tool; credentials, raw HTTP, arbitrary URLs, full entity edits and unbounded bulk deletes are not tools. This balances discoverability against overly large action enums.
The [input catalogue](03-input-schemas.md) defines every parameter, required field, enum and structural constraint. The [operation ledger](05-endpoint-coverage.md) supplies exact method/path pairs, wire parameters and upstream body/result types. Together with the rules below, these form the routing contract. There is no undocumented fallback precedence or generic endpoint passthrough.
## Catalogue
| Tool | Task | Bundle | Result family |
|---|---|---|---|
| `read_search` | Text, multi, movie refinement, actor search | core | media page |
| `read_discover` | Curated lists, similar movies, collection, credits, artist albums, advanced movie filters | core | media page |
| `read_media` | Details by explicit provider or request; ratings; streaming | core | media/details page |
| `read_reference` | Genres, languages, keywords, watch-provider catalogue, countries, issue categories | core | reference page |
| `read_requests` | List/get/search, TV children, recent requests, privileged retry queue | core | request page / retry page |
| `read_request_stats` | Counts, totals, per-media quota, user-has-requests | core | metrics |
| `read_issues` | Issues, grouped summaries, comments and counts | core | issue/comment/group page or metrics |
| `read_votes` | Global vote list or votes on a request | core | vote page |
| `read_users` | Self, authorized user lookup, claims, online users, preference read | core | user/reference page |
| `read_library` | Recent additions, calendar, artwork | core | media/calendar/artwork page |
| `read_server` | Status, version, features, stats, cron validation | core | metrics/reference page |
| `read_integration` | Saved ARR options and authorized media-server metadata | core | reference/user page |
| `write_request_create` | One media request or explicit collection request | core | mutation |
| `write_request_subscribe` | Subscribe/unsubscribe | core | mutation |
| `write_issue_create` | Report an issue | core | mutation |
| `write_issue_comment` | Add a comment | core | mutation |
| `write_vote` | Up/down vote | core | mutation |
| `write_user_preferences` | Language, streaming country, newsletter opt-out | core | mutation |
| `write_request_moderate` | Approval, denial, availability | moderation | mutation |
| `write_request_delete` | Explicit single movie/album/TV-parent/TV-child deletion | moderation | mutation |
| `write_request_options` | Advanced routing overrides and TV root/quality | moderation | mutation |
| `write_request_reprocess` | Reprocess an existing request | moderation | mutation |
| `write_issue_manage` | State, deletes, category management | moderation | mutation |
| `read_settings` | Safe configuration projection and revision | administration | settings |
| `write_settings_patch` | Typed non-secret patch or feature flag | administration | mutation |
| `write_user_manage` | Delete user or send welcome email | administration | mutation |
| `write_integration_test` | Test a saved profile; can send notifications | administration | mutation |
| `write_job_run` | Trigger permitted jobs / watchlist revalidation | administration | mutation |
| `write_notification_send` | Email explicit recipients | administration | mutation |
| `write_retry_remove` | Remove one queue entry | administration | mutation |
| `read_logs` | Bounded, sanitized diagnostic reads | administration | log page |
## Shared routing and input rules
1. Reject unknown properties and irrelevant branch arguments. Required `action`, `media`, `provider`, `kind` and IDs cannot be supplied by silent defaults. Optional page values default server-side to offset 0 / limit 25; maximum limit is 100. Schema defaults are documentation, not mutation of the input.
2. Upstream spelling and case are literal. `request/music`, `Request/movie`, `Requests/album`, `NotificationPreferences` and `notificationpreferences` are distinct contract spellings. Encode values; do not change literal paths to match public naming conventions.
3. The public media names are movie, TV, artist and album. “Music” is an upstream route segment or the multi-search filter, not a universal alias. No artist-request operation exists. External IDs are never interchangeable with Ombi request IDs. Music IDs are opaque nonempty provider strings until a stronger MusicBrainz format is verified.
4. Enum label maps are now published for four upstream integer enums. On the input side, string labels are the only accepted form — `*_code` integer fields are rejected via `additionalProperties:false`, and an unknown label fails with `INVALID_ARGUMENT`. On the read side, every mapped enum emits the string label in its `*_label` field (`status`, `request_type`, `agent`, `notification_type`) **and** the raw int in its `*_code` twin (`status_code`, `request_type_code`, `agent_code`, `notification_type_code`) for provenance; an unmapped upstream int omits the label, emits only `*_code`, and appends `unmapped upstream enum value <n> for <field>` to `warnings[]` (graceful degradation).
**T1 — `RequestType`** (upstream `enum:[0,1,2]`). Critical: upstream order is `TvShow=0, Movie=1, Album=2` — NOT positional. Verified against upstream source; still verify per-instance for version drift.
| MCP string | Upstream int | Used by |
|---|---|---|
| `movie` | 1 | `write_issue_create.request_type`, `write_request_reprocess.request_type`, issue/retry outputs |
| `tv` | 0 | same |
| `album` | 2 | same |
**T2 — `IssueStatus`** (upstream `enum:[0,1,2,3]`; frontend confirms Pending=0, InProgress=1, Resolved=2; `closed`→3).
| MCP string | Upstream int | Used by |
|---|---|---|
| `pending` | 0 | `read_issues` list/summary `status`, `write_issue_manage.set_status.status`, issue output `status` |
| `in_progress` | 1 | same |
| `resolved` | 2 | same |
| `closed` | 3 | same |
**T3 — `NotificationAgent`** (upstream `enum:[0..11]`).
| MCP string | Int | | MCP string | Int |
|---|---|---|---|---|
| `email` | 0 | | `mobile` | 7 |
| `discord` | 1 | | `gotify` | 8 |
| `pushbullet` | 2 | | `webhook` | 9 |
| `pushover` | 3 | | `whatsapp` | 10 |
| `telegram` | 4 | | `ntfy` | 11 |
| `slack` | 5 | | | |
| `mattermost` | 6 | | | |
**T4 — `NotificationType`** (upstream `enum:[0..16]`).
| MCP string | Int | | MCP string | Int |
|---|---|---|---|---|
| `new_request` | 0 | | `issue_resolved` | 9 |
| `issue` | 1 | | `issue_comment` | 10 |
| `request_available` | 2 | | `newsletter` | 11 |
| `request_approved` | 3 | | `partially_available` | 12 |
| `admin_note` | 4 | | `plex_watchlist_token_expired` | 13 |
| `test` | 5 | | `request_deleted` | 14 |
| `request_declined` | 6 | | `issue_in_progress` | 15 |
| `item_added_to_fault_queue` | 7 | | `issue_deleted` | 16 |
| `welcome_email` | 8 | | | |
Remaining numeric enums are unresolved gaps and stay numeric pending verification: `VoteType [0,1]` (labels unverified), `RequestSource`, `RequestLimitType [0,1,2]`, and the legacy `orderType`/`statusType`/`availabilityType` filter ints. Search response IDs must retain their source/provider namespace, including multi-search TV TMDB IDs.
5. A TV parent is a show record; a TV child is an individual request under it. `read_requests.list(media=tv)` uses v2 child pages. Parent detail and children enumeration use v1. A compact projection of v2 children is preferable to secretly swapping in `tvlite` parent results (the v2 list contract is preserved). Return `parent_request_id` alongside child IDs when present. `read_requests.search` for TV uses v1 `Request/tv/search/{term}` but falls back to a bounded v1 parent scan if it fails, compensating for an upstream LINQ bug. v2 list `{sort}` is the C# property `RequestedDate` (not the RAML example `requestDate`, which TypeDescriptor cannot resolve and which 500s every non-empty page).
6. Parameter names such as `currentPosition`, `position`, `skip`, `count`, `take` and `amountToLoad` are mapped exactly per ledger. Offsets are zero-based by this MCP contract; the adapter must verify ambiguous upstream paging behaviour. Requests use count then position; issue summary uses position then take; issue list uses take then skip. Do not reverse these pairs.
7. Array responses without server pagination are sliced locally only within a bounded fetched response. Mark pagination as local and total unknown unless the complete collection was obtained. A result-size cap is a truncation warning, not a fabricated server total or a promise that the next page exists.
8. `format: date-time` and cross-field comparisons must be enforced by the server, not assumed from a client's validator. Stats requires `from <= to` when both are supplied. Strings must contain non-whitespace text where used as queries/comments. Reject duplicate season numbers and duplicate episode numbers; impose a maximum of 2,000 selected episodes per call in addition to per-array limits.
9. Never infer permissions solely from the fact that a route is in RAML. Verify capability/role at call time, enforce local policy and retain upstream denial. Per-tool annotations are conservative: all mutations are non-idempotent until side effects are verified; all tools have `openWorldHint: true` because they interact with a configured external service.
## Search, discovery and details
`read_search.text` routes to the matching v1 `Search/movie`, `Search/tv`, `Search/music/artist`, or `Search/music/album` search-term route. It does not fan out implicitly. `multi` POSTs to v2 multi search; map `tv_shows` to `tvShows` and include all four Boolean properties explicitly, false for categories not selected. `movie_refine` POSTs `{searchTerm, year?, languageCode?}`. `actor` POSTs `{searchTerm, languageCode?}` to v1 movie/actor. Local `page` is never forwarded as an undocumented query parameter.
`read_discover.browse` uses paged v2 routes. Map `now_playing→nowplaying`, `top_rated→toprated`, `most_watched→mostwatched`. Movie allows popular/now-playing/top-rated/upcoming/seasonal/requested; TV allows popular/anticipated/most-watched/trending/requested. No other cross-product is valid. Nonpaged v1 and v2 equivalents are compatibility routes only.
`similar` is POST v2 movie/similar with `{theMovieDbId, languageCode?}`. A verified v1 POST equivalent can preserve language; a v1 GET cannot preserve a language argument. `collection` returns the collection's members/basic metadata, without creating requests. `credits` chooses actor/{actorId}/movie or /tv. `artist_albums` uses v1 music/artist/album/{foreignArtistId}.
`advanced_movie` POSTs the exact DiscoverModel property names: `type:"movie"`, `release_year→releaseYear`, `genre_ids→genreIds`, `keyword_ids→keywordIds`, `watch_provider_ids→watchProviders`, and decade unchanged. Ombi 4.53.x accepts but ignores `companies`, so `company_ids` is not advertised and legacy calls fail explicitly. Do not invent a `query` requirement for discovery. Release year and decade must agree if both are supplied.
`read_media.details` chooses v2 movie TMDB/IMDb, TV TVDB/TMDB, artist, or artist/album routes. TV `tmdb` uses v2 `Search/tv/moviedb/{id}`; TV `tvdb` uses the legacy v1 `Search/tv/info/{tvdbId}` route (the v2 `Search/tv/{tvdbId}` route is a TMDB-keyed alias and must never serve a TVDB lookup). The legacy v1 TVDB info route does not reliably carry request state; for TVDB, the adapter performs a bounded scan of v1 `Request/tv` (matching by `tvDbId` or `imdbId`) to overlay `requested`, `request_targets` (using the real parent request ID), and per-episode availability flags. IMDb path placeholder spelling differs from its parameter declaration. `by_request` chooses movie/request, tv/request or artist/request. `movie_localized` uses POST v1 movie/info. `ratings` first uses Ombi's title/year ratings route. Ombi 4.53.x still routes that operation through removed Rotten Tomatoes private endpoints; on a 404/5xx (or a successful empty response), the adapter searches the same media/title and requires an exact case-insensitive title and year match before returning explicitly labelled `tmdb_vote_*` or `tvmaze_site_rating` references. A warning identifies the fallback source; no fuzzy match is substituted. `streaming` uses TMDB even for TV.
TV identifier labels follow the origin route, not the upstream field name. The v1 TVMaze-backed routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) place the TVDB id in the misleadingly-named `theMovieDbId` field and the TVMaze id in `seriesId`; emit `tvdb` and `tvmaze` there. The v2 engine routes (browse, `moviedb`, `by_request`, multi-search) are TMDB-keyed; emit `tmdb` and do not treat `seriesId` as TVMaze. When `theMovieDbId` is absent, `id` is labelled with that origin namespace so browse and collection members still carry a usable identifier. Multi-search `mediaType` is case-insensitive (`Artist`→artist). The v1 `RecentlyAdded` TV model's id namespace is unverified — keep `tmdb` pending live confirmation.
`read_reference.keywords` passes query `searchTerm`; keyword detail uses its own keyword ID. Watch-provider catalogue search also has optional `searchTerm`; it is distinct from streaming availability for a particular title. Other branches have no request body. Reference values are good optional cached resources, but remain available through tools for tool-only clients.
## Requests and quotas
Use v2 list/status routes, always with sort and page segments. `status` defaults `all`; `sort_direction` maps to the `{sortOrder}` segment (`asc`/`desc`). Public `sort.field=request_date` maps to Ombi's `RequestedDate` property (the RAML example `requestDate` does not match and 500s on non-empty pages). No speculative sort fields are published. `all` means the base route, not an `/all/` segment. Album lacks an unavailable-status route, so that combination fails schema validation. The misspelled movie `availble` route is an explicitly gated compatibility alias, not the primary path.
`get` supports movie and TV parent only; no album single-request endpoint is advertised. `children` returns children for a parent; child ids are the real upstream child PKs (provider-shaped for first-request children by design), with provider ids surfaced from the embedded `parentRequest`. Request `search` uses the appropriate v1 route and rejects list-only status/sort arguments. `recent` uses v2 recentlyRequested; for movie/album `target.id` is `requestId`, while TV `requestId` is a child request id (often provider-shaped) that is resolved to the parent request id through a bounded v1 parent scan — unresolvable rows emit `target.id` 0 with a warning rather than a provider-shaped target. `retry_queue` is a privileged GET and returns queue IDs separately from underlying request IDs.
`read_request_stats.counts` uses Request/count; `total` uses the media's total endpoint; `quota` uses its remaining endpoint. Quota belongs to the actual upstream principal. `has_requests` requires an explicit `user_id` by MCP policy and sends it as the optional upstream `userId` query; viewing another user is subject to authorization. Do not combine instance totals with a per-user quota under an unlabeled “total”.
### Creating requests
| Branch | Route | Exact body construction |
|---|---|---|
| movie | POST `/api/v1/Request/movie` | `tmdb_id→theMovieDbId`, `is_4k→is4kRequest` (optional, defaults false), `language→languageCode`; authorized optional on-behalf/overrides |
| TV, TMDB | POST `/api/v2/Requests/tv` | `id→theMovieDbId`, optional `languageCode`, selection, language profile and overrides |
| TV, TVDB | POST `/api/v1/Request/tv` | `id→tvDbId`, selection, language profile and overrides; no languageCode or 4K field |
| album | POST `/api/v1/request/music` | `musicbrainz_id→foreignAlbumId`, optional `requested_by_alias→requestedByAlias`; no shared overrides |
| collection | POST `/api/v2/Requests/movie/collection/{collectionId}` | No documented body. No 4K, language or on-behalf overrides accepted |
| TV `season` selection | (TV branches above) | `season_numbers[]` → adapter reads details, expands each season's full episode list → `seasons[].episodes`; never sent as a bare season flag |
`on_behalf_user_id` is an MCP user-ID contract. Enable only after verifying whether upstream `requestOnBehalf` expects that ID or a username; if username, resolve ID to username internally and explicitly. Translate root/quality overrides to `rootFolderOverride`/`qualityPathOverride`. Never silently ignore unavailable options.
TV selection `all`, `first_season`, `latest_season` sets exactly one of `requestAll`, `firstSeason`, `latestSeason` true and the other two false. `season` takes `season_numbers[]`; the adapter reads TV details in the same provider namespace (TMDB via v2 `Search/tv/moviedb/{id}`, TVDB via v1 `Search/tv/info/{tvdbId}`), expands each listed season to its full episode list and emits explicit `seasons[].episodes` — expansion is the default construction, never an upstream empty-means-all assumption. `episodes` sets all false and maps seasons to `{seasonNumber, episodes:[{episodeNumber}]}`. Require explicit nonempty episode lists. RAML does not establish that an empty list means a whole season; a caller wanting a full season must read details and select its actual episodes. If a verified adapter later adds a whole-season shortcut, add an explicit branch. Do not fabricate `source` enum labels or send a Plex-watchlist origin from this MCP client.
Collection requests may have partial effects; RAML specifies a single `RequestEngineResult`, not a per-movie result list. Read/display collection membership for review, but do not imply transactional consistency between that read and the later creation. Return accepted/unknown detail where upstream offers no per-item results. Do not simulate collections by silently issuing many movie POSTs.
### Existing request writes
Moderation approve/available/unavailable is POST; deny is PUT for all three media families. Movie body is `{id, is4K}` and deny adds reason; `is_4k` is optional and defaults false. TV/album body is `{id}` and deny adds reason. Require a nonempty denial reason by MCP policy. Movie creation's `is4kRequest` and moderation's `is4K` are deliberately different wire names.
TV moderation IDs are treated as child IDs by the proposed contract, but RAML's bare `id` does not prove controller semantics. Verify before enabling, including subscription and advanced-options target semantics. Do not send a parent ID merely because an integer validates.
Delete routes distinguish movie, album, TV parent and TV child. Parent deletion may affect all associated children and must be described that way. No delete-all branch exists. Subscription has separate movie/TV subscribe/unsubscribe endpoints; album subscriptions are absent.
Advanced options body is `{requestId, rootPathOverride?, qualityOverride?, languageProfile?}`. The schema accepts only these editable fields. Direct TV root/quality PUTs put both IDs in the URL and have no body. Reprocess uses numeric RequestType, request ID and `is4K` path Boolean; enable supported type/ID/variant combinations only after verification. In particular, do not claim that album reprocessing is absent simply because album lacks its own named route.
Full-entity movie/TV/child PUTs are intentionally excluded: their server-owned and recursive fields are not safe public patch contracts. This does not prevent typed routing changes through the documented advanced-options routes.
## Issues, votes and users
Issue `list` uses v1 paged issues and returns individual records. `summary` uses v2 and returns provider-grouped summaries; these are not interchangeable pagination units. Provider details v2 is a summary route; the v1 provider/request routes have unspecified result schemas and must be verified before publishing their projections. Category reads are owned by `read_reference`, avoiding a second overlapping tool branch.
Issue creation maps only title, subject, description, `issueCategoryId`, numeric `requestType`, and optional `requestId`/`providerId`. Require at least one association by design; RAML marks these properties optional and does not establish a server requirement. If both are provided, verify they refer to the same media. The server supplies author, timestamps and state; never accept `userReported`, comments, resolved date or persistence ID from the model. Comment POST uses `{comment, issueId}`. Status POST uses `{issueId, status}`. Category POST uses `{value}`. Delete IDs are in the path; no speculative update-category endpoint exists.
Vote reads use `Vote/music/{requestId}` for albums; writes use `Vote/{up|down}/album/{requestId}`. Do not derive both directions from one generic segment rule. Vote toggling/repeated-call behaviour is unverified, hence no idempotence promise. On Ombi 4.53.10, the global vote view and movie vote-record route can return HTTP 500 for existing instance data while the corresponding empty TV route succeeds. Ombi exposes no equivalent read route from which the adapter can reconstruct vote ownership or totals. Preserve `UPSTREAM_REJECTED` in that case; never convert the failure into a fabricated empty `vote_page`.
User GET-by-ID is `/Identity/User/{id}`. `self`, all users, dropdown, claims, online and notification preferences each have their exact separate routes. Respect hide-user settings and upstream visibility; do not disclose hidden requester IDs simply to populate a normalized field.
Language writes send `{lang}`; country writes send `{code}`. Newsletter opt-out is the mutating GET `/Identity/newsletter/unsubscribe/{userId}` and is always a write tool. An explicit user ID prevents accidental interpretation as a query; enforce self-or-authorized-admin access. Preference values can be delivery credentials, so generic notification-preference mutation remains outside the tool surface.
User creation/update/local-profile mutation are intentionally excluded because their RAML bodies mix credentials, claims and internal state. Administrative deletion and welcome email are supported separately. Welcome email loads the existing user's required view-model fields internally; it never asks the model to construct a UserViewModel or password.
## Library, server, settings and integrations
Recent TV grouped and ungrouped routes are separate branches. Calendar has no documented date-range query: local bounding is labeled, not sent as an invented parameter. Movie images use TMDB; TV accepts explicit TVDB or TMDB. TV banners are not documented. Album art uses releasegroupart. The generic poster and background routes take no documented title ID; never describe them as a selected title's poster.
Images may be binary, redirects or URLs depending on the route; RAML leaves several response bodies unspecified. Return a safe resource reference only after validating actual content type/shape and destination. Do not invent a signed URL or expose an `ApiKey` query. Random backgrounds are read-only but their outputs are not deterministic; idempotent read annotations describe side effects, not identical results.
Stats requires RFC3339 `from` and `to` query values. `update_check` is GET Job/update; `update_info` is GET Update. Running updates is an explicit administrative job. `cron_validate` POSTs `{expression}` to Settings/testcron and is an administrator-gated read calculation, not a scheduled-job mutation. Ombi uses Quartz cron syntax: six or seven fields, with `?` in the unused day-of-month or day-of-week field.
`read_integration` prefers saved-settings GETs. Radarr 4K only applies to profiles/root folders, not tags. Sonarr language profiles uses `/v3/LanguageProfiles`. Lidarr Metadata is POST-only, so load saved Lidarr settings privately and construct its request internally. CouchPotato profile is singular and POST-only. Credential acquisition `/CouchPotato/apikey` is never a tool. RPC POST counterparts accepting settings are compatibility adapters to the same read intent; they do not add caller connection overrides.
Plex library lookup uses a known saved `machine_id` (discoverable via `read_integration plex servers`, which emits the machine identifier in `id`); Emby/Jellyfin info and Library POSTs use an authorized saved `server_id` to resolve the server settings privately (discoverable via `read_settings emby` / `jellyfin` / `plex`, which expose `/servers/<digits>/{id,serverId,machineIdentifier}` leaves in `values`). No arbitrary server object or connection destination is accepted. Routes that acquire Plex/Emby/Jellyfin access or provision accounts remain internal/manual because their authentication/side effects are not adequately specified here.
Settings section names and wire types are fully listed in [the registry](07-settings-types.md). Read-only flags and customization content are not accidentally accepted as writable sections. Before saving, privately read the original section, verify the revision, merge a typed patch preserving secrets and omitted fields, and POST the full correct wire model. An incomplete original object means save is unsupported. Redacted strings must never become stored credentials. Feature writes are enable/disable POSTs with `{name, enabled}`.
Integration testers take an administrator-provisioned profile ID. The server selects the exact tester body type, resolves credentials internally and forbids destination overrides. `profile_id` must match the service and authorized instance; it is not an arbitrary filesystem path. Some tests really send messages; completion means the tester returned, not necessarily that a human received a notification.
Job run names exactly match the 14 POST Job routes in the ledger, including the case-sensitive `arrAvailability`. `revalidate_watchlist` separately POSTs Plex/WatchlistUsers/revalidate. Update, clear-media-server-data, auto-delete and newsletter jobs have significant effects; deployments must opt in to each. No synthetic job-status polling API or MCP task handle is claimed.
Mass email maps subject/body/bcc and resolves explicit unique user IDs to the upstream `users` entity list privately. Verify the minimal accepted user fields before enabling; never accept arbitrary OmbiUser JSON. Recipient count is capped at 100; an empty list cannot mean “everyone”. Retry removal only DELETEs its queue ID, and never calls a nonexistent POST RequestRetry.
Logs list returns opaque file IDs bound to a vetted upstream basename. Read accepts that ID, with bounded local line slicing. Reject unknown IDs and traversal; sanitize before caching or returning. If robust sanitization is unavailable, omit logs tools. Raw log downloads and UI logging submission remain excluded.
## Compatibility policy
The ledger's alternative routes are accounted for, not all promised working fallbacks. Use only an adapter verified for the instance/version. A global 404 probe cannot distinguish unsupported routes from hidden or missing resources. Never fall back on 401/403, arbitrary 404, validation failures or ambiguous mutation outcomes. Do not transform TVDB to TMDB by copying an integer; any conversion requires an authoritative lookup and an unambiguous match.
Legacy request filters are integers without documented mappings. No passthrough filter integers are exposed and no “partial availability” mapping is invented. Where v2 is unavailable, a verified unpaged v1 list may be projected and locally bounded with truthful metadata; unavailable filtered semantics produce `UNSUPPORTED_CAPABILITY`. API version selection must not silently change parent/child granularity, counts, language, filters or target identity.
## Appendix A — Parameter name translation
Locked decision D3 keeps snake_case MCP field names; the Go adapter translates each to its exact wire form. This table is the mandated mapping layer — every public field name appears here with its upstream spelling per context.
| MCP field | Context | Wire form |
|---|---|---|
| `query` | search/requests/issues keywords | `{searchTerm}` path or `searchTerm` body/query |
| `person_id` | `read_discover.credits` | `{actorId}` path |
| `artist_id` | `read_discover.artist_albums` | `{foreignArtistId}` path |
| `collection_id` | `read_discover.collection`, `write_request_create.collection` | `{collectionId}` path |
| `tmdb_id` | `read_media`, `write_request_create.movie` | `theMovieDbId` body / `{movieDbId}`/`{theMovieDbId}` path |
| `tvdb_id` | `read_library.tv_images` | `{tvdbid}` path |
| `musicbrainz_id` | `write_request_create.album`, `read_library.album_art` | `foreignAlbumId` body / `{musicBrainzId}` path |
| `request_id` | many tools | `{requestId}`/`{id}` path or `requestId`/`id` body (per ledger) |
| `parent_request_id` | `read_requests.children`, `write_request_options.tv_*` | `{parentRequestId}`/`{requestId}` path (verify per route) |
| `issue_id` | `read_issues.comments`, `write_issue_manage`, `write_issue_comment` | `{id}` path / `issueId` body |
| `comment_id` | `write_issue_manage.delete_comment` | `{id}` path |
| `category_id` | `write_issue_manage.delete_category`, `write_issue_create` | `{catId}` path / `issueCategoryId` body |
| `keyword_id` | `read_reference.keyword` | `{keywordId}` path |
| `user_id` | `read_users.get`, `write_user_preferences`, `write_user_manage`, `write_notification_send` | `{id}`/`{userId}` path / `users` body list |
| `queue_id` | `write_retry_remove` | retry-queue `{requestId}` path (queue namespace only) |
| `file_id` | `read_logs.read` | `{logFileName}` path |
| `machine_id` | `read_integration.plex_libraries` | saved Plex server resolution (no passthrough) |
| `server_id` | `read_integration.media_server` | saved Emby/Jellyfin server resolution (no passthrough) |
| `profile_id` | `write_integration_test` | saved-settings resolution (no passthrough) |
| `root_folder_id` | create/options | `rootFolderOverride` body / `{rootFolderId}` path |
| `quality_profile_id` | create/options | `qualityPathOverride` body / `{qualityId}` path |
| `language_profile_id` | create/options | `languageProfile` body |
| `is_4k` | create/moderate/reprocess | `is4kRequest` body / `is4K` body / `{is4K}` path |
| `request_type` | `write_issue_create`, `write_request_reprocess` | `requestType` body int / `{type}` path int (T1 map) |
| `status` | `read_issues`, `write_issue_manage` | `{status}` path int / `status` body int (T2 map) |
| `sort_direction` | `read_requests.list` | `{sortOrder}` path `asc`/`desc` (sort=`RequestedDate`) |
| `season_numbers` | `write_request_create` tv season mode | adapter expansion → `seasons[].episodes` |
| `on_behalf_user_id` | `write_request_create` | `requestOnBehalf` body (**Verify**: id vs username) |
| `requested_by_alias` | `write_request_create.album` | `requestedByAlias` body |
| `from`,`to` | `read_server.stats` | `from`,`to` query |
| `expression` | `read_server.cron_validate` | `{expression}` body |
| `section`,`revision`,`changes` | `read_settings`,`write_settings_patch` | private load/merge → section POST body |
| `name`,`enabled` | `write_settings_patch` feature | `FeatureEnablement` body → enable/disable POST |
| `subject`,`body`,`bcc`,`user_ids` | `write_notification_send` | `NotificationMessage` fields; ids → `users` entity list |
| `job` | `write_job_run.run` | literal `/api/v1/Job/{job}` path segment |
| `media` | everywhere | route segment or body discriminator per ledger |