Item-shaped results now omit empty fields and drop low-value metadata (overview, lyrics, file_path, creation_date, premiere_date, bitrate); absent numeric fields drop the key instead of emitting "". Player sessions report hh:mm:ss times only; playlists drop overview/date_created. The lyrics_or_description search parameter is removed, along with the unused DTO fields, and upstream Fields requests are slimmed to match. Module path renamed to git.i3omb.com/gronod/emby-mcp.
241 lines
7.3 KiB
Go
241 lines
7.3 KiB
Go
// Package mcphttp serves the MCP server over the streamable HTTP transport,
|
|
// authenticating every request against the Emby server.
|
|
package mcphttp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.i3omb.com/gronod/emby-mcp/internal/config"
|
|
"git.i3omb.com/gronod/emby-mcp/internal/emby"
|
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
)
|
|
|
|
// Header names accepted for disambiguating a Bearer token that resolves to
|
|
// more than one Emby user (e.g. a server-wide API key).
|
|
const (
|
|
HeaderUserID = "X-Emby-User-Id"
|
|
HeaderUsername = "X-Emby-Username"
|
|
)
|
|
|
|
// credTTL bounds how long a resolved credential (token → user/client, or
|
|
// user:pass → acquired access token) is trusted without re-checking Emby.
|
|
const credTTL = 15 * time.Minute
|
|
|
|
// authDeviceID is the fixed Emby device ID used by authentication probes, so a
|
|
// passed-through token can be resolved to a user via GET /Sessions.
|
|
const authDeviceID = "emby-mcp-auth"
|
|
|
|
type ctxKey struct{}
|
|
|
|
// authContext carries the authenticated Emby identity for one HTTP request.
|
|
type authContext struct {
|
|
client *emby.Client
|
|
userID string
|
|
}
|
|
|
|
// authenticatedClient returns the Emby client and user resolved by the auth
|
|
// middleware, or nil/zero when the request was not authenticated.
|
|
func authenticatedClient(ctx context.Context) (*emby.Client, string) {
|
|
ac, _ := ctx.Value(ctxKey{}).(*authContext)
|
|
if ac == nil {
|
|
return nil, ""
|
|
}
|
|
return ac.client, ac.userID
|
|
}
|
|
|
|
type credEntry struct {
|
|
userID string
|
|
token string // Emby access token / API key
|
|
client *emby.Client
|
|
expiry time.Time
|
|
logout bool // revoke the token with Emby on expiry (password auth only)
|
|
expired bool
|
|
}
|
|
|
|
// authenticator validates request credentials against the Emby server and
|
|
// caches the resolved identity.
|
|
type authenticator struct {
|
|
serverURL string
|
|
verifySSL bool
|
|
clientName string
|
|
clientVer string
|
|
hostname string
|
|
goos string
|
|
|
|
mu sync.Mutex
|
|
cache map[[32]byte]*credEntry
|
|
}
|
|
|
|
func newAuthenticator(cfg *config.Config, clientName, clientVer, hostname, goos string) *authenticator {
|
|
return &authenticator{
|
|
serverURL: cfg.ServerURL,
|
|
verifySSL: cfg.VerifySSL,
|
|
clientName: clientName,
|
|
clientVer: clientVer,
|
|
hostname: hostname,
|
|
goos: goos,
|
|
cache: map[[32]byte]*credEntry{},
|
|
}
|
|
}
|
|
|
|
func keyOf(parts ...string) [32]byte {
|
|
return sha256.Sum256([]byte(strings.Join(parts, "\x00")))
|
|
}
|
|
|
|
// bearerReqKey keys cache entries by token AND any user-disambiguation
|
|
// headers, since the same token can resolve to different users.
|
|
func bearerReqKey(req *http.Request, token string) [32]byte {
|
|
return keyOf("bearer", token,
|
|
strings.TrimSpace(req.Header.Get(HeaderUserID)),
|
|
strings.TrimSpace(req.Header.Get(HeaderUsername)))
|
|
}
|
|
|
|
// probeClient builds an unauthenticated Emby client for credential checks.
|
|
func (a *authenticator) probeClient() *emby.Client {
|
|
return emby.NewClient(a.serverURL, emby.Options{
|
|
ClientName: a.clientName,
|
|
ClientVersion: a.clientVer,
|
|
DeviceName: fmt.Sprintf("%s (%s)", a.hostname, a.goos),
|
|
DeviceID: authDeviceID,
|
|
VerifySSL: a.verifySSL,
|
|
})
|
|
}
|
|
|
|
func (a *authenticator) get(k [32]byte) *credEntry {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
e := a.cache[k]
|
|
if e == nil || time.Now().Before(e.expiry) {
|
|
return e
|
|
}
|
|
a.evict(k, e)
|
|
return nil
|
|
}
|
|
|
|
func (a *authenticator) put(k [32]byte, e *credEntry) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if old := a.cache[k]; old != nil {
|
|
a.evict(k, old)
|
|
}
|
|
a.cache[k] = e
|
|
}
|
|
|
|
// evict drops an entry and best-effort revokes its Emby token when it was
|
|
// acquired via password authentication. Must be called with a.mu held.
|
|
func (a *authenticator) evict(k [32]byte, e *credEntry) {
|
|
delete(a.cache, k)
|
|
// Do not Logout here. TTL expiry used to revoke the Emby access token
|
|
// while the MCP session still held that same client, which surfaced as
|
|
// "auth token has expired" on the next tool call.
|
|
e.expired = true
|
|
}
|
|
|
|
// translateBasic converts "Authorization: Basic user:pass" into an Emby access
|
|
// token via AuthenticateByName and rewrites the request as a Bearer request, so
|
|
// the SDK's bearer middleware handles both schemes uniformly. Non-Basic
|
|
// requests pass through unchanged.
|
|
func (a *authenticator) translateBasic(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
k := keyOf("basic", user, pass)
|
|
e := a.get(k)
|
|
if e == nil {
|
|
c := a.probeClient()
|
|
res, err := c.Authenticate(r.Context(), user, pass)
|
|
if err != nil {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="emby-mcp"`)
|
|
http.Error(w, "invalid Emby credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
e = &credEntry{
|
|
userID: res.User.ID,
|
|
token: res.AccessToken,
|
|
client: c,
|
|
expiry: time.Now().Add(credTTL),
|
|
logout: true,
|
|
}
|
|
a.put(k, e)
|
|
// Index by the acquired token too, so the bearer verifier and the
|
|
// stash middleware find the same entry.
|
|
a.put(bearerReqKey(r, res.AccessToken), e)
|
|
}
|
|
r.Header.Set("Authorization", "Bearer "+e.token)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// verify is the auth.TokenVerifier for Bearer requests. The token is treated
|
|
// as an Emby credential (user access token or API key) and resolved to a user.
|
|
func (a *authenticator) verify(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) {
|
|
k := bearerReqKey(req, token)
|
|
if e := a.get(k); e != nil {
|
|
return &auth.TokenInfo{UserID: e.userID}, nil
|
|
}
|
|
c := a.probeClient()
|
|
c.SetToken(token, "")
|
|
userID, err := a.resolveUser(ctx, c, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err)
|
|
}
|
|
c.SetToken(token, userID) // rebuild auth header with the resolved user
|
|
a.put(k, &credEntry{
|
|
userID: userID,
|
|
token: token,
|
|
client: c,
|
|
expiry: time.Now().Add(credTTL),
|
|
})
|
|
return &auth.TokenInfo{UserID: userID}, nil
|
|
}
|
|
|
|
// resolveUser determines which Emby user a token acts as. Explicit
|
|
// X-Emby-User-Id / X-Emby-Username headers win; otherwise the token's own
|
|
// session is looked up by device ID.
|
|
func (a *authenticator) resolveUser(ctx context.Context, c *emby.Client, req *http.Request) (string, error) {
|
|
if id := strings.TrimSpace(req.Header.Get(HeaderUserID)); id != "" {
|
|
if _, err := c.GetUser(ctx, id); err != nil {
|
|
return "", fmt.Errorf("invalid %s: %v", HeaderUserID, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
if name := strings.TrimSpace(req.Header.Get(HeaderUsername)); name != "" {
|
|
users, err := c.GetUsers(ctx, "", name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(users) == 0 {
|
|
return "", fmt.Errorf("no Emby user matching %s %q", HeaderUsername, name)
|
|
}
|
|
return users[0].UserID, nil
|
|
}
|
|
id, err := c.SessionUserID(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("token does not identify a user; supply %s or %s: %v", HeaderUserID, HeaderUsername, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// stash attaches the resolved Emby client/user to the request context so the
|
|
// MCP handler's getServer callback can build a session-scoped server.
|
|
func (a *authenticator) stash(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fields := strings.Fields(r.Header.Get("Authorization"))
|
|
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
|
|
if e := a.get(bearerReqKey(r, fields[1])); e != nil {
|
|
r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, &authContext{client: e.client, userID: e.userID}))
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|