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
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.
277 lines
9.8 KiB
Markdown
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"`
|
|
}
|
|
```
|