PluginWorld
Ho

hoyolab-mcp

MCP

Enables models to query a user's Genshin Impact Battle Chronicle through read-only MCP tools, covering account statistics, characters, artifacts, Spiral Abyss, Imaginarium Theater, and exploration progress. It uses per-user encrypted HoYoLAB cookies and works with Open WebUI, Claude Code, Codex, and opencode.

@danisec · MIT License · updated today

SECURITY

B

SCORE

61

STARS

▲ 3

PLUG IN

git clone https://github.com/danisec/hoyolab-mcp.git

See the README to configure this MCP server

README

HoYoLAB Genshin Battle Chronicle — MCP server

CI Release Licence Python MCP

Read-only access to a Genshin Impact Battle Chronicle, exposed as MCP tools so a model can answer questions like "what are my Abyss stars this rotation?" or "which regions am I least finished with?".

Genshin Impact only — Star Rail, ZZZ and Honkai Impact 3rd are named by list_game_accounts but have no tools here.

Works with Open WebUI, Claude Code, Codex, and opencode.

cp .env.example .env
printf 'HOYOLAB_MCP_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
docker compose up -d --build
curl -s http://localhost:8899/health   # {"status":"ok"}

[!WARNING] Unofficial. This project is not affiliated with, endorsed by, or supported by HoYoverse. It calls the same internal web endpoints the Battle Chronicle page uses. Those endpoints are undocumented and can change or be restricted at any time.

Handle credentials carefully. Access requires HoYoLAB session cookies, which are equivalent to being logged into the account — not a read-only token. Use a secondary account if you are unsure, and be aware that using unofficial clients may conflict with HoYoverse's Terms of Service. You accept that risk by using this software.

Contents

Why this exists

The page at act.hoyolab.com/app/community-game-records-sea/ is a web app, not an API. To let a model read that data it has to be turned into tools, which is what this server does: it calls the same endpoints the page calls and returns condensed JSON.

Requirements

Python 3.10+ (only for the non-container path)
Container Docker with Compose v2, or Podman 4+ with podman-compose
Client Any MCP client that speaks Streamable HTTP
HoYoLAB A logged-in account with Battle Chronicle visibility enabled

No HoYoLAB developer account, API key, or OAuth application is needed — and none exists to be obtained (see the next section).

Quick start

Docker or Podman, both work:

git clone https://github.com/danisec/hoyolab-mcp.git
cd hoyolab-mcp

cp .env.example .env
printf 'HOYOLAB_MCP_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env

docker compose up -d --build        # or: podman compose up -d
curl -s http://localhost:8899/health

Then connect a client — see Connecting a client. For a command-line client that is two commands:

docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli link-url --user alice
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli issue-token --user alice

Authentication: cookies, not OAuth

The Battle Chronicle API does not use OAuth. It authenticates with HoYoLAB session cookies (ltuid_v2 / ltoken_v2 / ltmid_v2, and the legacy ltuid / ltoken).

Open WebUI's MCP support does speak OAuth 2.1, but that authenticates Open WebUI to an MCP server; it cannot mint a HoYoLAB credential. HoYoverse publishes no OAuth authorization server for third-party Battle Chronicle access — its standard discovery endpoints do not exist:

account.hoyoverse.com/.well-known/openid-configuration          -> HTTP 403
api-account-os.hoyoverse.com/.well-known/openid-configuration  -> HTTP 404
bbs-api-os.hoyolab.com/.well-known/oauth-authorization-server  -> HTTP 404

So cookies are the only mechanism. They are handled as follows.

Per-user, encrypted, never logged

  • Cookies are stored per user id, in SQLite, encrypted with AES-GCM. The user id is bound as associated data, so a ciphertext copied onto another user's row fails to decrypt instead of leaking an account.
  • API tokens (for command-line clients) are stored as SHA-256 hashes, never in plaintext, and each maps to exactly one user id.
  • There is no server-wide fallback credential. A fallback would mean that as soon as one admin linked an account, every other user of the tools would silently read that person's Genshin data.
  • Values never reach logs or error messages. redact() keeps a 4-character prefix; the link page logs cookie names only.
  • The server fails closed without HOYOLAB_MCP_SECRET: it starts, but every path that would touch credentials returns an error instead. /health answers 503 with the reason, and every tool call fails with the same message. Nothing is ever written unencrypted. Compose additionally refuses to start at all (see Option A — Compose), so the usual path fails earlier and more loudly.

