Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/theonetrade/ai-trading-mcp/llms.txt

Use this file to discover all available pages before exploring further.

The trading rig’s sensory input is a live Telegram channel piped directly into every get_status response. A GramJS user session — not a bot token — reads the channel as a regular account would, capturing both text posts and chart screenshot photos. The last 15 posts arrive in the model’s context as MCP text blocks (with ISO timestamps) and MCP image blocks (base64-encoded JPEG thumbnails). The feed is the only external signal source; the engine exposes no market-data endpoint to the agent.

How the Scraper Works

ScraperService connects to Telegram via a QR-authorized GramJS user session. Because it authenticates as a user account rather than a bot, it can read any channel the account has joined — including channels that restrict bots.

scrapeLast — the method that powers get_status

public scrapeLast = async (dto: {
  channel: string;
  limit: number;
  offset: number;
  when: Date;
}): Promise<ScraperMessage[]>
The method iterates the channel’s message history backwards from dto.when, skipping messages that have neither text nor a photo, and collects up to dto.limit results using pickDocuments from functools-kit. Each result conforms to the ScraperMessage model:
export interface ScraperMessage {
  id: number;
  channel: string;
  content: string;  // empty string for photo-only posts
  date: Date;
  photo: string | null;  // base64-encoded JPEG, or null
}
StatusControllerService calls scrapeLast with limit: 15 and offset: 0, asking for the last 15 posts, newest first. Each post is emitted as a text block carrying the ISO timestamp and caption. Photo-only posts (where content is an empty string) are described as "(photo post, image attached below)" so the model never has to guess whether an image has associated text:
const caption = post.content
  ? `\n${post.content}`
  : "\n(photo post, image attached below)";

messages.push({
  id: post.id,
  type: "text",
  text: `[${post.date.toISOString()}]${caption}`,
});
The CC_TELEGRAM_CHANNEL environment variable selects the channel to scrape. The default is -1002833393903 — a demo signals channel used for testing. Set your own channel ID before going live.

Image Handling

Telegram stores each photo at multiple resolutions: 320 px, 800 px, 1280 px, and 2560 px wide. At 320 px, card text is illegible. At 1280 px and above, the download is wasted weight for a model that only needs to read a chart. The scraper targets 800 px as the balance point.

The sizing constants and selection logic

// Target width: 800px — the middle of Telegram's 320/800/1280/2560 size ladder.
// At 320px card text is blurry; retina sizes (1280+) are excess weight.
const PHOTO_THUMB_WIDTH = 800;

// Picks the smallest real photo size with width >= PHOTO_THUMB_WIDTH,
// otherwise the largest available. Returns the exact Api.TypePhotoSize instance
// that downloadMedia accepts in `thumb` without casting.
const GET_PHOTO_THUMB_FN = (message: Api.Message): Api.TypePhotoSize | null => {
  if (!(message.photo instanceof Api.Photo)) {
    return null;
  }
  const candidates = message.photo.sizes.flatMap(
    (size): { size: Api.TypePhotoSize; width: number }[] => {
      if (size instanceof Api.PhotoSize) {
        return [{ size, width: size.w }];
      }
      if (size instanceof Api.PhotoSizeProgressive) {
        return [{ size, width: size.w }];
      }
      return [];
    },
  );
  if (!candidates.length) {
    return null;
  }
  candidates.sort((a, b) => a.width - b.width);
  const fit = candidates.find(({ width }) => width >= PHOTO_THUMB_WIDTH);
  return (fit ?? candidates[candidates.length - 1]).size;
};
The selection rule is: smallest size ≥ 800 px. If no size meets the threshold (e.g., the photo was uploaded at low resolution), the function falls back to the largest available size. If the size list itself is empty or the photo type is unrecognized, it returns null.

Download with fallback

const DOWNLOAD_MEDIA_FN = async (message: Api.Message) => {
  const client = await getTelegram();
  const thumb = GET_PHOTO_THUMB_FN(message);
  if (!thumb) {
    console.warn("ScraperService download size list failed");
    return await client.downloadMedia(message);       // full-size fallback
  }
  let media: string | Buffer | undefined;
  if (media = await client.downloadMedia(message, { thumb })) {
    return media;
  }
  console.warn("ScraperService download thumbnail failed");
  return await client.downloadMedia(message);         // full-size fallback
};
If downloadMedia with the selected thumbnail fails, the scraper retries with a full-size download. The result is always a Buffer that ScraperMessage.photo stores as a base64 string.

Delivery to the model

