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

277 lines
9.8 KiB
Markdown

# 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.
```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
}
```
## 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`:
```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.
```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
`internal/tools/results.go` — the `structuredContent` payload every tool returns.
```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"`
}
```