Перейти к основному содержимому

Как построить 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-summaryV1 Full Account Viewбалансы, активы, стейкинг, сводки в стиле кошелька
lookup-public-keyV1 Public Key Lookupразрешение публичного ключа в один или несколько аккаунтов
get-transactions-by-hashTransactions by Hashрасследование транзакций и читаемое продолжение по исполнению
get-latest-final-blockLast Final Block Redirectпроверки последней финализированной головы и сценарии опроса
view-account-rpcView 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:

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

FASTNEAR_API_KEY для многих публичных чтений не обязателен, но для аутентифицированных рантаймов и трафика с повышенными лимитами это правильный режим по умолчанию.

Вспомогательный маршрут NEAR Data API по пути /v0/last_block/final отвечает редиректом на маршрут текущего блока. Обычный fetch() проходит этот редирект автоматически, поэтому в примере не нужен отдельный код для обработки перенаправления.

Универсальная конфигурация клиента

Эту страницу лучше держать универсальной. У большинства локальных MCP-клиентов одни и те же основные составляющие, даже если путь к конфигу или форма JSON немного отличаются: command, args, env и иногда cwd.

Многие локальные MCP-клиенты принимают что-то очень похожее:

Пример конфигурации 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 с правилами маршрутизации и аутентификации именно вашей команды

Связанные руководства