Files
ombi-mcp/docs/schema/08-go-baseline.md
gronod 49bc0775de
Build and publish / Test and build (windows) (push) Failing after 11s
Build and publish / Test and build (darwin) (push) Successful in 1m32s
Build and publish / Test and build (linux) (push) Canceled after 0s
Build and publish / Build and publish Docker image (push) Canceled after 0s
Add HTTP/SSE transport alongside stdio (Phase 09)
Network MCP clients (e.g. browser-based frontends) can't spawn stdio
subprocesses, so the server now optionally serves the 2024-11-05 MCP
HTTP/SSE transport via MCP_TRANSPORT=sse and MCP_PORT (default 8080),
backed by the SDK's SSEHandler behind permissive CORS. stdio remains
the default and is behaviourally unchanged.
2026-09-18 21:13:09 +01:00

9.8 KiB

Go interface baseline

This is the compile-level contract for the implementation. It fixes package layout, configuration validation order, the upstream client surface, the MCP↔wire argument structs for the changed tools, and the shared result envelope. Handler logic is specified by the routing rules in 02-tool-mapping.md and is not part of this baseline.

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

internal/config/config.go — env loading and validation per the 01-authentication.md variable matrix. Validation order: OMBI_URL → OMBI_AUTH_MODE → mode-required credentials → OMBI_BUNDLES → MCP_TRANSPORT/MCP_PORT. MCP_TRANSPORT selects the client-facing transport (stdio default, sse for HTTP/SSE); MCP_PORT (default 8080) sets the SSE listen port. The SSE transport lives in internal/transport/ and is served by the Go SDK's mcp.NewSSEHandler behind permissive CORS.

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
}

Auth

internal/ombi/auth.go — the AuthManager skeleton specified in full by 01-authentication.md § "Go skeleton". Per-request Apply sets ApiKey (+ optional UserName) in api_key mode or blocks on a single-flight-renewed Bearer token in jwt mode; Invalidate discards the cached token on upstream 401.

Upstream client interface

internal/ombi/client.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)

Argument structs for the changed tools

internal/tools/args.go — MCP-facing snake_case fields; the adapter translates to wire names per 02-tool-mapping.md Appendix A.

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

internal/tools/results.go — the structuredContent payload every tool returns.

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"`
}