GW2 MCP Server
The GW2 MCP Server is an agentic AI assistant that provides Guild Wars 2 answers using official wiki content and live game data. It exposes tools to:
Wiki: Search and fetch cleaned wiki pages.
Events: Get event timers for world bosses, meta events, and daily fractals.
Items & Trading Post: Search items, get static data, and live buy/sell prices.
Skills: Search and fetch skill details.
Waypoints: Find waypoints and POIs with in-game chat codes.
Crafting: Find recipes for items and get recipe details.
Meta: Current game build number.
Personal Account (with stored API key): View account overview, wallet, storage (bank, materials, shared slots), characters (overview, inventory, equipment, crafting), unlocks (recipes, dyes, skins, etc.), and progression (achievements, masteries, raid clears, Wizard's Vault).
API Key Management:
account_infoprovides a secure URL to manage keys without entering them in chat.
The server works with any MCP client (e.g., Claude, ChatGPT, Gemini) via stdio or HTTP, and also powers its own agent for REPL and Discord bot use.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@GW2 MCP Server What are today's daily fractals?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
gw2mcp — GW2 Wiki Answers
An agentic AI assistant that answers Guild Wars 2 questions from authoritative
sources: the official wiki and the
official GW2 API, with citations back to the
wiki. Spec: specs/gw2-agentic-ai-spec.md.
Two components, one repo:
MCP tool server (
gw2mcp-server) — a FastMCP server exposing wiki search/fetch and GW2 API tools (items, skills, live trading-post prices, daily fractals, event timers, waypoint/POI chat codes) to any MCP client: Claude Desktop/Code, ChatGPT, Gemini, or our own agent.Agent core + adapters (
gw2mcp-repl,gw2mcp-discord) — the provider-pluggable tool-use loop (Anthropic, OpenAI, or OpenRouter) that turns "question in" into "grounded answer + citations out". Transport-agnostic: a terminal REPL and a Discord bot share the same core.
REPL / Discord bot any MCP client (Claude, ChatGPT, Gemini)
│ │
Agent core (Anthropic/OpenAI/OpenRouter) │
│ MCP: in-memory (default) or HTTP │ MCP: stdio or HTTP
└────────────────┬───────────────────────┘
FastMCP server (gw2mcp)
wiki_search · wiki_get_page · get_event_timer · search_items
get_item · get_item_prices · search_skills · get_skill
get_build_id · get_daily_fractals · search_waypoints
│
Supabase Postgres (TTL cache · name index · run traces)Setup
Prereqs: uv, Supabase CLI, Docker (for local Supabase).
supabase start # local Postgres on 127.0.0.1:54322
supabase migration up # create cache/name-index/run-trace tables
uv sync --all-groups
cp .env.example .env # then fill in ANTHROPIC_API_KEYEvery config variable the code reads is documented in
.env.example (optional tunables are listed commented-out with
their defaults). Secrets live only in .env (gitignored) — never commit them.
Related MCP server: Guild Wars 1 MCP
Run
uv run gw2mcp-repl # terminal REPL (agent + in-process server)
uv run gw2mcp-server # MCP server over stdio (Claude Desktop/Code)
uv run gw2mcp-server --transport http # MCP server over streamable HTTP :8720To point an external MCP client at the server, use stdio config
(command: uv, args: [run, gw2mcp-server], cwd this repo) or the HTTP URL
http://127.0.0.1:8720/mcp.
To run the agent against a separately running server instead of in-process,
set MCP_SERVER_URL in .env.
Authentication (optional)
By default, the HTTP MCP server is unauthenticated — any client can call tools.
Setting MCP_AUTH_ENABLED=true requires all HTTP clients to authenticate via OAuth
(WorkOS AuthKit) before accessing tools.
Local transports are unaffected: stdio (Claude Desktop) and the in-memory transport
(REPL, tests) never see the HTTP auth layer. Pointing the REPL at a remote authed
server via MCP_SERVER_URL is not supported (it sends no bearer token).
Discord /link flow
Users can link their Discord account to a WorkOS identity (for higher rate limits):
/linkopens a temporary login URL to your AuthKit application.User completes sign-up or login at the AuthKit domain.
Authorization redirects back to the MCP server with an authorization code (which the server exchanges with WorkOS).
The bot stores the linked identity (person and tier); future
/gw2calls run at the linked tier with higher rate limits.Manage your GW2 API key from the /account page (link via
/setkeyoraccount_info): keys are validated against the GW2 API before storing and encrypted at rest; Discord never processes them./whoamishows the current link status;/unlinkdisconnects Discord only — your account, stored GW2 API key, and history remain (delete them any time from the /account page).
/account — GW2 API key for MCP clients
MCP users (Claude Desktop, etc.) manage their GW2 API key in the browser at
{PUBLIC_BASE_URL}/account — sign in with the same WorkOS account the MCP
OAuth uses, then save, replace, or clear the key. Keys are validated against the
GW2 API before storing and encrypted at rest; the key never appears in the
agent conversation. The account_info MCP tool returns this URL plus
non-secret status (key stored, tier, Discord linked).
The key is stored on the person, not the surface: /account is the only
place to set, replace, or clear it, and the same key is available to both
Discord and MCP clients — provided you sign into the same WorkOS account in
both places. Discord's /setkey command is a pointer to /account, not an
input path; it never sees the key. /link now asks for an explicit
confirmation in the browser before attaching a Discord account.
New env var: ACCOUNT_SESSION_TTL_S (optional, default 1800) — browser
session lifetime for /account.
Upgrade note: when /link (WorkOS) settings are configured, GW2_KEY_ENCRYPTION_KEY
is now REQUIRED at startup — a server with link settings but a missing or invalid
Fernet key refuses to boot.
WorkOS dashboard setup
To enable the /link flow and OAuth on the HTTP server:
Create an AuthKit application in your WorkOS organization; note the AuthKit domain, Client ID, and API key.
In AuthKit → Configuration → Dynamic Client Registration: enable it (required so Claude/ChatGPT can self-register with the MCP server).
In the Redirects tab, add both redirect URIs:
Add
<PUBLIC_BASE_URL>/link/callback(e.g.,https://your-host.example.com/link/callback).Add
<PUBLIC_BASE_URL>/account/callbackto the allowed Redirect URIs (exact byte match —127.0.0.1vslocalhostmatters).
Recommended: In OAuth → Resource Indicators (RFC 8707): enable it and register the MCP resource URL (e.g.,
https://your-host.example.com/mcp). This ensures tokens carry the correctaud(audience) claim.
Fill in the corresponding .env variables:
WORKOS_CLIENT_ID, WORKOS_API_KEY, AUTHKIT_DOMAIN, PUBLIC_BASE_URL.
Generate LINK_SIGNING_KEY and GW2_KEY_ENCRYPTION_KEY as random cryptographic
values (see comments in .env.example).
Smoke-testing account tools
The REPL has no person identity, so account tools answer with setup guidance there — that itself is the no-identity smoke test. Real-data smoke tests (do NOT commit keys):
Discord path:
/link, then store a real API key (all permissions) at/account, then/ask what's in my material storage?in Discord — expect real counts, ephemeral.Hosted OAuth path: connect Claude to the hosted server, store a key at /account, then ask the same question.
Ask with a key lacking
inventories— expect a message naming the missing permission, not an error.
Discord bot
The Discord adapter (spec §6.5–6.6) runs the same agent core behind two slash
commands: /gw2 question:<text> (defers ephemerally, then edits the answer
in, splitting across follow-ups past Discord's 2000-char limit) and /about.
Per-user (5/10 min) and per-guild (30/hour) rate limits plus a global monthly
budget kill-switch ($50 default, summed across all runs including REPL and
eval) are enforced as aggregate queries over run_traces — fail-closed: if
Postgres is unreachable, the bot declines to answer rather than run unmetered.
Discord application setup (once per environment)
Dev and prod are separate Discord applications so guild-scoped dev commands never collide with global prod ones.
Create an application at the Discord Developer Portal → Bot tab → Reset Token → put it in
.envasDISCORD_TOKEN. No privileged intents are needed (slash commands only — leave presence, server members, and message content off).For dev: enable Developer Mode in your Discord client, right-click your test server → Copy Server ID → set
DISCORD_DEV_GUILD_IDin.env. Commands sync instantly to that guild. For prod: leave it unset — commands register globally (first propagation can take up to an hour).Invite the bot with (replace
<APP_ID>from the portal's General Information page):https://discord.com/api/oauth2/authorize?client_id=<APP_ID>&scope=bot%20applications.commands&permissions=2048permissions=2048is just Send Messages; interaction replies work even without it, keeping the permission footprint minimal.
Run
uv run gw2mcp-discord # requires DISCORD_TOKEN in .env, local Supabase runningRate-limit and budget knobs (RATE_LIMIT_*, MONTHLY_BUDGET_USD) are
documented in .env.example.
Eval
uv run python eval/run_eval.py # full 25-question set, manual scoring
uv run python eval/run_eval.py --category live --limit 2The question set (eval/questions.yaml) is versioned; re-run it on every
significant prompt/tool change. Results land in eval/results/*.jsonl.
Baseline (2026-07-22, claude-sonnet-5, prompt v1): 21/25 pass (84%),
3 partial, 1 fail · median latency 12.0s (target ≤15s ✓) · avg cost
$0.0621/question (target ≤$0.06, marginal). The fail + 2 partials traced to a
wikitext-cleaner bug that stripped inline cost templates ({{gold|…}},
{{item icon|…}}); fixed after this run — re-run the eval to record a v2
baseline against the ≥90% target.
Model
LLM_PROVIDER env var picks the backend — anthropic (default), openai, or
openrouter — and MODEL_ID names the model in that provider's naming
(default claude-sonnet-5, $3/M input, $15/M output — the best
price/quality Sonnet-class model per the spec's cost target of
≤ $0.06/question; OpenRouter ids are vendor-prefixed, e.g.
anthropic/claude-sonnet-5). The agent loop is provider-agnostic
(src/gw2mcp/agent/providers/); pricing for cost accounting lives in
src/gw2mcp/agent/costs.py and is keyed by (provider, model) — a pair
without a pricing entry refuses to start (fail-closed, so nothing can spend
invisibly to the monthly budget cap).
Tests
uv run pytest # fully offline: respx-mocked HTTP, in-memory MCP, fake cacheObservability
Every run writes one JSON line to logs/runs.jsonl (every tool call, tokens,
cost, latency) and a queryable row to the run_traces table in Postgres —
the table the Discord adapter's rate limits count over. The monthly budget
kill-switch aggregates over budget_ledger instead, so admission reserves
capacity atomically rather than reading a stale sum
(src/gw2mcp/agent/gating.py).
Retention: a scheduled sweeper (src/gw2mcp/common/retention.py, every
6h by default) prunes run_traces/logs/runs.jsonl after 90 days,
mcp_tool_calls after 7 days, and budget_ledger rows older than the
previous calendar month; cache_entries expire per-row on their own TTL.
See docs/data-retention.md for the full policy,
including what's collected, the access boundary, and deletion behavior.
Production deployment
Running against a hosted/production Supabase or Postgres instance needs a
different database role than local dev (gw2mcp_app, not postgres), a
password set for that role, and a decision on the Supabase Data API. See
docs/deployment.md for credential separation, the
runtime role's exact privileges, Data API guidance, a residual service_role
risk, a portability caveat for hosted projects, reverse-proxy setup, and the
process model for scaling.
Security
docs/threat-model.md covers the trust boundaries
(Discord, browser, MCP client, Postgres, external APIs), the protected assets,
the attackers each control is aimed at, and — importantly — the accepted
residual risks, including what the /account delete flow does not reach.
docs/security-findings-2026-07-27.md
is the findings report from the 2026-07-27 hardening pass: one entry per
reviewed hypothesis with status, evidence, files changed, verification, and
residual risk — plus the two claims that turned out to be false, four
issues the original review missed, and the exact verification commands and
their real output.
No GDPR, CCPA, SOC 2, or HIPAA compliance is claimed. See
docs/data-retention.md for what is collected and
for how long.
Politeness & attribution
Wiki requests carry a descriptive User-Agent and are limited to ≤ 2 req/s.
Wiki content is cached (pages 24h, search 1h); GW2 API static data is cached keyed to the game build; prices for 5 minutes.
Wiki content is GFDL; this project is not affiliated with ArenaNet.
Available Tools
19 toolsaccount_infoA
Where and how to manage your GW2 API key for account-aware features.
Returns the /account web page URL plus non-secret status (whether a key is stored, tier, Discord link). NEVER ask the user to paste an API key into the conversation — send them to the URL instead.
Inputs: no named parameters. Returns: object
find_recipes_for_itemA
Find crafting recipe ids that PRODUCE the given item id.
Use after search_items: item ids and recipe ids are different id spaces. Follow up with get_recipe for ingredients, and my_unlocks(kind="recipes", check_ids=[...]) to see if the user has learned a recipe. Empty recipe_ids means the item is not craftable.
Inputs: item_id (required). Returns: object
get_build_idA
Fetch the current game build number (useful to note data freshness).
Inputs: no named parameters. Returns: object
get_daily_fractalsA
Fetch today's daily fractal achievements from the official API.
Inputs: no named parameters. Returns: object
get_event_timerA
Get upcoming start times for GW2 timed events: map meta events, world bosses, day/night cycles, and festival activities.
Use this for any "when is the next X" or schedule question — wiki
pages render timers with browser JavaScript, so wiki_get_page can
never see them. query matches event, map, or category names (e.g.
"battle for the jade sea", "tequatl", "dragon's end"). Returns
matched events with up to count upcoming occurrences each:
start_utc, start_unix (Unix epoch seconds), duration_minutes,
starts_in_minutes, and in_progress. Matches may also carry chatlink,
an in-game chat code for the event's location (e.g. "[&BKIMAAA=]") —
relay it verbatim so players can paste it into the game. If nothing
matches, the error lists valid zone names to retry with.
Inputs: query (required), count. Returns: object
get_itemA
Fetch a GW2 item's static data (rarity, level, type, description) by id.
Inputs: item_id (required). Returns: object
get_item_pricesA
Fetch live Trading Post buy/sell prices for an item id.
Prices are in coins: 10000 coins = 1 gold, 100 coins = 1 silver.
buys.unit_price is the highest buy order; sells.unit_price is the
lowest sell listing.
Inputs: item_id (required). Returns: object
get_recipeA
Fetch a crafting recipe by recipe id: disciplines, required rating, output item, and ingredients (item ids resolved to names).
Inputs: recipe_id (required). Returns: object
get_skillA
Fetch a GW2 skill's data (description, facts, recharge) by id.
Inputs: skill_id (required). Returns: object
my_accountB
The user's own GW2 account data (requires their stored API key).
section="overview": account name, world, access, ranks. section="wallet": currency balances with names (coins: 10000 = 1 gold). If the result contains an "action" field, relay it to the user — it explains how to set up or fix their API key.
Inputs: section (required). Returns: object
my_charactersA
The user's characters (requires their stored API key).
section="list" (no name needed): character names. With name: "overview" (core stats), "inventory" (bag contents, aggregated, name_filter supported), "equipment" (worn gear with upgrades/stats preserved), "crafting" (disciplines + ratings). If the result contains an "action" field, relay it to the user.
Inputs: name, section, name_filter. Returns: object
my_progressA
The user's progression (requires their stored API key). kinds: achievements (requires achievement_ids, max 50 — find ids via the wiki or public tools first), masteries, raids, dungeons, worldbosses, mapchests, wizards_vault. raids/dungeons/worldbosses/mapchests list the encounters/chests cleared since their last reset. Note: the GW2 API has no per-map/world completion percentage — use achievements. If the result contains an "action" field, relay it to the user.
Inputs: kind (required), achievement_ids. Returns: object
my_storageA
The user's account-wide storage: bank, material storage, or shared inventory slots (requires their stored API key).
Item stacks come back aggregated with names resolved; pass name_filter (substring) to narrow — responses cap at 100 entries. If the result contains an "action" field, relay it to the user.
Inputs: section (required), name_filter. Returns: object
my_unlocksA
The user's unlocks (requires their stored API key). kinds: recipes, dyes, skins, minis, outfits, gliders, mounts, home_nodes.
Default: unlocked count only. check_ids (max 50): membership test — the way to answer "does the user know recipe X / own skin Y?" (mounts and home_nodes use string ids). list_missing=true: locked entries with names — only for dyes/minis/outfits/gliders/mounts/home_nodes. If the result contains an "action" field, relay it to the user.
Inputs: kind (required), check_ids, name_filter, list_missing. Returns: object
search_itemsA
Find GW2 item ids by (partial) item name.
Returns {status, matches: [{id, name}]}. If status is "index_warming" the local name index is still being built — use the wiki instead or retry with a numeric id. Follow up with get_item / get_item_prices.
Inputs: name (required). Returns: object
search_skillsB
Find GW2 skill ids by (partial) skill name. Same shape as search_items.
Inputs: name (required). Returns: object
search_waypointsA
Find GW2 waypoint and landmark (point of interest) chat codes and map locations by (partial) name.
Returns {status, matches: [{name, type, chat_link, map, continent}]}.
chat_link is a copy-pasteable in-game chat code, e.g. "[&BDoBAAA=]"
— players paste it into the chat window to locate the spot on their
map. Always relay it verbatim when telling a player about a waypoint
or landmark. If status is "index_warming" the local index is still
being built or refreshed for the current game build — partial matches
are included; retry shortly for full coverage.
Inputs: name (required). Returns: object
wiki_get_pageA
Fetch a Guild Wars 2 wiki page as cleaned plain text.
Returns {title, url, text, truncated}. Infobox stats are preserved as
"name: value" lines. Skill/trait facts show the wiki's own computed
values with game-mode labels, e.g. "Damage: 646 (1.6) [PvE]" — the
flat number is the level-80 exotic tooltip value, the parenthesized
one the coefficient. Waypoint and point-of-interest listings include
in-game chat codes like "[&BDoBAAA=]" — relay them verbatim when
directing a player somewhere. Redirects are followed. If truncated
is true, the page was cut at a size limit. Cite the returned url when
you use facts from this page.
Inputs: title (required). Returns: object
wiki_searchA
Search the official Guild Wars 2 wiki for pages matching a query.
Returns up to limit results with title, url, and a text snippet.
Use this first when you don't know the exact page title. Reformulate
the query (official game terms, singular nouns) if results look off.
Inputs: query (required), limit. Returns: result
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 19 tools
Tools target distinct GW2 domains (account, skills, items, wiki, events, waypoints, recipes, user data) with clear boundaries. Minor overlap between search_skills and search_items but descriptions disambiguate. The 'my_' prefix clearly separates user-specific from public data.
Most tools follow a verb_noun pattern (e.g., search_items, get_skill, wiki_get_page). Inconsistencies exist: 'find_recipes_for_item' is longer, and 'my_account' uses a prefix instead of 'get_my_account'. Overall predictable and readable.
19 tools is slightly above the typical range but each serves a distinct purpose for a game with many subsystems. Could be streamlined (e.g., get_daily_fractals as part of events) but overall well-scoped.
Covers core GW2 data: items, skills, events, wiki, recipes, and user progression, storage, characters, unlocks. Missing guild or PvP/WvW stats, but the wiki tools provide fallback. Minor gaps, but agents can handle most queries.
Maintenance
Related MCP Connectors
Driflyte MCP server which lets AI assistants query topic-specific knowledge from web and GitHub.
An MCP server that integrates with Discord to provide AI-powered features.
MCP server for skill documentation, generated by doc2mcp.
An MCP server that provides congressional transcripts
Related MCP Servers
- FlicenseBqualityDmaintenanceA MCP server that allows you to search and retrieve content on any wiki site using MediaWiki with LLMs 🤖. wikipedia.org, fandom.com, wiki.gg and more sites using Mediawiki are supported!227-
- AlicenseAqualityFmaintenanceRead-only MCP server for public Guild Wars 1 sources (wiki, builds, YouTube, Reddit) and optional local install inventory.142MIT
- AlicenseNot gradedqualityDmaintenanceAn MCP server that lets Claude answer ARK: Survival Ascended / Evolved questions by querying the ARK community wiki for taming, crafting, spawns, and stats.MIT
- AlicenseNot gradedqualityAmaintenanceAn MCP server that gives LLM clients reliable, deterministic knowledge of Guild Wars 1 builds: skill data lookup, official template code encoding/decoding, and build validation.1MIT