Linking an account

The link page is at /link. A user reaches it from the tool message, which includes a signed, 15-minute token identifying their user id. A raw user id in the URL would let anyone attach cookies to anyone else's account, so the id is HMAC-signed.

For command-line clients the same URL is printed by python -m hoyolab_mcp.cli link-url --user <id>, so linking does not require a browser session with Open WebUI.

Three identity sources, and why unsigned headers are refused

The server resolves the caller in this order:

  1. Authorization: Bearer <token> — a token minted per user id. Used by Claude Code, Codex and opencode. Verified against a stored SHA-256 hash; a revoked token stops working immediately. A bad token is an error, never a reason to fall back to something weaker.
  2. X-OpenWebUI-User-Jwt — the signed HS256 token Open WebUI forwards (iss=open-webui), verified with the shared secret.
  3. X-OpenWebUI-User-Id — the plain header, honoured only when no JWT secret is configured.

That last condition is a security boundary, not a convenience. If a JWT secret is configured, the unsigned header is ignored outright: honouring it would let anything that can reach the port name itself any user and read that person's Genshin data. The error message says so explicitly, because otherwise it looks like a broken server.

Tools

Tool Returns
hoyolab_link_status Whether this user has linked an account (never the cookie)
list_game_accounts Game accounts and UIDs linked to the HoYoLAB login
get_genshin_overview All 22 account statistics (see below) + exploration summary
get_characters Roster: name, level, rarity, element, constellation
get_character_artifacts Equipped artifacts + final stat totals (base/bonus/total)
get_spiral_abyss Stars per floor/chamber and the teams used
get_imaginarium_theater Per-period act, medals, coins, optional round roster
get_exploration Per-region exploration %, incomplete regions, offerings

All are read-only. Nothing here can modify an account.

get_genshin_overview returns every statistic the page shows

The API's stats object carries 22 top-level fields. Two of them are nested objects, which is easy to miss — they are why Imaginarium Theater and Stygian Onslaught were once reported as "not available from the API" when they were present all along:

Page label API field
Imaginarium Theater role_combat.max_round_id (highest act), difficulty_id
Stygian Onslaught hard_challenge.name (best record), difficulty

Everything else maps one-to-one: achievements, days active, characters, max-friendship characters, Spiral Abyss, all eight oculi (iceculus_number is Cryoculus, moonoculus_number is Lunoculus), waypoints, domains, and all five chest tiers (magic_chest_number is Remarkable Chest).

Why artifacts need their own tool

character/list returns only id, name, level, rarity, element, weapon type and weapon — no artifacts at all. Relic data lives behind character/detail, which also returns a property_map that translates numeric property_type codes into readable stat names. Without that map the stats are a wall of integers, so get_character_artifacts joins the two. It also distinguishes flat HP (type 2) from HP Percentage (type 3), which both report the name "HP" on their own, and keeps each substat's roll count so an invested piece is distinguishable from a fresh one.

Failure modes are specific

HoYoLAB answers HTTP 200 with a retcode even when it refuses, so errors are read from the body, not the status code:

retcode Meaning surfaced to the user
10001 Credential missing, expired, or for another account
10101 Credential does not match this game UID
10102 Player disabled their Battle Chronicle — a privacy setting, not a login problem
10103 Game account not found

10102 in particular is called out separately, because telling someone to re-link when the real fix is an in-game toggle sends them down the wrong path.

Installation

Three paths: Compose (recommended), plain docker run, or a local Python install. A fourth option covers a long-running Podman host that should supervise the service with systemd (deploy.sh, Option D).

Option A — Compose (recommended)

Works with Docker Compose and Podman Compose; the same compose.yaml is used by both.

git clone https://github.com/danisec/hoyolab-mcp.git
cd hoyolab-mcp

cp .env.example .env
printf 'HOYOLAB_MCP_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env

