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