SeerrSense
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@SeerrSenseFind the Nolan movie about dream heist and request it"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
SeerrSense
Give Seerr some sense.
AI companion for Seerr — natural-language media discovery, resolution, and MCP automation.
SeerrSense is an intelligence and agent layer for Seerr.
Describe a movie or TV show in natural language, resolve it to verified canonical media, check its status, and request it through your existing Seerr stack.
"фильм Нолана про сон во сне"
↓
SeerrSense
↓
Inception (2010)
TMDB: verified
↓
Seerr
↓
Radarr / SonarrWhy SeerrSense?
Seerr already orchestrates your media stack.
SeerrSense adds the intelligence layer:
Natural-language movie and TV resolution
Verified canonical media IDs
MCP support for Claude, ChatGPT, and other agents
REST API
Works with your existing Seerr / Radarr / Sonarr stack
LLMs interpret intent, but never become the source of truth for provider IDs
Related MCP server: Overseerr MCP
Core principle
Natural-language query
↓
Native Seerr search
↓
Semantic fallback when needed
↓
LLM extracts MediaIntent
↓
Seerr / metadata provider verification
↓
Canonical media candidate
↓
User confirmation
↓
Seerr requestLLMs may infer titles, people, directors, genres, years, or plot hints.
They do not generate trusted TMDB or IMDb IDs.
Interfaces
SeerrSense exposes the same core capabilities through:
MCP
REST API
Current MCP tools, with the annotations the connector directories read:
Tool | Does |
|
|
| title search through the person's Seerr | true | false |
| a description to one verified title | true | false |
| the canonical record for one TMDB id | true | false |
| files a request in the person's Seerr | false | false |
request_media is the write (readOnlyHint: false, which is what makes a
client confirm before calling it) but not destructive: it adds a request and
removes or overwrites nothing.
Every tool declares an outputSchema and answers with structuredContent.
request_media returns the request's id and status only; the raw Seerr
request object, which carries the requesting account, is never relayed.
Status
SeerrSense is stable and deployed: OAuth 2.1 with Google sign-in, per-person Seerr instances, PostgreSQL-backed state, and the four tools above. Work in progress is listed in the issue tracker.
Quick Start
Minimum settings for any of the three ways to run it: SEERR_URL (defaults to
http://127.0.0.1:5055), SEERR_API_KEY for the household Overseerr/Jellyseerr, and
SEERRSENSE_AUTH_TOKEN, which assertHttpConfig requires only when the HTTP server
is started — stdio mode has no network surface and does not need it.
Container. The published image is built from the Dockerfile (node:22-slim),
listens on port 8787 and answers /healthz for the container healthcheck:
docker run -p 8787:8787 \
-e SEERR_URL=https://media.example.com \
-e SEERR_API_KEY=... \
-e SEERRSENSE_AUTH_TOKEN=... \
ghcr.io/mctlhq/seerrsense:<tag>(<tag> is a released version; see Deployment for how images are
built and tagged.)
Standalone binary. Every tagged release attaches four bun-compiled executables
(.github/workflows/release-binaries.yml): seerrsense-linux-x64,
seerrsense-windows-x64.exe, seerrsense-darwin-x64 and seerrsense-darwin-arm64.
They are built primarily for seerrsense stdio below; whether a given release also
serves the HTTP landing page depends on public/ being present next to the binary,
which is not verified here — for HTTP mode, the container image is the documented
path.
stdio. For clients that own the process directly (Claude Desktop and similar), run the server on stdio instead of opening a port:
seerrsense stdioConfiguration
Variable | Required | Purpose |
| no | API key for the household Overseerr/Jellyseerr. Optional: a signed-in person can attach their own instead |
| no | Seerr base URL, default |
| no |
|
| no | Ceiling on one request to a Seerr, default 20000, max 60000. A GET that gets no answer within it is retried once, so one read can take up to twice this value; writes are never retried |
| HTTP only | Shared bearer token. Not needed in stdio mode |
| no | default |
| no | enable the semantic resolver |
| no | reach a Seerr behind Cloudflare Zero Trust |
Cloudflare Access and the semantic layer
CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET add the Cloudflare Access
service-token headers to every call the household Seerr client makes. A
signed-in person does not set these: the same two values are two fields on
/account, sealed per user and applied only to their own attached instance.
NEBIUS_API_KEY (with the optional NEBIUS_MODEL) turns on the semantic
resolver: when Seerr's native search and a normalised retry both come up
empty, unresolved phrasings go through a language model that proposes
candidate titles for verification against Seerr. Without it, search still
works, just without the fallback.
OAuth (optional)
Set all four and the server becomes its own OAuth 2.1 authorization server, with Google as the identity provider. Set none and it keeps using the shared token. Setting some but not all is refused at startup.
Variable | Purpose |
| Public base URL. Becomes the token issuer, so changing it invalidates every issued token |
| Google OAuth client. Its redirect URI must be |
| Google OAuth client secret |
| HS256 key for access tokens, at least 32 bytes |
| Comma-separated Google addresses allowed to sign in. Empty means nobody |
| Set to exactly |
| Comma-separated addresses allowed to fall back to the shared |
| Optional pre-registered clients, |
| Whether |
| Access token lifetime in seconds, default 3600 |
| Refresh token lifetime in seconds, default 30 days |
| PostgreSQL for OAuth state, attached Seerr instances and the resolve-budget counters. Without it all three live in memory and a restart detaches everyone and resets the budget |
| 32 bytes, hex or base64, sealing the Seerr API keys people attach. Without it nobody can attach one |
| Per-IP limit on |
| Per-IP limit on |
| Per-subject (falling back to per-IP) limit on |
| The domain-verification token OpenAI issues for a ChatGPT app submission, served verbatim at |
| Per-IP ceiling applied before authentication, so failed bearer attempts are metered too — the route limiters run after the bearer gate and never see a 401. Default 300 / 1 minute |
| How many proxy hops in front of the pod to trust when deriving the client address. Default 0 — no forwarding header is believed, which is correct for a directly exposed container. Set it to the number of hops your ingress actually adds (1 for a single reverse proxy). Setting it higher than the real number lets a caller forge |
| Model-backed |
| Model-backed |
| Pino log level for the HTTP server ( |
MCP
The MCP endpoint is /mcp, a Streamable HTTP transport speaking the 2026-07-28
protocol revision. It exposes four tools — search_media, resolve_media,
get_media and request_media, the last being the only one that writes — under
three scopes: seerr:read, seerr:request and offline_access. See
Security Model for how a token earns those scopes and what
each one gates.
A client discovers the authorization server the standard way: an
unauthenticated request to /mcp is refused with a WWW-Authenticate header
pointing at the RFC 9728 protected-resource document. Four .well-known
documents are served: /.well-known/oauth-authorization-server,
/.well-known/oauth-authorization-server/mcp,
/.well-known/oauth-protected-resource and
/.well-known/oauth-protected-resource/mcp.
Clients register through Client ID Metadata Documents: client_id is an
https URL with a path, naming a JSON document that lists the client's
client_name and allowed redirect_uris. Dynamic Client Registration is
deprecated in MCP 2026-07-28 and is not implemented. A client that cannot
publish a CIMD can instead be given a pre-registered entry via
SEERRSENSE_OAUTH_CLIENTS, formatted as
client_id=redirect_uri[,redirect_uri...];... (semicolon-separated entries,
comma-separated redirect URIs within one entry).
Whose Seerr
A person signs in at /account with the same Google account, enters the
address and API key of their Overseerr or Jellyseerr, and the page checks the
credentials against that instance before storing them. The key is never typed
into a chat and never returned by the API, not even masked.
The page authenticates with a short-lived session cookie, deliberately not with
an MCP access token: an assistant holding a token must not be able to read or
rewrite which Seerr it talks to. The two credentials carry different audiences,
so neither works in place of the other. The page reports what the signed-in
caller actually reaches — GET /api/v1/account/connection carries a
fallback: "household" | "none" field, computed by the same predicate
TenantResolver itself uses to admit a subject to the household instance, so
the page can never promise a shared instance a person is not allowed to use.
Each signed-in person can attach their own Overseerr or Jellyseerr; their API key is sealed with AES-256-GCM before it is stored and never leaves this server. Resolution order for a request:
the instance that person attached, if any;
otherwise the household instance from
SEERR_URLandSEERR_API_KEY, only for addresses listed inSEERRSENSE_HOUSEHOLD_EMAILS;otherwise the tools say so and point at the account page.
The legacy shared token and stdio mode have no person behind them, so they
always get the household instance, unaffected by SEERRSENSE_HOUSEHOLD_EMAILS.
On the household instance the API key belongs to its owner, which would file
every request under that one name. Overseerr accepts a userId on a request
made with an admin key, so the signed-in address is matched against the Seerr
user list and the request is filed as the person who actually asked — their
quota and approval rules then apply. On somebody's own instance this is moot:
the key is already theirs.
Web
GET / serves a static landing page from public/, with /favicon.svg,
/og.png, /icon-512.png (the square, opaque listing icon the connector
directories ask for: favicon.svg with a square background, rasterised at
512×512 and flattened to RGB, since one directory rejects transparency) and
/assets/*. Those paths, the health probes and the
OAuth endpoints are the only ones served without a token.
/privacy, /terms and /support state what is stored, where it goes, what
the operator can technically see, and where to write. Google requires the first
two before an OAuth app can leave testing; the connector directories require all
three; and a service holding other people's API keys owes them the statement
regardless.
There is no catch-all route: this is not a single-page app, and an undeclared path is never answered with the page.
The MCTL design tokens are vendored into public/assets/tokens.css by
npm run sync:tokens and committed, so the page fetches no third-party
stylesheet and cannot be restyled without a commit. npm run check:tokens runs
in CI and fails when the committed copy no longer matches the design system, so
an upstream change arrives as a diff to review rather than as a surprise on the
site.
The source is a pinned version — https://ui.mctl.ai/0.5.0/mctl.css — not the
floating mctl.css. That path is served immutable and mctl-design's CI
refuses to edit, move or delete a published version directory, so upgrading is
an edit made here on purpose. Change the SOURCE constant in
scripts/sync-tokens.mjs, run npm run sync:tokens, and commit the diff.
The MCP endpoint shown on the page is derived from window.location.origin, so
promoting a domain needs no change here.
REST API
The same four operations are available over REST at /api/v1, gated by the
same bearer token as /mcp:
GET /api/v1/search— query the tenant's Seerr.GET /api/v1/media/:mediaType/:tmdbId— canonical record for one TMDB id.POST /api/v1/request— file a request, guarded the same wayrequest_mediais.GET /api/v1/resolve— natural-language resolution, going through the semantic layer when it is configured.
/api/v1/account/connection has its own GET, PUT and DELETE methods for
reading, saving and removing a person's attached Seerr, but — as already noted
above — it authenticates with the browser session cookie set at /account, not
with an MCP access token: an assistant holding a token must not be able to read
or rewrite which Seerr it talks to. DELETE /api/v1/account is "Delete my
account": in one call (one transaction on Postgres) it removes the attached
Seerr, every refresh token the person has granted to any assistant, any login
in flight and the resolve counters, then ends the browser session that asked.
Access tokens already issued are stateless and live out their hour; nothing
they reach still exists. DELETE /api/v1/account/session ends that
browser session — it signs the person out of /account only, clearing the
seerrsense_session cookie and revoking it server-side. It does not touch any
MCP grant: refreshing or using an existing Claude/ChatGPT connection keeps
working after a sign-out, and the only way to revoke those is POST /oauth/revoke.
Architecture
src/api— the Fastify HTTP server:/mcp,/api/v1/*,/account/*, the static landing/account pages, and the health probes.src/auth— the optional OAuth 2.1 authorization server (config, routes, client resolution, token verification) and the legacy shared-token check.src/core— environment config and shared schemas.src/mcp— the MCP server factory and its four tools.src/providers/seerr— the Seerr HTTP client andTenantResolver.
TenantResolver decides which Seerr a request reaches, in order: the instance
that signed-in person attached themselves; otherwise the household instance
from SEERR_URL/SEERR_API_KEY; otherwise none, in which case the tools and
REST endpoints say so and point at /account. A resolution is cached for one
minute per subject so the MCP hot path costs no extra database read; saving or
deleting a connection calls forget() on that person's cache entry so the
change is not stuck behind the cache window.
Security Model
/mcp and /api/v1/* require a bearer token. /healthz, /readyz, /,
/.well-known/* and /oauth/* do not.
With OAuth configured, clients discover the flow the standard way: an
unauthenticated request is refused with WWW-Authenticate: Bearer …, resource_metadata="…", which points at the RFC 9728 document naming this
authorization server.
Identity comes from Google. Passkeys, 2FA and account recovery are Google's job; this server only checks the
id_tokenand the allowlist.Access is an allowlist,
SEERRSENSE_ALLOWED_EMAILS, and it fails closed: an unset variable admits nobody rather than everybody.SEERRSENSE_OPEN_SIGNUPis an explicit, separate opt-in to admit any Google account instead; a typo in its value (TRUE,1,yes) is treated as closed, so a misconfigured environment variable can never silently open the server.The shared household Seerr is owner-only. A signed-in person who has not attached their own instance is offered
SEERR_URLonly if their address is inSEERRSENSE_HOUSEHOLD_EMAILS; everyone else is told nothing is connected. This also fails closed: an unset variable offers the household instance to no signed-in subject. The one exception is a deployment with noSEERRSENSE_ENCRYPTION_KEY, where there is no per-user path to resolve at all and every caller reaches the household instance with no address checked. The account page states whichever of the two applies rather than guessing: it reads the resolver's ownhouseholdFallbackanswer instead of keeping a second copy of the email list.Signing out ends the browser session, not MCP access.
DELETE /api/v1/account/sessionclears theseerrsense_sessioncookie and records itsjtias revoked, so a captured cookie value is refused with 401 afterwards too, not just forgotten by the browser. It never touchesoauth_refresh_tokensoruser_connections; revoking an MCP grant is a separate, explicit action atPOST /oauth/revoke. OnMemoryAuthStorethe revocation list is lost on restart, exactly like refresh tokens — a self-hoster running withoutDATABASE_URLgets a stateless-again session on the next deploy rather than a hard failure.User-submitted Seerr addresses are guarded against SSRF. A submitted address must be
https, carry no userinfo, query or fragment, and must not resolve — by literal IP or by DNS, checked again on every dial, not only at submission — to a loopback, private, link-local, CGNAT, or otherwise non-public address (including cloud metadata endpoints like169.254.169.254). The dial is pinned to the address the guard approved and never follows a redirect, and a failure is reported to the caller generically — never the upstream status, body or resolved IP. The operator's own household instance is exempt, which is also what keeps a self-hoster's Seerr on a private LAN working.A Seerr behind Cloudflare Access is diagnosed, not just refused. A dial answered by a redirect to a
*.cloudflareaccess.comhost, or carrying Cloudflare Access response headers, gets a message naming the Zero Trust fields rather than a generic failure.Rate limits apply per IP on
/oauth/*,/account/sessionandPUT /api/v1/account/connection, and per authenticated subject (falling back to IP) on/mcpand/api/v1/*. Every window and ceiling is an environment variable — see Configuration. The store is per-process, so a rollout briefly enforces up to double the configured limit across two pods.The semantic resolver has a daily budget, per subject and globally (
SEERRSENSE_RESOLVE_DAILY_LIMIT,SEERRSENSE_RESOLVE_GLOBAL_DAILY_LIMIT), so an open server's Nebius bill is bounded. A cache hit or a query answered by Seerr's own search costs nothing; only a call that actually reaches the model does.Clients register through Client ID Metadata Documents, where the
client_idis an https URL naming a JSON document with the client's allowed redirect URIs. Pre-registered clients are supported too. Dynamic Client Registration is deprecated in MCP 2026-07-28 and is not implemented; a client that cannot use either path can be given a pre-registered entry instead.Redirect URIs match exactly, except for loopback, where the port is ignored as RFC 8252 requires — a native client binds an ephemeral port and cannot declare it in advance.
localhostand127.0.0.1stay distinct, and a URI carrying userinfo is refused outright.One consent screen names the client, the address you will be returned to and what is being granted. It is what makes a loopback client distinguishable from a local impostor, and it doubles as visible confirmation that the connection worked.
PKCE S256 is mandatory, on both legs: one exchange with the client, a separate one with Google.
Access tokens are short-lived HS256 JWTs audienced at
<public url>/mcp, so a token minted for another resource is refused here.Refresh tokens rotate. They are stored hashed, and presenting one that was already rotated revokes the entire family, on the assumption that two parties now hold it.
Scopes are
seerr:read,seerr:requestandoffline_access. Only those are advertised, because a scope advertised but not granted makes clients warn the user about permissions on a token that works.request_mediachecks forseerr:requestitself.The shared
SEERRSENSE_AUTH_TOKENis compared in constant time and, once OAuth is configured, not accepted unlessSEERRSENSE_LEGACY_TOKEN_ENABLEDis exactlytrue. Without OAuth it is the only credential and stays on unless that variable is set tofalse. Upgrading from 1.8 or earlier: a deployment with OAuth that never set the variable stops accepting the shared token; any client still configured with it starts getting 401 until it is moved to OAuth or the variable is set totrue.
Development
package.json scripts:
Script | What it does |
| run the server with |
| compile to |
|
|
| run the vitest suite |
| regenerate |
| verify the committed tokens file still matches that source (CI gate) |
CI (.github/workflows/ci.yml) runs, in order: check:tokens, typecheck,
test, then a docker build of the image with no push. The test step runs
against a postgres:16-alpine service container via TEST_DATABASE_URL, so
PostgresAuthStore — which carries every authorization code and refresh token
in production — is exercised for real, not only through the in-memory store.
Deployment
The version is managed by release-please,
which opens and merges the release PR and tags the resulting commit. Once a
release is created, that workflow hands the new tag to the platform's
mctl-gitops repository, which builds ghcr.io/mctlhq/seerrsense from this
repo's Dockerfile and updates the deployed image tag for ArgoCD to sync.
Tag pushes also trigger release-binaries.yml, which builds and attaches the
four standalone executables described in Quick Start to that
tag's GitHub release. ci.yml's own docker build step never pushes; it only
proves the image still builds on every PR.
This server cannot be deployed
Maintenance
Related MCP Connectors
OCR, transcription, file extraction, and image generation for AI agents via MCP.
Personal assistant MCP server with search, execute, packages, jobs, secrets, and integrations.
Manage SRG+ hubs, channels, content, assets, users, and workspaces from any MCP-aware AI agent.
MCP server for Clipkit — gives AI agents a video toolbox via the Clipkit schema.
Related MCP Servers
- AlicenseAqualityDmaintenanceMCP server for managing a media server stack (Plex, Radarr, Overseerr, Bazarr, Prowlarr, Trakt.tv) using natural language to browse, request, and discover content.12MIT
- AlicenseNot gradedqualityAmaintenanceEnables searching Overseerr media, retrieving TMDB-backed details, and submitting movie or TV requests through MCP tools.5MIT
- AlicenseNot gradedqualityAmaintenanceA Model Context Protocol server that exposes Sonarr, Radarr, Lidarr, and Jellyfin to any MCP client through a curated tool layer for LLM consumption.MIT
- FlicenseNot gradedqualityBmaintenanceMCP server for Overseerr that enables searching, requesting, and managing media (movies and TV shows) through natural language, with integration to Radarr/Sonarr for automated downloads.-