docker compose up -d --build     # or: podman compose up -d
curl -s http://localhost:8899/health   # {"status":"ok"}

Without the docker CLI, podman-compose works directly:

podman-compose up -d --build

Both were tested against this file: the container reports healthy, /health answers, and the MCP handshake returns all 8 tools.

If HOYOLAB_MCP_SECRET is missing or empty, Compose refuses to start with a message naming the variable, so this is caught before a container exists. Run without Compose and the server itself still refuses to serve: /health returns 503 and every tool call returns an error, rather than writing cookies unencrypted.

Common operations:

docker compose logs -f              # follow logs
docker compose ps                   # status + health
docker compose down                 # stop, keep the credential database
docker compose down -v              # stop and DELETE the database (all linked accounts)
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli status --user alice

With Podman, substitute podman compose or podman-compose for docker compose.

What the Compose file sets up

Concern Choice Why
State named volume hoyolab-data → /data holds the encrypted cookie database; a bind mount would leave it easy to copy or commit, and needs a manual chown 10001 on rootless Podman
Network exposure ${HOYOLAB_MCP_BIND:-127.0.0.1}:${HOYOLAB_MCP_PORT_HOST:-8899}:8899 the server has no TLS, so it binds to loopback by default
Root filesystem read_only: true + tmpfs: /tmp only /data is writable
Capabilities cap_drop: ALL the image already runs as uid 10001
Restart unless-stopped survives reboot and crashes

To run a published image instead of building, set HOYOLAB_MCP_IMAGE in .env:

HOYOLAB_MCP_IMAGE=ghcr.io/you/hoyolab-mcp:1.0.0
docker compose up -d          # no --build

If compose build fails with a metadata-file error

On a snap-confined Docker, docker compose build can fail with:

open /tmp/.tmp-compose-build-metadataFile-*.json: no such file or directory

That is a BuildKit/snap interaction, not this project — it reproduces with a two-line FROM alpine project. Build with BuildKit disabled, or build the image directly:

DOCKER_BUILDKIT=0 docker compose up -d --build
# or
docker build -t hoyolab-mcp:latest . && docker compose up -d

If containers exit with operation not permitted

If security_opt: [no-new-privileges:true] is added and every container then fails with exec ...: operation not permitted — including alpine sleep — the host is blocking it (e.g. snap-confined Docker). This file therefore leaves that option off; read_only and cap_drop: ALL are still applied. Re-enable it on a host that supports it.

Option B — plain docker run

Useful without Compose, or when embedding this in another setup.

git clone https://github.com/danisec/hoyolab-mcp.git
cd hoyolab-mcp

cp .env.example .env
printf 'HOYOLAB_MCP_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env

docker build -t hoyolab-mcp .
docker run -d --name hoyolab-mcp \
  -p 127.0.0.1:8899:8899 \
  --env-file .env \
  -v hoyolab-data:/data \
  --read-only --tmpfs /tmp --cap-drop ALL \
  hoyolab-mcp

--env-file .env passes every variable in the file, including HOYOLAB_MCP_DB and HOYOLAB_MCP_HOST, which compose.yaml sets explicitly.

Option C — local Python

git clone https://github.com/danisec/hoyolab-mcp.git
cd hoyolab-mcp
python -m venv .venv && .venv/bin/pip install -r requirements.txt

export HOYOLAB_MCP_SECRET="$(openssl rand -hex 32)"
export HOYOLAB_MCP_DB="$PWD/hoyolab.db"
export HOYOLAB_MCP_PUBLIC_URL="http://localhost:8899"
.venv/bin/python -m hoyolab_mcp.main

Requires Python 3.10 or newer (the code uses X | Y type syntax and dict[str, str]).

Option D — Podman Quadlet (deploy.sh)

For a long-running Podman host, deploy.sh installs the server as a systemd user service instead of a Compose container. A Quadlet is turned into a real systemd unit by podman-user-generator, so it starts on boot, restarts on failure, and needs no login session (given loginctl enable-linger "$USER"). Compose has no supervision of its own: after a reboot, nothing brings the container back.

git clone https://github.com/danisec/hoyolab-mcp.git
cd hoyolab-mcp

