hoyolab-mcp

mcp
Guvenlik Denetimi
Uyari
Health Uyari
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Low visibility — Only 7 GitHub stars
Code Gecti
  • Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Gecti
  • Permissions — No dangerous permissions requested

Bu listing icin henuz AI raporu yok.

SUMMARY

Read-only MCP server for a Genshin Impact Battle Chronicle. Works with Open WebUI, Claude Code, Codex and opencode. Per-user HoYoLAB cookies encrypted with AES-GCM; no shared credential.

README.md

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
get_daily_note Resin, realm currency, expeditions, daily tasks, weekly boss discount
get_attendance HoYoLAB daily check-in state, today's reward, and what could not be read

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

get_attendance deliberately reports the check-in state and never claims it. The
endpoint that claims the reward (POST /event/sol/sign) exists, and this server does not
call it — see Read-only means read-only.

Where resin comes from, and why it needs its own tool

The Battle Chronicle page does not show resin, and index — the endpoint behind every
other statistic here — carries no resin, expedition, daily-task or realm-currency field
at all. Measured against a live account, the whole payload was searched and none of those
keys appear.

Resin lives in a separate endpoint, dailyNote, which is what the mobile app's Daily
Note widget reads:

Field Value on a live account Reading
current_resin / max_resin 157 / 200 as-is
resin_recovery_time "20448" seconds, and a string
home_coin_recovery_time "0" already full, not "0 seconds left"
remain_resin_discount_num 0 of 3 weekly boss discounts left

Two traps, both of which produced a wrong answer before they were measured:

  • The unit is seconds, not minutes. Resin 156/200 needs 44 more at one per 8 minutes
    (352 minutes); the API reported 21072, which is 351 minutes. Read as minutes it would
    have claimed the account fills up in 14 days.
  • "0" means "nothing pending". Rendering it as a duration says "0 seconds", which
    reads as "about to tick over" — the opposite of "already full".

get_daily_note therefore returns each countdown twice: full_at_seconds as a number and
full_in as text ("5h 40m (20448 seconds)"), plus a state of recovering, full or
unknown. When state is full, full_in is null rather than "0s".

server is mandatory for this endpoint. Without it the API answers
retcode -1, "param server error: value length must be at least 1 runes" — a validation
complaint that never mentions a missing parameter. It is derived from the UID.

Daily check-in is a different service

get_attendance does not use the game-record host at all. The check-in service is
sg-hk4e-api.hoyolab.com/event/sol/*, and it is account-level: there is no role_id,
so the tool takes no uid.

Getting the host or path wrong fails misleadingly rather than with a 404:

Attempt Response
/event/luna/info on the public host retcode -100, "please open this from inside the game"
/event/sol/info on sg-hk4e-api retcode 0 — correct
unauthenticated retcode -100, "Not logged in" (not the record API's 10001)

The reward index is the other trap. total_sign_day counts days already signed, so
when today is unsigned the next reward is awards[total_sign_day], not
awards[total_sign_day - 1]. Measured live: 28 days signed, is_sign false, 31 rewards
that month — the reward on offer was awards[28] (Mora ×5000), while awards[27] was
Hero's Wit ×3. Off by one here reports yesterday's reward as today's.

Read-only means read-only

The check-in activity has a write endpoint, POST /event/sol/sign, which claims the
reward. This server does not implement it, does not expose it, and two tests hold that
line: one asserts the client has no sign method, the other drives the tool against a fake
transport and asserts every request it issues is a GET whose URL never contains
/sign.

The reason is that claiming is not reversible and not obvious: a model that silently
claims a daily reward removes the user's chance to see what it was. Reporting "you have
not checked in today, the reward is Mora ×5000" leaves the decision with the user.

Degrading instead of failing

get_attendance makes three upstream calls, and only one of them answers the question
people actually ask:

Call Answers If it fails
/event/sol/info did I sign in today? the tool fails — there is no answer without it
/event/sol/home what is today's reward? partial: ["rewards"], reward fields null
/event/sol/resign_info can a missed day be recovered? partial: ["make_up"], allowance null

A single failing call must not discard the main answer, so partial lists what could not
be read and is empty when everything worked. The same rule applies to get_daily_note: a
malformed transformer block yields nulls in parametric_transformer rather than taking
resin down with it.

That is not decoration. Before it was fixed, a non-numeric recovery_time.Day raised
TypeError: unsupported operand type(s) for *: 'NoneType' and 'int', and a data payload
that was a list instead of an object raised AttributeError — both failing the whole tool
over a field nobody asked about.

Two things called "attendance"

dailyNote returns attendance_rewards, which are in-game weekly task points (2000
each). get_attendance reports the HoYoLAB daily check-in. They are unrelated, and a
live chat that discussed both at once had to build its own table to keep them apart, so
the tool surfaces them as weekly_task_points — deliberately not echoing the API's name.

state: full requires real figures

resin.state is full only when current and max are both present and max is above
zero. A zero countdown alone is not proof: an earlier revision reported a full resin bar
for {"resin_recovery_time": "0"} — a payload with no resin count at all — while the
comment beside it claimed that could not happen. When the figures are missing the state is
unknown, and full_in is null rather than "0s".

The reward list can belong to the wrong month

home.month is the month the reward list belongs to; info.today is the current date. On
the first day of a month the two disagree until the list rolls over, so get_attendance
compares them. When they differ, reward fields are null and partial contains
reward_month_mismatch, rather than reporting last month's reward as today's.

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
1005 Request rejected as incomplete — for dailyNote this is what a mismatched server value looks like
-100 Check-in service: not logged in (the check-in service uses its own codes)
-1005 Check-in service: a required parameter is missing

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. 1005 gets the same
treatment for the opposite reason: it looks like a broken account when the real cause is
a UID whose server does not match.

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 10 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.1.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
"How much resin do I have?" get_daily_note
"Are my expeditions done?" get_daily_note
"Did I check in on HoYoLAB today?" get_attendance
"What's today's check-in reward?" get_attendance
"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.1.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.1.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 ten 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. That includes the daily
    check-in: the reward is reported, never claimed.
  • Genshin Impact only. Honkai: Star Rail, ZZZ and Honkai Impact 3rd are listed by
    list_game_accounts but have no tools. The check-in tool uses the Genshin (hk4e)
    activity, so it reports Genshin check-in only.
  • 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.
    get_attendance makes three requests (info, rewards, make-up allowance) rather than
    one, so it is the most rate-limit-sensitive tool here.
  • Partial answers are labelled, not hidden. When a supporting call fails,
    get_attendance still answers and names the gap in partial. A caller that ignores
    partial will see nulls where a value was expected — check it before presenting the
    result as complete.
  • Expedition icons, not names. dailyNote returns avatar_side_icon (an image URL)
    but no character name, so expedition entries carry no character name rather than a
    guessed one.
  • No per-task detail. The daily_task payload's per-task entries contain only a
    status string with no identifying field, so get_daily_note reports the finished/total
    count and not a named breakdown.

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

The two newer tools carry tests for the specific ways they can report a wrong number:
seconds-vs-minutes on resin_recovery_time, "0" meaning "full" rather than "0 seconds",
a missing max_resin not being called "full", the check-in reward index being off by one,
the check-in host and path, and — structurally — that no code path reaches
POST /event/sol/sign. ToolSignatureTests walks the registered tools and asserts each
declares ctx, because a tool that forgets it only fails at call time, as
name 'ctx' is not defined, which no helper-level test would catch.

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.

Yorumlar (0)

Sonuc bulunamadi