Files
ombi-mcp/docs/megaplans/m1/06-specification-harmonisation.md
gronod 722108ebd0 Initial commit: Ombi MCP server design docs and Go skeleton
Schema docs (docs/schema) cover tool mapping, input/output contracts and
endpoint coverage for all 31 MCP tools; megaplan M1 phases 01-03 applied
(namespace rename to read_*/write_*, input schema rectification).
Go module provides config loading, Ombi client, and tool registry
skeleton for implementation.
2026-09-18 15:55:44 +01:00

385 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 06 — Specification Harmonisation & Go Interface Baseline
## Objective
Synchronise all remaining documents to the new names, auth model, and schemas; publish the MCP→wire name-mapping appendix; land the Go baseline.
## Prerequisites
- Phases 01–05 complete: names realigned (01), auth rewritten (02), input schemas rectified (03), enum maps published (04), output schemas modularised (05).
- Locked decision D3: snake_case MCP names kept; the Go adapter translates to wire names — this phase mandates the explicit documented mapping layer (Appendix A in `02-tool-mapping.md`).
## Specification
### 6.1 `02-tool-mapping.md` edits
- Rename all `ombi_*` references (Phase 01) including inline mentions like "`ombi_search.text`", "`ombi_media.details`", "`ombi_request_create`".
- Update prose: shared routing rule #4 — replace "Numeric-code inputs are deliberately retained… Publish a verified label map later" with the Phase 04 label maps (now published; remaining numeric enums listed).
- Update request-create routing table: `is_4k` now optional-default-false; add `season` selection row (`season_numbers[]` → adapter episode expansion → `seasons[].episodes`).
- Update moderation paragraph: movie `is_4k` optional.
- Update `read_requests` paragraph: `status` defaults `all`; `sort_direction` maps to `{sortOrder}`.
- **Append new "Appendix A — Parameter name translation" table** (the mandated mapping layer):
| 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=`requestDate`) |
| `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 |
### 6.2 `05-endpoint-coverage.md` edits
- `Owner / branch` column: rename every `ombi_*` token (e.g. `ombi_media / by_request` → `read_media / by_request`; `ombi_settings_write / patch:*` → `write_settings_patch / patch:*`).
- Token rows 361–366: update Notes from "optional auth flow must be verified" → "JWT-primary auth adapter (`OMBI_AUTH_MODE=jwt`); login/refresh semantics per `01-authentication.md`; Bearer-on-all-routes remains Verify."
- Row for `Search/artist/request/{requestId}`: owner becomes `read_media / by_request`; note gains "MCP `media=album` maps to this literal `artist` route segment."
- Intro paragraph gains a line: ledger tool names reflect the `read_*`/`write_*` namespace.
- All 377 rows, dispositions, links, and counts unchanged.
### 6.3 `06-verification.md` edits
- "Enum labels" gap row: mark RequestType/IssueStatus/NotificationAgent/NotificationType label maps **published** (Phase 04); retain `VoteType`/`RequestSource`/`RequestLimitType`/legacy filters as gaps.
- "Whole-season semantics" row: update — `season` mode now exists; adapter expansion is the default construction; `episodes:[]` remains Verify.
- Acceptance criteria additions: "Omitted `is_4k`/`status`/`sort_direction` resolve to documented defaults without triggering conditional requirements"; "`season_numbers` requests produce explicit `seasons[].episodes` wire bodies"; "renamed catalogue matches 31 names; no `ombi_`-prefixed tool advertised"; "JWT startup path exercised; `api_key` mode sends `UserName` only when configured".
- Update all examples to new tool names (`read_search`, `read_media`, `write_request_create`, `write_request_moderate`, `write_issue_comment`, `write_settings_patch`, `write_integration_test`); add a `season` mode example and a `read_requests` minimal example.
- "Authentication model" row → JWT-primary per `01-authentication.md`.
### 6.4 `07-settings-types.md` edits
- Tool names → `write_settings_patch` / `read_settings`.
- Add note: `notificationTemplates` patch elements use `notification_type`/`agent` string labels; the merge layer translates label→int and key→`notificationType` before merging; reads emit labels with `*_code` fallback per Phase 04.
### 6.5 `README.md` edits
- Reading order gains `8. [Go interface baseline](08-go-baseline.md)`.
- Final paragraph: replace "`OMBI_API_KEY` remains the standard upstream credential" with "Upstream authentication is selected explicitly via `OMBI_AUTH_MODE` (`jwt` primary, `api_key` secondary); see `01-authentication.md`."
- Add naming sentence (Phase 01 rule 6).
### 6.6 `AGENTS.md` edits
- Conventions: replace `ombi_*` naming line with `read_*`/`write_*` rule.
- Environment variables: add `OMBI_AUTH_MODE`, `OMBI_USERNAME`, `OMBI_PASSWORD`, `OMBI_USER_NAME` to the list.
- Commands: fill `TBD`s with Go toolchain — install `go mod download`, build `go build ./...`, test `go test ./...`, lint/typecheck `go vet ./...`.
### 6.7 New file `docs/schema/08-go-baseline.md` (content specified in full)
Sections: package layout; config; auth (Phase 02 skeleton); upstream client interface; arg/result structs for the changed tools; envelope structs.
Package layout:
```
cmd/ombi-mcp/main.go — entrypoint, config load, server start
internal/config/config.go — env loading + validation (env-var matrix)
internal/ombi/client.go — HTTP client: base-URL join, encoding, retries
internal/ombi/auth.go — AuthManager (Phase 02 skeleton)
internal/ombi/wiretypes.go — upstream body/response types per ledger
internal/mcpserver/server.go — MCP transport, tools/list, tools/call
internal/tools/registry.go — tool defs, bundle filters, annotations
internal/tools/args.go — input arg structs (below)
internal/tools/results.go — result envelope + family structs (below)
internal/translate/enums.go — Phase 04 package (printed in full there)
```
Config (full):
```go
package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
)
type AuthMode string
const (
AuthModeJWT AuthMode = "jwt"
AuthModeAPIKey AuthMode = "api_key"
)
type Config struct {
BaseURL *url.URL
AuthMode AuthMode
Username string
Password string
APIKey string
UserName string
EnabledBundles map[string]bool // keys: core, moderation, administration
HTTPTimeout time.Duration
}
func Load() (*Config, error) {
c := &Config{HTTPTimeout: 30 * time.Second, EnabledBundles: map[string]bool{"core": true}}
raw := os.Getenv("OMBI_URL")
if raw == "" {
return nil, errors.New("OMBI_URL is required")
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("OMBI_URL invalid: %q", raw)
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("OMBI_URL must not contain credentials, query or fragment")
}
c.BaseURL = u
switch AuthMode(os.Getenv("OMBI_AUTH_MODE")) {
case AuthModeJWT:
c.AuthMode = AuthModeJWT
case AuthModeAPIKey:
c.AuthMode = AuthModeAPIKey
default:
return nil, errors.New("OMBI_AUTH_MODE must be \"jwt\" or \"api_key\"")
}
c.Username, c.Password = os.Getenv("OMBI_USERNAME"), os.Getenv("OMBI_PASSWORD")
c.APIKey, c.UserName = os.Getenv("OMBI_API_KEY"), os.Getenv("OMBI_USER_NAME")
if c.AuthMode == AuthModeJWT && (c.Username == "" || c.Password == "") {
return nil, errors.New("jwt mode requires OMBI_USERNAME and OMBI_PASSWORD")
}
if c.AuthMode == AuthModeAPIKey && c.APIKey == "" {
return nil, errors.New("api_key mode requires OMBI_API_KEY")
}
if b := os.Getenv("OMBI_BUNDLES"); b != "" {
c.EnabledBundles = map[string]bool{}
for _, s := range strings.Split(b, ",") {
s = strings.TrimSpace(s)
if s != "core" && s != "moderation" && s != "administration" {
return nil, fmt.Errorf("unknown bundle %q", s)
}
c.EnabledBundles[s] = true
}
}
return c, nil
}
```
Upstream client interface (full):
```go
package ombi
import (
"context"
"net/http"
)
// Client performs one authenticated upstream call. It applies auth,
// encodes segments, sends, and returns the raw response for the
// adapter to interpret. It never decides business outcomes.
type Client struct {
base string
auth *AuthManager
http *http.Client
}
// 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
// ErrUnauthorized after one failed post-renewal retry so the adapter
// can decide its own retry policy.
func (c *Client) Do(ctx context.Context, method, path string,
query map[string]string, body any) (*http.Response, error)
```
Changed-tool arg structs (full):
```go
package tools
// write_request_create
type RequestCreateArgs struct {
Action string `json:"action"` // movie|tv|album|collection
TmdbID *int `json:"tmdb_id,omitempty"`
Is4K bool `json:"is_4k"` // defaults false
Language string `json:"language,omitempty"`
Provider string `json:"provider,omitempty"` // tmdb|tvdb for tv
ID *int `json:"id,omitempty"`
Selection *TVSelect `json:"selection,omitempty"`
LanguageProfileID *int `json:"language_profile_id,omitempty"`
OnBehalfUserID string `json:"on_behalf_user_id,omitempty"`
Overrides *Overrides `json:"overrides,omitempty"`
MusicBrainzID string `json:"musicbrainz_id,omitempty"`
RequestedByAlias string `json:"requested_by_alias,omitempty"`
CollectionID *int `json:"collection_id,omitempty"`
}
type TVSelect struct {
Mode string `json:"mode"` // all|first_season|latest_season|season|episodes
SeasonNumbers []int `json:"season_numbers,omitempty"`
Seasons []Season `json:"seasons,omitempty"`
}
type Season struct {
SeasonNumber int `json:"season_number"`
Episodes []int `json:"episodes"`
}
type Overrides struct {
RootFolderID *int `json:"root_folder_id,omitempty"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
}
// write_request_moderate
type ModerateArgs struct {
Action string `json:"action"` // approve|deny|mark_available|mark_unavailable
Media string `json:"media"` // movie|tv|album
RequestID int `json:"request_id"`
Is4K bool `json:"is_4k"`
Reason string `json:"reason,omitempty"`
}
// write_request_options
type OptionsArgs struct {
Action string `json:"action"` // advanced|tv_root|tv_quality
Media string `json:"media,omitempty"`
RequestID *int `json:"request_id,omitempty"`
Options *AdvOptions `json:"options,omitempty"`
ParentRequestID *int `json:"parent_request_id,omitempty"`
RootFolderID *int `json:"root_folder_id,omitempty"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
}
type AdvOptions struct {
RootFolderID *int `json:"root_folder_id,omitempty"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
LanguageProfileID *int `json:"language_profile_id,omitempty"`
}
// write_request_reprocess
type ReprocessArgs struct {
RequestType string `json:"request_type"` // movie|tv|album
RequestID int `json:"request_id"`
Is4K bool `json:"is_4k"`
}
// write_issue_create
type IssueCreateArgs struct {
Title string `json:"title"`
Subject string `json:"subject,omitempty"`
Description string `json:"description"`
CategoryID int `json:"category_id"`
RequestType string `json:"request_type"` // movie|tv|album
RequestID *int `json:"request_id,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
}
// write_issue_manage / read_issues shared status label
type IssueStatus = string // "pending"|"in_progress"|"resolved"|"closed"
// read_requests
type RequestsListArgs struct {
Action string `json:"action"`
Media string `json:"media,omitempty"`
Status string `json:"status"` // adapter default "all"
SortDirection string `json:"sort_direction"` // adapter default "desc"
ParentRequestID *int `json:"parent_request_id,omitempty"`
Query string `json:"query,omitempty"`
Page *Page `json:"page,omitempty"`
Target *Target `json:"target,omitempty"`
}
type Page struct{ Offset, Limit int }
type Target struct{ Kind string; ID int }
// read_media
type MediaArgs struct {
Action string `json:"action"`
Target *Target `json:"target,omitempty"`
Media string `json:"media,omitempty"`
RequestID *int `json:"request_id,omitempty"`
TmdbID *int `json:"tmdb_id,omitempty"`
Language string `json:"language,omitempty"`
Name string `json:"name,omitempty"`
Year *int `json:"year,omitempty"`
}
```
Result envelope (full):
```go
package tools
// ToolResult is the structuredContent payload for every tool.
type ToolResult struct {
OK bool `json:"ok"`
Operation string `json:"operation"` // tool name
Data any `json:"data,omitempty"` // one family object
Error *ToolError `json:"error,omitempty"`
Warnings []string `json:"warnings"`
Truncated bool `json:"truncated"`
CorrelationID string `json:"correlation_id"`
}
type ToolError struct {
Code string `json:"code"` // INVALID_ARGUMENT … INTERNAL_ERROR
Message string `json:"message"`
Retryable bool `json:"retryable"`
HTTPStatus *int `json:"http_status,omitempty"`
Field string `json:"field,omitempty"`
RetryAfterSeconds *int `json:"retry_after_seconds,omitempty"`
}
```
## Target artefacts
- `docs/schema/02-tool-mapping.md` — §6.1 edits plus Appendix A table appended.
- `docs/schema/05-endpoint-coverage.md` — §6.2 edits; counts unchanged (377/321; D257/A37/P59/I12/X12).
- `docs/schema/06-verification.md` — §6.3 edits.
- `docs/schema/07-settings-types.md` — §6.4 edits.
- `docs/schema/README.md` — §6.5 edits.
- `docs/schema/08-go-baseline.md` — **new file** with all §6.7 content.
- `AGENTS.md` — §6.6 edits.
## Verification gates
- [ ] `grep -rn "ombi_" docs/` returns no tool-name occurrences (namespace enum values excepted).
- [ ] Appendix A table present in `02-tool-mapping.md` with every row above.
- [ ] `05-endpoint-coverage.md` owner column contains only `read_*`/`write_*` names; counts intact (377/321; D257/A37/P59/I12/X12).
- [ ] `06-verification.md` examples use new names only.
- [ ] `docs/schema/08-go-baseline.md` exists with all sections above.
- [ ] `AGENTS.md` env-var list and commands updated; no `ombi_*` convention remains.
- [ ] README references `OMBI_AUTH_MODE` and links `08-go-baseline.md`.
## Evidence labels
- **Documented** — Appendix A wire forms per the endpoint ledger; Go toolchain commands for `AGENTS.md`.
- **Design** — the explicit MCP→wire mapping layer (D3), package layout, config validation order, arg/envelope struct shapes.
- **Verify** — `on_behalf_user_id`→`requestOnBehalf` (id vs username); `parent_request_id` per-route segment verification; Bearer-on-all-routes note carried into ledger rows 361–366.