./deploy.sh              # init + build + install, then wait for health
./deploy.sh status       # unit, container, health, and link state
./deploy.sh logs         # follow the service log
./deploy.sh backup       # consistent copy of the credential database

Configuration lives in ~/.config/hoyolab-mcp/hoyolab-mcp.env (mode 600), outside the repository, so a git clean, a re-clone, or a compose down -v cannot destroy the key that makes already-stored cookies readable. The Quadlet itself is generated from that file by ./deploy.sh quadlet, so the published port and bind addresses always match the env file — there is no second copy to drift out of sync.

Set these before ./deploy.sh init to control what it writes:

HOYOLAB_MCP_PORT_HOST=8899                    # host port
HOYOLAB_MCP_BIND=127.0.0.1,100.64.0.5         # publish on loopback AND one more address
HOYOLAB_MCP_OWUI_CONTAINER=open-webui         # container to adopt the JWT secret from

HOYOLAB_MCP_BIND accepts a comma-separated list. Each address becomes its own PublishPort line, which is narrower than binding 0.0.0.0: the port stays closed on every interface not listed. Any non-loopback address is also added to HOYOLAB_MCP_ALLOWED_HOSTS and used as the host in HOYOLAB_MCP_PUBLIC_URL, because the DNS-rebinding check compares the full Host header and the link is opened in the user's browser.

./deploy.sh init adopts FORWARD_USER_INFO_HEADER_JWT_SECRET from the running Open WebUI container rather than generating one. This matters in both directions: when this server has the secret, an unsigned X-OpenWebUI-User-Id header is rejected outright, and when it does not, identity falls back to a header anything reaching the port can spoof. The two processes have to agree on the same value, and a generated value never would. Re-running init is safe: it reports a match and rewrites nothing.

Do not also run podman compose up for this project. Both would try to own a container named hoyolab-mcp and would fight over the port and the volume.

Verified on a Podman host: the generated Quadlet is accepted by podman quadlet -dryrun (exit 0, no parser errors, the expected PublishPort line), and the adopted secret matches Open WebUI byte for byte. The status report was exercised against three cases — this server's real port, a port belonging to another service, and a closed port — and refuses to print a foreign HTML page as if it were health output.

Not exercised end to end: build and install (they need a free port and would restart the live service). They are the same podman build and systemctl --user restart steps the Compose and docker run paths already use.

Configuration

HOYOLAB_MCP_SECRET is not optional, at least 16 characters. Without it the server still starts but refuses to do anything with credentials: /health returns 503 and tool calls return the same explanation, so no cookie is ever written unencrypted. Changing the secret later makes already-stored cookies unreadable (users simply re-link).

HOYOLAB_MCP_PUBLIC_URL is used to build the account-link URL, so it must be reachable from the user's browser, not just from the container. Behind a reverse proxy:

HOYOLAB_MCP_PUBLIC_URL=https://hoyolab-mcp.example.com
HOYOLAB_MCP_ALLOWED_HOSTS=hoyolab-mcp.example.com,hoyolab-mcp:8899,localhost:8899

HOYOLAB_MCP_ALLOWED_HOSTS must contain the exact Host header clients send. A missing entry is rejected with 421 Misdirected Request, which looks like a broken server but is MCP's DNS-rebinding protection doing its job.

Every variable, with its default:

