Skip to main content
Glama
rpeters1430

poe2-mcp-server

by rpeters1430
README.md
# poe2-mcp-server

An MCP server that gives an MCP-compatible AI CLI (Claude Code, Codex CLI,
Antigravity, or anything else speaking MCP) read access to your Path of
Exile 2 character data and recent in-game events, and one narrow, safe way
to talk back to you. See **[PROTOCOL.md](./PROTOCOL.md)** for the full
protocol spec and the reasoning behind its design, especially the
"advisory-only" boundary — read that before extending this.

## What this does and doesn't do

**Does:**
- Fetches character level/class/experience/equipment/skills from GGG's
  official developer API.
- Tails your local `Client.txt` log for near-real-time events: area
  entries, level-ups, deaths, trade whispers.
- Gives the AI one tool, `emit_advisory`, to surface a message to you via a
  desktop notification, spoken text-to-speech, a log line, or a file a
  separate overlay UI could render — nothing that touches the game.

**Doesn't:**
- Send input to PoE2, read/write its process memory, or otherwise automate
  gameplay. That's excluded on purpose — it's real automation of gameplay
  and against Path of Exile's ToS. `src/adapters/memory-adapter.ts` explains
  the tradeoffs if you ever want to go there yourself; it's an unimplemented
  stub here, not a starting point I'd hand you without you deciding that on
  purpose.
- Expose live combat stats (current HP/ES/mana, position, nearby monsters).
  GGG's API doesn't provide these; only the memory-reading route above
  would, with everything that implies.

## Setup

### 1. Install and build

```sh
npm install
npm run build
```

### 2. Register a GGG API application

1. Go to <https://www.pathofexile.com/developer> and register a new
   application.
2. Register it as a **public client** (no client secret) using **PKCE**.
3. Set its redirect URI to `http://127.0.0.1:8730/callback` (or pick a
   different port and set `POE2_REDIRECT_PORT` to match everywhere below).
4. Note the client ID it gives you.

### 3. Authorize the app against your account

```sh
export POE2_GGG_CLIENT_ID="the-client-id-from-step-2"
export POE2_CONTACT_EMAIL="you@example.com"   # GGG requires a contact in the User-Agent
npm run auth
```

This opens a consent URL (visit it in a browser, log in, approve), catches
the redirect locally, and stores an access/refresh token pair under
`~/.config/poe2-mcp-server/tokens.json` (mode `0600`, never written to the
project directory). The refresh token is valid 90 days per GGG's docs; after
that, run `npm run auth` again.

### 4. Point it at your Client.txt (optional — it tries to auto-detect first)

The server searches common Steam/Standalone/Epic install locations for your
platform on startup (see `src/config.ts`). If it doesn't find yours —
including if you're on Linux under Proton somewhere non-standard — set it
explicitly:

```sh
export POE2_CLIENT_LOG_PATH="/path/to/Path of Exile 2/logs/Client.txt"
```

### 5. Run it standalone once, to sanity-check

```sh
POE2_GGG_CLIENT_ID=... POE2_CONTACT_EMAIL=... node dist/index.js
```

