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

18 KiB
Raw Permalink Blame History

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 TBDs 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):

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):

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):

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):

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.