Variable Default Purpose
HOYOLAB_MCP_SECRET — (required) AES-GCM key for stored cookies; ≥16 chars
FORWARD_USER_INFO_HEADER_JWT_SECRET empty Verify Open WebUI's forwarded identity; must match Open WebUI
HOYOLAB_MCP_PUBLIC_URL empty (Compose sets http://localhost:8899) Base URL for the account-link message; must be browser-reachable
HOYOLAB_MCP_ALLOWED_HOSTS loopback + container names Hosts accepted by the DNS-rebinding check
HOYOLAB_MCP_BIND 127.0.0.1 Host interface to publish on; deploy.sh accepts a comma-separated list, Compose takes one
HOYOLAB_MCP_PORT_HOST 8899 Host port (Compose only); the container always uses 8899
HOYOLAB_MCP_IMAGE hoyolab-mcp:latest Image to run
HOYOLAB_MCP_VERSION dev Reported by /health; set at build time
HOYOLAB_MCP_DB /data/hoyolab.db Database path inside the container
HOYOLAB_MCP_LOG_LEVEL INFO Python log level

GET /health returns {"status":"ok","version":"..."}, which is the quickest way to confirm which build a running container is actually using.

Connecting a client

There are two kinds of client, and they authenticate differently. This matters more than it looks: the server never holds a shared credential, so each client must be able to say which user it is.

Client How identity reaches the server
Open WebUI Open WebUI signs and forwards a per-user header on every tool call
Claude Code / Codex / opencode A bearer API token you mint for that user

Open WebUI

In Open WebUI: Settings → Admin → Integrations → External Tool Servers → +, type MCP (Streamable HTTP), URL http://<host>:8899/mcp, auth None.

Auth is None because identity travels in the forwarded header, not an Authorization header. Two Open WebUI settings are required:

ENABLE_FORWARD_USER_INFO_HEADERS=true
FORWARD_USER_INFO_HEADER_JWT_SECRET=<a long random string>   # strongly recommended

FORWARD_USER_INFO_HEADER_JWT_SECRET must be the same value as this server's FORWARD_USER_INFO_HEADER_JWT_SECRET. Without it Open WebUI falls back to an unsigned X-OpenWebUI-User-Id header that anything reaching the port could forge.

There is deliberately no "Connect" button in Open WebUI for this server. Open WebUI only shows one for OAuth-backed MCP servers, and HoYoverse publishes no OAuth authorization server — so accounts are linked through the /link page instead, which the tool message links to.

Claude Code, Codex, opencode

These clients do not send Open WebUI's identity header, so they need a token. A token maps to exactly one user id, and that user id's cookies are the only ones ever used — the per-user isolation guarantee is unchanged.

1. Choose a user id and link a HoYoLAB account

A "user id" here is just a stable name, not necessarily an Open WebUI user id. Pick one per person (or per machine):

# Print a 15-minute link URL, then open it in a browser and paste your cookies
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli link-url --user alice

# Confirm it worked
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli status --user alice
#   user alice: linked (cookies: ltmid_v2, ltoken_v2, ltuid_v2)

docker compose exec picks the right container by service name, so it keeps working if the container is renamed. With Podman use podman compose exec, or plain podman exec hoyolab-mcp ....

Without a container at all, drop the prefix and run .venv/bin/python -m hoyolab_mcp.cli ... from the project directory.

2. Mint a token

docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli issue-token \
  --user alice --label "claude-code on laptop"
# hlt_1J2x...   <- shown once, never recoverable

Tokens are stored as SHA-256 hashes, so the database cannot be used to recover a working token. If you lose one, revoke it and mint another.

3. Configure the client

Claude Code — one command, or a JSON file:

claude mcp add --transport http hoyolab http://localhost:8899/mcp \
  --header "Authorization: Bearer hlt_your_token_here"

Or in .mcp.json (project) / ~/.claude.json (user):

{
  "mcpServers": {
    "hoyolab": {
      "type": "http",
      "url": "http://localhost:8899/mcp",
      "headers": { "Authorization": "Bearer hlt_your_token_here" }
    }
  }
}

The "type": "http" field is required — an entry with a url but no type is read as a stdio server and skipped.

Codex — in ~/.codex/config.toml:

[mcp_servers.hoyolab]
url = "http://localhost:8899/mcp"
http_headers = { Authorization = "Bearer hlt_your_token_here" }

Codex reads environment variables too, which keeps the token out of the file:

[mcp_servers.hoyolab]
url = "http://localhost:8899/mcp"
bearer_token_env_var = "HOYOLAB_MCP_TOKEN"
export HOYOLAB_MCP_TOKEN=hlt_your_token_here

opencode — in opencode.json (project) or ~/.config/opencode/opencode.json:

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "hoyolab": {
      "type": "remote",
      "url": "http://localhost:8899/mcp",
      "enabled": true,
      "headers": { "Authorization": "Bearer hlt_your_token_here" }
    }
  }
}

