# symbol

**Category:** 💻 Developer Tools  
**Repository:** https://github.com/inotakeh/symbol-mcp-server  
**Views:** 0  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/symbol

## Description
Read-only MCP server for the Symbol blockchain (XYM): node health, harvesting, voting keys.

## Claude Desktop Quick Installation
Heuristic fallback — verify the package name and runner against the repository README before running it. Uses `npx` (confidence: low):

```json
"mcpServers": {
  "symbol": {
    "command": "npx",
    "args": ["-y","symbol"]
  }
}
```

## Documentation & README

# symbol-mcp-server

[![npm version](https://img.shields.io/npm/v/symbol-mcp-server)](https://www.npmjs.com/package/symbol-mcp-server)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/inotakeh/symbol-mcp-server/badge)](https://scorecard.dev/viewer/?uri=github.com/inotakeh/symbol-mcp-server)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14763/badge)](https://www.bestpractices.dev/projects/14763)

> **Symbol only.** This server talks to [Symbol](https://docs.symbol.dev/) (catapult) nodes. It does not
> support NEM NIS1 (XEM), which is a separate chain with a different API.
> **Unofficial.** This is an independent project with no affiliation to the NEM or Symbol core teams.

[日本語版 README](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/README.ja.md)

Read-only [MCP](https://modelcontextprotocol.io/) server that turns the Symbol REST API into 22
task-level tools. Instead of mirroring REST endpoints one-to-one, each tool answers a question a
person actually asks:

- **Account holders:** balances with alias names and decimals applied, transaction history and
  details with decoded messages, mosaic and namespace lookups, fee estimates, address validation,
  height/epoch/time conversion.
- **Node operators:** node health and sync state, delegated-harvesting status, comparison against
  reference nodes, and above all **voting-key expiry**: remaining epochs, blocks and days, the
  estimated expiry date and a recommended renewal window.

Every tool returns `structuredContent` (validated against a published `outputSchema`) plus the same
JSON as text (only `symbol_harvesting_income` with `output: "csv"` puts CSV in the text instead),
with a short `summary` first: its first line answers the question, and further lines add details
such as each month or each check that is not ok. Amounts are returned both with divisibility
applied and as the raw integer; timestamps are ISO 8601 UTC, with a local time added when
`SYMBOL_TIMEZONE` is set.

## What it looks like

Two calls and excerpts of what they return. The values come from the test fixtures (a synthetic
node host and account), not from a live node.

**"Is my node healthy?"** → `symbol_node_health {}`

```jsonc
{
  "summary": "node health: healthy (node.test:3001, mainnet).",
  "network": "mainnet",
  "verdict": "healthy",
  "checks": [
    { "id": "api_node", "status": "ok", "detail": "API node service is up.", "hint": null },
    { "id": "db", "status": "ok", "detail": "Database service is up.", "hint": null },
    {
      "id": "clock_skew",
      "status": "ok",
      "detail": "Node clock is 1,000 ms behind this machine's clock (warn at 15,000 ms, fail at 30,000 ms).",
      "hint": null
    },
    {
      "id": "finalization_lag",
      "status": "ok",
      "detail": "Finalized height 5,763,656 is 19 blocks (about 9.5 min) behind height 5,763,675 (warn at 720, fail at 1,440 blocks).",
      "hint": null
    }
    // … storage_consistent and roles, then node, storage, chain, time and notes
  ]
}
```

A check that is not `ok` carries a `hint` with the next step, and the summary lists it on its own
line.

**"How much did I earn from harvesting, month by month?"**
→ `symbol_harvesting_income { "account": "NCV5HR…", "fromDate": "2026-09-01", "toDate": "2026-09-11", "granularity": "monthly" }`

```
NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY on mainnet, 2026-09-01 to 2026-09-11 (Asia/Tokyo; heights 5,736,305-5,767,984, 31,680 blocks): 20 harvest receipts totalling 662.574177 symbol.xym from 11 blocks (harvester share 461.240390 in 9 receipts, beneficiary share 201.333787 in 11 receipts).
Blocks: 9 harvested by this account, 2 harvested by others that paid it only the beneficiary share (typically delegators on its node). 9 of the 11 beneficiary receipts come from blocks it harvested itself, as its own node's beneficiary.
2026-09: 20 receipts, 662.574177 symbol.xym; 11 blocks: 9 harvested by this account, 2 by others (receipts 9 harvester / 11 beneficiary)
```

That is the `summary`; the same numbers are in `totals` and `monthly[]`, each amount both as a
decimal string (`"662.574177"`) and as the raw integer (`"662574177"`), summed by the server.
Receipts are shares of a block's reward, so an operator that is its own node's beneficiary gets two
for each block it harvests; `blocksHarvested` and `blocksBeneficiaryOnly` count the blocks.

## Requirements

- Node.js 22 or newer.
- A Symbol REST node reachable over `https://` (port 3001 on most public nodes). Public nodes are
  listed at https://nodewatch.symbol.tools/.

## Install

### Claude Desktop: one-click bundle (.mcpb)

1. Download the latest `symbol-mcp-server-<version>.mcpb` from
   [Releases](https://github.com/inotakeh/symbol-mcp-server/releases/latest).
2. Double-click it, or open Claude Desktop's **Settings → Extensions** and install it there.
3. In the settings form, enter the **Symbol node URL**, for example `https://<node-host>:3001` (your
   own node is best, see [Choosing a node](#choosing-a-node)). It is required: the extension does
   not start without it. Optionally set the time zone and a state directory for
   `symbol_harvester_watch`; the other fields can stay empty.
4. Enable the extension.

After changing these settings later, try them in a new conversation.

The bundle holds the server built from the published npm package with its production
dependencies and runs on the Node.js that ships with Claude Desktop. Use either the bundle or the
`npx` configuration below, not both, or every tool appears twice. Claude Desktop may show the
extension as unverified because the bundle is not signed with `mcpb sign`; its origin can be
checked with the build provenance the release workflow attaches:

```sh
gh attestation verify symbol-mcp-server-<version>.mcpb --repo inotakeh/symbol-mcp-server
```

### npm and source

**From npm** (recommended for every other MCP host):

```sh
npx -y symbol-mcp-server --help
```

Also listed in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.inotakeh/symbol`.

**From source:**

```sh
git clone https://github.com/inotakeh/symbol-mcp-server.git
cd symbol-mcp-server
npm ci
npm run build
SYMBOL_NODE_URL=https://<node-host>:3001 node dist/index.js
```

`node dist/index.js --help` prints the environment variables to stderr and exits;
`--version` prints the version. Apart from those two and the `check` subcommand (see
[CLI: monitoring from cron](#cli-monitoring-from-cron)), the binary takes no arguments: everything is
configured through the environment, so a model can never point it at another host.

## Configure your MCP host

The server speaks MCP over stdio. On start-up it fetches `/node/info`, detects mainnet or testnet
from the generation hash seed, and logs one line to stderr:

```
symbol-mcp-server <version>: mainnet via <node-host>:3001, timezone Asia/Tokyo
```

### Claude Desktop

Add to `claude_desktop_config.json`. With the npm package:

```json
{
  "mcpServers": {
    "symbol": {
      "command": "npx",
      "args": ["-y", "symbol-mcp-server"],
      "env": {
        "SYMBOL_NODE_URL": "https://<node-host>:3001",
        "SYMBOL_TIMEZONE": "Asia/Tokyo"
      }
    }
  }
}
```

From a source checkout:

```json
{
  "mcpServers": {
    "symbol": {
      "command": "node",
      "args": ["/path/to/symbol-mcp-server/dist/index.js"],
      "env": {
        "SYMBOL_NODE_URL": "https://<node-host>:3001"
      }
    }
  }
}
```

### Claude Code

```sh
claude mcp add symbol -s user -e SYMBOL_NODE_URL=https://<node-host>:3001 -e SYMBOL_TIMEZONE=Asia/Tokyo -- npx -y symbol-mcp-server
# or, from a source checkout:
claude mcp add symbol -s user -e SYMBOL_NODE_URL=https://<node-host>:3001 -- node /path/to/symbol-mcp-server/dist/index.js
```

Or commit a project-level `.mcp.json`:

```json
{
  "mcpServers": {
    "symbol": {
      "command": "npx",
      "args": ["-y", "symbol-mcp-server"],
      "env": { "SYMBOL_NODE_URL": "https://<node-host>:3001" }
    }
  }
}
```

### Other clients

The same `npx` command works in any MCP host that starts stdio servers. File locations and formats
below follow each client's own documentation.

**Cursor:** `~/.cursor/mcp.json` for all projects, or `.cursor/mcp.json` in one project.

```json
{
  "mcpServers": {
    "symbol": {
      "command": "npx",
      "args": ["-y", "symbol-mcp-server"],
      "env": { "SYMBOL_NODE_URL": "https://<node-host>:3001" }
    }
  }
}
```

**VS Code:** `.vscode/mcp.json` in the workspace, or the user-level file opened with the command
**MCP: Open User Configuration**. The top-level key is `servers`, not `mcpServers`.

```json
{
  "servers": {
    "symbol": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "symbol-mcp-server"],
      "env": { "SYMBOL_NODE_URL": "https://<node-host>:3001" }
    }
  }
}
```

**Cline:** in the Cline panel, open **MCP Servers → Configure → Configure MCP Servers** and add the
server under `mcpServers` (the Cline CLI reads the same format from `~/.cline/mcp.json`).

```json
{
  "mcpServers": {
    "symbol": {
      "command": "npx",
      "args": ["-y", "symbol-mcp-server"],
      "env": { "SYMBOL_NODE_URL": "https://<node-host>:3001" },
      "disabled": false,
      "autoApprove": []
    }
  }
}
```

On Windows, a client that cannot start `npx` may need `"command": "npx.cmd"`.

## Environment variables

| Variable | Required | Meaning |
|---|---|---|
| `SYMBOL_NODE_URL` | yes | REST URL of the node to query, e.g. `https://<node-host>:3001`. `https://` is required (`http://` only for `localhost` / `127.0.0.1` / `[::1]`). The port is used exactly as given. |
| `SYMBOL_NETWORK` | no | `mainnet` or `testnet`. When set, start-up fails if the node reports a different network. |
| `SYMBOL_TIMEZONE` | no | IANA zone such as `Asia/Tokyo`. Adds a local time next to every UTC timestamp. |
| `SYMBOL_REFERENCE_NODES` | no | Comma-separated `https://` node URLs that `symbol_network_compare` and `symbol_version_drift` check against. No other host is ever contacted. |
| `SYMBOL_REQUEST_TIMEOUT_MS` | no | Per-request timeout, 100 to 600000. Default `10000`. |
| `SYMBOL_STATE_DIR` | no | Absolute directory where `symbol_harvester_watch` keeps one snapshot file per node (unlocked harvester public keys, heights and times; no secrets). Created on first save with mode 0700. Unset: the tool reports the current list without a comparison. |

## Choosing a node

- **Your own node is the best choice.** Every call sends the addresses, public keys, hashes and
  namespace names you ask about to `SYMBOL_NODE_URL`. The reference nodes only receive
  `/node/info` and `/chain/info` requests, never your identifiers.
- **A public node works, but its operator can see what you look up.** Its access logs can show
  which accounts, transactions and namespaces were queried, when, and from which IP address. Use a
  node you trust, or your own node for anything you would rather keep private.
- **Finding one:** https://nodewatch.symbol.tools/ lists mainnet and testnet nodes with their height
  and version. Pick an API node that is at the current height, runs the majority version and answers
  over `https://` (usually port 3001). Set `SYMBOL_NETWORK` to make start-up fail if the node turns
  out to be on the other network, and try it with
  `SYMBOL_NODE_URL=https://<node-host>:3001 npx -y symbol-mcp-server check`.

## Tools

All 22 tools are read-only (`readOnlyHint: true`) and are listed in a fixed order. Arguments are
identifiers only, never URLs. Every `account` argument (and the `address` of
`symbol_transaction_search`) takes a base32 address, a 48-character hex address, a hex public key,
or a namespace name such as `alice` or `alice.pay` that carries an address alias; the resolution of
a name is reported in `accountResolution` and at the start of the summary.

| Tool | Arguments | Answers |
|---|---|---|
| `symbol_network_info` | none | Network name/identifier and generation hash seed, current and finalized height, finalization epoch, block target time, voting set grouping, epoch adjustment, XYM mosaic id/alias/divisibility, current fee multipliers. |
| `symbol_node_status` | none | Friendly name, host, roles (Peer/API/Voting), decoded version, health of API node and database, heights, peer count, and a sync check (latest block older than 5 minutes means `synced: false`). |
| `symbol_account_get` | `account` (address, public key or namespace name), `format` | Address in base32 and hex, public key, every mosaic balance with alias and decimals, importance, linked/VRF/node/voting keys, whether delegated harvesting is set up, multisig settings (as a multisig account or as a cosignatory). |
| `symbol_voting_key_status` | `account` | Every voting key with status (expired/active/future), remaining epochs/blocks/days, estimated expiry date, recommended renewal window (7 to 3 days before), slot usage including expired keys, voter eligibility versus `minVoterBalance`, warnings. |
| `symbol_transaction_get` | `transactionHash` | Looks in confirmed, unconfirmed and partial groups and reports the status; type name, signer and recipient, mosaics with aliases, decoded plain message or "encrypted" marker, fee, height and time, inner transactions of aggregates. |
| `symbol_transaction_search` | `address`, `type`, `pageSize`, `pageNumber`, `order`, `format` | Confirmed transactions involving an account, newest first by default, optional type filter by name (`transfer`) or code (`16724`), 10 to 100 per page. |
| `symbol_mosaic_get` | `mosaic` (hex id or alias such as `symbol.xym`) | Supply, divisibility, flags (supply mutable, transferable, restrictable, revokable), owner, start height, duration and estimated expiry. |
| `symbol_namespace_get` | `namespace` (name or hex id) | Owner, root or sub, level names, alias target (address or mosaic), start and end height, estimated expiry date. |
| `symbol_fee_estimate` | `transactionSizeBytes` (optional) | Slow/average/median/fast fee tiers in XYM computed from the node's current multipliers. Nothing is signed or sent. |
| `symbol_address_parse` | `value` (address, public key or namespace name) | Offline validation: checksum, network byte, base32/hex/dashed forms, and the addresses derived from a public key. A namespace name is resolved through the node to its address alias. |
| `symbol_time_convert` | one of `height`, `epoch`, `timestamp` | Height, finalization epoch, network timestamp and wall-clock time. Exact for the past, estimated (and flagged) for the future. |
| `symbol_harvesting_status` | `account` (optional) | Unlocked delegated harvesters on the node, harvesting limits and beneficiary percentage, and for the given account whether its linked key is unlocked here and its balance is within the limits (`minHarvesterBalance` to `maxHarvesterBalance`, both inclusive; above the maximum an account cannot harvest). |
| `symbol_network_compare` | none | Height and finalization of the node versus `SYMBOL_REFERENCE_NODES`, blocks behind the best, `lagging` flags. Explains what to do when no reference nodes are configured. |
| `symbol_harvesting_income` | `account`, `fromDate` + `toDate` or `fromHeight` + `toHeight`, `granularity`, `format`, `output` | Harvest rewards received in the period: receipt count and exact XYM total (summed on the server as integers), harvester / beneficiary / unknown split, block counts (`blocksHarvested`: blocks the account harvested; `blocksBeneficiaryOnly`: blocks others harvested that paid it only the beneficiary share), per-day buckets in `SYMBOL_TIMEZONE` or UTC, or a list of receipts. Dates are resolved to heights from block timestamps. `granularity: monthly` gives one row per calendar month (yearly questions); `output: csv` returns the rows as CSV text for a spreadsheet while the JSON stays available. A year or more in one call is fine: the range is read in chunks of about 90 days (`fetch` reports chunks, retries and pages). |
| `symbol_transaction_status` | `transactionHashes` (array, 1 to 20) | Where each transaction stands right now: confirmed (with height), unconfirmed, partial (waiting for cosignatures), failed (with the node's code and its meaning) or not_found. One request for the whole batch. |
| `symbol_finality_participation` | `account`, `epoch` (optional, default latest finalized), `epochs` (1 to 20, default 1), `format` | Whether the account's voting key actually signed the finalization proof of each epoch: participated (both prevote and precommit), missed (which stage was not signed), no_active_key or unavailable, with the signature count per stage (a stage that the proof splits into several message groups counts as one stage; a signature in any of its groups counts) and a warning when no key covers the current epoch or the current epoch was missed (historical epochs never warn). |
| `symbol_delegation_diagnose` | `account`, `recentDays` (1 to 30, default 7), `format` | Is delegated harvesting active, and if not, where does it stop: account exists, balance within the harvesting limits, importance above zero (or blocks until the next recalculation), linked/VRF/node keys, node key equal to the configured node's `nodePublicKey`, remote key unlocked on that node, account type, harvested blocks in the last N days, and the persistent delegation request transfer to the node. Verdict `active`, `not_active` or `cannot_verify` (delegation to another node cannot be checked from here). |
| `symbol_node_health` | `format` | Is the configured node running healthily right now: API node and database status (a 503 `/node/health` answer is read, not treated as a failure), database block count versus chain height, node clock versus this machine's clock, finalization lag in blocks and minutes, and roles. Six checks in a fixed order, each ok/warn/fail/unknown with a hint; verdict `healthy`, `degraded` (a warning or a check that could not be made) or `unhealthy`. Thresholds derive from the network properties. Complements `symbol_node_status`. |
| `symbol_version_drift` | `format` | Is the node's software version behind the network majority: versions of the peers the node knows plus the reference nodes, as a distribution with the majority version and the share running something newer. Verdict `ok`, `behind` (older than the majority, or newer versions hold at least half the sample), `far_behind` (75% or more newer: peers may refuse connections) or `unknown` (no usable peers, or the node reports no version of its own). Peers that report no version yet (0.0.0.0) are counted apart (`sample.unknownVersion`), not as a version. Peer hosts and keys are never reported. |
| `symbol_harvester_watch` | `mode` (`compare`, `compare_and_save`, `save_only`), `format` | Did the delegated harvesters unlocked on the node increase or decrease since the last call: added and removed remote keys, count delta, and min / max / average over the snapshots of the last 30 days. Snapshots are kept in one file per node under `SYMBOL_STATE_DIR`; without it the current list is reported and no comparison is possible. `compare` reads only, `compare_and_save` (default) also stores the current list, `save_only` stores without comparing. |
| `symbol_account_rank` | `account` (optional), `mosaic` (optional; hex id or alias, default XYM), `top` (1 to 100, default 20), `maxRank` (100 to 5000, default 1000), `format` | Where an account ranks among the holders of a mosaic and who the top holders are, like an explorer rich list: the account's balance, share of supply (4 decimals, integer arithmetic) and rank, the top N holders with balances and shares, and the combined top-N share. Holders are read from `GET /accounts?orderBy=balance` 100 per request, one request at a time, until the account is found or `maxRank` is reached (`rankBeyond` then says so). Omit `account` for the top list only. Ties are ordered by the node; no labels (exchange, foundation) are attached. |
| `symbol_holdings_value` | `account`, `unitPrice` (decimal string, e.g. `"12.34"`), `currency` (3 to 6 upper-case letters), `priceSource` (optional), `priceAsOf` (optional), `mosaic` (optional, default XYM), `decimals` (optional, 0 to 12), `format` | What the account's balance of a mosaic is worth at a unit price **the caller supplies**: the balance, the normalised price, the exact product and the product rounded half up, all in integer arithmetic. The rounding keeps the digits Intl (Unicode CLDR) gives the currency (JPY 0, USD 2, KWD 3, CLF 4); CLDR differs from ISO 4217 for a few codes (HUF, IDR, IQD and IRR have 0 digits in CLDR; ISO 4217 gives IQD 3 and the others 2), and the digits come from the Node.js that runs the server, so pass `decimals` to fix them. A code Intl does not know (BTC, USDT) is not rounded, and neither is a non-zero value that would round to 0; `value.decimalsSource` says which rule applied. The server never fetches or checks prices; `priceSource` and `priceAsOf` are echoed so the answer states where the number came from. Not a tax computation: no fees, spread or taxes. |

### Example questions

**"When does the voting key of NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY expire, and when should I renew it?"**
→ `symbol_voting_key_status { "account": "NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY" }`
Returns each key's `startEpoch`/`endEpoch`, the expiry height `(endEpoch - 1) × votingSetGrouping`,
remaining epochs, blocks and days, an estimated expiry date based on the measured average block
time, the renewal window, free slots (expired keys still occupy slots) and whether the balance
meets `minVoterBalance`.

**"Show me alice's account."**
→ `symbol_account_get { "account": "alice" }`
The namespace `alice` is resolved through the node to its address alias (a missing, expired,
mosaic-aliased or alias-less namespace is an error with a hint); the answer starts with
`alice → NCV5…` and carries the resolution in `accountResolution`. Works for every account argument.

**"How much XYM does NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY hold?"**
→ `symbol_account_get { "account": "NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY" }`
Returns every mosaic with `alias` (`symbol.xym`), `amount` (decimals applied) and `rawAmount`.

**"Show the last 20 transfers involving that account, then the details of the newest one."**
→ `symbol_transaction_search { "address": "NCV5HR…", "type": "transfer", "pageSize": 20 }`
→ `symbol_transaction_get { "transactionHash": "<hash from the list>" }`
The list gives hashes, dates, counterparties and message previews; the second call adds fees,
full decoded messages and inner transactions.

**"Is my node behind?"**
→ `symbol_node_status {}` checks the age of the latest block on the configured node;
→ `symbol_network_compare {}` reports how many blocks it trails `SYMBOL_REFERENCE_NODES`.

**"How much did NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY earn from harvesting in August 2026?"**
→ `symbol_harvesting_income { "account": "NCV5HR…", "fromDate": "2026-08-01", "toDate": "2026-08-31" }`
Resolves the dates to block heights, reads every HarvestFee receipt addressed to the account and
sums them as exact integers: total XYM, harvester versus beneficiary share, and one row per day.
Nothing is left for the model to add up.

**"I just announced my voting key link. Did transaction `<hash>` go through?"**
→ `symbol_transaction_status { "transactionHashes": ["<hash>"] }`
Answers confirmed (with the height), unconfirmed, partial (aggregate bonded waiting for
cosignatures), failed (with the node's code such as `Failure_Core_Insufficient_Balance` and its
meaning) or not_found. Always an array, up to 20 hashes per call.

**"Was my voting node NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY actually able to vote last week?"**
→ `symbol_finality_participation { "account": "NCV5HR…", "epochs": 14 }`
Reads the finalization proof of the latest finalized epoch and the 13 before it (an epoch is
`votingSetGrouping` blocks, about 12 hours on mainnet) and reports per epoch whether one of the
account's voting keys is among the signers of both stages, how many voters signed, and a warning
if the current epoch was missed or no key covers it.

**"I think my delegated harvesting is not working. Have a look at NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY."**
→ `symbol_delegation_diagnose { "account": "NCV5HR…" }`
Runs eleven checks in a fixed order (existence, balance limits, importance, the three key links, node
key versus the configured node, unlocked on that node, account type, recent harvested blocks, the
delegation request transfer) and answers `active`, `not_active` (with the failing step and a hint) or
`cannot_verify` (the account delegates to a node other than `SYMBOL_NODE_URL`, so the node side cannot
be checked).

**"Is my node healthy, and is its version behind?"**
→ `symbol_node_health {}` checks the API node, database, storage, clock and finalization lag of the
configured node and answers healthy / degraded / unhealthy with the failing checks;
→ `symbol_version_drift {}` compares the node version with its peers and the reference nodes and
answers ok / behind / far_behind. Both are the first things to look at after a node OS migration.

**"Have my delegators come back after the migration?"**
→ `symbol_harvester_watch {}` compares the harvesters unlocked on the node right now with the last
stored snapshot (added and removed keys, count delta, 30-day min / max / average) and stores today's
list for the next check. Needs `SYMBOL_STATE_DIR`; without it the tool reports the current count and
says no comparison is possible.

**"Where does NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY rank by XYM holdings? Who are the top 10?"**
→ `symbol_account_rank { "account": "NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY", "top": 10 }`
Reads the holder list ordered by balance 100 accounts at a time until the account turns up (or
`maxRank`, default 1000, is reached), and returns its balance, share of supply and rank together
with the top 10 holders and their combined share. All shares are computed by the server in integer
arithmetic; the top of the list is usually exchanges and the foundation, and the tool labels nobody.

**"If XYM is 12.34 yen, how much are my holdings worth? NCV5HRBSFEGTPNBIUPBVAGWXWXZ43C4TNOQUYUY"**
→ `symbol_holdings_value { "account": "NCV5HR…", "unitPrice": "12.34", "currency": "JPY" }`
Reads the balance and multiplies it by the price in integer arithmetic: `53,321,140 JPY` for
4,321,000 XYM, with the exact product alongside. **The price comes from the caller.** This server
never contacts a price API (it talks to `SYMBOL_NODE_URL` only), so when you ask *"what are my
holdings worth right now?"* the flow in Claude Desktop is: the model looks the price up first (a web
search, another MCP server that serves prices, or you type it in), then calls this tool with
`unitPrice`, `currency` and, ideally, `priceSource` / `priceAsOf` so the answer says where and when
the price was observed. The model is told not to multiply balance by price itself.

More cases, with the exact arguments expected for each, are in [`evals/cases.json`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/evals/cases.json).

## Prompts

Two MCP prompts (`prompts/list`) bundle the tool calls a node operator repeats. Both take one
argument, `account`: the 39-character base32 address of the voting / harvesting account. The
prompt text contains no addresses, hosts, keys or dates of its own.

| Prompt | What it walks through |
|---|---|
| `voting_key_renewal_checklist` | `symbol_voting_key_status` (expiry, renewal window, free slots), `symbol_node_status` (stop if not synced), `symbol_network_compare`, then, after the operator has announced the VotingKeyLink outside this server, `symbol_transaction_status` on the hash, a second `symbol_voting_key_status` to confirm the new key, and `symbol_finality_participation` once the new key's start epoch is finalized. Ends with a four-line summary. |
| `monthly_health_check` | `symbol_node_status`, `symbol_node_health` (unhealthy goes first), `symbol_version_drift` (behind or far_behind goes first), `symbol_network_compare`, `symbol_harvester_watch` (delta against the previous snapshot; `symbol_harvesting_status` only on request), `symbol_voting_key_status` (remaining days, expiry and warnings; a key expiring within 30 days, or no active key, goes first), `symbol_account_get` (balance versus `minVoterBalance`) and `symbol_harvesting_income` for the previous calendar month (the income, then the blocks the account harvested itself and the blocks of delegators or others as separate items). Reports on one screen as Action required / Attention / Normal. |

The server also sends short `instructions` at initialize time (read-only, account formats, which
tool answers the questions that are easy to mix up: harvest income, voting keys, node sync versus
health versus version, whether a transaction went through; use the returned numbers as they are).
Tools that answer neighbouring questions point to each other in their descriptions.

## CLI: monitoring from cron

The same binary has a one-shot `check` subcommand that needs no MCP client. It judges the node with
the tools above, prints one report and exits non-zero when something is wrong:

```
symbol-mcp-server check [--account <address|publicKey|namespace>] [--warn-days <n>]
                        [--format text|json] [--quiet]
```

It reads the same environment variables as the server (`SYMBOL_NODE_URL` is required;
`SYMBOL_TIMEZONE`, `SYMBOL_REFERENCE_NODES` and `SYMBOL_STATE_DIR` are optional) and needs
Node.js 22 or newer. Started without arguments the binary is still the MCP server, unchanged.

| # | Item | ok / warn / fail |
|---|---|---|
| 1 | `node_health` | `symbol_node_health`: healthy / degraded / unhealthy |
| 2 | `version_drift` | `symbol_version_drift`: ok / behind or unknown / far_behind |
| 3 | `harvester_watch` | `symbol_harvester_watch` (compare and save): warn when fewer harvesters are unlocked than at the previous run, or when the snapshot could not be saved. Skipped without `SYMBOL_STATE_DIR` |
| 4 | `voting_key_status` | With `--account`: warn when the active voting key expires within `--warn-days` (default 14, 1 to 120), fail within 3 days or without an active key; ok when a successor key is already registered without a gap. Skipped without `--account` |
| 5 | `finality_participation` | With `--account`, latest finalized epoch: participated / missed or no proof on the node / no key covers the epoch. Skipped without `--account` |

The judgments are the tools' own; the check only reads their output, and the hint printed under a
warn or fail line is the tool's text. A tool that fails (for example an HTTP error) fails its item
and the others still run.

| Exit code | Meaning |
|---|---|
| 0 | every item is ok or skipped |
| 1 | at least one warning, no failure |
| 2 | at least one failure |
| 3 | the check could not run: configuration error, node unreachable, or bad arguments (one or two lines on stderr say why) |

Text output (the default; illustrative values):

```
symbol check: WARN (node.example:3001, mainnet, 2026-01-15T07:00:03+09:00)
[ok] node_health: healthy (finalization lag 12 blocks)
[ok] version_drift: ok. node.example:3001 runs 1.0.3.9; majority of 24 sampled nodes runs 1.0.3.9; 0% run something newer.
[ok] harvester_watch: 18 unlocked harvesters on node.example:3001, unchanged since 2026-01-14T07:00:02+09:00 (2026-01-13T22:00:02.000Z). Snapshot saved (31 stored).
[warn] voting_key_status: active key 0A1B2C3D… expires in about 12.4 days (epoch 4321, estimated 2026-01-27T16:40:00+09:00 (2026-01-27T07:40:00.000Z))
  hint: Active voting key 0A1B2C3D… expires at epoch 4321 in about 12.4 days (...) and no successor key is registered.
[ok] finality_participation: epoch 4290: participated (signed prevote and precommit)
```

`--format json` prints the same report as one JSON document: `{ verdict, exitCode, node: { host,
network }, checkedAt, checks: [{ id, status, detail, hint }], warnDays, account }`, with `verdict`
one of `ok`, `warn`, `fail`, `error` (exit code 3) and `account` the resolved address. `--quiet`
prints nothing when the exit code is 0, so cron only mails when there is something to read:

```
MAILTO=you@example.com
0 7 * * * SYMBOL_NODE_URL=https://node.example:3001 SYMBOL_STATE_DIR=/var/lib/symbol-mcp-server \
  npx --yes symbol-mcp-server check --account NXXX... --warn-days 14 --quiet
```

- **The check sends no notification.** It writes to stdout and stderr and sets the exit code; mail
  is cron's job (`MAILTO`). It contacts `SYMBOL_NODE_URL` and the `SYMBOL_REFERENCE_NODES`, nothing
  else, and is as read-only as the server. With `SYMBOL_STATE_DIR` set, every run appends one
  snapshot to the file `symbol_harvester_watch` uses (the newest 60 are kept).
- The whole run is limited to 120 seconds. At the limit the remaining items are skipped, the reason
  goes to stderr, and the result is WARN at best, printed even with `--quiet`.
- A typo in the subcommand name is an unknown argument of the server and exits with 2, as before.

## Security

- **Read-only.** No tool signs, builds or announces transactions. No argument accepts a private key,
  mnemonic or token. Nothing is stored between calls, except that `symbol_harvester_watch` keeps its
  per-node snapshot of unlocked harvester public keys, heights and times under `SYMBOL_STATE_DIR` when
  that variable is set (no secrets; delete the file to start over).
- **Fixed destinations.** The server contacts only `SYMBOL_NODE_URL` and, for
  `symbol_network_compare` and `symbol_version_drift`, the hosts listed in `SYMBOL_REFERENCE_NODES`. Tools never take a URL as
  an argument, so a model cannot redirect requests. There is no telemetry.
- **Untrusted chain data.** Transfer messages, metadata values, alias names and what a node reports
  about itself (friendly name, host name, status and version strings) are written by third parties.
  They are exposed under names that make this obvious (`messageText`) and made one line: tabs and
  line breaks become a space, so words stay apart, and runs of spaces become one. Every other
  control and invisible format character is stripped (zero-width and bidi characters, soft hyphens,
  and the tag characters U+E0000 to U+E007F that people cannot see but models can read), and length
  is capped without splitting a character. Variation selectors are kept, so emoji and ideograph variants
  survive; emoji joined by a zero-width joiner come out as separate emoji. The 17 tools that show such
  text report in `invisibleCharactersRemoved` how many characters were removed from it, and when any
  were, the summary ends with a line saying so. Treat all of it as data, not instructions.
- **Fail loudly.** A network mismatch (`SYMBOL_NETWORK` versus the node), an unreachable node or an
  unexpected response shape is an error with a recovery hint, never a silent fallback to another
  network. Stack traces and raw HTTP bodies are never returned to the model.
- **Request hygiene.** Per-request timeout, `User-Agent`, a 5 MB response cap applied while the body
  streams in (a larger declared `Content-Length` is refused unread), at most 4 concurrent requests
  per node, and schema validation of every response. Redirects are never followed: a node that
  answers with HTTP 3xx gets an error, and the address it points to is not contacted. Request paths
  carry plain identifiers only.
- **Repository settings.** CodeQL code scanning, secret scanning with push protection, Dependabot
  (security updates and grouped monthly version updates), branch protection on `main` (every change
  lands through a pull request, with linear history) and private vulnerability reporting are enabled.

Vulnerability reports: see [`SECURITY.md`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/SECURITY.md).

## Release integrity

- **Published from CI, with provenance.** Every npm release is built and published by the GitHub
  Actions workflow [`release.yml`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/.github/workflows/release.yml) through npm trusted publishing
  (OIDC). There is no npm token, neither on a maintainer's machine nor in the repository secrets.
  Each version carries a provenance attestation that links it to the source commit and the workflow
  run that built it.
- **Check it yourself.**
  - The package page on [npmjs.com](https://www.npmjs.com/package/symbol-mcp-server) shows a
    **Provenance** section with the commit and the workflow run.
  - `npm view symbol-mcp-server dist.attestations` prints the attestation URL and the provenance
    predicate type of the latest version.
  - In a project that installs it, `npm audit signatures` verifies the registry signatures and
    provenance attestations of the installed packages.
- **GitHub Releases carry the same package.** From 0.8.0 on, each GitHub Release has three files
  attached (earlier releases have the first two): `symbol-mcp-server-<version>.tgz`, byte for byte
  the tarball npm serves (its SHA-512 is checked against the registry's `dist.integrity`),
  `symbol-mcp-server-<version>.tgz.sigstore.json`, npm's SLSA provenance for that tarball as a
  Sigstore bundle (its subject is checked to be the tarball's SHA-512), and the Claude Desktop
  bundle `symbol-mcp-server-<version>.mcpb` (next point). The first two are collected by
  [`scripts/release-assets.sh`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/scripts/release-assets.sh). To verify a downloaded pair with the
  [GitHub CLI](https://cli.github.com/manual/gh_attestation_verify):

  ```sh
  gh attestation verify symbol-mcp-server-<version>.tgz \
    --bundle symbol-mcp-server-<version>.tgz.sigstore.json \
    --repo inotakeh/symbol-mcp-server --digest-alg sha512
  ```

  `--digest-alg sha512` is needed because npm's provenance names the tarball by its SHA-512.
- **The Claude Desktop bundle is built from that same tarball.** `symbol-mcp-server-<version>.mcpb`
  is made by [`scripts/build-mcpb.sh`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/scripts/build-mcpb.sh) from the checked npm tarball above plus
  the production dependencies installed with `npm ci --omit=dev` from the lockfile of that release;
  nothing else is compiled or downloaded. The release workflow attaches a GitHub build provenance
  attestation to it (`gh attestation verify … --repo inotakeh/symbol-mcp-server`, see
  [Install](#install)).
- **Who can release.** Only maintainers create release tags (`v1.2.3`). The workflow first waits
  in the `npm-publish` GitHub Environment until a maintainer approves the run; only then does it
  check that the tag matches `package.json` and the other release files, run lint, typecheck and
  tests, and publish. The whole procedure is in [`docs/RELEASING.md`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/docs/RELEASING.md).

## Supported networks

| Network | Identifier | Detected by generation hash seed |
|---|---|---|
| Symbol mainnet | 104 | `57F7DA20…72B2D6` |
| Symbol testnet (sai) | 152 | `49D6E1CE…FC665A4` |

The network is detected from the node at start-up. Any other generation hash seed (private
networks, NEM NIS1) is rejected. Nodes of both networks are listed at
https://nodewatch.symbol.tools/; see [Choosing a node](#choosing-a-node).

## Limitations

- **Node history and limits.** Results come from the configured node. Nodes that prune transaction
  history return only what they still hold, so `symbol_transaction_search` may miss old
  transactions on such nodes. A public node may also limit how many requests it accepts. The calls
  that send the most are `symbol_account_rank` (up to 50 pages) and `symbol_harvesting_income` over
  a long period (up to 200 pages); use your own node for those.
- **Future dates are estimates.** Expiry dates for voting keys, namespaces and mosaics, and any
  future height or epoch, are projected from the measured average block time over the last 10,000
  blocks (about 30 s on mainnet) and are flagged as estimates.
- **Encrypted messages are not decrypted**; they are reported as encrypted.
- **Page size is 10 to 100**, because catapult-rest coerces smaller pages to 10.
- **Confirmed transactions only** in search. Unconfirmed and partial transactions are visible
  through `symbol_transaction_get` by hash.
- **Harvesting status covers the configured node** (`/node/unlockedaccount`), not the whole network.
- **No prices.** `symbol_holdings_value` multiplies a balance by a unit price the caller passes in; it
  does not fetch, check or remember prices, and the result is only as good as that input. Look the
  price up first (web search, a price MCP server, or the user) and pass it with `priceSource` and
  `priceAsOf`. The value is the plain product: no fees, spread or taxes, and no tax lot accounting.
- **Holder rank is a scan, not an index.** `symbol_account_rank` reads the holder list 100 accounts per
  request down to `maxRank` (at most 5,000, i.e. 50 requests); an account below that gets `rank: null`
  with `rankBeyond`. Equal balances are ordered by the node and may swap between calls.
- **Harvester history is local.** `symbol_harvester_watch` compares against snapshots it wrote itself
  under `SYMBOL_STATE_DIR`; another machine, a deleted file or a changed node key (a new node.key.pem
  after a migration) starts a new baseline. Repeated calls on the same day add repeated snapshots;
  only the newest 60 are kept.
- **Harvest income reads at most 20,000 statements per call** (200 pages of 100). A longer period
  comes back `truncated`; split it with `fromHeight`/`toHeight`. Rewards are summed from HarvestFee
  receipts, so a node that prunes receipts reports less than the chain holds.
- **Harvest income for a year is read in pieces.** catapult-rest answers a wide height range slowly,
  so the range is split into chunks of about 90 days that are read one after another; a chunk whose
  first page times out is halved (down to about 7 days) and retried. The totals are the same as from
  one query, the `fetch` field says how the range was read, and only a node that cannot answer about
  7 days within `SYMBOL_REQUEST_TIMEOUT_MS` makes the call fail.
- **Finality participation reads the proofs the node holds.** `unavailable` means the node has no
  proof for that epoch (not finalized yet, or outside the history it keeps), not that the account
  did not vote. The server does not know how many voters are registered, so `signatureCount` can
  only be compared with an external list such as nodewatch.
- **Version drift is sampled, not surveyed.** `symbol_version_drift` sees the peers the configured
  node currently knows plus the reference nodes, not the whole network; the full picture is on
  nodewatch. Clock skew in `symbol_node_health` is measured against the clock of the machine running
  this server, which may itself be off.
- **Mainnet and testnet only.** No transaction building, signing or announcing, by design.
- **The URL is used as given.** The server does not switch ports or schemes on its own; if a node
  only serves port 3000 over http, it cannot be used unless it is on localhost.

## Troubleshooting

Start-up errors go to stderr as one line starting with `symbol-mcp-server failed to start:`. MCP
hosts keep stderr in their logs (Claude Desktop: `~/Library/Logs/Claude/mcp*.log` on macOS,
`%APPDATA%\Claude\logs` on Windows), and running the same command in a terminal prints it too.

| You see | Cause and fix |
|---|---|
| `SYMBOL_NODE_URL is required` | The variable does not reach the server process. Put it in the `env` block of the host configuration; an `export` in your shell does not reach a server that a desktop app starts. |
| `SYMBOL_NODE_URL must use https://` or `SYMBOL_NODE_URL must start with https://` | Use the node's `https://` URL (usually port 3001). `http://` is accepted only for `localhost` / `127.0.0.1` / `[::1]`. |
| `SYMBOL_NETWORK=mainnet but node <host> is on testnet` | The node is on the other network. Point `SYMBOL_NODE_URL` at a node of the network you want, or correct `SYMBOL_NETWORK`. |
| `Node <host> reports an unknown network` | The node's generation hash seed is neither Symbol mainnet nor testnet (a private network, or a NEM NIS1 node). Use a Symbol mainnet or testnet node. |
| `<host> did not answer /node/info within 10000 ms` or `could not reach <host> for /node/info` at start-up; `Node <host> did not answer … within … ms` or `Could not connect to node <host>` from a tool | The node is down, overloaded, or the port is wrong (3001 for https). Try another node from nodewatch, or give a slow node more time with `SYMBOL_REQUEST_TIMEOUT_MS` (up to 600000). |
| `<host> answered /node/info with a redirect (HTTP 301), which is not followed` at start-up; `Node <host> answered … with a redirect` from a tool | The URL leads to something that redirects: http to https, a proxy, or a moved path. Redirects are never followed and the address in them is not contacted. Set `SYMBOL_NODE_URL` to the node's REST API URL itself, usually `https://<node-host>:3001`. |
| `Node <host> answered HTTP 429 for …` (or 503) from a tool | The node limits how many requests it accepts, or is overloaded. Wait and try again. For calls that read many pages (`symbol_account_rank` with a large `maxRank`, `symbol_harvesting_income` over a long period), use your own node or another node from nodewatch. |
| Claude Desktop lists no `symbol_*` tools | With the bundle (.mcpb): open the extension's settings; it does not start while the **Symbol node URL** is empty. With a JSON configuration: Claude Desktop reads it at start-up only. Quit it completely (closing the window is not enough) and reopen it. If the tools are still missing, look for a `failed to start` line in the log above and check that the JSON is valid. |
| `npm warn EBADENGINE Unsupported engine` (printed by npm) | Node.js is older than 22. Check `node --version` and install 22 or newer (for example `nvm install 22`). A desktop app may find a different `node` / `npx` on its `PATH` than your shell does; give the full path in `command` if needed. |
| An older version keeps running after a release | npx reuses its cache. Run `npx -y symbol-mcp-server@latest --version`, or put `symbol-mcp-server@latest` in `args`. |

## Development

```sh
npm ci
npm run lint && npm run typecheck && npm test
npm run build
SYMBOL_NODE_URL=https://<node-host>:3001 node dist/index.js
npx @modelcontextprotocol/inspector -e SYMBOL_NODE_URL=https://<node-host>:3001 node dist/index.js
SYMBOL_INTEGRATION=1 SYMBOL_NODE_URL=https://<testnet-node>:3001 npm test   # live-node tests
SYMBOL_INTEGRATION=1 SYMBOL_NODE_URL=https://<node-host>:3001 SYMBOL_INTEGRATION_ACCOUNT=<address> npm test   # account tools against a specific account
SYMBOL_INTEGRATION=1 SYMBOL_NODE_URL=https://<node-host>:3001 SYMBOL_INTEGRATION_MULTISIG_ACCOUNT=<address> npm test   # symbol_account_get against a multisig account and its first cosignatory
node scripts/capture-fixtures.mjs https://<node-host>:3001   # refresh test/fixtures/<network>/ from a node
```

The MCP Inspector gives the server it starts only a few of its own environment variables (`PATH`,
`HOME` and the like, the MCP SDK's default) plus the ones it is given, so a variable set in front of
`npx` does not reach the server. Pass each one with `-e KEY=VALUE` as above, or enter it in the
Inspector's form; on macOS and Linux, wrapping the server command in `env` works too.

Design notes: [`docs/DESIGN-BRIEF.md`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/docs/DESIGN-BRIEF.md). Changes: [`CHANGELOG.md`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/CHANGELOG.md).
Releases: [`docs/RELEASING.md`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/docs/RELEASING.md).

## Contributing

Bug reports, feature requests and pull requests are welcome. See [`CONTRIBUTING.md`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/CONTRIBUTING.md)
for the development setup, how to add a tool and the design rules; security problems go through
[`SECURITY.md`](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/SECURITY.md).

## License

[MIT](https://github.com/inotakeh/symbol-mcp-server/blob/HEAD/LICENSE)

