diff --git a/.env.example b/.env.example index 6f2918e..946e261 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,23 @@ AELFSCAN_TIMEOUT_MS=10000 # Retry count for transient request failures AELFSCAN_RETRY=1 + +# Retry backoff base milliseconds and max milliseconds (exponential + jitter) +AELFSCAN_RETRY_BASE_MS=200 +AELFSCAN_RETRY_MAX_MS=3000 + +# HTTP client concurrency limit +AELFSCAN_MAX_CONCURRENT_REQUESTS=5 + +# Default in-memory cache TTL for statistics GET requests (milliseconds) +AELFSCAN_CACHE_TTL_MS=60000 +# Maximum in-memory cache entries before FIFO eviction +AELFSCAN_CACHE_MAX_ENTRIES=500 + +# Pagination maxResultCount upper bound +AELFSCAN_MAX_RESULT_COUNT=200 + +# MCP output controls +AELFSCAN_MCP_MAX_ITEMS=50 +AELFSCAN_MCP_MAX_CHARS=60000 +AELFSCAN_MCP_INCLUDE_RAW=false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1a30a8d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,59 @@ +name: Publish to npm + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bunx tsc --noEmit + + - name: Run tests + run: bun run test + + - name: Verify generated openclaw config + run: bun run build:openclaw:check + + publish: + if: startsWith(github.ref, 'refs/tags/v') + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Ensure latest npm (trusted publishing requires >= 11.5.1) + run: npm install -g npm@latest + + - name: Verify tag matches package.json version + run: | + PKG_VERSION=$(node -p "require('./package.json').version") + TAG_VERSION="${GITHUB_REF_NAME#v}" + if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match package.json ($PKG_VERSION)" + exit 1 + fi + + - run: npm publish --provenance --access public diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e3f7f73 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + - master + - 'codex/**' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bunx tsc --noEmit + + - name: Run tests + run: bun run test + + - name: Unit coverage + run: bun run test:unit:coverage + + - name: Verify generated openclaw config + run: bun run build:openclaw:check + + - name: Upload coverage to Codecov + if: ${{ env.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@v5 + with: + token: ${{ env.CODECOV_TOKEN }} + files: ./coverage/lcov.info + fail_ci_if_error: true + + - name: Skip Codecov upload (missing token) + if: ${{ env.CODECOV_TOKEN == '' }} + run: echo "CODECOV_TOKEN is not set; skipping Codecov upload." diff --git a/README.md b/README.md index b7f8026..26cb899 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ AelfScan explorer skill toolkit for AI agents, with **SDK + MCP + CLI + OpenClaw - Token: token list/detail/transfers/holders - NFT: collections/detail/transfers/holders/inventory/item detail/item holders/item activity - Statistics: daily transactions/addresses/activity, produce metrics, fees/reward/burn, supply/market/staking/TVL, node/ELF supply and date-range summary APIs +- Metadata-driven tool registry: one source of truth for SDK/CLI/MCP/OpenClaw +- MCP output governance: array truncation + max chars + configurable raw payload inclusion - Unified output shape: `ToolResult` with `traceId`, standardized errors, and `raw` payload ## Architecture @@ -20,7 +22,8 @@ aelfscan-skill/ ├── aelfscan_skill.ts # CLI adapter ├── src/ │ ├── core/ # Domain logic (search/blockchain/address/token/nft/statistics) -│ └── mcp/server.ts # MCP adapter +│ ├── tooling/ # Single source tool descriptors +│ └── mcp/ # MCP adapter + output policy ├── lib/ # Config/http/errors/trace/types ├── bin/setup.ts # Setup for claude/cursor/openclaw ├── openclaw.json @@ -58,6 +61,7 @@ bun run aelfscan_skill.ts blockchain log-events --input '{"chainId":"AELF","cont bun run aelfscan_skill.ts address detail --input '{"chainId":"AELF","address":"JRmBduh4nXWi1aXgdUsj5gJrzeZb2LxmrAbf7W99faZSvoAaE"}' bun run aelfscan_skill.ts statistics daily-transactions --input '{"chainId":"AELF"}' bun run aelfscan_skill.ts statistics daily-transaction-info --input '{"chainId":"AELF","startDate":"2026-02-20","endDate":"2026-02-26"}' +bun run aelfscan_skill.ts statistics metric --input '{"metric":"dailyTransactions","chainId":"AELF"}' ``` ## MCP Config Example @@ -72,12 +76,15 @@ bun run setup cursor bun run setup cursor --global bun run setup openclaw bun run setup list +bun run build:openclaw ``` ## Tests ```bash bun run test:unit +bun run test:unit:coverage +bun run coverage:badge bun run test:integration bun run test:e2e @@ -91,6 +98,15 @@ RUN_LIVE_TESTS=1 bun run test:e2e - `AELFSCAN_DEFAULT_CHAIN_ID` (default: empty for multi-chain) - `AELFSCAN_TIMEOUT_MS` (default: `10000`) - `AELFSCAN_RETRY` (default: `1`) +- `AELFSCAN_RETRY_BASE_MS` (default: `200`) +- `AELFSCAN_RETRY_MAX_MS` (default: `3000`) +- `AELFSCAN_MAX_CONCURRENT_REQUESTS` (default: `5`) +- `AELFSCAN_CACHE_TTL_MS` (default: `60000`) +- `AELFSCAN_CACHE_MAX_ENTRIES` (default: `500`) +- `AELFSCAN_MAX_RESULT_COUNT` (default: `200`) +- `AELFSCAN_MCP_MAX_ITEMS` (default: `50`) +- `AELFSCAN_MCP_MAX_CHARS` (default: `60000`) +- `AELFSCAN_MCP_INCLUDE_RAW` (default: `false`) ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index fa8cf39..62d1c07 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,6 +10,8 @@ - Token:列表、详情、转账、持有人 - NFT:合集列表/详情、转账、持有人、库存、Item 详情/持有人/活动 - Statistics:交易/地址活跃度、产块指标、手续费/奖励/销毁、供给/市值/质押/TVL、节点与 ELF 供给、按日期区间汇总 +- 单一元数据源:SDK/CLI/MCP/OpenClaw 共用 tool descriptor +- MCP 输出治理:数组截断 + 文本长度上限 + `raw` 可配置 - 统一返回模型:`ToolResult`,包含 `traceId`、标准化错误和 `raw` 原始响应 ## 架构 @@ -20,7 +22,8 @@ aelfscan-skill/ ├── aelfscan_skill.ts # CLI 适配层 ├── src/ │ ├── core/ # 域逻辑(search/blockchain/address/token/nft/statistics) -│ └── mcp/server.ts # MCP 适配层 +│ ├── tooling/ # Tool descriptor 单一真源 +│ └── mcp/ # MCP 适配层与输出治理 ├── lib/ # config/http/errors/trace/types ├── bin/setup.ts # claude/cursor/openclaw 一键配置 ├── openclaw.json @@ -58,6 +61,7 @@ bun run aelfscan_skill.ts blockchain log-events --input '{"chainId":"AELF","cont bun run aelfscan_skill.ts address detail --input '{"chainId":"AELF","address":"JRmBduh4nXWi1aXgdUsj5gJrzeZb2LxmrAbf7W99faZSvoAaE"}' bun run aelfscan_skill.ts statistics daily-transactions --input '{"chainId":"AELF"}' bun run aelfscan_skill.ts statistics daily-transaction-info --input '{"chainId":"AELF","startDate":"2026-02-20","endDate":"2026-02-26"}' +bun run aelfscan_skill.ts statistics metric --input '{"metric":"dailyTransactions","chainId":"AELF"}' ``` ## MCP 配置模板 @@ -72,12 +76,15 @@ bun run setup cursor bun run setup cursor --global bun run setup openclaw bun run setup list +bun run build:openclaw ``` ## 测试 ```bash bun run test:unit +bun run test:unit:coverage +bun run coverage:badge bun run test:integration bun run test:e2e @@ -91,6 +98,15 @@ RUN_LIVE_TESTS=1 bun run test:e2e - `AELFSCAN_DEFAULT_CHAIN_ID`(默认空字符串,表示 multi-chain) - `AELFSCAN_TIMEOUT_MS`(默认 `10000`) - `AELFSCAN_RETRY`(默认 `1`) +- `AELFSCAN_RETRY_BASE_MS`(默认 `200`) +- `AELFSCAN_RETRY_MAX_MS`(默认 `3000`) +- `AELFSCAN_MAX_CONCURRENT_REQUESTS`(默认 `5`) +- `AELFSCAN_CACHE_TTL_MS`(默认 `60000`) +- `AELFSCAN_CACHE_MAX_ENTRIES`(默认 `500`) +- `AELFSCAN_MAX_RESULT_COUNT`(默认 `200`) +- `AELFSCAN_MCP_MAX_ITEMS`(默认 `50`) +- `AELFSCAN_MCP_MAX_CHARS`(默认 `60000`) +- `AELFSCAN_MCP_INCLUDE_RAW`(默认 `false`) ## License diff --git a/aelfscan_skill.ts b/aelfscan_skill.ts index 5aa6743..679b96c 100755 --- a/aelfscan_skill.ts +++ b/aelfscan_skill.ts @@ -1,138 +1,7 @@ #!/usr/bin/env bun import { Command } from 'commander'; -import { - getAccounts, - getAddressDetail, - getAddressDictionary, - getAddressNftAssets, - getAddressTokens, - getAddressTransfers, - getAvgBlockDuration, - getBlockDetail, - getBlockchainOverview, - getBlockProduceRate, - getBlocks, - getCurrencyPrice, - getCycleCount, - getDailyActiveAddresses, - getDailyActivityAddress, - getDailyAvgBlockSize, - getDailyAvgTransactionFee, - getDailyBlockReward, - getDailyContractCall, - getDailyDeployContract, - getDailyElfPrice, - getDailyHolder, - getDailyMarketCap, - getDailyStaked, - getDailySupplyGrowth, - getDailyTotalBurnt, - getDailyTransactionInfo, - getDailyTransactions, - getDailyTvl, - getDailyTxFee, - getElfSupply, - getContractEvents, - getContractHistory, - getContracts, - getContractSource, - getLatestBlocks, - getLatestTransactions, - getLogEvents, - getMonthlyActiveAddresses, - getNftCollectionDetail, - getNftCollections, - getNftHolders, - getNftInventory, - getNftItemActivity, - getNftItemDetail, - getNftItemHolders, - getNftTransfers, - getSearchFilters, - getTokenDetail, - getTokenHolders, - getTokens, - getTokenTransfers, - getTopContractCall, - getTransactionDataChart, - getTransactionDetail, - getTransactions, - getUniqueAddresses, - getNodeBlockProduce, - getNodeCurrentProduceInfo, - search, -} from './index.js'; -import type { ToolResult } from './lib/types.js'; - -type Handler = (input: Record) => Promise>; - -const handlers: Record = { - 'search.filters': async (input) => getSearchFilters(input), - 'search.query': async (input) => search(input as any), - - 'blockchain.blocks': async (input) => getBlocks(input), - 'blockchain.blocks-latest': async (input) => getLatestBlocks(input), - 'blockchain.block-detail': async (input) => getBlockDetail(input as any), - 'blockchain.transactions': async (input) => getTransactions(input), - 'blockchain.transactions-latest': async (input) => getLatestTransactions(input), - 'blockchain.transaction-detail': async (input) => getTransactionDetail(input as any), - 'blockchain.overview': async (input) => getBlockchainOverview(input), - 'blockchain.transaction-data-chart': async (input) => getTransactionDataChart(input), - 'blockchain.address-dictionary': async (input) => getAddressDictionary(input as any), - 'blockchain.log-events': async (input) => getLogEvents(input as any), - - 'address.accounts': async (input) => getAccounts(input), - 'address.contracts': async (input) => getContracts(input), - 'address.detail': async (input) => getAddressDetail(input as any), - 'address.tokens': async (input) => getAddressTokens(input as any), - 'address.nft-assets': async (input) => getAddressNftAssets(input as any), - 'address.transfers': async (input) => getAddressTransfers(input as any), - 'address.contract-history': async (input) => getContractHistory(input as any), - 'address.contract-events': async (input) => getContractEvents(input as any), - 'address.contract-source': async (input) => getContractSource(input as any), - - 'token.list': async (input) => getTokens(input), - 'token.detail': async (input) => getTokenDetail(input as any), - 'token.transfers': async (input) => getTokenTransfers(input as any), - 'token.holders': async (input) => getTokenHolders(input), - - 'nft.collections': async (input) => getNftCollections(input), - 'nft.collection-detail': async (input) => getNftCollectionDetail(input as any), - 'nft.transfers': async (input) => getNftTransfers(input as any), - 'nft.holders': async (input) => getNftHolders(input as any), - 'nft.inventory': async (input) => getNftInventory(input as any), - 'nft.item-detail': async (input) => getNftItemDetail(input as any), - 'nft.item-holders': async (input) => getNftItemHolders(input as any), - 'nft.item-activity': async (input) => getNftItemActivity(input as any), - - 'statistics.daily-transactions': async (input) => getDailyTransactions(input), - 'statistics.unique-addresses': async (input) => getUniqueAddresses(input), - 'statistics.daily-active-addresses': async (input) => getDailyActiveAddresses(input), - 'statistics.monthly-active-addresses': async (input) => getMonthlyActiveAddresses(input), - 'statistics.block-produce-rate': async (input) => getBlockProduceRate(input), - 'statistics.avg-block-duration': async (input) => getAvgBlockDuration(input), - 'statistics.cycle-count': async (input) => getCycleCount(input), - 'statistics.node-block-produce': async (input) => getNodeBlockProduce(input), - 'statistics.daily-avg-transaction-fee': async (input) => getDailyAvgTransactionFee(input), - 'statistics.daily-tx-fee': async (input) => getDailyTxFee(input), - 'statistics.daily-total-burnt': async (input) => getDailyTotalBurnt(input), - 'statistics.daily-elf-price': async (input) => getDailyElfPrice(input), - 'statistics.daily-deploy-contract': async (input) => getDailyDeployContract(input), - 'statistics.daily-block-reward': async (input) => getDailyBlockReward(input), - 'statistics.daily-avg-block-size': async (input) => getDailyAvgBlockSize(input), - 'statistics.top-contract-call': async (input) => getTopContractCall(input), - 'statistics.daily-contract-call': async (input) => getDailyContractCall(input), - 'statistics.daily-supply-growth': async (input) => getDailySupplyGrowth(input), - 'statistics.daily-market-cap': async (input) => getDailyMarketCap(input), - 'statistics.daily-staked': async (input) => getDailyStaked(input), - 'statistics.daily-holder': async (input) => getDailyHolder(input), - 'statistics.daily-tvl': async (input) => getDailyTvl(input), - 'statistics.node-current-produce-info': async (input) => getNodeCurrentProduceInfo(input), - 'statistics.elf-supply': async (input) => getElfSupply(input), - 'statistics.daily-transaction-info': async (input) => getDailyTransactionInfo(input as any), - 'statistics.daily-activity-address': async (input) => getDailyActivityAddress(input as any), - 'statistics.currency-price': async (input) => getCurrencyPrice(input), -}; +import { ZodError } from 'zod'; +import { CLI_TOOL_DESCRIPTOR_BY_KEY } from './src/tooling/tool-descriptors.js'; function parseInput(raw?: string): Record { if (!raw) { @@ -153,14 +22,15 @@ function parseInput(raw?: string): Record { async function runCommand(domain: string, action: string, inputRaw?: string): Promise { const key = `${domain}.${action}`; - const handler = handlers[key]; + const descriptor = CLI_TOOL_DESCRIPTOR_BY_KEY.get(key); - if (!handler) { + if (!descriptor) { throw new Error(`Unsupported command: ${key}`); } const input = parseInput(inputRaw); - const result = await handler(input); + const validatedInput = descriptor.parse(input); + const result = await descriptor.handler(validatedInput); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); if (!result.success) { @@ -180,6 +50,12 @@ program }); program.parseAsync(process.argv).catch((error: unknown) => { + if (error instanceof ZodError) { + process.stderr.write(`[ERROR] Invalid input: ${error.message}\n`); + process.exit(1); + return; + } + process.stderr.write(`[ERROR] ${(error as Error).message}\n`); process.exit(1); }); diff --git a/bin/generate-coverage-badge.ts b/bin/generate-coverage-badge.ts new file mode 100755 index 0000000..52ec84c --- /dev/null +++ b/bin/generate-coverage-badge.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env bun +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const root = path.resolve(import.meta.dir, '..'); +const lcovPath = path.join(root, 'coverage', 'lcov.info'); +const outputPath = path.join(root, 'coverage', 'coverage-badge.json'); + +if (!fs.existsSync(lcovPath)) { + process.stderr.write(`[ERROR] lcov not found: ${lcovPath}\n`); + process.exit(1); +} + +const lcov = fs.readFileSync(lcovPath, 'utf-8'); +let linesFound = 0; +let linesHit = 0; + +for (const line of lcov.split('\n')) { + if (line.startsWith('LF:')) { + linesFound += Number(line.slice(3)) || 0; + } + + if (line.startsWith('LH:')) { + linesHit += Number(line.slice(3)) || 0; + } +} + +const coverage = linesFound > 0 ? (linesHit / linesFound) * 100 : 0; +const rounded = Math.round(coverage * 100) / 100; + +let color = 'red'; +if (rounded >= 90) { + color = 'brightgreen'; +} else if (rounded >= 80) { + color = 'green'; +} else if (rounded >= 70) { + color = 'yellowgreen'; +} else if (rounded >= 60) { + color = 'yellow'; +} else if (rounded >= 50) { + color = 'orange'; +} + +const badge = { + schemaVersion: 1, + label: 'coverage', + message: `${rounded}%`, + color, + generatedAt: new Date().toISOString(), +}; + +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(badge, null, 2)}\n`, 'utf-8'); +process.stdout.write(`[OK] Coverage badge generated: ${outputPath} (${badge.message})\n`); diff --git a/bin/generate-openclaw.ts b/bin/generate-openclaw.ts new file mode 100755 index 0000000..f274527 --- /dev/null +++ b/bin/generate-openclaw.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env bun +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { OPENCLAW_TOOL_DESCRIPTORS } from '../src/tooling/tool-descriptors.js'; + +const packageRoot = path.resolve(import.meta.dir, '..'); +const targetPath = path.join(packageRoot, 'openclaw.json'); + +const openclaw = { + name: 'aelfscan-skill', + description: 'AelfScan explorer tools for search, blockchain, addresses, tokens, NFTs, and statistics.', + tools: OPENCLAW_TOOL_DESCRIPTORS.map(descriptor => ({ + name: descriptor.mcpName, + description: descriptor.description, + command: 'bun', + args: ['run', 'aelfscan_skill.ts', descriptor.domain, descriptor.action], + cwd: '.', + })), +}; + +const serialized = `${JSON.stringify(openclaw, null, 2)}\n`; +const checkMode = process.argv.includes('--check'); + +if (checkMode) { + if (!fs.existsSync(targetPath)) { + process.stderr.write(`[ERROR] ${targetPath} does not exist\n`); + process.exit(1); + } + + const existing = fs.readFileSync(targetPath, 'utf-8'); + if (existing !== serialized) { + process.stderr.write('[ERROR] openclaw.json is out of date. Run `bun run build:openclaw`\n'); + process.exit(1); + } + + process.stdout.write('[OK] openclaw.json is up to date\n'); + process.exit(0); +} + +fs.writeFileSync(targetPath, serialized, 'utf-8'); +process.stdout.write(`[OK] Generated ${targetPath} with ${openclaw.tools.length} tools\n`); diff --git a/index.ts b/index.ts index fdb5b88..709f081 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,5 @@ export * from './lib/types.js'; +export * from './lib/api-types.js'; export { getConfig, resetConfigCache } from './lib/config.js'; export { search, getSearchFilters } from './src/core/search.js'; @@ -69,4 +70,6 @@ export { getDailyTransactionInfo, getDailyActivityAddress, getCurrencyPrice, + getStatisticsByMetric, + STATISTICS_METRICS, } from './src/core/statistics.js'; diff --git a/lib/api-types.ts b/lib/api-types.ts new file mode 100644 index 0000000..af5864c --- /dev/null +++ b/lib/api-types.ts @@ -0,0 +1,332 @@ +export interface ApiValidationError { + memberNames?: string[]; + errorMessage?: string; +} + +export interface ApiSuccessEnvelope { + code: string; + message?: string; + data: T; +} + +export interface ApiErrorEnvelope { + code: string; + message?: string; + data?: unknown; + validationErrors?: ApiValidationError[]; +} + +export interface ApiPagedList { + total?: number; + list?: TItem[]; + items?: TItem[]; + [key: string]: unknown; +} + +export interface SearchFilterOption { + filterType?: number; + filterInfo?: string; + searchType?: number; + searchInfo?: string; + [key: string]: unknown; +} + +export interface SearchFiltersResponse { + filterTypes?: SearchFilterOption[]; + searchTypes?: SearchFilterOption[]; + [key: string]: unknown; +} + +export interface BlockSummary { + blockHeight?: number; + blockHash?: string; + chainId?: string; + blockTime?: string; + txns?: number; + [key: string]: unknown; +} + +export interface TransactionSummary { + transactionId?: string; + blockHeight?: number; + blockHash?: string; + from?: string; + to?: string; + methodName?: string; + status?: string; + blockTime?: string; + [key: string]: unknown; +} + +export interface LogEvent { + name?: string; + indexed?: unknown[]; + nonIndexed?: unknown[]; + [key: string]: unknown; +} + +export interface SearchEntity { + symbol?: string; + address?: string; + blockHeight?: number; + transactionId?: string; + [key: string]: unknown; +} + +export interface SearchResponse { + tokens?: SearchEntity[]; + nfts?: SearchEntity[]; + accounts?: SearchEntity[]; + contracts?: SearchEntity[]; + blocks?: BlockSummary[]; + block?: BlockSummary; + transaction?: TransactionSummary | null; + [key: string]: unknown; +} + +export interface BlockListResponse extends ApiPagedList { + blocks?: BlockSummary[]; +} + +export interface BlockDetailResponse extends BlockSummary { + transactions?: TransactionSummary[]; + [key: string]: unknown; +} + +export interface TransactionListResponse extends ApiPagedList { + transactions?: TransactionSummary[]; +} + +export interface TransactionDetailResponse extends TransactionSummary { + logEvents?: LogEvent[] | null; + [key: string]: unknown; +} + +export interface BlockchainOverviewResponse { + transactions?: number; + tokenPriceInUsd?: number; + tokenDailyPriceInUsd?: number; + tokenPriceRate24h?: number; + [key: string]: unknown; +} + +export interface TransactionChartPoint { + start?: number; + end?: number; + count?: number; + [key: string]: unknown; +} + +export interface TransactionDataChartResponse { + all?: TransactionChartPoint[]; + mainChain?: TransactionChartPoint[]; + sideChain?: TransactionChartPoint[]; + [key: string]: unknown; +} + +export interface AddressDictionaryResponse { + name?: string; + addresses?: string[]; + [key: string]: unknown; +} + +export interface LogEventsResponse { + total?: number; + logEvents?: LogEvent[] | null; + [key: string]: unknown; +} + +export interface AccountSummary { + address?: string; + balance?: number; + txns?: number; + [key: string]: unknown; +} + +export interface ContractSummary { + address?: string; + contractName?: string; + type?: string; + txns?: number; + [key: string]: unknown; +} + +export interface AddressDetailResponse { + address?: string; + balance?: number; + accountType?: string; + [key: string]: unknown; +} + +export interface AddressAssetItem { + symbol?: string; + balance?: number; + amount?: string | number; + [key: string]: unknown; +} + +export interface AddressTransferItem { + transactionId?: string; + symbol?: string; + from?: string; + to?: string; + amount?: string | number; + [key: string]: unknown; +} + +export interface ContractHistoryItem { + transactionId?: string; + blockHeight?: number; + updateTime?: string; + [key: string]: unknown; +} + +export interface ContractEventItem { + blockHeight?: number; + transactionId?: string; + eventName?: string; + [key: string]: unknown; +} + +export interface ContractSourceResponse { + address?: string; + codeHash?: string; + version?: string; + [key: string]: unknown; +} + +export interface TokenSummary { + symbol?: string; + tokenName?: string; + decimals?: number; + supply?: string | number; + [key: string]: unknown; +} + +export interface TokenDetailResponse extends TokenSummary { + holders?: number; + transfers?: number; + [key: string]: unknown; +} + +export interface TokenTransferItem { + transactionId?: string; + from?: string; + to?: string; + amount?: string | number; + [key: string]: unknown; +} + +export interface TokenHolderItem { + address?: string; + amount?: string | number; + percentage?: string | number; + [key: string]: unknown; +} + +export interface NftCollectionSummary { + collectionSymbol?: string; + collectionName?: string; + items?: number; + holders?: number; + [key: string]: unknown; +} + +export interface NftCollectionDetailResponse extends NftCollectionSummary { + description?: string; + [key: string]: unknown; +} + +export interface NftTransferItem { + transactionId?: string; + symbol?: string; + from?: string; + to?: string; + [key: string]: unknown; +} + +export interface NftHolderItem { + address?: string; + amount?: string | number; + [key: string]: unknown; +} + +export interface NftInventoryItem { + symbol?: string; + owner?: string; + [key: string]: unknown; +} + +export interface NftItemDetailResponse { + symbol?: string; + collectionSymbol?: string; + owner?: string; + [key: string]: unknown; +} + +export interface StatisticsSeriesPoint { + date?: number; + dateStr?: string; + value?: number | string; + [key: string]: unknown; +} + +export interface StatisticsListResponse extends ApiPagedList { + list?: StatisticsSeriesPoint[]; +} + +export interface DailyTransactionsPoint extends StatisticsSeriesPoint { + transactionCount?: number; + blockCount?: number; + mergeTransactionCount?: number; +} + +export interface DailyTransactionsResponse extends StatisticsListResponse { + list?: DailyTransactionsPoint[]; +} + +export interface DailyTransactionInfoChain { + transactionAvgByAllType?: number; + transactionAvgByExcludeSystem?: number; + [key: string]: unknown; +} + +export interface DailyTransactionInfoResponse { + mainChain?: DailyTransactionInfoChain; + sideChain?: DailyTransactionInfoChain; + [key: string]: unknown; +} + +export interface DailyActivityAddressChain { + max?: number; + min?: number; + avg?: number; + [key: string]: unknown; +} + +export interface DailyActivityAddressResponse { + mainChain?: DailyActivityAddressChain; + sideChain?: DailyActivityAddressChain; + [key: string]: unknown; +} + +export interface NodeCurrentProduceInfoItem { + nodeAddress?: string; + producedBlockCount?: number; + expectedBlockCount?: number; + [key: string]: unknown; +} + +export interface NodeCurrentProduceInfoResponse { + roundNumber?: number; + list?: NodeCurrentProduceInfoItem[]; + [key: string]: unknown; +} + +export interface ElfSupplyResponse { + maxSupply?: number; + burn?: number; + totalSupply?: number; + circulatingSupply?: number; + [key: string]: unknown; +} diff --git a/lib/config.ts b/lib/config.ts index da26328..4686523 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -4,6 +4,15 @@ import type { AelfscanConfig } from './types.js'; const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_RETRY = 1; const DEFAULT_API_BASE_URL = 'https://aelfscan.io'; +const DEFAULT_RETRY_BASE_MS = 200; +const DEFAULT_RETRY_MAX_MS = 3_000; +const DEFAULT_MAX_CONCURRENT_REQUESTS = 5; +const DEFAULT_CACHE_TTL_MS = 60_000; +const DEFAULT_CACHE_MAX_ENTRIES = 500; +const DEFAULT_MAX_RESULT_COUNT = 200; +const DEFAULT_MCP_MAX_ITEMS = 50; +const DEFAULT_MCP_MAX_CHARS = 60_000; +const DEFAULT_MCP_INCLUDE_RAW = false; let cachedConfig: AelfscanConfig | null = null; @@ -16,6 +25,23 @@ function toNumber(raw: string | undefined, fallback: number): number { return Number.isFinite(value) && value >= 0 ? value : fallback; } +function toBoolean(raw: string | undefined, fallback: boolean): boolean { + if (!raw) { + return fallback; + } + + const normalized = raw.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') { + return true; + } + + if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') { + return false; + } + + return fallback; +} + function trimSlash(input: string): string { return input.replace(/\/+$/, ''); } @@ -30,6 +56,17 @@ export function getConfig(): AelfscanConfig { defaultChainId: normalizeChainId(process.env.AELFSCAN_DEFAULT_CHAIN_ID || ''), timeoutMs: toNumber(process.env.AELFSCAN_TIMEOUT_MS, DEFAULT_TIMEOUT_MS), retry: Math.floor(toNumber(process.env.AELFSCAN_RETRY, DEFAULT_RETRY)), + retryBaseMs: Math.floor(toNumber(process.env.AELFSCAN_RETRY_BASE_MS, DEFAULT_RETRY_BASE_MS)), + retryMaxMs: Math.floor(toNumber(process.env.AELFSCAN_RETRY_MAX_MS, DEFAULT_RETRY_MAX_MS)), + maxConcurrentRequests: Math.floor( + toNumber(process.env.AELFSCAN_MAX_CONCURRENT_REQUESTS, DEFAULT_MAX_CONCURRENT_REQUESTS), + ), + cacheTtlMs: Math.floor(toNumber(process.env.AELFSCAN_CACHE_TTL_MS, DEFAULT_CACHE_TTL_MS)), + cacheMaxEntries: Math.floor(toNumber(process.env.AELFSCAN_CACHE_MAX_ENTRIES, DEFAULT_CACHE_MAX_ENTRIES)), + maxResultCount: Math.floor(toNumber(process.env.AELFSCAN_MAX_RESULT_COUNT, DEFAULT_MAX_RESULT_COUNT)), + mcpMaxItems: Math.floor(toNumber(process.env.AELFSCAN_MCP_MAX_ITEMS, DEFAULT_MCP_MAX_ITEMS)), + mcpMaxChars: Math.floor(toNumber(process.env.AELFSCAN_MCP_MAX_CHARS, DEFAULT_MCP_MAX_CHARS)), + mcpIncludeRaw: toBoolean(process.env.AELFSCAN_MCP_INCLUDE_RAW, DEFAULT_MCP_INCLUDE_RAW), }; return cachedConfig; diff --git a/lib/http-client.ts b/lib/http-client.ts index 551a723..a2c28bf 100644 --- a/lib/http-client.ts +++ b/lib/http-client.ts @@ -9,8 +9,15 @@ export interface HttpRequestOptions { query?: Record; body?: unknown; headers?: Record; + traceId?: string; + cacheTtlMs?: number; + disableCache?: boolean; } +const responseCache = new Map }>(); +const pendingResolvers: Array<() => void> = []; +let activeRequests = 0; + function shouldRetry(error: unknown): boolean { if (!(error instanceof SkillError)) { return true; @@ -23,6 +30,12 @@ function shouldRetry(error: unknown): boolean { return error.httpStatus >= 500; } +function getRetryDelayMs(attempt: number, retryBaseMs: number, retryMaxMs: number): number { + const exponential = Math.min(retryBaseMs * 2 ** attempt, retryMaxMs); + const jitter = Math.floor(Math.random() * Math.max(1, Math.floor(exponential * 0.25))); + return exponential + jitter; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -48,16 +61,121 @@ function ensurePath(path: string): string { return `/${path}`; } +async function acquireSlot(limit: number): Promise<() => void> { + if (limit <= 0) { + return () => {}; + } + + if (activeRequests < limit) { + activeRequests += 1; + return releaseSlot; + } + + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests += 1; + return releaseSlot; +} + +function releaseSlot(): void { + activeRequests = Math.max(0, activeRequests - 1); + const next = pendingResolvers.shift(); + if (next) { + next(); + } +} + +function getCacheTtlMs(method: 'GET' | 'POST', path: string, options: HttpRequestOptions): number { + const config = getConfig(); + + if (options.disableCache) { + return 0; + } + + if (options.cacheTtlMs !== undefined) { + return Math.max(0, options.cacheTtlMs); + } + + if (method === 'GET' && path.startsWith('/api/app/statistics/')) { + return Math.max(0, config.cacheTtlMs); + } + + return 0; +} + +function getCacheKey(method: 'GET' | 'POST', url: string): string { + return `${method}:${url}`; +} + +function getCachedValue(cacheKey: string): HttpClientResult | null { + const cached = responseCache.get(cacheKey); + if (!cached) { + return null; + } + + if (cached.expiresAt <= Date.now()) { + responseCache.delete(cacheKey); + return null; + } + + // Keep most recently used cache item at the tail. + responseCache.delete(cacheKey); + responseCache.set(cacheKey, cached); + return cached.value as HttpClientResult; +} + +function setCachedValue(cacheKey: string, value: HttpClientResult, expiresAt: number, maxEntries: number): void { + if (maxEntries <= 0) { + return; + } + + if (responseCache.has(cacheKey)) { + responseCache.delete(cacheKey); + } + + responseCache.set(cacheKey, { expiresAt, value }); + + while (responseCache.size > maxEntries) { + const oldest = responseCache.keys().next().value; + if (oldest === undefined) { + break; + } + responseCache.delete(oldest); + } +} + +export function resetHttpClientState(): void { + responseCache.clear(); + activeRequests = 0; + while (pendingResolvers.length > 0) { + const next = pendingResolvers.shift(); + if (next) { + next(); + } + } +} + export async function request(options: HttpRequestOptions): Promise> { const config = getConfig(); const method = options.method || 'GET'; const path = ensurePath(options.path); const queryString = serializeQuery(options.query); const url = `${config.apiBaseUrl}${path}${queryString ? `?${queryString}` : ''}`; + const traceId = options.traceId; + const cacheTtlMs = getCacheTtlMs(method, path, options); + const cacheMaxEntries = Math.max(0, config.cacheMaxEntries); + const cacheKey = getCacheKey(method, url); + + if (cacheTtlMs > 0 && cacheMaxEntries > 0 && method === 'GET') { + const cached = getCachedValue(cacheKey); + if (cached) { + return cached; + } + } let lastError: unknown; for (let attempt = 0; attempt <= config.retry; attempt += 1) { + const releaseSlotHandle = await acquireSlot(config.maxConcurrentRequests); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeoutMs); @@ -68,6 +186,7 @@ export async function request(options: HttpRequestOptions): Promise(options: HttpRequestOptions): Promise 0 && cacheMaxEntries > 0 && method === 'GET') { + setCachedValue(cacheKey, successResult as HttpClientResult, Date.now() + cacheTtlMs, cacheMaxEntries); + } + + return successResult; } - return { + const successResult = { data: rawBody as T, raw: rawBody, }; + + if (cacheTtlMs > 0 && cacheMaxEntries > 0 && method === 'GET') { + setCachedValue(cacheKey, successResult as HttpClientResult, Date.now() + cacheTtlMs, cacheMaxEntries); + } + + return successResult; } catch (error) { lastError = error; if (attempt < config.retry && shouldRetry(error)) { - await sleep(200 * (attempt + 1)); + const delayMs = getRetryDelayMs(attempt, config.retryBaseMs, config.retryMaxMs); + await sleep(delayMs); continue; } break; } finally { clearTimeout(timeout); + releaseSlotHandle(); } } diff --git a/lib/serializer.ts b/lib/serializer.ts index 6306bec..ee63db7 100644 --- a/lib/serializer.ts +++ b/lib/serializer.ts @@ -36,9 +36,12 @@ function processObject(parts: string[], source: Record, prefix? return; } - if (value === '' || value || value === 0 || value === false) { - pushQueryPart(parts, prefixedKey, value); + if (typeof value === 'number' && Number.isNaN(value)) { + return; } + + // Primitive values reach here after null/undefined/object filtering. + pushQueryPart(parts, prefixedKey, value); }); } diff --git a/lib/types.ts b/lib/types.ts index 9b7604e..5f11f48 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -46,6 +46,15 @@ export interface AelfscanConfig { defaultChainId: string; timeoutMs: number; retry: number; + retryBaseMs: number; + retryMaxMs: number; + maxConcurrentRequests: number; + cacheTtlMs: number; + cacheMaxEntries: number; + maxResultCount: number; + mcpMaxItems: number; + mcpMaxChars: number; + mcpIncludeRaw: boolean; } export interface SearchInput { @@ -244,3 +253,36 @@ export interface StatisticsDateRangeInput extends StatisticsQueryInput { startDate: string; endDate: string; } + +export type StatisticsMetric = + | 'dailyTransactions' + | 'uniqueAddresses' + | 'dailyActiveAddresses' + | 'monthlyActiveAddresses' + | 'blockProduceRate' + | 'avgBlockDuration' + | 'cycleCount' + | 'nodeBlockProduce' + | 'dailyAvgTransactionFee' + | 'dailyTxFee' + | 'dailyTotalBurnt' + | 'dailyElfPrice' + | 'dailyDeployContract' + | 'dailyBlockReward' + | 'dailyAvgBlockSize' + | 'topContractCall' + | 'dailyContractCall' + | 'dailySupplyGrowth' + | 'dailyMarketCap' + | 'dailyStaked' + | 'dailyHolder' + | 'dailyTvl' + | 'nodeCurrentProduceInfo' + | 'elfSupply' + | 'dailyTransactionInfo' + | 'dailyActivityAddress' + | 'currencyPrice'; + +export interface StatisticsMetricInput extends StatisticsQueryInput { + metric: StatisticsMetric; +} diff --git a/openclaw.json b/openclaw.json index 3a78a7f..d4d62d8 100644 --- a/openclaw.json +++ b/openclaw.json @@ -3,92 +3,104 @@ "description": "AelfScan explorer tools for search, blockchain, addresses, tokens, NFTs, and statistics.", "tools": [ { - "name": "aelfscan_accounts", - "description": "List top accounts", + "name": "aelfscan_search_filters", + "description": "Get search filter metadata used by explorer search UI.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "address", - "accounts" + "search", + "filters" ], "cwd": "." }, { - "name": "aelfscan_address_detail", - "description": "Get address detail", + "name": "aelfscan_search", + "description": "Search tokens/accounts/contracts/NFTs/blocks/transactions on AelfScan explorer.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "address", - "detail" + "search", + "query" ], "cwd": "." }, { - "name": "aelfscan_address_dictionary", - "description": "Resolve address dictionary metadata", + "name": "aelfscan_blocks", + "description": "List blocks with pagination.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "blockchain", - "address-dictionary" + "blocks" ], "cwd": "." }, { - "name": "aelfscan_address_nft_assets", - "description": "Get address NFT assets", + "name": "aelfscan_blocks_latest", + "description": "Get latest blocks (uses blocks API with skipCount=0).", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "address", - "nft-assets" + "blockchain", + "blocks-latest" ], "cwd": "." }, { - "name": "aelfscan_address_tokens", - "description": "Get address tokens", + "name": "aelfscan_block_detail", + "description": "Get block detail by block height.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "address", - "tokens" + "blockchain", + "block-detail" ], "cwd": "." }, { - "name": "aelfscan_address_transfers", - "description": "Get address transfers", + "name": "aelfscan_transactions", + "description": "List transactions with optional filters.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "address", - "transfers" + "blockchain", + "transactions" ], "cwd": "." }, { - "name": "aelfscan_block_detail", - "description": "Get block detail", + "name": "aelfscan_transactions_latest", + "description": "Get latest transactions (uses transactions API with skipCount=0).", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "blockchain", - "block-detail" + "transactions-latest" + ], + "cwd": "." + }, + { + "name": "aelfscan_transaction_detail", + "description": "Get transaction detail by transaction id.", + "command": "bun", + "args": [ + "run", + "aelfscan_skill.ts", + "blockchain", + "transaction-detail" ], "cwd": "." }, { "name": "aelfscan_blockchain_overview", - "description": "Get blockchain overview metrics", + "description": "Get blockchain overview metrics and aggregate stats.", "command": "bun", "args": [ "run", @@ -99,626 +111,626 @@ "cwd": "." }, { - "name": "aelfscan_blocks", - "description": "List blocks", + "name": "aelfscan_transaction_data_chart", + "description": "Get transaction data chart series.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "blockchain", - "blocks" + "transaction-data-chart" ], "cwd": "." }, { - "name": "aelfscan_blocks_latest", - "description": "Get latest blocks", + "name": "aelfscan_address_dictionary", + "description": "Resolve or query address dictionary metadata.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "blockchain", - "blocks-latest" + "address-dictionary" ], "cwd": "." }, { - "name": "aelfscan_contract_events", - "description": "Get contract events", + "name": "aelfscan_log_events", + "description": "Get contract log events by contract address.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "address", - "contract-events" + "blockchain", + "log-events" ], "cwd": "." }, { - "name": "aelfscan_contract_history", - "description": "Get contract history", + "name": "aelfscan_accounts", + "description": "List top accounts.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "address", - "contract-history" + "accounts" ], "cwd": "." }, { - "name": "aelfscan_contract_source", - "description": "Get contract source", + "name": "aelfscan_contracts", + "description": "List contracts.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "address", - "contract-source" + "contracts" ], "cwd": "." }, { - "name": "aelfscan_contracts", - "description": "List contracts", + "name": "aelfscan_address_detail", + "description": "Get address detail (EOA/contract profile and portfolio).", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "address", - "contracts" + "detail" ], "cwd": "." }, { - "name": "aelfscan_log_events", - "description": "Get contract log events", + "name": "aelfscan_address_tokens", + "description": "Get token holdings for an address.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "blockchain", - "log-events" + "address", + "tokens" ], "cwd": "." }, { - "name": "aelfscan_nft_collection_detail", - "description": "Get NFT collection detail", + "name": "aelfscan_address_nft_assets", + "description": "Get NFT holdings for an address.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "nft", - "collection-detail" + "address", + "nft-assets" ], "cwd": "." }, { - "name": "aelfscan_nft_collections", - "description": "List NFT collections", + "name": "aelfscan_address_transfers", + "description": "Get transfer history for an address.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "nft", - "collections" + "address", + "transfers" ], "cwd": "." }, { - "name": "aelfscan_nft_holders", - "description": "Get NFT holders", + "name": "aelfscan_contract_history", + "description": "Get contract deploy/update history.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "nft", - "holders" + "address", + "contract-history" ], "cwd": "." }, { - "name": "aelfscan_nft_inventory", - "description": "Get NFT inventory", + "name": "aelfscan_contract_events", + "description": "Get contract events list.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "nft", - "inventory" + "address", + "contract-events" ], "cwd": "." }, { - "name": "aelfscan_nft_item_activity", - "description": "Get NFT item activity", + "name": "aelfscan_contract_source", + "description": "Get verified contract source metadata.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "nft", - "item-activity" + "address", + "contract-source" ], "cwd": "." }, { - "name": "aelfscan_nft_item_detail", - "description": "Get NFT item detail", + "name": "aelfscan_tokens", + "description": "List tokens.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "nft", - "item-detail" + "token", + "list" ], "cwd": "." }, { - "name": "aelfscan_nft_item_holders", - "description": "Get NFT item holders", + "name": "aelfscan_token_detail", + "description": "Get token detail by symbol.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "nft", - "item-holders" + "token", + "detail" ], "cwd": "." }, { - "name": "aelfscan_nft_transfers", - "description": "Get NFT transfers", + "name": "aelfscan_token_transfers", + "description": "Get token transfer list by symbol.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "nft", + "token", "transfers" ], "cwd": "." }, { - "name": "aelfscan_search", - "description": "Search explorer entities", + "name": "aelfscan_token_holders", + "description": "Get token holders.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "search", - "query" + "token", + "holders" ], "cwd": "." }, { - "name": "aelfscan_search_filters", - "description": "Get search filter metadata", + "name": "aelfscan_nft_collections", + "description": "List NFT collections.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "search", - "filters" + "nft", + "collections" ], "cwd": "." }, { - "name": "aelfscan_statistics_avg_block_duration", - "description": "Get average block duration statistics", + "name": "aelfscan_nft_collection_detail", + "description": "Get NFT collection detail.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "statistics", - "avg-block-duration" + "nft", + "collection-detail" ], "cwd": "." }, { - "name": "aelfscan_statistics_block_produce_rate", - "description": "Get block produce rate statistics", + "name": "aelfscan_nft_transfers", + "description": "Get NFT transfers by collection symbol.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "statistics", - "block-produce-rate" + "nft", + "transfers" ], "cwd": "." }, { - "name": "aelfscan_statistics_currency_price", - "description": "Get currency price statistics", + "name": "aelfscan_nft_holders", + "description": "Get NFT holders by collection symbol.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "statistics", - "currency-price" + "nft", + "holders" ], "cwd": "." }, { - "name": "aelfscan_statistics_cycle_count", - "description": "Get cycle count statistics", + "name": "aelfscan_nft_inventory", + "description": "Get NFT inventory by collection symbol.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "statistics", - "cycle-count" + "nft", + "inventory" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_active_addresses", - "description": "Get daily active addresses statistics", + "name": "aelfscan_nft_item_detail", + "description": "Get NFT item detail by symbol.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "statistics", - "daily-active-addresses" + "nft", + "item-detail" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_activity_address", - "description": "Get daily activity address by date range", + "name": "aelfscan_nft_item_holders", + "description": "Get holders of a specific NFT item.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "statistics", - "daily-activity-address" + "nft", + "item-holders" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_avg_block_size", - "description": "Get daily average block size statistics", + "name": "aelfscan_nft_item_activity", + "description": "Get activity list of a specific NFT item.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "statistics", - "daily-avg-block-size" + "nft", + "item-activity" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_avg_transaction_fee", - "description": "Get daily average transaction fee statistics", + "name": "aelfscan_statistics", + "description": "Get statistics by metric enum, supports all existing statistics endpoints.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-avg-transaction-fee" + "metric" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_block_reward", - "description": "Get daily block reward statistics", + "name": "aelfscan_statistics_daily_transactions", + "description": "Get daily transactions statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-block-reward" + "daily-transactions" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_contract_call", - "description": "Get daily contract call statistics", + "name": "aelfscan_statistics_unique_addresses", + "description": "Get unique addresses statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-contract-call" + "unique-addresses" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_deploy_contract", - "description": "Get daily deploy contract statistics", + "name": "aelfscan_statistics_daily_active_addresses", + "description": "Get daily active addresses statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-deploy-contract" + "daily-active-addresses" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_elf_price", - "description": "Get daily ELF price statistics", + "name": "aelfscan_statistics_monthly_active_addresses", + "description": "Get monthly active addresses statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-elf-price" + "monthly-active-addresses" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_holder", - "description": "Get daily holder statistics", + "name": "aelfscan_statistics_block_produce_rate", + "description": "Get block produce rate statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-holder" + "block-produce-rate" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_market_cap", - "description": "Get daily market cap statistics", + "name": "aelfscan_statistics_avg_block_duration", + "description": "Get average block duration statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-market-cap" + "avg-block-duration" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_staked", - "description": "Get daily staked statistics", + "name": "aelfscan_statistics_cycle_count", + "description": "Get cycle count statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-staked" + "cycle-count" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_supply_growth", - "description": "Get daily supply growth statistics", + "name": "aelfscan_statistics_node_block_produce", + "description": "Get node block produce statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-supply-growth" + "node-block-produce" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_total_burnt", - "description": "Get daily total burnt statistics", + "name": "aelfscan_statistics_daily_avg_transaction_fee", + "description": "Get daily average transaction fee statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-total-burnt" + "daily-avg-transaction-fee" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_transaction_info", - "description": "Get daily transaction info by date range", + "name": "aelfscan_statistics_daily_tx_fee", + "description": "Get daily transaction fee statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-transaction-info" + "daily-tx-fee" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_transactions", - "description": "Get daily transactions statistics", + "name": "aelfscan_statistics_daily_total_burnt", + "description": "Get daily total burnt statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-transactions" + "daily-total-burnt" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_tvl", - "description": "Get daily TVL statistics", + "name": "aelfscan_statistics_daily_elf_price", + "description": "Get daily ELF price statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-tvl" + "daily-elf-price" ], "cwd": "." }, { - "name": "aelfscan_statistics_daily_tx_fee", - "description": "Get daily transaction fee statistics", + "name": "aelfscan_statistics_daily_deploy_contract", + "description": "Get daily deploy contract statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "daily-tx-fee" + "daily-deploy-contract" ], "cwd": "." }, { - "name": "aelfscan_statistics_elf_supply", - "description": "Get ELF supply statistics", + "name": "aelfscan_statistics_daily_block_reward", + "description": "Get daily block reward statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "elf-supply" + "daily-block-reward" ], "cwd": "." }, { - "name": "aelfscan_statistics_monthly_active_addresses", - "description": "Get monthly active addresses statistics", + "name": "aelfscan_statistics_daily_avg_block_size", + "description": "Get daily average block size statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "monthly-active-addresses" + "daily-avg-block-size" ], "cwd": "." }, { - "name": "aelfscan_statistics_node_block_produce", - "description": "Get node block produce statistics", + "name": "aelfscan_statistics_top_contract_call", + "description": "Get top contract call statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "node-block-produce" + "top-contract-call" ], "cwd": "." }, { - "name": "aelfscan_statistics_node_current_produce_info", - "description": "Get node current produce info", + "name": "aelfscan_statistics_daily_contract_call", + "description": "Get daily contract call statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "node-current-produce-info" + "daily-contract-call" ], "cwd": "." }, { - "name": "aelfscan_statistics_top_contract_call", - "description": "Get top contract call statistics", + "name": "aelfscan_statistics_daily_supply_growth", + "description": "Get daily supply growth statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "top-contract-call" + "daily-supply-growth" ], "cwd": "." }, { - "name": "aelfscan_statistics_unique_addresses", - "description": "Get unique addresses statistics", + "name": "aelfscan_statistics_daily_market_cap", + "description": "Get daily market cap statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", "statistics", - "unique-addresses" + "daily-market-cap" ], "cwd": "." }, { - "name": "aelfscan_token_detail", - "description": "Get token detail", + "name": "aelfscan_statistics_daily_staked", + "description": "Get daily staked statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "token", - "detail" + "statistics", + "daily-staked" ], "cwd": "." }, { - "name": "aelfscan_token_holders", - "description": "Get token holders", + "name": "aelfscan_statistics_daily_holder", + "description": "Get daily holder statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "token", - "holders" + "statistics", + "daily-holder" ], "cwd": "." }, { - "name": "aelfscan_token_transfers", - "description": "Get token transfers", + "name": "aelfscan_statistics_daily_tvl", + "description": "Get daily TVL statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "token", - "transfers" + "statistics", + "daily-tvl" ], "cwd": "." }, { - "name": "aelfscan_tokens", - "description": "List tokens", + "name": "aelfscan_statistics_node_current_produce_info", + "description": "Get current node produce information.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "token", - "list" + "statistics", + "node-current-produce-info" ], "cwd": "." }, { - "name": "aelfscan_transaction_data_chart", - "description": "Get transaction data chart", + "name": "aelfscan_statistics_elf_supply", + "description": "Get ELF supply statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "blockchain", - "transaction-data-chart" + "statistics", + "elf-supply" ], "cwd": "." }, { - "name": "aelfscan_transaction_detail", - "description": "Get transaction detail", + "name": "aelfscan_statistics_daily_transaction_info", + "description": "Get daily transaction summary for a date range.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "blockchain", - "transaction-detail" + "statistics", + "daily-transaction-info" ], "cwd": "." }, { - "name": "aelfscan_transactions", - "description": "List transactions", + "name": "aelfscan_statistics_daily_activity_address", + "description": "Get daily activity address summary for a date range.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "blockchain", - "transactions" + "statistics", + "daily-activity-address" ], "cwd": "." }, { - "name": "aelfscan_transactions_latest", - "description": "Get latest transactions", + "name": "aelfscan_statistics_currency_price", + "description": "Get currency price statistics.", "command": "bun", "args": [ "run", "aelfscan_skill.ts", - "blockchain", - "transactions-latest" + "statistics", + "currency-price" ], "cwd": "." } diff --git a/package.json b/package.json index 062d96a..947118a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aelfscan/agent-skills", - "version": "0.1.0", + "version": "0.2.0", "description": "AelfScan explorer skill toolkit for AI agents: MCP, CLI, and SDK interfaces.", "type": "module", "main": "index.ts", @@ -29,8 +29,12 @@ "setup": "bun run bin/setup.ts", "mcp": "bun run src/mcp/server.ts", "cli": "bun run aelfscan_skill.ts", + "build:openclaw": "bun run bin/generate-openclaw.ts", + "build:openclaw:check": "bun run bin/generate-openclaw.ts --check", "test": "bun test tests/", "test:unit": "bun test tests/unit/", + "test:unit:coverage": "bun run test:unit --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage", + "coverage:badge": "bun run bin/generate-coverage-badge.ts", "test:integration": "bun test tests/integration/", "test:e2e": "bun test tests/e2e/" }, @@ -45,6 +49,17 @@ "explorer" ], "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/AelfScanProject/aelfscan-skill.git" + }, + "homepage": "https://github.com/AelfScanProject/aelfscan-skill#readme", + "bugs": { + "url": "https://github.com/AelfScanProject/aelfscan-skill/issues" + }, + "publishConfig": { + "access": "public" + }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "commander": "^12.1.0", diff --git a/src/core/address.ts b/src/core/address.ts index 67f4f65..b1cad2c 100644 --- a/src/core/address.ts +++ b/src/core/address.ts @@ -1,5 +1,16 @@ import { request } from '../../lib/http-client.js'; import { requireField } from '../../lib/errors.js'; +import type { + AccountSummary, + AddressAssetItem, + AddressDetailResponse, + AddressTransferItem, + ApiPagedList, + ContractEventItem, + ContractHistoryItem, + ContractSourceResponse, + ContractSummary, +} from '../../lib/api-types.js'; import type { AccountsInput, AddressDetailInput, @@ -14,9 +25,10 @@ import type { } from '../../lib/types.js'; import { executeTool, resolveAddress, resolveChainId, toPaginationQuery } from './common.js'; -export async function getAccounts(input: AccountsInput = {}): Promise> { - return executeTool(async () => { - return request({ +export async function getAccounts(input: AccountsInput = {}): Promise>> { + return executeTool(async (traceId) => { + return request>({ + traceId, path: '/api/app/address/accounts', query: { chainId: resolveChainId(input.chainId), @@ -26,9 +38,10 @@ export async function getAccounts(input: AccountsInput = {}): Promise> { - return executeTool(async () => { - return request({ +export async function getContracts(input: ContractsInput = {}): Promise>> { + return executeTool(async (traceId) => { + return request>({ + traceId, path: '/api/app/address/contracts', query: { chainId: resolveChainId(input.chainId), @@ -38,11 +51,12 @@ export async function getContracts(input: ContractsInput = {}): Promise> { - return executeTool(async () => { +export async function getAddressDetail(input: AddressDetailInput): Promise> { + return executeTool(async (traceId) => { requireField(input.address, 'address'); - return request({ + return request({ + traceId, path: '/api/app/address/detail', query: { chainId: resolveChainId(input.chainId), @@ -52,11 +66,12 @@ export async function getAddressDetail(input: AddressDetailInput): Promise> { - return executeTool(async () => { +export async function getAddressTokens(input: AddressTokensInput): Promise>> { + return executeTool(async (traceId) => { requireField(input.address, 'address'); - return request({ + return request>({ + traceId, path: '/api/app/address/tokens', query: { chainId: resolveChainId(input.chainId), @@ -68,11 +83,14 @@ export async function getAddressTokens(input: AddressTokensInput): Promise> { - return executeTool(async () => { +export async function getAddressNftAssets( + input: AddressNftAssetsInput, +): Promise>> { + return executeTool(async (traceId) => { requireField(input.address, 'address'); - return request({ + return request>({ + traceId, path: '/api/app/address/nft-assets', query: { chainId: resolveChainId(input.chainId), @@ -84,11 +102,14 @@ export async function getAddressNftAssets(input: AddressNftAssetsInput): Promise }, 'GET_ADDRESS_NFT_ASSETS_FAILED'); } -export async function getAddressTransfers(input: AddressTransfersInput): Promise> { - return executeTool(async () => { +export async function getAddressTransfers( + input: AddressTransfersInput, +): Promise>> { + return executeTool(async (traceId) => { requireField(input.address, 'address'); - return request({ + return request>({ + traceId, path: '/api/app/address/transfers', query: { chainId: resolveChainId(input.chainId), @@ -101,11 +122,14 @@ export async function getAddressTransfers(input: AddressTransfersInput): Promise }, 'GET_ADDRESS_TRANSFERS_FAILED'); } -export async function getContractHistory(input: ContractHistoryInput): Promise> { - return executeTool(async () => { +export async function getContractHistory( + input: ContractHistoryInput, +): Promise>> { + return executeTool(async (traceId) => { requireField(input.address, 'address'); - return request({ + return request>({ + traceId, path: '/api/app/address/contract/history', query: { chainId: resolveChainId(input.chainId), @@ -115,11 +139,14 @@ export async function getContractHistory(input: ContractHistoryInput): Promise> { - return executeTool(async () => { +export async function getContractEvents( + input: ContractEventsInput, +): Promise>> { + return executeTool(async (traceId) => { requireField(input.contractAddress, 'contractAddress'); - return request({ + return request>({ + traceId, path: '/api/app/address/contract/events', query: { chainId: resolveChainId(input.chainId), @@ -131,11 +158,12 @@ export async function getContractEvents(input: ContractEventsInput): Promise> { - return executeTool(async () => { +export async function getContractSource(input: ContractSourceInput): Promise> { + return executeTool(async (traceId) => { requireField(input.address, 'address'); - return request({ + return request({ + traceId, path: '/api/app/address/contract/file', query: { chainId: resolveChainId(input.chainId), diff --git a/src/core/blockchain.ts b/src/core/blockchain.ts index 9f09be7..8f315bb 100644 --- a/src/core/blockchain.ts +++ b/src/core/blockchain.ts @@ -1,5 +1,15 @@ import { request } from '../../lib/http-client.js'; import { requireField, SkillError } from '../../lib/errors.js'; +import type { + AddressDictionaryResponse, + BlockDetailResponse, + BlockchainOverviewResponse, + BlockListResponse, + LogEventsResponse, + TransactionDataChartResponse, + TransactionDetailResponse, + TransactionListResponse, +} from '../../lib/api-types.js'; import type { AddressDictionaryInput, BlockchainOverviewInput, @@ -13,9 +23,10 @@ import type { } from '../../lib/types.js'; import { executeTool, resolveChainId, toPaginationQuery } from './common.js'; -export async function getBlocks(input: BlocksInput = {}): Promise> { - return executeTool(async () => { - const result = await request({ +export async function getBlocks(input: BlocksInput = {}): Promise> { + return executeTool(async (traceId) => { + const result = await request({ + traceId, path: '/api/app/blockchain/blocks', query: { chainId: resolveChainId(input.chainId), @@ -28,7 +39,7 @@ export async function getBlocks(input: BlocksInput = {}): Promise = {}): Promise> { +export async function getLatestBlocks(input: Omit = {}): Promise> { return getBlocks({ ...input, skipCount: 0, @@ -36,11 +47,12 @@ export async function getLatestBlocks(input: Omit = {} }); } -export async function getBlockDetail(input: BlockDetailInput): Promise> { - return executeTool(async () => { +export async function getBlockDetail(input: BlockDetailInput): Promise> { + return executeTool(async (traceId) => { requireField(input.blockHeight, 'blockHeight'); - const result = await request({ + const result = await request({ + traceId, path: '/api/app/blockchain/blockDetail', query: { chainId: resolveChainId(input.chainId), @@ -52,9 +64,10 @@ export async function getBlockDetail(input: BlockDetailInput): Promise> { - return executeTool(async () => { - const result = await request({ +export async function getTransactions(input: TransactionsInput = {}): Promise> { + return executeTool(async (traceId) => { + const result = await request({ + traceId, path: '/api/app/blockchain/transactions', query: { chainId: resolveChainId(input.chainId), @@ -73,7 +86,7 @@ export async function getTransactions(input: TransactionsInput = {}): Promise = {}, -): Promise> { +): Promise> { return getTransactions({ ...input, skipCount: 0, @@ -81,11 +94,12 @@ export async function getLatestTransactions( }); } -export async function getTransactionDetail(input: TransactionDetailInput): Promise> { - return executeTool(async () => { +export async function getTransactionDetail(input: TransactionDetailInput): Promise> { + return executeTool(async (traceId) => { requireField(input.transactionId, 'transactionId'); - const result = await request({ + const result = await request({ + traceId, path: '/api/app/blockchain/transactionDetail', query: { chainId: resolveChainId(input.chainId), @@ -98,9 +112,12 @@ export async function getTransactionDetail(input: TransactionDetailInput): Promi }, 'GET_TRANSACTION_DETAIL_FAILED'); } -export async function getBlockchainOverview(input: BlockchainOverviewInput = {}): Promise> { - return executeTool(async () => { - const result = await request({ +export async function getBlockchainOverview( + input: BlockchainOverviewInput = {}, +): Promise> { + return executeTool(async (traceId) => { + const result = await request({ + traceId, method: 'POST', path: '/api/app/blockchain/blockchainOverview', body: { @@ -113,9 +130,12 @@ export async function getBlockchainOverview(input: BlockchainOverviewInput = {}) }, 'GET_BLOCKCHAIN_OVERVIEW_FAILED'); } -export async function getTransactionDataChart(input: TransactionDataChartInput = {}): Promise> { - return executeTool(async () => { - const result = await request({ +export async function getTransactionDataChart( + input: TransactionDataChartInput = {}, +): Promise> { + return executeTool(async (traceId) => { + const result = await request({ + traceId, method: 'POST', path: '/api/app/blockchain/transactionDataChart', body: { @@ -128,15 +148,16 @@ export async function getTransactionDataChart(input: TransactionDataChartInput = }, 'GET_TRANSACTION_DATA_CHART_FAILED'); } -export async function getAddressDictionary(input: AddressDictionaryInput): Promise> { - return executeTool(async () => { +export async function getAddressDictionary(input: AddressDictionaryInput): Promise> { + return executeTool(async (traceId) => { requireField(input.name, 'name'); const addresses = requireField(input.addresses, 'addresses'); if (!Array.isArray(addresses) || addresses.length === 0) { throw new SkillError('INVALID_INPUT', 'addresses must be a non-empty array'); } - const result = await request({ + const result = await request({ + traceId, method: 'POST', path: '/api/app/blockchain/addressDictionary', body: { @@ -149,11 +170,12 @@ export async function getAddressDictionary(input: AddressDictionaryInput): Promi }, 'GET_ADDRESS_DICTIONARY_FAILED'); } -export async function getLogEvents(input: LogEventsInput): Promise> { - return executeTool(async () => { +export async function getLogEvents(input: LogEventsInput): Promise> { + return executeTool(async (traceId) => { requireField(input.contractAddress, 'contractAddress'); - const result = await request({ + const result = await request({ + traceId, method: 'POST', path: '/api/app/blockchain/logEvents', body: { diff --git a/src/core/common.ts b/src/core/common.ts index 1f04f43..30c0dfb 100644 --- a/src/core/common.ts +++ b/src/core/common.ts @@ -1,4 +1,4 @@ -import { fail, ok } from '../../lib/errors.js'; +import { fail, ok, SkillError } from '../../lib/errors.js'; import { getConfig } from '../../lib/config.js'; import { normalizeAddress, normalizeChainId } from '../../lib/normalize.js'; import { createTraceId } from '../../lib/trace.js'; @@ -33,12 +33,29 @@ export function resolveAddress(address?: string): string { } export function toPaginationQuery(input: PaginationInput): Record { - return { - skipCount: input.skipCount, - maxResultCount: input.maxResultCount, + const config = getConfig(); + const query: Record = { orderBy: input.orderBy, sort: input.sort, orderInfos: input.orderInfos, searchAfter: input.searchAfter, }; + + if (input.skipCount !== undefined) { + if (!Number.isInteger(input.skipCount) || input.skipCount < 0) { + throw new SkillError('INVALID_INPUT', 'skipCount must be an integer greater than or equal to 0'); + } + query.skipCount = input.skipCount; + } + + if (input.maxResultCount !== undefined) { + if (!Number.isInteger(input.maxResultCount) || input.maxResultCount <= 0) { + throw new SkillError('INVALID_INPUT', 'maxResultCount must be a positive integer'); + } + query.maxResultCount = Math.min(input.maxResultCount, config.maxResultCount); + } + + return { + ...query, + }; } diff --git a/src/core/nft.ts b/src/core/nft.ts index b02c523..b7438f3 100644 --- a/src/core/nft.ts +++ b/src/core/nft.ts @@ -1,5 +1,14 @@ import { request } from '../../lib/http-client.js'; import { requireField } from '../../lib/errors.js'; +import type { + ApiPagedList, + NftCollectionDetailResponse, + NftCollectionSummary, + NftHolderItem, + NftInventoryItem, + NftItemDetailResponse, + NftTransferItem, +} from '../../lib/api-types.js'; import type { NftCollectionDetailInput, NftCollectionsInput, @@ -13,9 +22,12 @@ import type { } from '../../lib/types.js'; import { executeTool, resolveAddress, resolveChainId, toPaginationQuery } from './common.js'; -export async function getNftCollections(input: NftCollectionsInput = {}): Promise> { - return executeTool(async () => { - return request({ +export async function getNftCollections( + input: NftCollectionsInput = {}, +): Promise>> { + return executeTool(async (traceId) => { + return request>({ + traceId, path: '/api/app/token/nft/collection-list', query: { chainId: resolveChainId(input.chainId), @@ -32,11 +44,14 @@ export async function getNftCollections(input: NftCollectionsInput = {}): Promis }, 'GET_NFT_COLLECTIONS_FAILED'); } -export async function getNftCollectionDetail(input: NftCollectionDetailInput): Promise> { - return executeTool(async () => { +export async function getNftCollectionDetail( + input: NftCollectionDetailInput, +): Promise> { + return executeTool(async (traceId) => { requireField(input.collectionSymbol, 'collectionSymbol'); - return request({ + return request({ + traceId, path: '/api/app/token/nft/collection-detail', query: { chainId: resolveChainId(input.chainId), @@ -46,11 +61,12 @@ export async function getNftCollectionDetail(input: NftCollectionDetailInput): P }, 'GET_NFT_COLLECTION_DETAIL_FAILED'); } -export async function getNftTransfers(input: NftTransfersInput): Promise> { - return executeTool(async () => { +export async function getNftTransfers(input: NftTransfersInput): Promise>> { + return executeTool(async (traceId) => { requireField(input.collectionSymbol, 'collectionSymbol'); - return request({ + return request>({ + traceId, path: '/api/app/token/nft/transfers', query: { chainId: resolveChainId(input.chainId), @@ -63,11 +79,12 @@ export async function getNftTransfers(input: NftTransfersInput): Promise> { - return executeTool(async () => { +export async function getNftHolders(input: NftHoldersInput): Promise>> { + return executeTool(async (traceId) => { requireField(input.collectionSymbol, 'collectionSymbol'); - return request({ + return request>({ + traceId, path: '/api/app/token/nft/holders', query: { chainId: resolveChainId(input.chainId), @@ -79,11 +96,12 @@ export async function getNftHolders(input: NftHoldersInput): Promise> { - return executeTool(async () => { +export async function getNftInventory(input: NftInventoryInput): Promise>> { + return executeTool(async (traceId) => { requireField(input.collectionSymbol, 'collectionSymbol'); - return request({ + return request>({ + traceId, path: '/api/app/token/nft/inventory', query: { chainId: resolveChainId(input.chainId), @@ -95,11 +113,12 @@ export async function getNftInventory(input: NftInventoryInput): Promise> { - return executeTool(async () => { +export async function getNftItemDetail(input: NftItemDetailInput): Promise> { + return executeTool(async (traceId) => { requireField(input.symbol, 'symbol'); - return request({ + return request({ + traceId, path: '/api/app/token/nft/item-detail', query: { chainId: resolveChainId(input.chainId), @@ -109,11 +128,14 @@ export async function getNftItemDetail(input: NftItemDetailInput): Promise> { - return executeTool(async () => { +export async function getNftItemHolders( + input: NftItemHoldersInput, +): Promise>> { + return executeTool(async (traceId) => { requireField(input.symbol, 'symbol'); - return request({ + return request>({ + traceId, path: '/api/app/token/nft/item-holders', query: { chainId: resolveChainId(input.chainId), @@ -125,11 +147,14 @@ export async function getNftItemHolders(input: NftItemHoldersInput): Promise> { - return executeTool(async () => { +export async function getNftItemActivity( + input: NftItemActivityInput, +): Promise>> { + return executeTool(async (traceId) => { requireField(input.symbol, 'symbol'); - return request({ + return request>({ + traceId, path: '/api/app/token/nft/item-activity', query: { chainId: resolveChainId(input.chainId), diff --git a/src/core/search.ts b/src/core/search.ts index 235eade..6ac8c2c 100644 --- a/src/core/search.ts +++ b/src/core/search.ts @@ -1,11 +1,13 @@ import { request } from '../../lib/http-client.js'; import { requireField } from '../../lib/errors.js'; +import type { SearchFiltersResponse, SearchResponse } from '../../lib/api-types.js'; import type { SearchFiltersInput, SearchInput, ToolResult } from '../../lib/types.js'; import { executeTool, resolveAddress, resolveChainId } from './common.js'; -export async function getSearchFilters(input: SearchFiltersInput = {}): Promise> { - return executeTool(async () => { - const result = await request({ +export async function getSearchFilters(input: SearchFiltersInput = {}): Promise> { + return executeTool(async (traceId) => { + const result = await request({ + traceId, path: '/api/app/blockchain/filters', query: { chainId: resolveChainId(input.chainId), @@ -16,11 +18,12 @@ export async function getSearchFilters(input: SearchFiltersInput = {}): Promise< }, 'SEARCH_FILTERS_FAILED'); } -export async function search(input: SearchInput): Promise> { - return executeTool(async () => { +export async function search(input: SearchInput): Promise> { + return executeTool(async (traceId) => { requireField(input.keyword, 'keyword'); - const result = await request({ + const result = await request({ + traceId, path: '/api/app/blockchain/search', query: { chainId: resolveChainId(input.chainId), diff --git a/src/core/statistics.ts b/src/core/statistics.ts index 83db8f4..825e2ed 100644 --- a/src/core/statistics.ts +++ b/src/core/statistics.ts @@ -1,6 +1,20 @@ +import type { + DailyActivityAddressResponse, + DailyTransactionInfoResponse, + DailyTransactionsResponse, + ElfSupplyResponse, + NodeCurrentProduceInfoResponse, + StatisticsListResponse, +} from '../../lib/api-types.js'; import { requireField } from '../../lib/errors.js'; import { request } from '../../lib/http-client.js'; -import type { StatisticsDateRangeInput, StatisticsQueryInput, ToolResult } from '../../lib/types.js'; +import type { + StatisticsDateRangeInput, + StatisticsMetric, + StatisticsMetricInput, + StatisticsQueryInput, + ToolResult, +} from '../../lib/types.js'; import { executeTool, resolveChainId } from './common.js'; function withChainId(input: StatisticsQueryInput): Record { @@ -10,13 +24,14 @@ function withChainId(input: StatisticsQueryInput): Record { }; } -async function getStatistics( +async function getStatistics( path: string, input: StatisticsQueryInput = {}, fallbackCode: string, -): Promise> { - return executeTool(async () => { - const result = await request({ +): Promise> { + return executeTool(async (traceId) => { + const result = await request({ + traceId, path, query: withChainId(input), }); @@ -25,16 +40,17 @@ async function getStatistics( }, fallbackCode); } -async function getDateRangeStatistics( +async function getDateRangeStatistics( path: string, input: StatisticsDateRangeInput, fallbackCode: string, -): Promise> { - return executeTool(async () => { +): Promise> { + return executeTool(async (traceId) => { requireField(input.startDate, 'startDate'); requireField(input.endDate, 'endDate'); - const result = await request({ + const result = await request({ + traceId, path, query: withChainId(input), }); @@ -43,114 +59,191 @@ async function getDateRangeStatistics( }, fallbackCode); } -export async function getDailyTransactions(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyTransactions(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyTransactions', input, 'GET_DAILY_TRANSACTIONS_FAILED'); } -export async function getUniqueAddresses(input: StatisticsQueryInput = {}): Promise> { +export async function getUniqueAddresses(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/uniqueAddresses', input, 'GET_UNIQUE_ADDRESSES_FAILED'); } -export async function getDailyActiveAddresses(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyActiveAddresses(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyActiveAddresses', input, 'GET_DAILY_ACTIVE_ADDRESSES_FAILED'); } -export async function getMonthlyActiveAddresses(input: StatisticsQueryInput = {}): Promise> { +export async function getMonthlyActiveAddresses( + input: StatisticsQueryInput = {}, +): Promise> { return getStatistics('/api/app/statistics/monthlyActiveAddresses', input, 'GET_MONTHLY_ACTIVE_ADDRESSES_FAILED'); } -export async function getBlockProduceRate(input: StatisticsQueryInput = {}): Promise> { +export async function getBlockProduceRate(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/blockProduceRate', input, 'GET_BLOCK_PRODUCE_RATE_FAILED'); } -export async function getAvgBlockDuration(input: StatisticsQueryInput = {}): Promise> { +export async function getAvgBlockDuration(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/avgBlockDuration', input, 'GET_AVG_BLOCK_DURATION_FAILED'); } -export async function getCycleCount(input: StatisticsQueryInput = {}): Promise> { +export async function getCycleCount(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/cycleCount', input, 'GET_CYCLE_COUNT_FAILED'); } -export async function getNodeBlockProduce(input: StatisticsQueryInput = {}): Promise> { +export async function getNodeBlockProduce(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/nodeBlockProduce', input, 'GET_NODE_BLOCK_PRODUCE_FAILED'); } -export async function getDailyAvgTransactionFee(input: StatisticsQueryInput = {}): Promise> { - return getStatistics( - '/api/app/statistics/dailyAvgTransactionFee', - input, - 'GET_DAILY_AVG_TRANSACTION_FEE_FAILED', - ); +export async function getDailyAvgTransactionFee( + input: StatisticsQueryInput = {}, +): Promise> { + return getStatistics('/api/app/statistics/dailyAvgTransactionFee', input, 'GET_DAILY_AVG_TRANSACTION_FEE_FAILED'); } -export async function getDailyTxFee(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyTxFee(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyTxFee', input, 'GET_DAILY_TX_FEE_FAILED'); } -export async function getDailyTotalBurnt(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyTotalBurnt(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyTotalBurnt', input, 'GET_DAILY_TOTAL_BURNT_FAILED'); } -export async function getDailyElfPrice(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyElfPrice(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyElfPrice', input, 'GET_DAILY_ELF_PRICE_FAILED'); } -export async function getDailyDeployContract(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyDeployContract( + input: StatisticsQueryInput = {}, +): Promise> { return getStatistics('/api/app/statistics/dailyDeployContract', input, 'GET_DAILY_DEPLOY_CONTRACT_FAILED'); } -export async function getDailyBlockReward(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyBlockReward(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyBlockReward', input, 'GET_DAILY_BLOCK_REWARD_FAILED'); } -export async function getDailyAvgBlockSize(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyAvgBlockSize(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyAvgBlockSize', input, 'GET_DAILY_AVG_BLOCK_SIZE_FAILED'); } -export async function getTopContractCall(input: StatisticsQueryInput = {}): Promise> { +export async function getTopContractCall(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/topContractCall', input, 'GET_TOP_CONTRACT_CALL_FAILED'); } -export async function getDailyContractCall(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyContractCall(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyContractCall', input, 'GET_DAILY_CONTRACT_CALL_FAILED'); } -export async function getDailySupplyGrowth(input: StatisticsQueryInput = {}): Promise> { +export async function getDailySupplyGrowth(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailySupplyGrowth', input, 'GET_DAILY_SUPPLY_GROWTH_FAILED'); } -export async function getDailyMarketCap(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyMarketCap(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyMarketCap', input, 'GET_DAILY_MARKET_CAP_FAILED'); } -export async function getDailyStaked(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyStaked(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyStaked', input, 'GET_DAILY_STAKED_FAILED'); } -export async function getDailyHolder(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyHolder(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyHolder', input, 'GET_DAILY_HOLDER_FAILED'); } -export async function getDailyTvl(input: StatisticsQueryInput = {}): Promise> { +export async function getDailyTvl(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/dailyTvl', input, 'GET_DAILY_TVL_FAILED'); } -export async function getNodeCurrentProduceInfo(input: StatisticsQueryInput = {}): Promise> { +export async function getNodeCurrentProduceInfo( + input: StatisticsQueryInput = {}, +): Promise> { return getStatistics('/api/app/statistics/nodeCurrentProduceInfo', input, 'GET_NODE_CURRENT_PRODUCE_INFO_FAILED'); } -export async function getElfSupply(input: StatisticsQueryInput = {}): Promise> { +export async function getElfSupply(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/elfSupply', input, 'GET_ELF_SUPPLY_FAILED'); } -export async function getDailyTransactionInfo(input: StatisticsDateRangeInput): Promise> { +export async function getDailyTransactionInfo( + input: StatisticsDateRangeInput, +): Promise> { return getDateRangeStatistics('/api/app/statistics/dailyTransactionInfo', input, 'GET_DAILY_TRANSACTION_INFO_FAILED'); } -export async function getDailyActivityAddress(input: StatisticsDateRangeInput): Promise> { +export async function getDailyActivityAddress( + input: StatisticsDateRangeInput, +): Promise> { return getDateRangeStatistics('/api/app/statistics/dailyActivityAddress', input, 'GET_DAILY_ACTIVITY_ADDRESS_FAILED'); } -export async function getCurrencyPrice(input: StatisticsQueryInput = {}): Promise> { +export async function getCurrencyPrice(input: StatisticsQueryInput = {}): Promise> { return getStatistics('/api/app/statistics/currencyPrice', input, 'GET_CURRENCY_PRICE_FAILED'); } + +export const STATISTICS_METRICS = [ + 'dailyTransactions', + 'uniqueAddresses', + 'dailyActiveAddresses', + 'monthlyActiveAddresses', + 'blockProduceRate', + 'avgBlockDuration', + 'cycleCount', + 'nodeBlockProduce', + 'dailyAvgTransactionFee', + 'dailyTxFee', + 'dailyTotalBurnt', + 'dailyElfPrice', + 'dailyDeployContract', + 'dailyBlockReward', + 'dailyAvgBlockSize', + 'topContractCall', + 'dailyContractCall', + 'dailySupplyGrowth', + 'dailyMarketCap', + 'dailyStaked', + 'dailyHolder', + 'dailyTvl', + 'nodeCurrentProduceInfo', + 'elfSupply', + 'dailyTransactionInfo', + 'dailyActivityAddress', + 'currencyPrice', +] as const satisfies ReadonlyArray; + +type StatisticsMetricPayload = Omit; +type StatisticsMetricHandler = (input: StatisticsMetricPayload) => Promise>; + +const STATISTICS_METRIC_HANDLER_MAP: Record = { + dailyTransactions: getDailyTransactions, + uniqueAddresses: getUniqueAddresses, + dailyActiveAddresses: getDailyActiveAddresses, + monthlyActiveAddresses: getMonthlyActiveAddresses, + blockProduceRate: getBlockProduceRate, + avgBlockDuration: getAvgBlockDuration, + cycleCount: getCycleCount, + nodeBlockProduce: getNodeBlockProduce, + dailyAvgTransactionFee: getDailyAvgTransactionFee, + dailyTxFee: getDailyTxFee, + dailyTotalBurnt: getDailyTotalBurnt, + dailyElfPrice: getDailyElfPrice, + dailyDeployContract: getDailyDeployContract, + dailyBlockReward: getDailyBlockReward, + dailyAvgBlockSize: getDailyAvgBlockSize, + topContractCall: getTopContractCall, + dailyContractCall: getDailyContractCall, + dailySupplyGrowth: getDailySupplyGrowth, + dailyMarketCap: getDailyMarketCap, + dailyStaked: getDailyStaked, + dailyHolder: getDailyHolder, + dailyTvl: getDailyTvl, + nodeCurrentProduceInfo: getNodeCurrentProduceInfo, + elfSupply: getElfSupply, + dailyTransactionInfo: input => getDailyTransactionInfo(input as StatisticsDateRangeInput), + dailyActivityAddress: input => getDailyActivityAddress(input as StatisticsDateRangeInput), + currencyPrice: getCurrencyPrice, +}; + +export async function getStatisticsByMetric(input: StatisticsMetricInput): Promise> { + const { metric, ...payload } = input; + const handler = STATISTICS_METRIC_HANDLER_MAP[metric]; + return handler(payload); +} diff --git a/src/core/token.ts b/src/core/token.ts index 708dc72..c6648f6 100644 --- a/src/core/token.ts +++ b/src/core/token.ts @@ -1,11 +1,13 @@ import { request } from '../../lib/http-client.js'; import { requireField } from '../../lib/errors.js'; +import type { ApiPagedList, TokenDetailResponse, TokenHolderItem, TokenSummary, TokenTransferItem } from '../../lib/api-types.js'; import type { TokenDetailInput, TokenHoldersInput, TokenListInput, TokenTransfersInput, ToolResult } from '../../lib/types.js'; import { executeTool, resolveAddress, resolveChainId, toPaginationQuery } from './common.js'; -export async function getTokens(input: TokenListInput = {}): Promise> { - return executeTool(async () => { - return request({ +export async function getTokens(input: TokenListInput = {}): Promise>> { + return executeTool(async (traceId) => { + return request>({ + traceId, path: '/api/app/token/list', query: { chainId: resolveChainId(input.chainId), @@ -22,11 +24,12 @@ export async function getTokens(input: TokenListInput = {}): Promise> { - return executeTool(async () => { +export async function getTokenDetail(input: TokenDetailInput): Promise> { + return executeTool(async (traceId) => { requireField(input.symbol, 'symbol'); - return request({ + return request({ + traceId, path: '/api/app/token/detail', query: { chainId: resolveChainId(input.chainId), @@ -36,11 +39,12 @@ export async function getTokenDetail(input: TokenDetailInput): Promise> { - return executeTool(async () => { +export async function getTokenTransfers(input: TokenTransfersInput): Promise>> { + return executeTool(async (traceId) => { requireField(input.symbol, 'symbol'); - return request({ + return request>({ + traceId, path: '/api/app/token/transfers', query: { chainId: resolveChainId(input.chainId), @@ -57,9 +61,10 @@ export async function getTokenTransfers(input: TokenTransfersInput): Promise> { - return executeTool(async () => { - return request({ +export async function getTokenHolders(input: TokenHoldersInput): Promise>> { + return executeTool(async (traceId) => { + return request>({ + traceId, path: '/api/app/token/holders', query: { chainId: resolveChainId(input.chainId), diff --git a/src/mcp/output.ts b/src/mcp/output.ts new file mode 100644 index 0000000..55d1772 --- /dev/null +++ b/src/mcp/output.ts @@ -0,0 +1,199 @@ +import { getConfig } from '../../lib/config.js'; +import type { ToolOutputPolicy } from '../tooling/tool-descriptors.js'; + +interface TruncationMeta { + truncated: boolean; + maxItems: number; + maxChars: number; + originalSizeEstimate: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function summarizeValue(value: unknown): Record { + if (Array.isArray(value)) { + return { + type: 'array', + length: value.length, + preview: value.slice(0, 3), + }; + } + + if (isRecord(value)) { + const keys = Object.keys(value); + return { + type: 'object', + keys, + keyCount: keys.length, + }; + } + + return { + type: typeof value, + value, + }; +} + +function truncateArrays(value: unknown, maxItems: number): { value: unknown; truncated: boolean } { + if (Array.isArray(value)) { + const truncatedItems = value.slice(0, maxItems).map(item => truncateArrays(item, maxItems)); + const wasTruncated = value.length > maxItems || truncatedItems.some(item => item.truncated); + + return { + value: truncatedItems.map(item => item.value), + truncated: wasTruncated, + }; + } + + if (isRecord(value)) { + let truncated = false; + const next: Record = {}; + + for (const [key, itemValue] of Object.entries(value)) { + const child = truncateArrays(itemValue, maxItems); + if (child.truncated) { + truncated = true; + } + next[key] = child.value; + } + + return { + value: next, + truncated, + }; + } + + return { + value, + truncated: false, + }; +} + +function stripRawByConfig(value: unknown, includeRaw: boolean): unknown { + if (includeRaw) { + return value; + } + + if (!isRecord(value)) { + return value; + } + + const next = { ...value }; + delete next.raw; + return next; +} + +function attachMeta(value: unknown, meta: TruncationMeta): unknown { + if (isRecord(value)) { + return { + ...value, + meta, + }; + } + + return { + data: value, + meta, + }; +} + +function safeSerialize(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return JSON.stringify({ data: summarizeValue(value) }, null, 2); + } +} + +function shrinkForMaxChars(value: unknown, meta: TruncationMeta): unknown { + if (isRecord(value)) { + return { + success: value.success, + traceId: value.traceId, + dataSummary: summarizeValue(value.data), + error: value.error, + meta, + }; + } + + return { + dataSummary: summarizeValue(value), + meta, + }; +} + +function applySummaryPolicy( + value: unknown, + policy: ToolOutputPolicy, + maxItems: number, +): { value: unknown; truncated: boolean } { + if (policy !== 'summary') { + return { value, truncated: false }; + } + + if (!isRecord(value) || !isRecord(value.data)) { + return { value, truncated: false }; + } + + const data = value.data as Record; + const nextData = { ...data }; + let truncated = false; + + for (const key of ['list', 'items', 'blocks', 'transactions', 'logEvents']) { + const item = nextData[key]; + if (Array.isArray(item) && item.length > maxItems) { + nextData[key] = item.slice(0, maxItems); + truncated = true; + } + } + + return { + value: { + ...value, + data: nextData, + }, + truncated, + }; +} + +export function asMcpResult(data: unknown, outputPolicy: ToolOutputPolicy = 'normal') { + const config = getConfig(); + const maxItems = Math.max(1, config.mcpMaxItems); + const maxChars = Math.max(1, config.mcpMaxChars); + + const stripped = stripRawByConfig(data, config.mcpIncludeRaw); + const summaryApplied = applySummaryPolicy(stripped, outputPolicy, maxItems); + const truncatedArrays = truncateArrays(summaryApplied.value, maxItems); + + const initialMeta: TruncationMeta = { + truncated: summaryApplied.truncated || truncatedArrays.truncated, + maxItems, + maxChars, + originalSizeEstimate: safeSerialize(truncatedArrays.value).length, + }; + + const meta: TruncationMeta = initialMeta; + let payload = attachMeta(truncatedArrays.value, meta); + let serialized = safeSerialize(payload); + + if (serialized.length > maxChars) { + const reducedMeta: TruncationMeta = { + ...meta, + truncated: true, + }; + + payload = shrinkForMaxChars(truncatedArrays.value, reducedMeta); + serialized = safeSerialize(payload); + } + + return { + content: [ + { + type: 'text' as const, + text: serialized, + }, + ], + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 65dfea1..5058b47 100755 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,872 +1,29 @@ #!/usr/bin/env bun import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import { - getAccounts, - getAddressDetail, - getAddressDictionary, - getAddressNftAssets, - getAddressTokens, - getAddressTransfers, - getAvgBlockDuration, - getBlockDetail, - getBlockchainOverview, - getBlockProduceRate, - getBlocks, - getContractEvents, - getContractHistory, - getContracts, - getContractSource, - getCurrencyPrice, - getCycleCount, - getDailyActiveAddresses, - getDailyActivityAddress, - getDailyAvgBlockSize, - getDailyAvgTransactionFee, - getDailyBlockReward, - getDailyContractCall, - getDailyDeployContract, - getDailyElfPrice, - getDailyHolder, - getDailyMarketCap, - getDailyStaked, - getDailySupplyGrowth, - getDailyTotalBurnt, - getDailyTransactionInfo, - getDailyTransactions, - getDailyTvl, - getDailyTxFee, - getElfSupply, - getLatestBlocks, - getLatestTransactions, - getLogEvents, - getMonthlyActiveAddresses, - getNftCollectionDetail, - getNftCollections, - getNftHolders, - getNftInventory, - getNftItemActivity, - getNftItemDetail, - getNftItemHolders, - getNftTransfers, - getNodeBlockProduce, - getNodeCurrentProduceInfo, - getSearchFilters, - getTopContractCall, - getTokenDetail, - getTokenHolders, - getTokens, - getTokenTransfers, - getTransactionDataChart, - getTransactionDetail, - getTransactions, - getUniqueAddresses, - search, -} from '../../index.js'; +import packageJson from '../../package.json'; +import { asMcpResult } from './output.js'; +import { MCP_TOOL_DESCRIPTORS } from '../tooling/tool-descriptors.js'; const server = new McpServer({ name: 'aelfscan-skill', - version: '0.1.0', + version: packageJson.version, }); -function asMcpResult(data: unknown) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; -} - -const sortDirectionSchema = z.enum(['Asc', 'Desc']); - -const orderInfoSchema = z.object({ - orderBy: z.string().describe('Sort field name, e.g. BlockTime'), - sort: sortDirectionSchema.describe('Sort direction Asc or Desc'), -}); - -const paginationSchema = { - chainId: z.string().optional().describe('Chain id, e.g. AELF/tDVV; empty for multi-chain'), - skipCount: z.number().int().optional().describe('Offset for pagination'), - maxResultCount: z.number().int().optional().describe('Page size'), - orderBy: z.string().optional().describe('Simple sort field'), - sort: sortDirectionSchema.optional().describe('Simple sort direction'), - orderInfos: z.array(orderInfoSchema).optional().describe('Advanced sorting list'), - searchAfter: z.array(z.string()).optional().describe('search_after cursor values'), -}; - -const statisticsSchema = { - chainId: z.string().optional().describe('Chain id, e.g. AELF/tDVV; empty for multi-chain'), - startDate: z.string().optional().describe('Start date (YYYY-MM-DD)'), - endDate: z.string().optional().describe('End date (YYYY-MM-DD)'), -}; - -server.registerTool( - 'aelfscan_search_filters', - { - description: 'Get search filter metadata used by explorer search UI.', - inputSchema: { - chainId: z.string().optional(), - }, - }, - async input => asMcpResult(await getSearchFilters(input)), -); - -server.registerTool( - 'aelfscan_search', - { - description: 'Search tokens/accounts/contracts/NFTs/blocks/transactions on AelfScan explorer.', - inputSchema: { - chainId: z.string().optional(), - keyword: z.string().describe('Search keyword'), - filterType: z.number().int().optional().describe('0:all,1:tokens,2:accounts,3:contracts,4:nfts'), - searchType: z.number().int().optional().describe('0:fuzzy,1:exact'), - }, - }, - async input => asMcpResult(await search(input)), -); - -server.registerTool( - 'aelfscan_blocks', - { - description: 'List blocks with pagination.', - inputSchema: { - ...paginationSchema, - isLastPage: z.boolean().optional(), - }, - }, - async input => asMcpResult(await getBlocks(input)), -); - -server.registerTool( - 'aelfscan_blocks_latest', - { - description: 'Get latest blocks (uses blocks API with skipCount=0).', - inputSchema: { - chainId: z.string().optional(), - maxResultCount: z.number().int().optional(), - orderBy: z.string().optional(), - sort: sortDirectionSchema.optional(), - orderInfos: z.array(orderInfoSchema).optional(), - searchAfter: z.array(z.string()).optional(), - isLastPage: z.boolean().optional(), - }, - }, - async input => asMcpResult(await getLatestBlocks(input)), -); - -server.registerTool( - 'aelfscan_block_detail', - { - description: 'Get block detail by block height.', - inputSchema: { - chainId: z.string().optional(), - blockHeight: z.number().int().describe('Block height'), - }, - }, - async input => asMcpResult(await getBlockDetail(input)), -); - -server.registerTool( - 'aelfscan_transactions', - { - description: 'List transactions with optional filters.', - inputSchema: { - ...paginationSchema, - transactionId: z.string().optional(), - blockHeight: z.number().int().optional(), - address: z.string().optional(), - startTime: z.number().int().optional(), - endTime: z.number().int().optional(), - }, - }, - async input => asMcpResult(await getTransactions(input)), -); - -server.registerTool( - 'aelfscan_transactions_latest', - { - description: 'Get latest transactions (uses transactions API with skipCount=0).', - inputSchema: { - chainId: z.string().optional(), - maxResultCount: z.number().int().optional(), - orderBy: z.string().optional(), - sort: sortDirectionSchema.optional(), - orderInfos: z.array(orderInfoSchema).optional(), - searchAfter: z.array(z.string()).optional(), - transactionId: z.string().optional(), - blockHeight: z.number().int().optional(), - address: z.string().optional(), - startTime: z.number().int().optional(), - endTime: z.number().int().optional(), - }, - }, - async input => asMcpResult(await getLatestTransactions(input)), -); - -server.registerTool( - 'aelfscan_transaction_detail', - { - description: 'Get transaction detail by transaction id.', - inputSchema: { - chainId: z.string().optional(), - transactionId: z.string().describe('Transaction id'), - blockHeight: z.number().int().optional(), - }, - }, - async input => asMcpResult(await getTransactionDetail(input)), -); - -server.registerTool( - 'aelfscan_blockchain_overview', - { - description: 'Get blockchain overview metrics and aggregate stats.', - inputSchema: { - chainId: z.string().optional(), - }, - }, - async input => asMcpResult(await getBlockchainOverview(input)), -); - -server.registerTool( - 'aelfscan_transaction_data_chart', - { - description: 'Get transaction data chart series.', - inputSchema: { - chainId: z.string().optional(), - }, - }, - async input => asMcpResult(await getTransactionDataChart(input)), -); - -server.registerTool( - 'aelfscan_address_dictionary', - { - description: 'Resolve or query address dictionary metadata.', - inputSchema: { - chainId: z.string().optional(), - name: z.string().describe('Dictionary name'), - addresses: z.array(z.string()).min(1).describe('Address list'), - }, - }, - async input => asMcpResult(await getAddressDictionary(input)), -); - -server.registerTool( - 'aelfscan_log_events', - { - description: 'Get contract log events by contract address.', - inputSchema: { - ...paginationSchema, - contractAddress: z.string().describe('Contract address'), - address: z.string().optional(), - eventName: z.string().optional(), - transactionId: z.string().optional(), - blockHeight: z.number().int().optional(), - startBlockHeight: z.number().int().optional(), - endBlockHeight: z.number().int().optional(), - }, - }, - async input => asMcpResult(await getLogEvents(input)), -); - -server.registerTool( - 'aelfscan_accounts', - { - description: 'List top accounts.', - inputSchema: { - ...paginationSchema, +for (const descriptor of MCP_TOOL_DESCRIPTORS) { + server.registerTool( + descriptor.mcpName, + { + description: descriptor.description, + inputSchema: descriptor.inputSchema, }, - }, - async input => asMcpResult(await getAccounts(input)), -); - -server.registerTool( - 'aelfscan_contracts', - { - description: 'List contracts.', - inputSchema: { - ...paginationSchema, + async (input: Record) => { + const validatedInput = descriptor.parse(input); + const result = await descriptor.handler(validatedInput); + return asMcpResult(result, descriptor.outputPolicy); }, - }, - async input => asMcpResult(await getContracts(input)), -); - -server.registerTool( - 'aelfscan_address_detail', - { - description: 'Get address detail (EOA/contract profile and portfolio).', - inputSchema: { - chainId: z.string().optional(), - address: z.string().describe('Address, supports ELF_xxx_chain format'), - }, - }, - async input => asMcpResult(await getAddressDetail(input)), -); - -server.registerTool( - 'aelfscan_address_tokens', - { - description: 'Get token holdings for an address.', - inputSchema: { - ...paginationSchema, - address: z.string().describe('Address'), - fuzzySearch: z.string().optional(), - }, - }, - async input => asMcpResult(await getAddressTokens(input)), -); - -server.registerTool( - 'aelfscan_address_nft_assets', - { - description: 'Get NFT holdings for an address.', - inputSchema: { - ...paginationSchema, - address: z.string().describe('Address'), - fuzzySearch: z.string().optional(), - }, - }, - async input => asMcpResult(await getAddressNftAssets(input)), -); - -server.registerTool( - 'aelfscan_address_transfers', - { - description: 'Get transfer history for an address.', - inputSchema: { - ...paginationSchema, - address: z.string().describe('Address'), - symbol: z.string().optional(), - tokenType: z.number().int().optional().describe('0:token,1:nft'), - }, - }, - async input => asMcpResult(await getAddressTransfers(input)), -); - -server.registerTool( - 'aelfscan_contract_history', - { - description: 'Get contract deploy/update history.', - inputSchema: { - chainId: z.string().optional(), - address: z.string().describe('Contract address'), - }, - }, - async input => asMcpResult(await getContractHistory(input)), -); - -server.registerTool( - 'aelfscan_contract_events', - { - description: 'Get contract events list.', - inputSchema: { - ...paginationSchema, - chainId: z.string().optional(), - contractAddress: z.string().describe('Contract address'), - blockHeight: z.number().int().optional(), - }, - }, - async input => asMcpResult(await getContractEvents(input)), -); - -server.registerTool( - 'aelfscan_contract_source', - { - description: 'Get verified contract source metadata.', - inputSchema: { - chainId: z.string().optional(), - address: z.string().describe('Contract address'), - }, - }, - async input => asMcpResult(await getContractSource(input)), -); - -server.registerTool( - 'aelfscan_tokens', - { - description: 'List tokens.', - inputSchema: { - ...paginationSchema, - types: z.array(z.number().int()).optional(), - symbols: z.array(z.string()).optional(), - collectionSymbols: z.array(z.string()).optional(), - search: z.string().optional(), - exactSearch: z.string().optional(), - fuzzySearch: z.string().optional(), - beginBlockTime: z.union([z.string(), z.number()]).optional(), - }, - }, - async input => asMcpResult(await getTokens(input)), -); - -server.registerTool( - 'aelfscan_token_detail', - { - description: 'Get token detail by symbol.', - inputSchema: { - chainId: z.string().optional(), - symbol: z.string().describe('Token symbol'), - }, - }, - async input => asMcpResult(await getTokenDetail(input)), -); - -server.registerTool( - 'aelfscan_token_transfers', - { - description: 'Get token transfer list by symbol.', - inputSchema: { - ...paginationSchema, - symbol: z.string().describe('Token symbol'), - search: z.string().optional(), - collectionSymbol: z.string().optional(), - address: z.string().optional(), - types: z.array(z.number().int()).optional(), - fuzzySearch: z.string().optional(), - beginBlockTime: z.union([z.string(), z.number()]).optional(), - }, - }, - async input => asMcpResult(await getTokenTransfers(input)), -); - -server.registerTool( - 'aelfscan_token_holders', - { - description: 'Get token holders.', - inputSchema: { - ...paginationSchema, - symbol: z.string().optional(), - collectionSymbol: z.string().optional(), - address: z.string().optional(), - partialSymbol: z.string().optional(), - search: z.string().optional(), - types: z.array(z.number().int()).optional(), - symbols: z.array(z.string()).optional(), - addressList: z.array(z.string()).optional(), - searchSymbols: z.array(z.string()).optional(), - fuzzySearch: z.string().optional(), - amountGreaterThanZero: z.boolean().optional(), - }, - }, - async input => asMcpResult(await getTokenHolders(input)), -); - -server.registerTool( - 'aelfscan_nft_collections', - { - description: 'List NFT collections.', - inputSchema: { - ...paginationSchema, - types: z.array(z.number().int()).optional(), - symbols: z.array(z.string()).optional(), - collectionSymbols: z.array(z.string()).optional(), - search: z.string().optional(), - exactSearch: z.string().optional(), - fuzzySearch: z.string().optional(), - beginBlockTime: z.union([z.string(), z.number()]).optional(), - }, - }, - async input => asMcpResult(await getNftCollections(input)), -); - -server.registerTool( - 'aelfscan_nft_collection_detail', - { - description: 'Get NFT collection detail.', - inputSchema: { - chainId: z.string().optional(), - collectionSymbol: z.string().describe('NFT collection symbol'), - }, - }, - async input => asMcpResult(await getNftCollectionDetail(input)), -); - -server.registerTool( - 'aelfscan_nft_transfers', - { - description: 'Get NFT transfers by collection symbol.', - inputSchema: { - ...paginationSchema, - chainId: z.string().optional(), - collectionSymbol: z.string().describe('NFT collection symbol'), - search: z.string().optional(), - address: z.string().optional(), - }, - }, - async input => asMcpResult(await getNftTransfers(input)), -); - -server.registerTool( - 'aelfscan_nft_holders', - { - description: 'Get NFT holders by collection symbol.', - inputSchema: { - ...paginationSchema, - chainId: z.string().optional(), - collectionSymbol: z.string().describe('NFT collection symbol'), - search: z.string().optional(), - }, - }, - async input => asMcpResult(await getNftHolders(input)), -); - -server.registerTool( - 'aelfscan_nft_inventory', - { - description: 'Get NFT inventory by collection symbol.', - inputSchema: { - ...paginationSchema, - chainId: z.string().optional(), - collectionSymbol: z.string().describe('NFT collection symbol'), - search: z.string().optional(), - }, - }, - async input => asMcpResult(await getNftInventory(input)), -); - -server.registerTool( - 'aelfscan_nft_item_detail', - { - description: 'Get NFT item detail by symbol.', - inputSchema: { - chainId: z.string().optional(), - symbol: z.string().describe('NFT item symbol'), - }, - }, - async input => asMcpResult(await getNftItemDetail(input)), -); - -server.registerTool( - 'aelfscan_nft_item_holders', - { - description: 'Get holders of a specific NFT item.', - inputSchema: { - ...paginationSchema, - chainId: z.string().optional(), - symbol: z.string().describe('NFT item symbol'), - types: z.array(z.number().int()).optional(), - }, - }, - async input => asMcpResult(await getNftItemHolders(input)), -); - -server.registerTool( - 'aelfscan_nft_item_activity', - { - description: 'Get activity list of a specific NFT item.', - inputSchema: { - ...paginationSchema, - chainId: z.string().optional(), - symbol: z.string().describe('NFT item symbol'), - }, - }, - async input => asMcpResult(await getNftItemActivity(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_transactions', - { - description: 'Get daily transactions statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyTransactions(input)), -); - -server.registerTool( - 'aelfscan_statistics_unique_addresses', - { - description: 'Get unique addresses statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getUniqueAddresses(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_active_addresses', - { - description: 'Get daily active addresses statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyActiveAddresses(input)), -); - -server.registerTool( - 'aelfscan_statistics_monthly_active_addresses', - { - description: 'Get monthly active addresses statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getMonthlyActiveAddresses(input)), -); - -server.registerTool( - 'aelfscan_statistics_block_produce_rate', - { - description: 'Get block produce rate statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getBlockProduceRate(input)), -); - -server.registerTool( - 'aelfscan_statistics_avg_block_duration', - { - description: 'Get average block duration statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getAvgBlockDuration(input)), -); - -server.registerTool( - 'aelfscan_statistics_cycle_count', - { - description: 'Get cycle count statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getCycleCount(input)), -); - -server.registerTool( - 'aelfscan_statistics_node_block_produce', - { - description: 'Get node block produce statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getNodeBlockProduce(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_avg_transaction_fee', - { - description: 'Get daily average transaction fee statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyAvgTransactionFee(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_tx_fee', - { - description: 'Get daily transaction fee statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyTxFee(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_total_burnt', - { - description: 'Get daily total burnt statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyTotalBurnt(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_elf_price', - { - description: 'Get daily ELF price statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyElfPrice(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_deploy_contract', - { - description: 'Get daily deploy contract statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyDeployContract(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_block_reward', - { - description: 'Get daily block reward statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyBlockReward(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_avg_block_size', - { - description: 'Get daily average block size statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyAvgBlockSize(input)), -); - -server.registerTool( - 'aelfscan_statistics_top_contract_call', - { - description: 'Get top contract call statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getTopContractCall(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_contract_call', - { - description: 'Get daily contract call statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyContractCall(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_supply_growth', - { - description: 'Get daily supply growth statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailySupplyGrowth(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_market_cap', - { - description: 'Get daily market cap statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyMarketCap(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_staked', - { - description: 'Get daily staked statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyStaked(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_holder', - { - description: 'Get daily holder statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyHolder(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_tvl', - { - description: 'Get daily TVL statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getDailyTvl(input)), -); - -server.registerTool( - 'aelfscan_statistics_node_current_produce_info', - { - description: 'Get current node produce information.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getNodeCurrentProduceInfo(input)), -); - -server.registerTool( - 'aelfscan_statistics_elf_supply', - { - description: 'Get ELF supply statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getElfSupply(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_transaction_info', - { - description: 'Get daily transaction summary for a date range.', - inputSchema: { - chainId: z.string().optional(), - startDate: z.string().describe('Start date (YYYY-MM-DD)'), - endDate: z.string().describe('End date (YYYY-MM-DD)'), - }, - }, - async input => asMcpResult(await getDailyTransactionInfo(input)), -); - -server.registerTool( - 'aelfscan_statistics_daily_activity_address', - { - description: 'Get daily activity address summary for a date range.', - inputSchema: { - chainId: z.string().optional(), - startDate: z.string().describe('Start date (YYYY-MM-DD)'), - endDate: z.string().describe('End date (YYYY-MM-DD)'), - }, - }, - async input => asMcpResult(await getDailyActivityAddress(input)), -); - -server.registerTool( - 'aelfscan_statistics_currency_price', - { - description: 'Get currency price statistics.', - inputSchema: { - ...statisticsSchema, - }, - }, - async input => asMcpResult(await getCurrencyPrice(input)), -); + ); +} const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tooling/tool-descriptors.ts b/src/tooling/tool-descriptors.ts new file mode 100644 index 0000000..e91862f --- /dev/null +++ b/src/tooling/tool-descriptors.ts @@ -0,0 +1,954 @@ +import { z } from 'zod'; +import type { ToolResult } from '../../lib/types.js'; +import { + getAccounts, + getAddressDetail, + getAddressNftAssets, + getAddressTokens, + getAddressTransfers, + getContractEvents, + getContractHistory, + getContracts, + getContractSource, +} from '../core/address.js'; +import { + getAddressDictionary, + getBlockDetail, + getBlockchainOverview, + getBlocks, + getLatestBlocks, + getLatestTransactions, + getLogEvents, + getTransactionDataChart, + getTransactionDetail, + getTransactions, +} from '../core/blockchain.js'; +import { + getNftCollectionDetail, + getNftCollections, + getNftHolders, + getNftInventory, + getNftItemActivity, + getNftItemDetail, + getNftItemHolders, + getNftTransfers, +} from '../core/nft.js'; +import { getSearchFilters, search } from '../core/search.js'; +import { + getAvgBlockDuration, + getBlockProduceRate, + getCurrencyPrice, + getCycleCount, + getDailyActiveAddresses, + getDailyActivityAddress, + getDailyAvgBlockSize, + getDailyAvgTransactionFee, + getDailyBlockReward, + getDailyContractCall, + getDailyDeployContract, + getDailyElfPrice, + getDailyHolder, + getDailyMarketCap, + getDailyStaked, + getDailySupplyGrowth, + getDailyTotalBurnt, + getDailyTransactionInfo, + getDailyTransactions, + getDailyTvl, + getDailyTxFee, + getElfSupply, + getMonthlyActiveAddresses, + getNodeBlockProduce, + getNodeCurrentProduceInfo, + getStatisticsByMetric, + STATISTICS_METRICS, + getTopContractCall, + getUniqueAddresses, +} from '../core/statistics.js'; +import { getTokenDetail, getTokenHolders, getTokens, getTokenTransfers } from '../core/token.js'; + +export type ToolOutputPolicy = 'normal' | 'summary'; +export type ToolAdapter = 'sdk' | 'cli' | 'mcp' | 'openclaw'; + +const DEFAULT_ADAPTERS = ['sdk', 'cli', 'mcp', 'openclaw'] as const; +const DETAILED_STATISTICS_ADAPTERS = ['sdk', 'cli', 'openclaw'] as const; + +export interface ToolDescriptor { + key: string; + domain: string; + action: string; + mcpName: string; + description: string; + inputSchema: z.ZodRawShape; + parser: z.ZodObject; + parse: (input: unknown) => Record; + handler: (input: Record) => Promise>; + outputPolicy: ToolOutputPolicy; + adapters: ReadonlyArray; +} + +function defineTool(config: { + key: string; + domain: string; + action: string; + mcpName: string; + description: string; + inputSchema: S; + handler: (input: z.infer>) => Promise>; + outputPolicy?: ToolOutputPolicy; + adapters?: ReadonlyArray; +}): ToolDescriptor { + const parser = z.object(config.inputSchema).passthrough(); + + return { + key: config.key, + domain: config.domain, + action: config.action, + mcpName: config.mcpName, + description: config.description, + inputSchema: config.inputSchema, + parser: parser as z.ZodObject, + parse: (input: unknown) => parser.parse(input) as Record, + handler: (input: Record) => config.handler(input as z.infer>) as Promise>, + outputPolicy: config.outputPolicy ?? 'normal', + adapters: config.adapters ?? DEFAULT_ADAPTERS, + }; +} + +function defineDetailedStatisticsTool(config: { + key: string; + domain: string; + action: string; + mcpName: string; + description: string; + inputSchema: S; + handler: (input: z.infer>) => Promise>; + outputPolicy?: ToolOutputPolicy; +}): ToolDescriptor { + return defineTool({ + ...config, + adapters: DETAILED_STATISTICS_ADAPTERS, + }); +} + +const sortDirectionSchema = z.enum(['Asc', 'Desc']); + +const orderInfoSchema = z.object({ + orderBy: z.string().describe('Sort field name, e.g. BlockTime'), + sort: sortDirectionSchema.describe('Sort direction Asc or Desc'), +}); + +const paginationSchema = { + chainId: z.string().optional().describe('Chain id, e.g. AELF/tDVV; empty for multi-chain'), + skipCount: z.number().int().optional().describe('Offset for pagination'), + maxResultCount: z.number().int().optional().describe('Page size'), + orderBy: z.string().optional().describe('Simple sort field'), + sort: sortDirectionSchema.optional().describe('Simple sort direction'), + orderInfos: z.array(orderInfoSchema).optional().describe('Advanced sorting list'), + searchAfter: z.array(z.string()).optional().describe('search_after cursor values'), +} satisfies z.ZodRawShape; + +const statisticsSchema = { + chainId: z.string().optional().describe('Chain id, e.g. AELF/tDVV; empty for multi-chain'), + startDate: z.string().optional().describe('Start date (YYYY-MM-DD)'), + endDate: z.string().optional().describe('End date (YYYY-MM-DD)'), +} satisfies z.ZodRawShape; + +const statisticsDateRangeSchema = { + chainId: z.string().optional().describe('Chain id, e.g. AELF/tDVV; empty for multi-chain'), + startDate: z.string().describe('Start date (YYYY-MM-DD)'), + endDate: z.string().describe('End date (YYYY-MM-DD)'), +} satisfies z.ZodRawShape; + +const statisticsMetricSchema = { + metric: z.enum(STATISTICS_METRICS), + chainId: z.string().optional().describe('Chain id, e.g. AELF/tDVV; empty for multi-chain'), + startDate: z.string().optional().describe('Start date (YYYY-MM-DD)'), + endDate: z.string().optional().describe('End date (YYYY-MM-DD)'), +} satisfies z.ZodRawShape; + +export const TOOL_DESCRIPTORS = [ + defineTool({ + key: 'search.filters', + domain: 'search', + action: 'filters', + mcpName: 'aelfscan_search_filters', + description: 'Get search filter metadata used by explorer search UI.', + inputSchema: { + chainId: z.string().optional(), + }, + handler: getSearchFilters, + outputPolicy: 'summary', + }), + defineTool({ + key: 'search.query', + domain: 'search', + action: 'query', + mcpName: 'aelfscan_search', + description: 'Search tokens/accounts/contracts/NFTs/blocks/transactions on AelfScan explorer.', + inputSchema: { + chainId: z.string().optional(), + keyword: z.string().describe('Search keyword'), + filterType: z.number().int().optional().describe('0:all,1:tokens,2:accounts,3:contracts,4:nfts'), + searchType: z.number().int().optional().describe('0:fuzzy,1:exact'), + }, + handler: search, + outputPolicy: 'summary', + }), + + defineTool({ + key: 'blockchain.blocks', + domain: 'blockchain', + action: 'blocks', + mcpName: 'aelfscan_blocks', + description: 'List blocks with pagination.', + inputSchema: { + ...paginationSchema, + isLastPage: z.boolean().optional(), + }, + handler: getBlocks, + outputPolicy: 'summary', + }), + defineTool({ + key: 'blockchain.blocks-latest', + domain: 'blockchain', + action: 'blocks-latest', + mcpName: 'aelfscan_blocks_latest', + description: 'Get latest blocks (uses blocks API with skipCount=0).', + inputSchema: { + chainId: z.string().optional(), + maxResultCount: z.number().int().optional(), + orderBy: z.string().optional(), + sort: sortDirectionSchema.optional(), + orderInfos: z.array(orderInfoSchema).optional(), + searchAfter: z.array(z.string()).optional(), + isLastPage: z.boolean().optional(), + }, + handler: getLatestBlocks, + outputPolicy: 'summary', + }), + defineTool({ + key: 'blockchain.block-detail', + domain: 'blockchain', + action: 'block-detail', + mcpName: 'aelfscan_block_detail', + description: 'Get block detail by block height.', + inputSchema: { + chainId: z.string().optional(), + blockHeight: z.number().int().describe('Block height'), + }, + handler: getBlockDetail, + }), + defineTool({ + key: 'blockchain.transactions', + domain: 'blockchain', + action: 'transactions', + mcpName: 'aelfscan_transactions', + description: 'List transactions with optional filters.', + inputSchema: { + ...paginationSchema, + transactionId: z.string().optional(), + blockHeight: z.number().int().optional(), + address: z.string().optional(), + startTime: z.number().int().optional(), + endTime: z.number().int().optional(), + }, + handler: getTransactions, + outputPolicy: 'summary', + }), + defineTool({ + key: 'blockchain.transactions-latest', + domain: 'blockchain', + action: 'transactions-latest', + mcpName: 'aelfscan_transactions_latest', + description: 'Get latest transactions (uses transactions API with skipCount=0).', + inputSchema: { + chainId: z.string().optional(), + maxResultCount: z.number().int().optional(), + orderBy: z.string().optional(), + sort: sortDirectionSchema.optional(), + orderInfos: z.array(orderInfoSchema).optional(), + searchAfter: z.array(z.string()).optional(), + transactionId: z.string().optional(), + blockHeight: z.number().int().optional(), + address: z.string().optional(), + startTime: z.number().int().optional(), + endTime: z.number().int().optional(), + }, + handler: getLatestTransactions, + outputPolicy: 'summary', + }), + defineTool({ + key: 'blockchain.transaction-detail', + domain: 'blockchain', + action: 'transaction-detail', + mcpName: 'aelfscan_transaction_detail', + description: 'Get transaction detail by transaction id.', + inputSchema: { + chainId: z.string().optional(), + transactionId: z.string().describe('Transaction id'), + blockHeight: z.number().int().optional(), + }, + handler: getTransactionDetail, + }), + defineTool({ + key: 'blockchain.overview', + domain: 'blockchain', + action: 'overview', + mcpName: 'aelfscan_blockchain_overview', + description: 'Get blockchain overview metrics and aggregate stats.', + inputSchema: { + chainId: z.string().optional(), + }, + handler: getBlockchainOverview, + outputPolicy: 'summary', + }), + defineTool({ + key: 'blockchain.transaction-data-chart', + domain: 'blockchain', + action: 'transaction-data-chart', + mcpName: 'aelfscan_transaction_data_chart', + description: 'Get transaction data chart series.', + inputSchema: { + chainId: z.string().optional(), + }, + handler: getTransactionDataChart, + outputPolicy: 'summary', + }), + defineTool({ + key: 'blockchain.address-dictionary', + domain: 'blockchain', + action: 'address-dictionary', + mcpName: 'aelfscan_address_dictionary', + description: 'Resolve or query address dictionary metadata.', + inputSchema: { + chainId: z.string().optional(), + name: z.string().describe('Dictionary name'), + addresses: z.array(z.string()).min(1).describe('Address list'), + }, + handler: getAddressDictionary, + }), + defineTool({ + key: 'blockchain.log-events', + domain: 'blockchain', + action: 'log-events', + mcpName: 'aelfscan_log_events', + description: 'Get contract log events by contract address.', + inputSchema: { + ...paginationSchema, + contractAddress: z.string().describe('Contract address'), + address: z.string().optional(), + eventName: z.string().optional(), + transactionId: z.string().optional(), + blockHeight: z.number().int().optional(), + startBlockHeight: z.number().int().optional(), + endBlockHeight: z.number().int().optional(), + }, + handler: getLogEvents, + outputPolicy: 'summary', + }), + + defineTool({ + key: 'address.accounts', + domain: 'address', + action: 'accounts', + mcpName: 'aelfscan_accounts', + description: 'List top accounts.', + inputSchema: { + ...paginationSchema, + }, + handler: getAccounts, + outputPolicy: 'summary', + }), + defineTool({ + key: 'address.contracts', + domain: 'address', + action: 'contracts', + mcpName: 'aelfscan_contracts', + description: 'List contracts.', + inputSchema: { + ...paginationSchema, + }, + handler: getContracts, + outputPolicy: 'summary', + }), + defineTool({ + key: 'address.detail', + domain: 'address', + action: 'detail', + mcpName: 'aelfscan_address_detail', + description: 'Get address detail (EOA/contract profile and portfolio).', + inputSchema: { + chainId: z.string().optional(), + address: z.string().describe('Address, supports ELF_xxx_chain format'), + }, + handler: getAddressDetail, + }), + defineTool({ + key: 'address.tokens', + domain: 'address', + action: 'tokens', + mcpName: 'aelfscan_address_tokens', + description: 'Get token holdings for an address.', + inputSchema: { + ...paginationSchema, + address: z.string().describe('Address'), + fuzzySearch: z.string().optional(), + }, + handler: getAddressTokens, + outputPolicy: 'summary', + }), + defineTool({ + key: 'address.nft-assets', + domain: 'address', + action: 'nft-assets', + mcpName: 'aelfscan_address_nft_assets', + description: 'Get NFT holdings for an address.', + inputSchema: { + ...paginationSchema, + address: z.string().describe('Address'), + fuzzySearch: z.string().optional(), + }, + handler: getAddressNftAssets, + outputPolicy: 'summary', + }), + defineTool({ + key: 'address.transfers', + domain: 'address', + action: 'transfers', + mcpName: 'aelfscan_address_transfers', + description: 'Get transfer history for an address.', + inputSchema: { + ...paginationSchema, + address: z.string().describe('Address'), + symbol: z.string().optional(), + tokenType: z.number().int().optional().describe('0:token,1:nft'), + }, + handler: getAddressTransfers, + outputPolicy: 'summary', + }), + defineTool({ + key: 'address.contract-history', + domain: 'address', + action: 'contract-history', + mcpName: 'aelfscan_contract_history', + description: 'Get contract deploy/update history.', + inputSchema: { + chainId: z.string().optional(), + address: z.string().describe('Contract address'), + }, + handler: getContractHistory, + outputPolicy: 'summary', + }), + defineTool({ + key: 'address.contract-events', + domain: 'address', + action: 'contract-events', + mcpName: 'aelfscan_contract_events', + description: 'Get contract events list.', + inputSchema: { + ...paginationSchema, + chainId: z.string().optional(), + contractAddress: z.string().describe('Contract address'), + blockHeight: z.number().int().optional(), + }, + handler: getContractEvents, + outputPolicy: 'summary', + }), + defineTool({ + key: 'address.contract-source', + domain: 'address', + action: 'contract-source', + mcpName: 'aelfscan_contract_source', + description: 'Get verified contract source metadata.', + inputSchema: { + chainId: z.string().optional(), + address: z.string().describe('Contract address'), + }, + handler: getContractSource, + }), + + defineTool({ + key: 'token.list', + domain: 'token', + action: 'list', + mcpName: 'aelfscan_tokens', + description: 'List tokens.', + inputSchema: { + ...paginationSchema, + types: z.array(z.number().int()).optional(), + symbols: z.array(z.string()).optional(), + collectionSymbols: z.array(z.string()).optional(), + search: z.string().optional(), + exactSearch: z.string().optional(), + fuzzySearch: z.string().optional(), + beginBlockTime: z.union([z.string(), z.number()]).optional(), + }, + handler: getTokens, + outputPolicy: 'summary', + }), + defineTool({ + key: 'token.detail', + domain: 'token', + action: 'detail', + mcpName: 'aelfscan_token_detail', + description: 'Get token detail by symbol.', + inputSchema: { + chainId: z.string().optional(), + symbol: z.string().describe('Token symbol'), + }, + handler: getTokenDetail, + }), + defineTool({ + key: 'token.transfers', + domain: 'token', + action: 'transfers', + mcpName: 'aelfscan_token_transfers', + description: 'Get token transfer list by symbol.', + inputSchema: { + ...paginationSchema, + symbol: z.string().describe('Token symbol'), + search: z.string().optional(), + collectionSymbol: z.string().optional(), + address: z.string().optional(), + types: z.array(z.number().int()).optional(), + fuzzySearch: z.string().optional(), + beginBlockTime: z.union([z.string(), z.number()]).optional(), + }, + handler: getTokenTransfers, + outputPolicy: 'summary', + }), + defineTool({ + key: 'token.holders', + domain: 'token', + action: 'holders', + mcpName: 'aelfscan_token_holders', + description: 'Get token holders.', + inputSchema: { + ...paginationSchema, + symbol: z.string().optional(), + collectionSymbol: z.string().optional(), + address: z.string().optional(), + partialSymbol: z.string().optional(), + search: z.string().optional(), + types: z.array(z.number().int()).optional(), + symbols: z.array(z.string()).optional(), + addressList: z.array(z.string()).optional(), + searchSymbols: z.array(z.string()).optional(), + fuzzySearch: z.string().optional(), + amountGreaterThanZero: z.boolean().optional(), + }, + handler: getTokenHolders, + outputPolicy: 'summary', + }), + + defineTool({ + key: 'nft.collections', + domain: 'nft', + action: 'collections', + mcpName: 'aelfscan_nft_collections', + description: 'List NFT collections.', + inputSchema: { + ...paginationSchema, + types: z.array(z.number().int()).optional(), + symbols: z.array(z.string()).optional(), + collectionSymbols: z.array(z.string()).optional(), + search: z.string().optional(), + exactSearch: z.string().optional(), + fuzzySearch: z.string().optional(), + beginBlockTime: z.union([z.string(), z.number()]).optional(), + }, + handler: getNftCollections, + outputPolicy: 'summary', + }), + defineTool({ + key: 'nft.collection-detail', + domain: 'nft', + action: 'collection-detail', + mcpName: 'aelfscan_nft_collection_detail', + description: 'Get NFT collection detail.', + inputSchema: { + chainId: z.string().optional(), + collectionSymbol: z.string().describe('NFT collection symbol'), + }, + handler: getNftCollectionDetail, + }), + defineTool({ + key: 'nft.transfers', + domain: 'nft', + action: 'transfers', + mcpName: 'aelfscan_nft_transfers', + description: 'Get NFT transfers by collection symbol.', + inputSchema: { + ...paginationSchema, + chainId: z.string().optional(), + collectionSymbol: z.string().describe('NFT collection symbol'), + search: z.string().optional(), + address: z.string().optional(), + }, + handler: getNftTransfers, + outputPolicy: 'summary', + }), + defineTool({ + key: 'nft.holders', + domain: 'nft', + action: 'holders', + mcpName: 'aelfscan_nft_holders', + description: 'Get NFT holders by collection symbol.', + inputSchema: { + ...paginationSchema, + chainId: z.string().optional(), + collectionSymbol: z.string().describe('NFT collection symbol'), + search: z.string().optional(), + }, + handler: getNftHolders, + outputPolicy: 'summary', + }), + defineTool({ + key: 'nft.inventory', + domain: 'nft', + action: 'inventory', + mcpName: 'aelfscan_nft_inventory', + description: 'Get NFT inventory by collection symbol.', + inputSchema: { + ...paginationSchema, + chainId: z.string().optional(), + collectionSymbol: z.string().describe('NFT collection symbol'), + search: z.string().optional(), + }, + handler: getNftInventory, + outputPolicy: 'summary', + }), + defineTool({ + key: 'nft.item-detail', + domain: 'nft', + action: 'item-detail', + mcpName: 'aelfscan_nft_item_detail', + description: 'Get NFT item detail by symbol.', + inputSchema: { + chainId: z.string().optional(), + symbol: z.string().describe('NFT item symbol'), + }, + handler: getNftItemDetail, + }), + defineTool({ + key: 'nft.item-holders', + domain: 'nft', + action: 'item-holders', + mcpName: 'aelfscan_nft_item_holders', + description: 'Get holders of a specific NFT item.', + inputSchema: { + ...paginationSchema, + chainId: z.string().optional(), + symbol: z.string().describe('NFT item symbol'), + types: z.array(z.number().int()).optional(), + }, + handler: getNftItemHolders, + outputPolicy: 'summary', + }), + defineTool({ + key: 'nft.item-activity', + domain: 'nft', + action: 'item-activity', + mcpName: 'aelfscan_nft_item_activity', + description: 'Get activity list of a specific NFT item.', + inputSchema: { + ...paginationSchema, + chainId: z.string().optional(), + symbol: z.string().describe('NFT item symbol'), + }, + handler: getNftItemActivity, + outputPolicy: 'summary', + }), + + defineTool({ + key: 'statistics.metric', + domain: 'statistics', + action: 'metric', + mcpName: 'aelfscan_statistics', + description: 'Get statistics by metric enum, supports all existing statistics endpoints.', + inputSchema: statisticsMetricSchema, + handler: getStatisticsByMetric, + outputPolicy: 'summary', + }), + + defineDetailedStatisticsTool({ + key: 'statistics.daily-transactions', + domain: 'statistics', + action: 'daily-transactions', + mcpName: 'aelfscan_statistics_daily_transactions', + description: 'Get daily transactions statistics.', + inputSchema: statisticsSchema, + handler: getDailyTransactions, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.unique-addresses', + domain: 'statistics', + action: 'unique-addresses', + mcpName: 'aelfscan_statistics_unique_addresses', + description: 'Get unique addresses statistics.', + inputSchema: statisticsSchema, + handler: getUniqueAddresses, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-active-addresses', + domain: 'statistics', + action: 'daily-active-addresses', + mcpName: 'aelfscan_statistics_daily_active_addresses', + description: 'Get daily active addresses statistics.', + inputSchema: statisticsSchema, + handler: getDailyActiveAddresses, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.monthly-active-addresses', + domain: 'statistics', + action: 'monthly-active-addresses', + mcpName: 'aelfscan_statistics_monthly_active_addresses', + description: 'Get monthly active addresses statistics.', + inputSchema: statisticsSchema, + handler: getMonthlyActiveAddresses, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.block-produce-rate', + domain: 'statistics', + action: 'block-produce-rate', + mcpName: 'aelfscan_statistics_block_produce_rate', + description: 'Get block produce rate statistics.', + inputSchema: statisticsSchema, + handler: getBlockProduceRate, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.avg-block-duration', + domain: 'statistics', + action: 'avg-block-duration', + mcpName: 'aelfscan_statistics_avg_block_duration', + description: 'Get average block duration statistics.', + inputSchema: statisticsSchema, + handler: getAvgBlockDuration, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.cycle-count', + domain: 'statistics', + action: 'cycle-count', + mcpName: 'aelfscan_statistics_cycle_count', + description: 'Get cycle count statistics.', + inputSchema: statisticsSchema, + handler: getCycleCount, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.node-block-produce', + domain: 'statistics', + action: 'node-block-produce', + mcpName: 'aelfscan_statistics_node_block_produce', + description: 'Get node block produce statistics.', + inputSchema: statisticsSchema, + handler: getNodeBlockProduce, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-avg-transaction-fee', + domain: 'statistics', + action: 'daily-avg-transaction-fee', + mcpName: 'aelfscan_statistics_daily_avg_transaction_fee', + description: 'Get daily average transaction fee statistics.', + inputSchema: statisticsSchema, + handler: getDailyAvgTransactionFee, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-tx-fee', + domain: 'statistics', + action: 'daily-tx-fee', + mcpName: 'aelfscan_statistics_daily_tx_fee', + description: 'Get daily transaction fee statistics.', + inputSchema: statisticsSchema, + handler: getDailyTxFee, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-total-burnt', + domain: 'statistics', + action: 'daily-total-burnt', + mcpName: 'aelfscan_statistics_daily_total_burnt', + description: 'Get daily total burnt statistics.', + inputSchema: statisticsSchema, + handler: getDailyTotalBurnt, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-elf-price', + domain: 'statistics', + action: 'daily-elf-price', + mcpName: 'aelfscan_statistics_daily_elf_price', + description: 'Get daily ELF price statistics.', + inputSchema: statisticsSchema, + handler: getDailyElfPrice, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-deploy-contract', + domain: 'statistics', + action: 'daily-deploy-contract', + mcpName: 'aelfscan_statistics_daily_deploy_contract', + description: 'Get daily deploy contract statistics.', + inputSchema: statisticsSchema, + handler: getDailyDeployContract, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-block-reward', + domain: 'statistics', + action: 'daily-block-reward', + mcpName: 'aelfscan_statistics_daily_block_reward', + description: 'Get daily block reward statistics.', + inputSchema: statisticsSchema, + handler: getDailyBlockReward, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-avg-block-size', + domain: 'statistics', + action: 'daily-avg-block-size', + mcpName: 'aelfscan_statistics_daily_avg_block_size', + description: 'Get daily average block size statistics.', + inputSchema: statisticsSchema, + handler: getDailyAvgBlockSize, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.top-contract-call', + domain: 'statistics', + action: 'top-contract-call', + mcpName: 'aelfscan_statistics_top_contract_call', + description: 'Get top contract call statistics.', + inputSchema: statisticsSchema, + handler: getTopContractCall, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-contract-call', + domain: 'statistics', + action: 'daily-contract-call', + mcpName: 'aelfscan_statistics_daily_contract_call', + description: 'Get daily contract call statistics.', + inputSchema: statisticsSchema, + handler: getDailyContractCall, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-supply-growth', + domain: 'statistics', + action: 'daily-supply-growth', + mcpName: 'aelfscan_statistics_daily_supply_growth', + description: 'Get daily supply growth statistics.', + inputSchema: statisticsSchema, + handler: getDailySupplyGrowth, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-market-cap', + domain: 'statistics', + action: 'daily-market-cap', + mcpName: 'aelfscan_statistics_daily_market_cap', + description: 'Get daily market cap statistics.', + inputSchema: statisticsSchema, + handler: getDailyMarketCap, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-staked', + domain: 'statistics', + action: 'daily-staked', + mcpName: 'aelfscan_statistics_daily_staked', + description: 'Get daily staked statistics.', + inputSchema: statisticsSchema, + handler: getDailyStaked, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-holder', + domain: 'statistics', + action: 'daily-holder', + mcpName: 'aelfscan_statistics_daily_holder', + description: 'Get daily holder statistics.', + inputSchema: statisticsSchema, + handler: getDailyHolder, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-tvl', + domain: 'statistics', + action: 'daily-tvl', + mcpName: 'aelfscan_statistics_daily_tvl', + description: 'Get daily TVL statistics.', + inputSchema: statisticsSchema, + handler: getDailyTvl, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.node-current-produce-info', + domain: 'statistics', + action: 'node-current-produce-info', + mcpName: 'aelfscan_statistics_node_current_produce_info', + description: 'Get current node produce information.', + inputSchema: statisticsSchema, + handler: getNodeCurrentProduceInfo, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.elf-supply', + domain: 'statistics', + action: 'elf-supply', + mcpName: 'aelfscan_statistics_elf_supply', + description: 'Get ELF supply statistics.', + inputSchema: statisticsSchema, + handler: getElfSupply, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-transaction-info', + domain: 'statistics', + action: 'daily-transaction-info', + mcpName: 'aelfscan_statistics_daily_transaction_info', + description: 'Get daily transaction summary for a date range.', + inputSchema: statisticsDateRangeSchema, + handler: getDailyTransactionInfo, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.daily-activity-address', + domain: 'statistics', + action: 'daily-activity-address', + mcpName: 'aelfscan_statistics_daily_activity_address', + description: 'Get daily activity address summary for a date range.', + inputSchema: statisticsDateRangeSchema, + handler: getDailyActivityAddress, + outputPolicy: 'summary', + }), + defineDetailedStatisticsTool({ + key: 'statistics.currency-price', + domain: 'statistics', + action: 'currency-price', + mcpName: 'aelfscan_statistics_currency_price', + description: 'Get currency price statistics.', + inputSchema: statisticsSchema, + handler: getCurrencyPrice, + outputPolicy: 'summary', + }), +] as const; + +export const TOOL_DESCRIPTOR_BY_KEY = new Map(TOOL_DESCRIPTORS.map(tool => [tool.key, tool])); + +export const MCP_TOOL_DESCRIPTORS = TOOL_DESCRIPTORS.filter(tool => tool.adapters.includes('mcp')); +export const CLI_TOOL_DESCRIPTORS = TOOL_DESCRIPTORS.filter(tool => tool.adapters.includes('cli')); +export const OPENCLAW_TOOL_DESCRIPTORS = TOOL_DESCRIPTORS.filter(tool => tool.adapters.includes('openclaw')); + +export const CLI_TOOL_DESCRIPTOR_BY_KEY = new Map(CLI_TOOL_DESCRIPTORS.map(tool => [tool.key, tool])); +export const TOOL_DESCRIPTOR_BY_MCP_NAME = new Map(MCP_TOOL_DESCRIPTORS.map(tool => [tool.mcpName, tool])); diff --git a/tests/integration/core-extended.test.ts b/tests/integration/core-extended.test.ts index bb7dd7e..6c5a014 100644 --- a/tests/integration/core-extended.test.ts +++ b/tests/integration/core-extended.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, test } from 'bun:test'; +import { resetHttpClientState } from '../../lib/http-client.js'; import { getBlockchainOverview } from '../../src/core/blockchain.js'; -import { getDailyTransactionInfo, getDailyTransactions } from '../../src/core/statistics.js'; +import { getDailyTransactionInfo, getDailyTransactions, getStatisticsByMetric } from '../../src/core/statistics.js'; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; + resetHttpClientState(); }); describe('extended core endpoints', () => { @@ -69,4 +71,28 @@ describe('extended core endpoints', () => { expect(result.success).toBe(false); expect(result.error?.code).toBe('INVALID_INPUT'); }); + + test('routes statistics by metric', async () => { + let requestUrl = ''; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + requestUrl = String(input); + return new Response( + JSON.stringify({ + code: '20000', + data: { list: [] }, + message: '', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as typeof fetch; + + const result = await getStatisticsByMetric({ + metric: 'dailyTransactions', + chainId: 'AELF', + }); + + expect(result.success).toBe(true); + expect(requestUrl.includes('/api/app/statistics/dailyTransactions')).toBe(true); + }); }); diff --git a/tests/integration/http-client-edge.test.ts b/tests/integration/http-client-edge.test.ts index 1a8e2e9..02f69fd 100644 --- a/tests/integration/http-client-edge.test.ts +++ b/tests/integration/http-client-edge.test.ts @@ -1,9 +1,17 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { resetConfigCache } from '../../lib/config.js'; -import { request } from '../../lib/http-client.js'; +import { request, resetHttpClientState } from '../../lib/http-client.js'; const originalFetch = globalThis.fetch; -const ENV_KEYS = ['AELFSCAN_TIMEOUT_MS', 'AELFSCAN_RETRY'] as const; +const ENV_KEYS = [ + 'AELFSCAN_TIMEOUT_MS', + 'AELFSCAN_RETRY', + 'AELFSCAN_RETRY_BASE_MS', + 'AELFSCAN_RETRY_MAX_MS', + 'AELFSCAN_CACHE_TTL_MS', + 'AELFSCAN_CACHE_MAX_ENTRIES', + 'AELFSCAN_MAX_CONCURRENT_REQUESTS', +] as const; type EnvSnapshot = Record<(typeof ENV_KEYS)[number], string | undefined>; @@ -24,14 +32,21 @@ beforeEach(() => { snapshot = { AELFSCAN_TIMEOUT_MS: process.env.AELFSCAN_TIMEOUT_MS, AELFSCAN_RETRY: process.env.AELFSCAN_RETRY, + AELFSCAN_RETRY_BASE_MS: process.env.AELFSCAN_RETRY_BASE_MS, + AELFSCAN_RETRY_MAX_MS: process.env.AELFSCAN_RETRY_MAX_MS, + AELFSCAN_CACHE_TTL_MS: process.env.AELFSCAN_CACHE_TTL_MS, + AELFSCAN_CACHE_MAX_ENTRIES: process.env.AELFSCAN_CACHE_MAX_ENTRIES, + AELFSCAN_MAX_CONCURRENT_REQUESTS: process.env.AELFSCAN_MAX_CONCURRENT_REQUESTS, }; resetConfigCache(); + resetHttpClientState(); }); afterEach(() => { globalThis.fetch = originalFetch; restoreEnv(snapshot); resetConfigCache(); + resetHttpClientState(); }); describe('http client edge cases', () => { @@ -108,6 +123,149 @@ describe('http client edge cases', () => { expect(result.raw).toBe('pong'); }); + test('injects X-Trace-Id header', async () => { + let headers: HeadersInit | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + headers = init?.headers; + return new Response(JSON.stringify({ code: '20000', data: { ok: true }, message: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + await request({ + path: '/api/trace', + traceId: 'trace-id-1', + }); + + const map = new Headers(headers); + expect(map.get('X-Trace-Id')).toBe('trace-id-1'); + }); + + test('uses statistics GET cache by default ttl', async () => { + process.env.AELFSCAN_CACHE_TTL_MS = '60000'; + process.env.AELFSCAN_CACHE_MAX_ENTRIES = '100'; + resetConfigCache(); + resetHttpClientState(); + + let callCount = 0; + globalThis.fetch = (async () => { + callCount += 1; + return new Response(JSON.stringify({ code: '20000', data: { ok: true }, message: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + await request({ path: '/api/app/statistics/dailyTransactions', query: { chainId: 'AELF' } }); + await request({ path: '/api/app/statistics/dailyTransactions', query: { chainId: 'AELF' } }); + + expect(callCount).toBe(1); + }); + + test('evicts oldest cache entries when cache exceeds max size', async () => { + process.env.AELFSCAN_CACHE_TTL_MS = '60000'; + process.env.AELFSCAN_CACHE_MAX_ENTRIES = '2'; + resetConfigCache(); + resetHttpClientState(); + + let callCount = 0; + globalThis.fetch = (async () => { + callCount += 1; + return new Response(JSON.stringify({ code: '20000', data: { ok: true }, message: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + await request({ path: '/api/app/statistics/dailyTransactions', query: { chainId: 'AELF' } }); + await request({ path: '/api/app/statistics/dailyTransactions', query: { chainId: 'tDVV' } }); + await request({ path: '/api/app/statistics/dailyTransactions', query: { chainId: 'tDVW' } }); + await request({ path: '/api/app/statistics/dailyTransactions', query: { chainId: 'AELF' } }); + + expect(callCount).toBe(4); + }); + + test('limits concurrent requests by semaphore', async () => { + process.env.AELFSCAN_MAX_CONCURRENT_REQUESTS = '2'; + process.env.AELFSCAN_TIMEOUT_MS = '1000'; + process.env.AELFSCAN_RETRY = '0'; + resetConfigCache(); + resetHttpClientState(); + + let inFlight = 0; + let peak = 0; + globalThis.fetch = (async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise(resolve => setTimeout(resolve, 25)); + inFlight -= 1; + return new Response(JSON.stringify({ code: '20000', data: { ok: true }, message: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + await Promise.all( + Array.from({ length: 6 }).map((_, index) => + request({ + path: '/api/concurrency', + query: { index }, + disableCache: true, + }), + ), + ); + + expect(peak).toBeLessThanOrEqual(2); + }); + + test('resetHttpClientState clears pending queue and allows queued requests to continue', async () => { + process.env.AELFSCAN_MAX_CONCURRENT_REQUESTS = '1'; + process.env.AELFSCAN_TIMEOUT_MS = '2000'; + process.env.AELFSCAN_RETRY = '0'; + resetConfigCache(); + resetHttpClientState(); + + let callCount = 0; + let releaseFirstFetch: (() => void) | undefined; + + globalThis.fetch = (async () => { + callCount += 1; + + if (callCount === 1) { + await new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + } + + return new Response(JSON.stringify({ code: '20000', data: { ok: true }, message: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + const firstRequest = request({ path: '/api/reset-state-1', disableCache: true }); + await new Promise(resolve => setTimeout(resolve, 10)); + + const secondRequest = request({ path: '/api/reset-state-2', disableCache: true }); + let secondCompleted = false; + void secondRequest.then(() => { + secondCompleted = true; + }); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(secondCompleted).toBe(false); + + resetHttpClientState(); + + const second = await secondRequest; + expect(second.data).toEqual({ ok: true }); + + if (releaseFirstFetch) { + releaseFirstFetch(); + } + await firstRequest; + }); + test('does not retry for 4xx http errors', async () => { process.env.AELFSCAN_RETRY = '1'; process.env.AELFSCAN_TIMEOUT_MS = '1000'; diff --git a/tests/integration/pagination-guard.test.ts b/tests/integration/pagination-guard.test.ts new file mode 100644 index 0000000..219667c --- /dev/null +++ b/tests/integration/pagination-guard.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { resetConfigCache } from '../../lib/config.js'; +import { getBlocks } from '../../src/core/blockchain.js'; + +const originalFetch = globalThis.fetch; +const originalMaxResultCountEnv = process.env.AELFSCAN_MAX_RESULT_COUNT; + +beforeEach(() => { + resetConfigCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalMaxResultCountEnv === undefined) { + delete process.env.AELFSCAN_MAX_RESULT_COUNT; + } else { + process.env.AELFSCAN_MAX_RESULT_COUNT = originalMaxResultCountEnv; + } + resetConfigCache(); +}); + +describe('pagination guard', () => { + test('clamps maxResultCount to configured upper limit', async () => { + process.env.AELFSCAN_MAX_RESULT_COUNT = '200'; + resetConfigCache(); + + let requestUrl = ''; + globalThis.fetch = (async (input: RequestInfo | URL) => { + requestUrl = String(input); + return new Response(JSON.stringify({ code: '20000', data: { list: [] }, message: '' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + const result = await getBlocks({ chainId: 'AELF', maxResultCount: 999, skipCount: 0 }); + + expect(result.success).toBe(true); + expect(requestUrl.includes('maxResultCount=200')).toBe(true); + }); + + test('returns INVALID_INPUT for invalid pagination values', async () => { + const result = await getBlocks({ chainId: 'AELF', maxResultCount: -1 }); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_INPUT'); + }); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 919aa57..ef443fc 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -6,6 +6,15 @@ const ENV_KEYS = [ 'AELFSCAN_DEFAULT_CHAIN_ID', 'AELFSCAN_TIMEOUT_MS', 'AELFSCAN_RETRY', + 'AELFSCAN_RETRY_BASE_MS', + 'AELFSCAN_RETRY_MAX_MS', + 'AELFSCAN_MAX_CONCURRENT_REQUESTS', + 'AELFSCAN_CACHE_TTL_MS', + 'AELFSCAN_CACHE_MAX_ENTRIES', + 'AELFSCAN_MAX_RESULT_COUNT', + 'AELFSCAN_MCP_MAX_ITEMS', + 'AELFSCAN_MCP_MAX_CHARS', + 'AELFSCAN_MCP_INCLUDE_RAW', ] as const; type EnvSnapshot = Record<(typeof ENV_KEYS)[number], string | undefined>; @@ -29,6 +38,15 @@ beforeEach(() => { AELFSCAN_DEFAULT_CHAIN_ID: process.env.AELFSCAN_DEFAULT_CHAIN_ID, AELFSCAN_TIMEOUT_MS: process.env.AELFSCAN_TIMEOUT_MS, AELFSCAN_RETRY: process.env.AELFSCAN_RETRY, + AELFSCAN_RETRY_BASE_MS: process.env.AELFSCAN_RETRY_BASE_MS, + AELFSCAN_RETRY_MAX_MS: process.env.AELFSCAN_RETRY_MAX_MS, + AELFSCAN_MAX_CONCURRENT_REQUESTS: process.env.AELFSCAN_MAX_CONCURRENT_REQUESTS, + AELFSCAN_CACHE_TTL_MS: process.env.AELFSCAN_CACHE_TTL_MS, + AELFSCAN_CACHE_MAX_ENTRIES: process.env.AELFSCAN_CACHE_MAX_ENTRIES, + AELFSCAN_MAX_RESULT_COUNT: process.env.AELFSCAN_MAX_RESULT_COUNT, + AELFSCAN_MCP_MAX_ITEMS: process.env.AELFSCAN_MCP_MAX_ITEMS, + AELFSCAN_MCP_MAX_CHARS: process.env.AELFSCAN_MCP_MAX_CHARS, + AELFSCAN_MCP_INCLUDE_RAW: process.env.AELFSCAN_MCP_INCLUDE_RAW, }; resetConfigCache(); }); @@ -44,6 +62,15 @@ describe('config', () => { process.env.AELFSCAN_DEFAULT_CHAIN_ID = ' multiChain '; process.env.AELFSCAN_TIMEOUT_MS = '1234'; process.env.AELFSCAN_RETRY = '2.9'; + process.env.AELFSCAN_RETRY_BASE_MS = '300'; + process.env.AELFSCAN_RETRY_MAX_MS = '900'; + process.env.AELFSCAN_MAX_CONCURRENT_REQUESTS = '8'; + process.env.AELFSCAN_CACHE_TTL_MS = '5000'; + process.env.AELFSCAN_CACHE_MAX_ENTRIES = '666'; + process.env.AELFSCAN_MAX_RESULT_COUNT = '300'; + process.env.AELFSCAN_MCP_MAX_ITEMS = '80'; + process.env.AELFSCAN_MCP_MAX_CHARS = '80000'; + process.env.AELFSCAN_MCP_INCLUDE_RAW = 'true'; const config = getConfig(); @@ -51,16 +78,43 @@ describe('config', () => { expect(config.defaultChainId).toBe(''); expect(config.timeoutMs).toBe(1234); expect(config.retry).toBe(2); + expect(config.retryBaseMs).toBe(300); + expect(config.retryMaxMs).toBe(900); + expect(config.maxConcurrentRequests).toBe(8); + expect(config.cacheTtlMs).toBe(5000); + expect(config.cacheMaxEntries).toBe(666); + expect(config.maxResultCount).toBe(300); + expect(config.mcpMaxItems).toBe(80); + expect(config.mcpMaxChars).toBe(80000); + expect(config.mcpIncludeRaw).toBe(true); }); test('falls back on invalid numeric env values', () => { process.env.AELFSCAN_TIMEOUT_MS = 'abc'; process.env.AELFSCAN_RETRY = '-1'; + process.env.AELFSCAN_RETRY_BASE_MS = '-1'; + process.env.AELFSCAN_RETRY_MAX_MS = 'nan'; + process.env.AELFSCAN_MAX_CONCURRENT_REQUESTS = '-10'; + process.env.AELFSCAN_CACHE_TTL_MS = 'oops'; + process.env.AELFSCAN_CACHE_MAX_ENTRIES = '-1'; + process.env.AELFSCAN_MAX_RESULT_COUNT = '-2'; + process.env.AELFSCAN_MCP_MAX_ITEMS = 'oops'; + process.env.AELFSCAN_MCP_MAX_CHARS = '-1'; + process.env.AELFSCAN_MCP_INCLUDE_RAW = 'invalid'; const config = getConfig(); expect(config.timeoutMs).toBe(10000); expect(config.retry).toBe(1); + expect(config.retryBaseMs).toBe(200); + expect(config.retryMaxMs).toBe(3000); + expect(config.maxConcurrentRequests).toBe(5); + expect(config.cacheTtlMs).toBe(60000); + expect(config.cacheMaxEntries).toBe(500); + expect(config.maxResultCount).toBe(200); + expect(config.mcpMaxItems).toBe(50); + expect(config.mcpMaxChars).toBe(60000); + expect(config.mcpIncludeRaw).toBe(false); }); test('caches config until reset', () => { diff --git a/tests/unit/mcp-output.test.ts b/tests/unit/mcp-output.test.ts new file mode 100644 index 0000000..83b3c9f --- /dev/null +++ b/tests/unit/mcp-output.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { resetConfigCache } from '../../lib/config.js'; +import { asMcpResult } from '../../src/mcp/output.js'; + +const ENV_KEYS = ['AELFSCAN_MCP_MAX_ITEMS', 'AELFSCAN_MCP_MAX_CHARS', 'AELFSCAN_MCP_INCLUDE_RAW'] as const; +type EnvSnapshot = Record<(typeof ENV_KEYS)[number], string | undefined>; + +let snapshot: EnvSnapshot; + +function restoreEnv(): void { + ENV_KEYS.forEach((key) => { + const value = snapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }); +} + +beforeEach(() => { + snapshot = { + AELFSCAN_MCP_MAX_ITEMS: process.env.AELFSCAN_MCP_MAX_ITEMS, + AELFSCAN_MCP_MAX_CHARS: process.env.AELFSCAN_MCP_MAX_CHARS, + AELFSCAN_MCP_INCLUDE_RAW: process.env.AELFSCAN_MCP_INCLUDE_RAW, + }; + resetConfigCache(); +}); + +afterEach(() => { + restoreEnv(); + resetConfigCache(); +}); + +describe('mcp output', () => { + test('truncates long arrays and strips raw by default', () => { + process.env.AELFSCAN_MCP_MAX_ITEMS = '3'; + process.env.AELFSCAN_MCP_MAX_CHARS = '20000'; + process.env.AELFSCAN_MCP_INCLUDE_RAW = 'false'; + resetConfigCache(); + + const result = asMcpResult( + { + success: true, + traceId: 'trace-1', + data: { + total: 100, + list: Array.from({ length: 10 }).map((_, i) => ({ i })), + }, + raw: { shouldNotAppear: true }, + }, + 'summary', + ); + + const text = result.content[0]?.text || ''; + const parsed = JSON.parse(text) as { + data?: { list?: Array }; + raw?: unknown; + meta?: { truncated?: boolean }; + }; + + expect(parsed.raw).toBeUndefined(); + expect(parsed.data?.list?.length).toBe(3); + expect(parsed.meta?.truncated).toBe(true); + }); + + test('falls back to compact summary when payload exceeds max chars', () => { + process.env.AELFSCAN_MCP_MAX_ITEMS = '50'; + process.env.AELFSCAN_MCP_MAX_CHARS = '220'; + resetConfigCache(); + + const result = asMcpResult({ + success: true, + traceId: 'trace-2', + data: { + hugeText: 'x'.repeat(5000), + }, + }); + + const text = result.content[0]?.text || ''; + const parsed = JSON.parse(text) as { + dataSummary?: unknown; + meta?: { truncated?: boolean; maxChars?: number }; + }; + + expect(parsed.meta?.truncated).toBe(true); + expect(parsed.dataSummary).toBeDefined(); + expect(parsed.meta?.maxChars).toBe(220); + }); +}); diff --git a/tests/unit/response-shape.test.ts b/tests/unit/response-shape.test.ts index edc9a4c..6dacd80 100644 --- a/tests/unit/response-shape.test.ts +++ b/tests/unit/response-shape.test.ts @@ -40,4 +40,30 @@ describe('tool result shape', () => { expect(result.error?.code).toBe('HTTP_ERROR'); expect(typeof result.traceId).toBe('string'); }); + + test('propagates traceId to request header', async () => { + let traceHeader = ''; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + traceHeader = headers.get('X-Trace-Id') || ''; + + return new Response( + JSON.stringify({ + code: '20000', + data: { + total: 0, + blocks: [], + }, + message: '', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as unknown as typeof fetch; + + const result = await getBlocks({ chainId: 'AELF', maxResultCount: 1, skipCount: 0 }); + + expect(result.success).toBe(true); + expect(traceHeader).toBe(result.traceId); + }); }); diff --git a/tests/unit/tool-surface.test.ts b/tests/unit/tool-surface.test.ts index bd4d827..32b358b 100644 --- a/tests/unit/tool-surface.test.ts +++ b/tests/unit/tool-surface.test.ts @@ -1,75 +1,27 @@ import { describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { MCP_TOOL_DESCRIPTORS, OPENCLAW_TOOL_DESCRIPTORS, TOOL_DESCRIPTORS } from '../../src/tooling/tool-descriptors.js'; const ROOT = path.resolve(import.meta.dir, '..', '..'); const OPENCLAW_PATH = path.join(ROOT, 'openclaw.json'); const MCP_SERVER_PATH = path.join(ROOT, 'src', 'mcp', 'server.ts'); +const CLI_PATH = path.join(ROOT, 'aelfscan_skill.ts'); -const TOOL_NAMES = [ - 'aelfscan_search_filters', - 'aelfscan_search', - 'aelfscan_blocks', - 'aelfscan_blocks_latest', - 'aelfscan_block_detail', - 'aelfscan_transactions', - 'aelfscan_transactions_latest', - 'aelfscan_transaction_detail', - 'aelfscan_blockchain_overview', - 'aelfscan_transaction_data_chart', - 'aelfscan_address_dictionary', - 'aelfscan_log_events', - 'aelfscan_accounts', - 'aelfscan_contracts', - 'aelfscan_address_detail', - 'aelfscan_address_tokens', - 'aelfscan_address_nft_assets', - 'aelfscan_address_transfers', - 'aelfscan_contract_history', - 'aelfscan_contract_events', - 'aelfscan_contract_source', - 'aelfscan_tokens', - 'aelfscan_token_detail', - 'aelfscan_token_transfers', - 'aelfscan_token_holders', - 'aelfscan_nft_collections', - 'aelfscan_nft_collection_detail', - 'aelfscan_nft_transfers', - 'aelfscan_nft_holders', - 'aelfscan_nft_inventory', - 'aelfscan_nft_item_detail', - 'aelfscan_nft_item_holders', - 'aelfscan_nft_item_activity', - 'aelfscan_statistics_daily_transactions', - 'aelfscan_statistics_unique_addresses', - 'aelfscan_statistics_daily_active_addresses', - 'aelfscan_statistics_monthly_active_addresses', - 'aelfscan_statistics_block_produce_rate', - 'aelfscan_statistics_avg_block_duration', - 'aelfscan_statistics_cycle_count', - 'aelfscan_statistics_node_block_produce', - 'aelfscan_statistics_daily_avg_transaction_fee', - 'aelfscan_statistics_daily_tx_fee', - 'aelfscan_statistics_daily_total_burnt', - 'aelfscan_statistics_daily_elf_price', - 'aelfscan_statistics_daily_deploy_contract', - 'aelfscan_statistics_daily_block_reward', - 'aelfscan_statistics_daily_avg_block_size', - 'aelfscan_statistics_top_contract_call', - 'aelfscan_statistics_daily_contract_call', - 'aelfscan_statistics_daily_supply_growth', - 'aelfscan_statistics_daily_market_cap', - 'aelfscan_statistics_daily_staked', - 'aelfscan_statistics_daily_holder', - 'aelfscan_statistics_daily_tvl', - 'aelfscan_statistics_node_current_produce_info', - 'aelfscan_statistics_elf_supply', - 'aelfscan_statistics_daily_transaction_info', - 'aelfscan_statistics_daily_activity_address', - 'aelfscan_statistics_currency_price', -]; +const OPENCLAW_TOOL_NAMES = OPENCLAW_TOOL_DESCRIPTORS.map(tool => tool.mcpName); describe('tool surface', () => { + test('descriptors are unique and complete', () => { + const keys = new Set(TOOL_DESCRIPTORS.map(tool => tool.key)); + const mcpNames = new Set(TOOL_DESCRIPTORS.map(tool => tool.mcpName)); + const mcpStatisticsKeys = MCP_TOOL_DESCRIPTORS.filter(tool => tool.domain === 'statistics').map(tool => tool.key); + + expect(keys.size).toBe(TOOL_DESCRIPTORS.length); + expect(mcpNames.size).toBe(TOOL_DESCRIPTORS.length); + expect(keys.has('statistics.metric')).toBe(true); + expect(mcpStatisticsKeys).toEqual(['statistics.metric']); + }); + test('openclaw includes all required tools', () => { const json = JSON.parse(fs.readFileSync(OPENCLAW_PATH, 'utf-8')) as { tools?: Array<{ name?: string }>; @@ -77,16 +29,22 @@ describe('tool surface', () => { const names = new Set((json.tools || []).map(tool => tool.name || '')); - TOOL_NAMES.forEach((name) => { + OPENCLAW_TOOL_NAMES.forEach((name) => { expect(names.has(name)).toBe(true); }); + + expect(names.size).toBe(OPENCLAW_TOOL_DESCRIPTORS.length); }); - test('mcp registers all required tools', () => { + test('mcp and cli are descriptor-driven', () => { const source = fs.readFileSync(MCP_SERVER_PATH, 'utf-8'); + const cliSource = fs.readFileSync(CLI_PATH, 'utf-8'); - TOOL_NAMES.forEach((name) => { - expect(source.includes(`'${name}'`)).toBe(true); - }); + expect(source.includes('MCP_TOOL_DESCRIPTORS')).toBe(true); + expect(source.includes('registerTool')).toBe(true); + expect(source.includes('descriptor.mcpName')).toBe(true); + expect(cliSource.includes('CLI_TOOL_DESCRIPTOR_BY_KEY')).toBe(true); + expect(cliSource.includes('descriptor.parse')).toBe(true); + expect(cliSource.includes('as any')).toBe(false); }); });