4. Verify

# Ask the tool to confirm which account it sees
curl -s http://localhost:8899/mcp \
  -H "Authorization: Bearer hlt_your_token_here" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"hoyolab_link_status","arguments":{}}}'

Then in the client itself: "Use the hoyolab tools to tell me my Spiral Abyss stars."

Managing tokens

docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli list-tokens   --user alice
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli revoke-token --user alice --id 54a5448353b4
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli revoke-all   --user alice
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli unlink       --user alice

Revoking is immediate — the next tool call with that token is refused. There is no HTTP route for issuing tokens on purpose: issuing a token turns a user id into a credential, so it stays a local command requiring filesystem access to the database.

unlink deletes the stored cookies and revokes that user's tokens; see Removing your data.

What the model sees

The server tells the model its own usage rules through MCP instructions, so no client needs a custom prompt. If a user has not linked an account, every tool returns a message containing their personal link URL rather than an empty result — the model will surface it.

Usage

Once connected, ask in natural language. The model picks the tool.

Ask Tool used
"Do I have a HoYoLAB account linked?" hoyolab_link_status
"Which Genshin accounts do I have?" list_game_accounts
"How many achievements and chests do I have?" get_genshin_overview
"What are my Spiral Abyss stars this rotation?" get_spiral_abyss
"How did I do in Imaginarium Theater?" get_imaginarium_theater
"Which regions are least explored?" get_exploration
"Show my Furina's artifacts and total CRIT" get_character_artifacts
"List my characters by level" get_characters

get_character_artifacts answers build questions from your own account's numbers rather than a wiki, which is why it exists separately from get_characters.

Upgrading

The stored credential database is forward compatible: new versions read databases written by older ones without migration, so upgrading is replacing the image and restarting.

# If you run a published image (HOYOLAB_MCP_IMAGE points at a registry):
docker compose pull
docker compose up -d

# If you build locally (the default), pull would fail: the image is not in a registry.
docker compose up -d --build

Then confirm what is actually running:

curl -s http://localhost:8899/health   # {"status":"ok","version":"1.0.0"}

version comes from the HOYOLAB_MCP_VERSION build argument, so it reports the image you are running rather than whatever the source tree says. If it is missing, the image was built without it — see Configuration.

Before a major version, back up the volume — it is the only copy of every linked account:

docker run --rm -v hoyolab-mcp_hoyolab-data:/data -v "$PWD":/backup \
  docker.io/library/alpine:3.20 tar czf /backup/hoyolab-backup.tgz -C /data .

The image is named in full (docker.io/library/alpine:3.20) rather than as alpine, because Podman refuses a bare short name when no unqualified-search registry is configured.

To undo an upgrade, run the previous image tag again against the same volume:

HOYOLAB_MCP_IMAGE=hoyolab-mcp:1.0.0 docker compose up -d

Rolling back is safe because the database is only ever extended, never rewritten in a way older versions cannot read. Restoring the backup above is only needed if a version wrote something incompatible — which would be a major version, and is called out in the changelog.

What is stable between versions. These are treated as public interface, and changing one would be a breaking change called out in the changelog:

Interface Value
MCP endpoint path /mcp
Link page path /link
Health path /health
Environment variables HOYOLAB_MCP_* (see Configuration)
Container port 8899
Tool names the eight names in Tools
Database format readable by later versions

If you upgrade an existing install, nothing needs to be re-registered. The URL, the auth mode and the tool names above are unchanged, so an Open WebUI connection created against an earlier build keeps working as-is.

Release notes for each version are in CHANGELOG.md.

Removing your data

Because this server stores a login credential, there is an explicit way to take it back — you do not have to delete the whole database.

# Delete this user's stored cookies (and their tokens)
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli unlink --user alice

# Keep the tokens, remove only the cookies
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli unlink --user alice --keep-tokens

# Revoke tokens without touching the cookie
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli revoke-all --user alice

Removing the credential from this database does not invalidate it at HoYoLAB. To do that, log out of HoYoLAB in the browser the cookies came from — that invalidates the session for every client, including this one.

To remove everything at once (all users, all tokens):

