Skip to main content
Glama
GW2 MCP Server

GW2 MCP Server

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_KEY

Every 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 :8720

To 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):

  1. /link opens a temporary login URL to your AuthKit application.

  2. User completes sign-up or login at the AuthKit domain.

  3. Authorization redirects back to the MCP server with an authorization code (which the server exchanges with WorkOS).

  4. The bot stores the linked identity (person and tier); future /gw2 calls run at the linked tier with higher rate limits.

  5. Manage your GW2 API key from the /account page (link via /setkey or account_info): keys are validated against the GW2 API before storing and encrypted at rest; Discord never processes them.

  6. /whoami shows the current link status; /unlink disconnects 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:

  1. Create an AuthKit application in your WorkOS organization; note the AuthKit domain, Client ID, and API key.

  2. In AuthKit → Configuration → Dynamic Client Registration: enable it (required so Claude/ChatGPT can self-register with the MCP server).

  3. 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/callback to the allowed Redirect URIs (exact byte match — 127.0.0.1 vs localhost matters).

  4. 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 correct aud (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):

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

  2. Hosted OAuth path: connect Claude to the hosted server, store a key at /account, then ask the same question.

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

  1. Create an application at the Discord Developer Portal → Bot tab → Reset Token → put it in .env as DISCORD_TOKEN. No privileged intents are needed (slash commands only — leave presence, server members, and message content off).

  2. For dev: enable Developer Mode in your Discord client, right-click your test server → Copy Server ID → set DISCORD_DEV_GUILD_ID in .env. Commands sync instantly to that guild. For prod: leave it unset — commands register globally (first propagation can take up to an hour).

  3. 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=2048

    permissions=2048 is 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 running

Rate-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 2

The 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 cache

Observability

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 tools
account_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

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.9/5.0

    Scored across 19 tools

    Disambiguation4/5

    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.

    Naming Consistency4/5

    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.

    Tool Count4/5

    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.

    Completeness4/5

    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

    ActivityInactive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • F
      license
      B
      quality
      D
      maintenance
      A 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!
      2
      27
      -
    • A
      license
      Not graded
      quality
      D
      maintenance
      An MCP server that lets Claude answer ARK: Survival Ascended / Evolved questions by querying the ARK community wiki for taming, crafting, spawns, and stats.
      MIT
    • A
      license
      Not graded
      quality
      A
      maintenance
      An 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.
      1
      MIT