Skip to main content
Glama
danisec

HoYoLAB Genshin Battle Chronicle MCP Server

by danisec

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

Related MCP server: steam-mcp

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

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.

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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    F
    maintenance
    A comprehensive MCP server that wraps the complete World of Warcraft retail API into 197 tools for Game Data and Profile information. It enables users to query character statistics, achievements, collections, and game mechanics across all global regions and locales.
    11
    -
  • A
    license
    A
    quality
    B
    maintenance
    Exposes Steam Web API tools as MCP resources for Claude Code, Claude Desktop, and Gemini CLI, enabling profile lookups, game searches, achievement tracking, and more.
    11
    18 npm
    1
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP server for World of Warcraft that provides 26 tools for accessing characters, guilds, realms, game data, and auction house info via Blizzard's Battle.net APIs. It enables natural-language queries for character audits, Mythic+ reviews, market analysis, and more.
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Provides read-only access to HoYoLAB and HoYoWiki APIs, enabling search and retrieval of posts, wiki entries, and article content converted to Markdown.
    3
    AGPL 3.0