Как построить MCP-сервер на FastNear
Эта страница предназначена для команд, которым нужно отдать FastNear через собственный MCP-сервер для Claude Desktop, Codex, Cursor или другого MCP-клиента. docs.fastnear.com сам по себе не является MCP-сервером. Цель этой страницы — показать чистую стартовую точку поверх уже существующих RPC- и REST-API FastNear.
Когда MCP действительно нужен
Стройте MCP-сервер, когда нужен один стабильный набор инструментов поверх API FastNear:
- данные цепочки должны быть доступны как инструменты в desktop-клиенте или IDE
- нужно, чтобы агент сам выбирал между поверхностями аккаунта, транзакций, блоков и RPC
- требуется спрятать аутентификацию и базовые URL за небольшим доверенным бэкендом или локальным процессом
- нужен переиспользуемый контракт инструментов вместо того, чтобы каждый агент или сценарий напрямую вызывал сырой HTTP
Если ваш сценарий и так контролирует HTTP и не нуждается в обнаружении инструментов, MCP может быть лишним. Документация и API FastNear уже хорошо работают как обычные HTTP-поверхности.
Рекомендуемый первый набор инструментов
Начинайте с нескольких широких инструментов, а не с зеркалирования каждого эндпоинта:
| MCP-инструмент | Поверхность FastNear | Для чего использовать |
|---|---|---|
get-account-summary | V1 Full Account View | балансы, активы, стейкинг, сводки в стиле кошелька |
lookup-public-key | V1 Public Key Lookup | разрешение публичного ключа в один или несколько аккаунтов |
get-transactions-by-hash | Transactions by Hash | расследование транзакций и читаемое продолжение по исполнению |
get-latest-final-block | Last Final Block Redirect | проверки последней финализированной головы и сценарии опроса |
view-account-rpc | View Account | точное каноническое состояние аккаунта, когда индексированной сводки недостаточно |
Такой набор покрывает большинство сценариев «что есть у этого аккаунта?», «что произошло с этой транзакцией?» и «какой сейчас последний финализированный блок?» без требования понимать всю поверхность FastNear заранее.
Почему пример использует прямой HTTP
Этот пример намеренно использует сырые вызовы fetch(), а не near-api-js.
- Документация, которую вы читаете, устроена вокруг сырых RPC- и REST-контрактов.
- Прямой HTTP удерживает поведение MCP-инструментов в точном соответствии с документацией.
- Это помогает избежать пробелов абстракции SDK, версионного дрейфа и поведения вспомогательных функций, скрывающих реальный формат запросов и ответов.
- Для MCP-инструментов с упором на чтение прямой HTTP обычно оказывается самым простым рабочим решением.
Если позже в том же процессе понадобятся подпись транзакций, вспомогательные функции для аккаунтов или сценарии, завязанные на кошелёк, near-api-js всё ещё может иметь смысл. Но для обучающего примера, сфокусированного на RPC и API FastNear, прямые вызовы — более чистый выбор по умолчанию.
Установка
Используйте Node.js 20 или новее, чтобы глобальный fetch() уже был доступен.
mkdir fastnear-mcp
cd fastnear-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D tsx typescript @types/nodeОфициальная документация MCP TypeScript SDK сейчас рекомендует стабильную ветку v1.x для продовых сценариев. Команда установки выше подтянет текущий стабильный релиз пакета.
TypeScript-пример, который можно сразу брать за основу
Создайте fastnear-mcp.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
type Network = "mainnet" | "testnet";
const DEFAULT_NETWORK: Network =
process.env.FASTNEAR_DEFAULT_NETWORK === "testnet" ? "testnet" : "mainnet";
const URLS: Record<
Network,
{
api: string;
rpc: string;
neardata: string;
tx: string;
}
> = {
mainnet: {
api: "https://api.fastnear.com",
rpc: "https://rpc.mainnet.fastnear.com",
neardata: "https://mainnet.neardata.xyz",
tx: "https://tx.main.fastnear.com",
},
testnet: {
api: "https://test.api.fastnear.com",
rpc: "https://rpc.testnet.fastnear.com",
neardata: "https://testnet.neardata.xyz",
tx: "https://tx.test.fastnear.com",
},
};
const apiKey = process.env.FASTNEAR_API_KEY;
function selectNetwork(network?: Network): Network {
return network ?? DEFAULT_NETWORK;
}
function authHeaders(extra: HeadersInit = {}): HeadersInit {
if (!apiKey) {
return extra;
}
return {
...extra,
Authorization: `Bearer ${apiKey}`,
};
}
async function requestJson(url: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
const text = await response.text();
let body: unknown = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!response.ok) {
const detail =
typeof body === "string" ? body : JSON.stringify(body, null, 2);
throw new Error(`${response.status} ${response.statusText}: ${detail}`);
}
return body;
}
function toolResult(data: unknown) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(data, null, 2),
},
],
};
}
function toolError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [
{
type: "text" as const,
text: message,
},
],
};
}
async function runTool(work: () => Promise<unknown>) {
try {
return toolResult(await work());
} catch (error) {
return toolError(error);
}
}
function toIsoFromNanoseconds(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
try {
return new Date(Number(BigInt(value) / 1_000_000n)).toISOString();
} catch {
return null;
}
}
async function getAccountSummary(network: Network, accountId: string) {
const baseUrl = URLS[network].api;
return requestJson(
`${baseUrl}/v1/account/${encodeURIComponent(accountId)}/full`,
{
headers: authHeaders(),
},
);
}
async function lookupPublicKey(network: Network, publicKey: string) {
const baseUrl = URLS[network].api;
return requestJson(
`${baseUrl}/v1/public_key/${encodeURIComponent(publicKey)}`,
{
headers: authHeaders(),
},
);
}
async function getTransactionsByHash(network: Network, txHashes: string[]) {
const baseUrl = URLS[network].tx;
return requestJson(`${baseUrl}/v0/transactions`, {
method: "POST",
headers: authHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify({
tx_hashes: txHashes,
}),
});
}
async function getLatestFinalBlock(network: Network) {
const baseUrl = URLS[network].neardata;
const result = (await requestJson(`${baseUrl}/v0/last_block/final`, {
headers: authHeaders(),
})) as {
block?: {
author?: string;
chunks?: unknown[];
header?: {
height?: number;
hash?: string;
prev_hash?: string;
timestamp_nanosec?: string;
};
};
};
const block = result.block;
const header = block?.header;
return {
network,
source: "NEAR Data API",
finality: "final",
block_height: header?.height ?? null,
block_hash: header?.hash ?? null,
prev_block_hash: header?.prev_hash ?? null,
author: block?.author ?? null,
timestamp_nanosec: header?.timestamp_nanosec ?? null,
timestamp_iso: toIsoFromNanoseconds(header?.timestamp_nanosec),
chunk_count: Array.isArray(block?.chunks) ? block.chunks.length : null,
};
}
async function viewAccountRpc(
network: Network,
accountId: string,
finality: "final" | "optimistic",
) {
const rpcUrl = URLS[network].rpc;
return requestJson(rpcUrl, {
method: "POST",
headers: authHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify({
jsonrpc: "2.0",
id: `view-account:${accountId}`,
method: "query",
params: {
request_type: "view_account",
finality,
account_id: accountId,
},
}),
});
}
const server = new McpServer({
name: "fastnear-direct-http",
version: "0.1.0",
});
server.registerTool(
"get-account-summary",
{
title: "Get account summary",
description:
"Fetch a combined FastNear account view with balances, assets, and staking data.",
inputSchema: {
accountId: z
.string()
.min(2)
.describe("NEAR account ID, for example fastnear.near"),
network: z
.enum(["mainnet", "testnet"])
.optional()
.describe("Defaults to FASTNEAR_DEFAULT_NETWORK or mainnet."),
},
},
async ({ accountId, network }) =>
runTool(() => getAccountSummary(selectNetwork(network), accountId)),
);
server.registerTool(
"lookup-public-key",
{
title: "Lookup public key",
description:
"Resolve a public key to one or more NEAR accounts using the FastNear API.",
inputSchema: {
publicKey: z
.string()
.min(16)
.describe("Public key, for example ed25519:..."),
network: z
.enum(["mainnet", "testnet"])
.optional()
.describe("Defaults to FASTNEAR_DEFAULT_NETWORK or mainnet."),
},
},
async ({ publicKey, network }) =>
runTool(() => lookupPublicKey(selectNetwork(network), publicKey)),
);
server.registerTool(
"get-transactions-by-hash",
{
title: "Get transactions by hash",
description:
"Fetch up to 20 transactions by hash from the Transactions API.",
inputSchema: {
txHashes: z
.array(z.string().min(32))
.min(1)
.max(20)
.describe("One or more base58 transaction hashes."),
network: z
.enum(["mainnet", "testnet"])
.optional()
.describe("Defaults to FASTNEAR_DEFAULT_NETWORK or mainnet."),
},
},
async ({ txHashes, network }) =>
runTool(() => getTransactionsByHash(selectNetwork(network), txHashes)),
);
server.registerTool(
"get-latest-final-block",
{
title: "Get latest final block",
description:
"Fetch a compact summary of the latest finalized block from the NEAR Data API.",
inputSchema: {
network: z
.enum(["mainnet", "testnet"])
.optional()
.describe("Defaults to FASTNEAR_DEFAULT_NETWORK or mainnet."),
},
},
async ({ network }) =>
runTool(() => getLatestFinalBlock(selectNetwork(network))),
);
server.registerTool(
"view-account-rpc",
{
title: "View account via RPC",
description:
"Fetch canonical account state directly from NEAR JSON-RPC.",
inputSchema: {
accountId: z
.string()
.min(2)
.describe("NEAR account ID, for example fastnear.near"),
finality: z
.enum(["final", "optimistic"])
.optional()
.describe("Defaults to final."),
network: z
.enum(["mainnet", "testnet"])
.optional()
.describe("Defaults to FASTNEAR_DEFAULT_NETWORK or mainnet."),
},
},
async ({ accountId, finality, network }) =>
runTool(() =>
viewAccountRpc(
selectNetwork(network),
accountId,
finality ?? "final",
),
),
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});Локальный запуск:
FASTNEAR_API_KEY=your_key_here \
FASTNEAR_DEFAULT_NETWORK=mainnet \
npx tsx fastnear-mcp.tsFASTNEAR_API_KEY для многих публичных чтений не обязателен, но для аутентифицированных рантаймов и трафика с повышенными лимитами это правильный режим по умолчанию.
Вспомогательный маршрут NEAR Data API по пути /v0/last_block/final отвечает редиректом на маршрут текущего блока. Обычный fetch() проходит этот редирект автоматически, поэтому в примере не нужен отдельный код для обработки перенаправления.
Универсальная конфигурация клиента
Эту страницу лучше держать универсальной. У большинства локальных MCP-клиентов одни и те же основные составляющие, даже если путь к конфигу или форма JSON немного отличаются: command, args, env и иногда cwd.
Многие локальные MCP-клиенты принимают что-то очень похожее:
{
"mcpServers": {
"fastnear": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/fastnear-mcp.ts"],
"env": {
"FASTNEAR_API_KEY": "your_key_here",
"FASTNEAR_DEFAULT_NETWORK": "mainnet"
}
}
}
}Если MCP-клиент запускает команды из другого рабочего каталога, задайте его опцию cwd или workingDirectory, если она поддерживается, либо замените npx tsx на абсолютный путь к локальному бинарнику tsx. Важно, чтобы клиент мог разрешить локально установленный пакет tsx до запуска fastnear-mcp.ts.
Одного универсального примера конфигурации для такой страницы обычно достаточно. Фрагменты конфигурации под конкретные продукты и кнопки в духе «open in ...» меняются быстрее, чем сам контракт MCP-инструментов.
Если позже понадобится удалённое или командное развёртывание, начните с этой же поверхности инструментов и только потом переходите от stdio к сетевому транспорту, когда действительно понадобится удалённый сервер.
Чеклист проектирования инструментов
Когда вы превращаете FastNear в MCP-инструменты, эти значения по умолчанию обычно оказываются устойчивыми:
- Называйте инструменты по задачам пользователя, а не по путям эндпоинтов.
- Начинайте с трёх-шести инструментов, а не с пятидесяти.
- Используйте FastNear API для сводок и задач разрешения идентификаторов, Transactions API для читаемой истории, а Справочник RPC — только когда важна каноническая семантика протокола.
- Делайте
networkопциональным, но явным, с разумным значением по умолчанию. - Возвращайте компактный JSON. Не тяните огромные пэйлоады, если инструменту нужен только один срез ответа.
- Для инструментов, работающих в режиме опроса, вроде маршрутов по последнему блоку, по умолчанию лучше возвращать сводку, а не полное тело блока.
- Храните API-ключи в переменных окружения или менеджере секретов и подставляйте их на стороне сервера.
- Сохраняйте непрозрачные токены пагинации ровно в том виде, в каком их вернул FastNear.
- Ясно сообщайте вызывающей стороне, возвращает ли инструмент индексированную сводку или канонические данные RPC.
- Делайте так, чтобы один инструмент отвечал за один тип ответа. Не создавайте несколько перекрывающихся инструментов, которые частично решают одну и ту же задачу.
Частые ошибки
- Не выставляйте каждый эндпоинт FastNear как отдельный MCP-инструмент в первый же день.
- Не заставляйте сценарии со сводкой по аккаунту идти через сырой RPC, если индексированное API уже отвечает на реальный вопрос пользователя.
- Не скрывайте разницу между индексированными данными и каноническими данными узла.
- Не кладите
FASTNEAR_API_KEYв промпты, браузерное хранилище или закоммиченный конфиг. - Не превращайте
NEAR Data APIв фальшивую потоковую абстракцию. Это поверхность чтения, ориентированная на явный опрос.
Полезные следующие расширения
После того как базовый сервер заработал, следующими полезными инструментами обычно становятся:
- история аккаунта поверх Transactions API
- история только переводов поверх Transfers API
view-вызовы контрактов поверх Call a Function- небольшой
resourceилиpromptс правилами маршрутизации и аутентификации именно вашей команды