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.
385 lines
18 KiB
Markdown
385 lines
18 KiB
Markdown
# 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.
|