Each photo is pushed into the message list as an MCP image block immediately after its text block:
if (post.photo) {
  messages.push({
    id: `${post.id}-photo`,
    type: "image",
    mimeType: "image/jpeg",
    data: post.photo,   // base64-encoded JPEG
  });
}
The model receives the image as a standard MCP image content block — indistinguishable from any other image the host application might provide.

Concurrency and Timeout Guards

Two guards protect the GramJS client from being overwhelmed or left hanging.

timeout(90_000)FEED_FETCH_TIMEOUT

The entire Telegram fetch is wrapped in a 90-second timeout:
const FEED_MESSAGES_LIMIT = 15;
const FEED_FETCH_TIMEOUT = 90_000;

const FETCH_TELEGRAM_HISTORY_FN = timeout(
  async (self: StatusControllerService, when: Date): Promise<IMCPMessage[]> => {
    // iterates messages, downloads photos, assembles MCP blocks
  },
  FEED_FETCH_TIMEOUT,
);
If the fetch does not complete within 90 seconds, timeout() injects TIMEOUT_SYMBOL into the message array. GET_STATUS_FN checks for its presence after all three message sources have been awaited.

queued() — serial execution

The full get_status composition is wrapped in queued() so concurrent calls line up rather than stampeding the client:
const GET_STATUS_FN = queued(
  async (
    self: StatusControllerService,
    dto: { context: IMCPContext; when: Date; mcpName: string },
  ) => {
    let messages: Message[] = [];
    messages = messages.concat(
      await MCP.getDefaultMessages(dto.context, dto.when, dto.mcpName),
    );
    messages = messages.concat(await MCP.getHistoryMessages(dto.mcpName));
    messages = messages.concat(
      await FETCH_TELEGRAM_HISTORY_FN(self, dto.when),
    );

    if (messages.includes(TIMEOUT_SYMBOL)) {
      await RESTART_TELEGRAM_FN();
    }

    if (messages.includes(TIMEOUT_SYMBOL)) {
      throw new Error(
        `StatusControllerService.getStatus: timeout fetching feed messages`,
      );
    }
    // ...
  },
);

Timeout recovery

When TIMEOUT_SYMBOL is detected, RESTART_TELEGRAM_FN disconnects the client and clears the singleton so the next call gets a fresh connection:
const RESTART_TELEGRAM_FN = async () => {
  const telegram = await getTelegram();
  await telegram.disconnect();
  getTelegram.clear();   // next getTelegram() call will reconnect
};
The error thrown after the restart surfaces to the agent as a clean isError tool result — "StatusControllerService.getStatus: timeout fetching feed messages" — rather than a hanging bridge call.
A timeout tears down the GramJS session entirely. The next get_status call will reconnect from scratch, which may add a few seconds of latency while the session handshake completes.

Feed Registration

The entire integration surface is one schema registration in packages/agent/src/config/setup.ts:
import { addMCPSchema } from "backtest-kit";
import ioc from "../lib";

addMCPSchema({
  mcpName: "manual_mcp",
  async getMessages(context, when, mcpName) {
    return await ioc.statusControllerService.getStatus(context, when, mcpName);
  },
});
addMCPSchema registers the manual_mcp renderer with the engine. When the MCP bridge receives a get_status call, the engine invokes getMessages, which delegates to StatusControllerService.getStatus — the queued + timeout-guarded composition described above.

What the Model Receives

Each get_status call delivers the Telegram section as a sequence of interleaved text and image blocks. From a real dump:
Telegram feed -1002833393903 (last 15 messages, newest first):

[2026-08-02T09:34:19.000Z]
Работаю с BTC — в шорт, взял с учетом возможного добора
<MCP image block: exchange position screenshot — BTCUSDT Short, Cross 100x>

[2026-08-01T06:57:32.000Z]
Доброе утро, дорогие мои ☀️ Суббота, значит отдыхаем…
Each post is:
  • A text block with an ISO 8601 timestamp and the post’s text content. Photo-only posts read (photo post, image attached below).
  • Optionally, an image block immediately following the text block — a base64 JPEG delivered as an MCP image content block, visible to the model exactly like any other image in its context.
The feed is treated as untrusted input by design. Instructions embedded in channel posts are data, not commands — the agent’s vocabulary (three tools) and the engine’s validation chain bound the blast radius of any single post. Every get_status response, including all image blocks, is dumped to dump/mcp/<timestamp>.md and dump/images/ before it leaves the process, creating a complete audit trail of what the model saw.

Build docs developers (and LLMs) love