docker compose down -v      # -v deletes the named volume holding the database

unlink is deliberate about scope: it deletes only the named user's row. The database is shared, so there is no "delete everything" flag that could be run by accident.

Troubleshooting

Symptom Cause Fix
421 Misdirected Request MCP's DNS-rebinding protection; the Host header is not in the allow-list Add the exact host:port you connect with to HOYOLAB_MCP_ALLOWED_HOSTS
No user identity was forwarded Client sent no bearer token and Open WebUI is not forwarding headers Mint a token, or set ENABLE_FORWARD_USER_INFO_HEADERS=true in Open WebUI
A plain X-OpenWebUI-User-Id header was sent, but it is ignored A JWT secret is configured, so unsigned identity is refused Send the signed X-OpenWebUI-User-Jwt, or use an API token
The API token ... is not valid or has been revoked Token typo, or revoked issue-token a new one
required variable HOYOLAB_MCP_SECRET is missing a value .env not created or key empty printf 'HOYOLAB_MCP_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
/health answers 503 with HOYOLAB_MCP_SECRET must be set to at least 16 characters Running outside Compose with no secret, so the server fails closed instead of writing cookies unencrypted Export a secret of 16+ characters, or use Compose, which refuses to start without one
Container is unhealthy but /health answers Healthcheck timeout too tight on an emulated host Raise healthcheck.timeout in compose.yaml
Container exits: exec ...: operation not permitted Host blocks no-new-privileges (e.g. snap Docker) Remove that security_opt entry
retcode 10001 Cookies expired or belong to another account Re-link via link-url
retcode 10102 Battle Chronicle is disabled in-game Enable "Show my Battle Chronicle on my profile" in HoYoLAB settings — re-linking will not help
Malformed link token The URL was truncated when copied Run link-url again; tokens last 15 minutes
Tools report not linked right after linking Different --user value than the token was minted for Check status --user <id> for the same id

Diagnostics:

docker compose logs -f                       # server logs
docker compose exec hoyolab-mcp python -m hoyolab_mcp.cli status --user alice
curl -s http://localhost:8899/health

Security

What this server does to protect the credentials it holds:

  • Cookies are encrypted at rest with AES-GCM; the user id is bound as associated data, so a ciphertext moved to another user's row fails to decrypt.
  • API tokens are stored as SHA-256 hashes — a database dump yields no usable token.
  • Cookie values never reach logs or error text; only names are logged.
  • No shared or fallback credential exists, so one user's link can never serve another.
  • The container runs as uid 10001 with read_only root, cap_drop: ALL, and only /data writable.

What it does not do, so you can plan around it:

  • It is not a public-internet service. It speaks plain HTTP with no TLS, and a bearer token is the only thing protecting the data. Put it behind a TLS-terminating proxy and keep the port on loopback unless you have a reason not to.
  • It cannot revoke a HoYoLAB session. Only logging out of HoYoLAB does that.
  • HOYOLAB_MCP_SECRET protects the database, not the host. Anyone with root on the host can read the key and decrypt the cookies.

To report a vulnerability, please do not open a public issue — use GitHub's private vulnerability reporting (Security → Report a vulnerability). See SECURITY.md for scope and what to include.

Limitations

  • Read-only. Nothing here can modify a Genshin account.
  • Genshin Impact only. Honkai: Star Rail, ZZZ and Honkai Impact 3rd are listed by list_game_accounts but have no tools.
  • Unofficial endpoints. HoYoverse can change or restrict them without notice; a breaking change here would surface as an unexpected retcode, not a version warning.
  • Overseas accounts only. The DS salt and endpoints used are the overseas (os_*) set; mainland China (Miyoushe) is untested.
  • No caching. Every call hits HoYoLAB, so a chatty model can hit rate limits.

Tests

python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python tests/test_hoyolab_mcp.py

Covers cross-user isolation, ciphertext binding, plaintext-on-disk, log redaction, credential validation, link-token tampering/expiry, API-token issue/resolve/revoke and hash-only storage, unlinking, identity resolution across all three sources (including that an unsigned header is refused once a JWT secret exists), UID→server mapping, and the exact wire shape of each API call (two real bugs were found this way: character/list takes its parameters in the JSON body, and the account list lives on the community host).

