Signed-off-by: Michael Mayer <michael@photoprism.app>
16 KiB
PhotoPrism MCP Server
Last Updated: April 20, 2026
See
specs/platform/mcp.mdfor the canonical specification, including the rationale for the user-access policy and the role/grant matrix per edition.
Current Capabilities
- Transports:
- CLI:
photoprism mcp serve(stdio, no auth; development and testing) - HTTP:
POST/GET/DELETE /api/v1/mcp(Streamable HTTP, authenticated). Can be disabled via--disable-mcp/PHOTOPRISM_DISABLE_MCP/DisableMCPso the route responds with the standard 404 when an operator does not want the endpoint exposed. The flag is also surfaced to the frontend throughClientConfig.Disable.MCP(disable.mcp), letting the UI hide MCP-related controls while the endpoint is off.
- CLI:
- Authorization: HTTP endpoint enforces the
ResourceMCPACL (admin plus the API client roles in every edition, manager in Pro/Portal); anonymous access is permitted in public mode for the currently registered read-only tools. - Request Body Cap: HTTP POST bodies are bounded at
MaxMCPRequestBytes(currentlyMaxMutationRequestBytes, 256 KiB). Oversized requests receive the standard413 Request Entity Too Largeresponse before the upstream SDK reads the body. Early rejection viaContent-Lengthprotects against large known-size payloads;http.MaxBytesReaderplus a response-writer wrapper handle chunked bodies. The wrapper translates the SDK's internal400 "failed to read body"into a consistent413and suppresses the SDK's error phrasing so it does not leak to clients. - Session Timeout: Streamable HTTP sessions idle out after
McpSessionTimeout(5 minutes by default). Active clients renew the idle timer on every JSON-RPC request, so interactive IDE use is unaffected; sessions abandoned without theDELETEtear-down free up promptly instead of lingering. - Read-only resources:
photoprism://config-optionsphotoprism://search-filters
- Read-only tools:
list_config_keysfind_search_filters
Package layout
| Package | Purpose |
|---|---|
internal/mcp/ |
Core MCP logic: server factory, data pipeline, resources, tools |
internal/api/mcp.go |
Gin HTTP handler with auth middleware, route registration |
internal/commands/mcp.go |
CLI command (photoprism mcp serve) using stdio transport |
internal/auth/acl/ |
ResourceMCP constant and ACL grant rules (GrantFullAccess for admin; GrantSearchAll for manager in Pro/Portal and for the API client roles: client, instance, service, portal) |
Scope
In scope for the current server:
- Reuse existing internal reference data (
config.Flags,config.OptionsReportSections,form.Report(&form.SearchPhotos{})) instead of maintaining a parallel dataset. - Keep outputs compact enough for LLM consumption.
- Authenticated remote access via the Streamable HTTP transport, plus a stdio transport for local development and testing.
Out of scope for the current server (must not regress without additional per-tool gates):
- Write-capable tools.
- Direct database access.
- Live PhotoPrism instance or API queries.
- Per-user state (albums, photos, sessions, settings).
Internal data sources
- Config options:
internal/config.Flagsplusinternal/config.OptionsReportSections - Search filters:
internal/form.Report(&form.SearchPhotos{})
Run locally (stdio)
Build the CLI:
go build ./cmd/photoprism
Start the MCP server over stdio:
./photoprism mcp serve
The process waits for an MCP client on stdin/stdout. Logs are written to stderr so the MCP message stream stays valid.
Run via HTTP
Start PhotoPrism:
./photoprism start
The MCP endpoint is available at /api/v1/mcp. Authenticate with an admin token:
# Initialize session
curl -X POST http://localhost:2342/api/v1/mcp \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
Test with MCP inspector
Stdio transport:
npx @modelcontextprotocol/inspector ./photoprism mcp serve
Useful smoke tests:
- List resources
- Read
photoprism://config-options - Read
photoprism://search-filters - Call
list_config_keyswith{"query":"http","limit":3} - Call
find_search_filterswith{"query":"Berlin","limit":5}
Available resources
photoprism://config-options
- JSON payload with
editionanditems - Each item includes
section,environment,cli_flag,default, anddescription
photoprism://search-filters
- JSON payload with
editionanditems - Each item includes
filter,type,examples, andnotes
Available tools
list_config_keys
- Inputs:
section,query,edition,limit - Returns matching config rows with environment variables, CLI flags, defaults, descriptions, and a conservative
edition_supporthint - Validation rejects unsupported
editionvalues
find_search_filters
- Inputs:
query,type,limit - Returns matching search filters with examples and notes
- Validation rejects unsupported filter
typevalues
MCP Client Compatibility
Most MCP clients natively support Streamable HTTP with custom headers (url + headers in config). Clients that only support stdio-based servers in their config file require mcp-remote as a stdio-to-HTTP bridge.
Direct HTTP config (clients with native support):
{
"mcpServers": {
"photoprism": {
"url": "http://localhost:2342/api/v1/mcp",
"headers": {
"Authorization": "Bearer <admin-token>"
}
}
}
}
stdio bridge (clients without native HTTP support):
{
"mcpServers": {
"photoprism": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"http://localhost:2342/api/v1/mcp",
"--header", "Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer <admin-token>"
}
}
}
}
Authorization
The HTTP endpoint uses PhotoPrism's existing ACL system:
- Resource:
ResourceMCP("mcp") - Permission:
ActionViewfor read-only tools (handler-level check) - Grants:
RoleAdmin→GrantFullAccessin every edition.RoleManager→GrantSearchAllin Pro and Portal builds (the role does not exist in CE/Plus).RoleClient,RoleInstance,RoleService,RolePortal→GrantSearchAllin every edition.- All other roles (
user,viewer,guest,visitor,contributor, default) are denied.
- Why
GrantSearchAllfor non-admins? It includesAccessAll,ActionView, andActionSearch— exactly what the read-only tools need — but excludesActionManage/ActionUpdate/ActionDelete/ActionCreate. Any future write-capable MCP tool gated on those permissions will automatically be admin-only without needing per-tool checks. - Client tokens: API client sessions must also include the
mcpresource (or a wildcard) in their session scope; the ACL grant alone is not sufficient. - Auth model: request-level. The handler runs
Auth(c, acl.ResourceMCP, acl.ActionView)followed bys.Abort(c), which writes the matching status code (401unauthenticated,403ACL deny,429rate-limited) and returnstrueso the handler canreturnearly. - Public mode: anonymous access is permitted. In public mode,
api.Session()returns the default public session (effectively admin), soAuth(...)passes and the currently registered read-only tools are reachable without a token. This is an intentional, narrow allowance for demo deployments (demo.photoprism.app); it is safe only because every registered tool today returns static reference metadata derived fromconfig.Flagsandform.Report(&form.SearchPhotos{})— no database access, no per-user state, no secrets, no mutations. Any future tool that touches per-user state, the database, or mutates anything MUST NOT be registered on this server without an additional per-tool check. See Extending the Tool Surface below.
Rate Limiting
The MCP handler does not install a custom rate limiter — there is no per-endpoint bucket. Coverage depends on the edition:
| Build | Generic per-IP HTTP limiter? | Notes |
|---|---|---|
| CE | no | Only the admin/client auth check protects the endpoint. In public mode all callers are anonymous, so CE deployments that run with --public have no per-request throttle in front of MCP. |
| Plus | no | Same as CE. The IPS middleware exists but only consumes tokens on known scanner/exploit paths, so it does not throttle MCP traffic. |
| Pro | yes | pro/internal/server/register.go calls router.Use(limiter.Middleware(limiter.NewLimit(rate.Every(secOpt.RequestInterval), secOpt.RequestLimit))) when both options are set. The limiter is per client IP and applies to every API endpoint, MCP included. |
| Portal | yes | Same wiring as Pro in portal/internal/server/register.go. |
A per-endpoint limiter (via limiter.Auth / limiter.Login / limiter.AbortJSON) is only worth adding when MCP grows write-capable tools or endpoints that warrant stricter throttling than the generic IP limiter — for example, anything that mutates state or that triggers expensive backend work.
CE and Plus deployments that expose the endpoint to untrusted networks should enforce per-IP request limits at the reverse proxy. The application-level McpSessionTimeout (5 minutes) and MaxMCPRequestBytes (256 KiB) bound the per-session memory footprint and per-request allocation, but they do not throttle request frequency; a proxy rule (for example nginx limit_req_zone or Traefik rateLimit middleware) is the recommended companion control in builds without a generic HTTP limiter.
Scope Plumbing
mcp is the canonical scope token for ResourceMCP. The relevant pieces:
- Sanitization:
pkg/clean.Scopelowercases the input and parses it throughpkg/list.ParseAttr. There is no allowlist of valid scope tokens, so--scope mcp,--scope "mcp metrics", and--scope "*"are all accepted byclients addandauth addwithout any registry update. - Authorization:
internal/auth/acl.ScopePermitschecks the parsed attribute list against the resource string. Becauseacl.ResourceMCP.String() == "mcp", the existingattr.Contains(...)path matches a session that holds themcptoken. SeeTestScopePermits/MCPScopeininternal/auth/acl/scope_test.gofor the canonical assertions (admin token, mixed scopes, case-insensitivity, deny on unrelated scopes). - Cluster JWTs: instance-side validation runs through
Config.JWTAllowedScopes()(internal/api/api_auth_jwt.go). The default allowlist isDefaultJWTAllowedScopes = "config cluster vision metrics mcp"ininternal/config/config_cluster.go, so portal-issued JWTs withscope=mcpare accepted out of the box. Operators that override the list via--jwt-scope/PHOTOPRISM_JWT_SCOPEneed to includemcpthemselves.
Extending the Tool Surface
Anonymous access in public mode is only safe as long as every registered tool returns static reference metadata. Before adding a new tool, confirm it fits the existing contract:
- No database reads or writes.
- No per-user state (albums, photos, sessions, settings).
- No filesystem, network, or subprocess side effects.
- No access to secrets or runtime config values (only flag schema/defaults are allowed).
If a proposed tool does not fit that contract, do not register it on the default *sdkmcp.Server. Instead, take one of these paths (ordered by how much work they are):
- Two servers, one factory.
internal/api/mcp.goalready passes a factory tosdkmcp.NewStreamableHTTPHandler. Build a second server with the full tool set and return it from the factory only when the request is non-public and authenticated; keep the default server restricted to the public-safe tools. Policy becomes declarative at construction time, and a missing tool in the public server surfaces as a standard "tool not found" from the SDK rather than leaking its existence. - Per-tool context checks.
sdkmcp.AddToolclosures receive a*sdkmcp.CallToolRequest; stash caller context on the MCP session atinitializeand reject inside the tool closure when public mode is active or the ACL deny list applies. Use this when the same tool has different output per caller (e.g. admin sees raw values, client sees redacted). - SDK middleware. For cross-cutting concerns such as per-tool rate limits or structured audit entries, wire an
sdkmcp.Middlewarethat inspects the JSON-RPC method and tool name before dispatch.
Whichever path you pick, add a test in internal/mcp/server_test.go that fails if the restricted tool shows up in tools/list or is callable over the public path, and update the Available Tools table below.
How Users Get Access
Regular user accounts (RoleUser, RoleViewer, etc.) are intentionally not in the ResourceMCP ACL. Regular users typically don't have shell access to the server, so they can't run the CLI commands themselves — and the currently registered tools only return static reference data, so there's no per-user information to authorize against. Access is therefore granted through admin-issued client tokens.
To onboard a user (or a CI job, IDE, etc.), an administrator runs the following on the PhotoPrism server:
./photoprism clients add \
--name "Alice's IDE" \
--scope mcp \
--role client \
--expires 2592000 # 30 days; use -1 for no expiry
The command prints a client ID and secret. Combine them into a bearer token (or pass them through the OAuth2 client-credentials flow) and paste the resulting value into the user's MCP client config — the same JSON snippets shown above under MCP Client Compatibility apply unchanged. Replace <admin-token> with the issued token.
To revoke access without disabling the user account, the administrator runs:
./photoprism clients remove <client-id>
Heads up:
photoprism auth add --scope mcp <username>creates an app password tied to a user account, but it currently does not grant MCP access —RoleUseris not in theResourceMCPACL. Usephotoprism clients addfor MCP integrations until that policy changes. The reasoning is documented inspecs/platform/mcp.mdunder User Access Model (deliberate hold, not an oversight).
When MCP eventually grows tools that need user-scoped data (e.g. "list my albums"), the team will revisit the policy and likely add RoleUser → GrantSearchAll so the app-password path lights up. Until then, every MCP integration is an admin-provisioned client token tied to a named application.