Files
gronod 2d5763d6c6 feat: slim tool responses, drop lyrics search, rename module (1.0.11)
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.
2026-09-21 23:54:07 +01:00

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)
})
}