You should see it print the resolved `Client.txt` path (or a warning if it
couldn't find one) and then sit waiting for an MCP client on stdio.

### Optional: authenticated trade-site searches

`find_trade_upgrades` first tries GGG's trade-site endpoints without a
session. If the site returns HTTP 401/403, set `POE2_TRADE_POESESSID` in the
server process environment to the value from your own logged-in Path of Exile
browser session. Treat this value like a password: never paste it into an AI
conversation, commit it, or put it in an example config.

## Wiring it into an AI CLI

All three examples below assume you've already run steps 1–4 above; they
just tell the CLI how to launch the server.

### Claude Code

Either run:

```sh
claude mcp add poe2 -- node /absolute/path/to/poe2-mcp-server/dist/index.js
```

(then set the env vars through `claude mcp add --env` or your shell), or
add `examples/claude-code-mcp.json`'s contents to a `.mcp.json` in your
project or `~/.claude.json` under `mcpServers`.

### Codex CLI

Either run:

```sh
codex mcp add poe2 -- node /absolute/path/to/poe2-mcp-server/dist/index.js
```

or add `examples/codex-config.toml`'s `[mcp_servers.poe2]` block to
`~/.codex/config.toml` directly.

### Antigravity

Add `examples/antigravity-mcp-config.json`'s contents under `mcpServers` in
`~/.gemini/config/mcp_config.json` (global) or `.agents/mcp_config.json`
(per-workspace), or use the `/mcp` command inside Antigravity to add it
interactively.

In all three cases, fill in the real absolute path to `dist/index.js` and
your actual `POE2_GGG_CLIENT_ID`/`POE2_CONTACT_EMAIL` — none of the example
files above are usable verbatim.

## Remote access: Docker, web dashboard, and clipboard-to-compare

Everything above launches the server as a **local stdio subprocess** on the
same machine as PoE2. If you'd rather run PoE2 and this server on your
**desktop** and interact from a **laptop** over your LAN — a browser
dashboard, and/or a remote AI CLI — use `src/serve.ts` instead of
`src/index.ts`. It exposes the exact same MCP tool surface at `/mcp`
(over `StreamableHTTPServerTransport` instead of stdio), plus a small REST
API and a browser dashboard at `/`. See PROTOCOL.md's "Interaction model"
for how this relates to the stdio-only design: the AI's MCP interaction is
still pull-only either way, this only changes the transport and adds a
separate human-facing dashboard alongside it.

### Run it (native, no Docker)

```sh
npm run build
POE2_GGG_CLIENT_ID=... POE2_CONTACT_EMAIL=... npm run start:serve
```

Then open `http://localhost:8787/` (or `http://<desktop-ip>:8787/` from
another machine on your LAN) for the dashboard.

### Run it as a Docker image, on the desktop with the game

```sh
docker build -t poe2-mcp-server .
```

**Before running the container**, do the one-time GGG OAuth flow *outside*
Docker (`npm run auth` — see Setup step 3 above), on the desktop. This
writes `tokens.json` under `~/.config/poe2-mcp-server`, which the container
then reads via a bind mount — simpler than trying to complete GGG's
loopback OAuth redirect against a port published from inside a container.

Create a `.env` file next to `docker-compose.yml` with:

```sh
POE2_GGG_CLIENT_ID=...
POE2_CONTACT_EMAIL=...
POE2_WEB_TOKEN=pick-a-long-random-string   # see "A note on exposure" below
POE2_CONFIG_DIR_HOST=C:\Users\you\.config\poe2-mcp-server
POE2_CLIENT_LOG_DIR_HOST=C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs
POE2_POB_BUILDS_DIR_HOST=C:\Users\you\Documents\Path of Building (PoE2)\Builds
```

then:

```sh
docker compose up -d
```

**Important**: `src/config.ts`'s auto-detection of `Client.txt`/PoB2 paths
is gated on the *container's* platform (`linux`), not the Windows host's —
those Windows candidate paths never match inside a Linux container even
though the mounted files are Windows-authored. `docker-compose.yml` already
sets `POE2_CLIENT_LOG_PATH`/`POE2_POB_BUILDS_PATH` explicitly to the
in-container mount points so this isn't an issue as long as the three
`*_HOST` paths in your `.env` are correct. Windows Defender Firewall will
likely prompt to allow inbound connections the first time the container
listens on the published port — allow it for your local network.

### Clipboard → compare, automatically

`src/clipboard-watcher.ts` runs **natively on the Windows desktop, never in
Docker** — a container can't see the Windows clipboard even under Docker
Desktop's WSL2 backend. On the desktop (same machine as PoE2):

```sh
POE2_SERVE_URL=http://127.0.0.1:8787 npm run watch-clipboard
```

It polls the clipboard, and when it sees text starting with `Item Class:`
(PoE2's Ctrl+C item-text format) it pushes it to the server, which
broadcasts it over a websocket to any open dashboard tab — the Compare
panel auto-fills and re-runs the comparison. Copy an item in-game and it
just shows up; no manual paste needed. This is a browser-UI convenience
only, not a channel to the AI (see PROTOCOL.md).

### Remote AI CLI over the network

Once `serve.ts` is running, add it as a remote MCP server from your laptop
instead of a local subprocess. For Claude Code, an HTTP-type server entry
pointing at `http://<desktop-ip>:8787/mcp` (with header
`X-POE2-Token: <your POE2_WEB_TOKEN>` if you set one); consult your CLI's
docs for the exact remote-MCP-server syntax, since this differs from the
stdio `claude mcp add ...` form used above.

### A note on exposure

This server holds your GGG OAuth tokens and can trigger desktop
notifications/TTS on the desktop it runs on. Once it's reachable beyond
`localhost` (LAN access from your laptop, or a published Docker port),
anything else on that network can reach `/api`, `/ws`, and `/mcp` too. Set
`POE2_WEB_TOKEN` to a long random string (checked via an `X-POE2-Token`
header, or a `?token=` query param for the dashboard/websocket) unless
you're certain you trust everything on your LAN. Don't expose this port
past your home network/router.

## Tools exposed

| Tool | Direction | Summary |
|---|---|---|
| `list_characters` | server → AI | Character names on your account (via GGG API or poe.ninja) |
| `get_character_state` | server → AI | Level/class/xp/league for one character (via GGG API or poe.ninja) |
| `get_inventory` | server → AI | Equipped items + skill gems for character or active build |
| `get_current_character` | server → AI | Which character other tools default to (explicit or log-inferred) |
| `set_active_character` | server → AI | Pin the default character for the tools below |
| `get_active_build_status` | server → AI | Active build source, identity, age, and refresh capability |
| `refresh_active_build` | AI → server | Reload the same PoB file or poe.ninja character |
| `clear_active_build` | AI → server | Clear only the local active-build selection |
| `set_account_name` | AI → server | Set PoE account name (e.g. `rpeters1428-1042`) for poe.ninja queries |
| `import_poe_ninja_character` | server → AI | Import character build directly from poe.ninja profile/URL |
| `get_passive_tree` | server → AI | Allocated passive nodes, resolved to names/stats where possible, + jewel data |
| `get_defenses` | server → AI | Gear-only life/ES/armour/evasion/resistances/block/attributes |
| `get_offense_stats` | server → AI | Gear-only weapon damage/crit/speed stats (not a DPS number) |
| `compare_item` | server → AI | Diff a pasted item against what's currently equipped in that slot |
| `find_trade_upgrades` | server → AI | Search/rank live listings against equipped gear and return the official trade URL |
| `get_recent_events` | server → AI | Recent parsed log events (area/level/death/trade/chat); raw diagnostics are opt-in |
| `get_current_area` | server → AI | Last area entered, per the log |
| `get_session_summary` | server → AI | Areas visited / deaths / level-ups this session |
| `is_pob_running` | server → AI | Best-effort check for a running Path of Building 2 process |
| `list_recent_pob_builds` | server → AI | Recently saved PoB2 `.xml` builds, most-recent first |
| `import_pob_build` | server → AI | Parse a PoB2 share code/raw XML, approved URL, or `.xml` inside `POE2_POB_BUILDS_PATH` |
| `emit_advisory` | AI → server | The only "action" tool — see PROTOCOL.md |

Full schemas: `src/types.ts`. Full protocol rationale: `PROTOCOL.md`.

## A note on the log-line patterns

`src/adapters/client-log.ts` parses `Client.txt` with regexes based on
long-standing community knowledge of the format — GGG doesn't publish a
spec for it, and exact wording can drift between patches or locales.
Unmatched lines are retained in a separate diagnostics buffer and returned
only when the caller explicitly requests `types: ["raw_unmatched"]` (with
the original text). If you notice events not firing, `tail -f` your real
`Client.txt`, find the actual line, and adjust the pattern.

## A note on the GGG API adapter

`src/adapters/ggg-api.ts` targets `GET /character/poe2` and
`GET /character/poe2/<name>` per GGG's published reference
(<https://www.pathofexile.com/developer/docs/reference>), requiring the
`account:characters` scope. This is account-sheet data (refreshed on
request), not a live feed — there's no current HP/mana/position here, by
design of GGG's own API, independent of anything this project chose to
build or not build.

## A note on `get_passive_tree`'s node name resolution

`src/adapters/tree-data.ts` resolves the raw allocated passive node hashes
`get_passive_tree` returns to names/stats, using GGG's own official PoE2 tree
export (`github.com/grindinggear/poe2-skilltree-export`'s `data.json`),
cached locally for 24h since it's patch-versioned data, not per-request. This
works entirely independently of the GGG developer API/client ID: it's a
public, unauthenticated file, so it resolves node names for
`import_pob_build`/`import_poe_ninja_character` builds too, not just
`get_passive_tree` via the GGG API path. The schema was verified against a
live fetch of the real ~5MB `data.json`: nodes are keyed by id in the same
id space as `passives.hashes`/PoB2's `<Spec nodes="...">`, with real fields
`name`, `isKeystone`/`isNotable`/`isMastery`, `stats`, and `ascendancyId` (a
slug like `"Ranger3"`, not the ascendancy's flavor name like "Deadeye" —
this project doesn't map slot-to-flavor-name yet). `resolveNodeNames` never
throws and always returns one entry per allocated hash (with `name: null`
for anything it can't resolve), so the raw hash is never lost even if a
future patch changes the schema; if resolution starts coming back empty,
re-fetch `data.json` and check the field candidates in `tree-data.ts`.

## A note on the Path of Building 2 tools

GGG's OAuth application registration is closed to new applications as of this writing ("We are
currently unable to process new applications") — see `pathofexile.com/developer/docs/index`. If
you don't already have a registered client ID, every tool above that hits the GGG API
(`list_characters`, `get_character_state`, `get_inventory`, `get_passive_tree`, `get_defenses`,
`get_offense_stats`, `compare_item`) is unusable until either you get one or GGG reopens
applications (contact `oauth@grindinggear.com`, per a GGG staff forum reply — no confirmed
turnaround time).

`is_pob_running`/`list_recent_pob_builds`/`import_pob_build` (`src/adapters/pob.ts`,
`src/build/pob-decode.ts`, `src/build/pob-parser.ts`) are an alternative that doesn't need any of
that: paste a build exported from Path of Building 2 (its "Generate POB Code" feature, or a saved
`.xml` file) and get back equipment/skills/passive allocation plus **PoB's own already-computed
stats** (real DPS/EHP/crit/etc, with full skill+support+tree interactions) — better than this
project's own gear-only `get_defenses`/`get_offense_stats` where it's available, since PoB actually
simulates the build rather than aggregating raw affixes. There's no live IPC into a running PoB2
window (same reasoning as `memory-adapter.ts` for PoE2 itself), so `is_pob_running` is a best-effort
process-name check and `list_recent_pob_builds`'s "most recent" is a guess at what you're working
on, not a confirmation — `import_pob_build` (paste or pick a file) is the reliable path. The exact
schema was verified against PoB2's own Lua source and cross-checked against an independent
third-party parser, but a few specifics (the share-code compression variant, the real running
process name, `Settings.xml`'s custom build-path attribute) weren't confirmable without a live
install — if `is_pob_running`/`list_recent_pob_builds` don't find your install, adjust the
candidate lists in `src/adapters/pob.ts`/`src/config.ts` the same way you'd adjust a
`client-log.ts` regex that's gone stale.

Imported builds are stored with a provenance envelope instead of as an
unlabeled snapshot. `get_active_build_status` shows whether selection was
explicitly pinned or automatic, the known character/league identity, refresh
age, and a redacted source filename. File-backed builds refresh when their
mtime changes; poe.ninja-backed builds refresh after five minutes or on
`refresh_active_build`. Pasted share-code/XML builds cannot be re-fetched and
must be imported again. `clear_active_build` removes only this local selection.

## Trade upgrade searches

`find_trade_upgrades` translates a build-aware request into a live PoE2 trade
search. For example, “a helmet that improves cold resistance and maximum life,
costs at most 1 exalted, and requires level 40 or lower” maps to:

```json
{
  "slot": "Helm",
  "priorities": ["cold_resistance", "maximum_life"],
  "maxPrice": 1,
  "currency": "exalted",
  "maxRequiredLevel": 40
}
```

The tool reads the equipped helmet, raises each trade filter to at least one
point above that item's contribution, requests online listings, compares up to
10 fetched candidates, and returns `searchUrl` for the official trade page.
Pass `league` explicitly when the active character/build has no verified
league identity. Listings can disappear or change price at any time.
Candidate mods include stats granted by socketed Runes/Soul Cores/Talismans
(PoE2's trade API represents these as nested `socketedItems`, not a flat mod
list on the parent item). `slot: "Offhand"` spans four distinct trade
categories (Shield, Buckler, Focus, Quiver); the tool infers the right one
from the currently equipped item's base type and reports the resolved
`category` in `appliedFilters`, falling back to Shield with an explicit
warning when nothing is equipped or its type can't be determined.

This uses endpoints hosted by GGG's official trade site, but those endpoints
are not documented in GGG's published developer API. The adapter therefore
uses short timeouts, bounded responses, at most 10 detail results, reports rate
limit headers, and labels the source `undocumented_official_site_endpoint`.

## A note on `get_defenses`/`get_offense_stats`/`compare_item`

These aggregate stats from **equipped gear only** (`src/build/defenses.ts`,
`src/build/offense.ts`, `src/build/mod-parser.ts`). GGG's API has no base
life/mana-per-level, no passive-tree stat values (only allocated node
hashes — see `get_passive_tree`), and no skill/support gem data, so these
are not the character's actual in-game totals and `get_offense_stats` is
deliberately not a DPS number. Each response's own `note` field says so.
Affix text parsing is regex-based, same "best-effort, keep the raw text so
nothing is silently dropped" approach as the log-line patterns above — if
you notice a common affix not being picked up, extend the `PATTERNS` table
in `mod-parser.ts`. Passive node hashes are now resolved to names/stats (see
`get_passive_tree`'s note above), but that resolved data isn't folded into
these aggregates yet — a PoB2-backed calculation engine and parsing passive
node stat text the same way gear affixes are parsed are deliberately not
part of this milestone. Skill/support gem scaling is also out of scope here.

TDQS

A4.1/5.0

Scored across 22 tools

Disambiguation4/5

Most tools separate cleanly by resource (character, build, log, PoB, trade), and outputs are distinct even when data sources overlap. A few pairs—get_current_area/get_current_character/get_active_build_status and refresh_active_build/import_pob_build/import_poe_ninja_character—share 'current/active/import' phrasing, so an agent must read descriptions carefully to avoid misselection.

Naming Consistency5/5

All 22 tools use snake_case with a clear verb-first pattern: get_, set_, list_, import_, clear_, refresh_, find_, compare_, emit_, and is_. The active/current qualifiers and PoB/poe.ninja suffixes are applied consistently enough to make tool intent predictable.

Tool Count3/5

At 22 tools, the surface is at the heavy end and includes several granular log/status getters that could plausibly be consolidated, such as get_current_area, get_recent_events, and get_session_summary. The breadth is justified by covering GGG API, PoB, poe.ninja, local logs, and trade, but it still feels slightly over-scoped for a single server.

Completeness4/5

The core workflows—character selection, build import/refresh/clear, character state, defenses/offense/inventory, item comparison, trade upgrades, and local log monitoring—are covered without major dead ends. Minor gaps exist, such as no direct skill/quest/league progression tools, and some build data can only be refreshed from its original source, but agents can work around these.

Maintenance

ActivityMaintained
ResponsivenessUnresponsive