For a live server, verify_e2e.py checks the deployed behaviour end to end. It is a host-side script and is not baked into the image, and the container's root filesystem is read-only, so it cannot simply be copied in. Run it in a throwaway container that shares the server's volume and can reach it over the network:

docker run --rm --network host \
  -v hoyolab-mcp_hoyolab-data:/data \
  -v "$PWD":/src:ro \
  -e HOYOLAB_MCP_SECRET="$(grep '^HOYOLAB_MCP_SECRET=' .env | cut -d= -f2-)" \
  -e HOYOLAB_MCP_DB=/data/hoyolab.db \
  -w /src --entrypoint python hoyolab-mcp:latest verify_e2e.py \
  --url http://127.0.0.1:8899

Add --jwt-secret "<Open WebUI's value>" to include the Open WebUI identity checks. The token checks need the shared volume, which is why it is mounted rather than pointing the script at a copy of the database.

Contributing

Issues and pull requests are welcome. Full guidance is in CONTRIBUTING.md; the short version:

  • Keep the tests green — python tests/test_hoyolab_mcp.py must pass. New behaviour needs a test; a bug fix needs a test that fails without the fix.
  • Preserve the security properties listed under Security. In particular do not add a shared credential, do not log cookie values, and do not let an unsigned header identify a user once a JWT secret is configured.
  • No new runtime dependencies without a reason in the PR — the small dependency surface is deliberate.
  • Match the surrounding style: comments explain why, not what.

There is no CLA. By contributing you agree your work is licensed under the MIT licence below.

Credits and attribution

Prior work this project learned from

genshin.py — MIT, originally by sadru, now maintained by seriaati.

This project does not depend on or vendor genshin.py; the client here is written against the HTTP API directly. However, genshin.py was the reference used to work out parts of the protocol, and three things in this codebase derive from reading it:

  1. The DS header algorithm (make_ds in hoyolab_mcp/client.py) — md5("salt=…&t=…&r=…") with a 6-character random component. The algorithm belongs to HoYoverse's web client, but this implementation was written after reading genshin/utility/ds.py.
  2. The UID → server mapping (GENSHIN_SERVERS) — the prefix ranges are HoYoverse facts, but the table was transcribed from genshin/utility/uid.py.
  3. The exploration_percentage divisor — genshin.py divides by 10, which is what revealed that an earlier version of this project dividing by 100 was wrong.

Its MIT licence text is reproduced in THIRD_PARTY_LICENSES.md.

genshinstats — the predecessor library by sadru, superseded by genshin.py. Same lineage for the API knowledge above.

UIGF-org/mihoyo-api-collect — community documentation of the HoYoLAB/Miyoushe token and record APIs. Used to confirm which cookie names carry authentication (ltuid_v2 / ltoken_v2 / ltmid_v2 and the legacy pair).

Libraries

Library Licence Used for
mcp (Anthropic) MIT MCP server framework and client (tests)
httpx BSD-3-Clause HTTP client for HoYoLAB
PyJWT MIT Verifying Open WebUI's forwarded identity token
cryptography Apache-2.0 OR BSD-3-Clause AES-GCM encryption of stored cookies
uvicorn / starlette BSD-3-Clause ASGI server and routes (via mcp)

All are permissively licensed; none require attribution in documentation, but they are listed for transparency. Full texts in THIRD_PARTY_LICENSES.md.

Data and trademarks

Genshin Impact, HoYoLAB and related marks are trademarks of HoYoverse. This project is unofficial and unaffiliated. Endpoint URLs, property-type codes and game constants are facts about HoYoverse's own services, reproduced here for interoperability.

What is original

Everything else: the MCP tool design, per-user encrypted credential store, the account-linking flow with signed tokens, the Open WebUI integration, response shaping, and the test suite.

Licence

MIT — see LICENSE. Third-party licence texts are in THIRD_PARTY_LICENSES.md.

Using this software does not grant any rights to Genshin Impact, HoYoLAB, or HoYoverse services, and this project is not affiliated with them.

SIMILAR PLUGINS