Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 84 additions & 28 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,105 @@
* TradingView API Client
*/

import fetch from "node-fetch";
import { createRequire } from "module";
import type { ScreenerRequest, ScreenerResponse } from "./types.js";
import type { ScreenerCell, ScreenerRequest, ScreenerResponse } from "./types.js";
import {
requestJson,
type TransportOptions,
UpstreamError,
} from "./transport.js";

const require = createRequire(import.meta.url);
const pkg = require("../../package.json");

const API_BASE = "https://scanner.tradingview.com";
const API_TIMEOUT = 10000; // 10 seconds

function isScreenerCell(value: unknown): value is ScreenerCell {
if (
value === null
|| typeof value === "number"
|| typeof value === "string"
|| typeof value === "boolean"
) {
return true;
}
if (Array.isArray(value)) {
return value.every(isScreenerCell);
}
if (typeof value === "object") {
return Object.values(value).every(isScreenerCell);
}
return false;
}

function isScreenerRow(
value: unknown,
): value is ScreenerResponse["data"][number] {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
if (!("s" in value) || !("d" in value)) return false;
return typeof value.s === "string"
&& Array.isArray(value.d)
&& value.d.every(isScreenerCell);
}

function isScreenerResponse(value: unknown): value is ScreenerResponse {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
if (!("totalCount" in value) || !("data" in value)) return false;
const totalCount = value.totalCount;
const data = value.data;
return typeof totalCount === "number"
&& Number.isInteger(totalCount)
&& totalCount >= 0
&& Array.isArray(data)
&& data.every(isScreenerRow);
}

export function validateScreenerResponse(
value: unknown,
endpoint: string,
): ScreenerResponse {
if (!isScreenerResponse(value)) {
throw new Error(`TradingView returned malformed screener response for ${endpoint}`);
}
return value;
}

export class TradingViewClient {
constructor(private readonly transportOptions: TransportOptions = {}) {}

private async makeRequest(
endpoint: string,
payload: ScreenerRequest
payload: ScreenerRequest,
): Promise<ScreenerResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT);

try {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": `tradingview-mcp-server/${pkg.version}`,
const data = await requestJson(
`${API_BASE}${endpoint}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": `tradingview-mcp-server/${pkg.version}`,
},
body: JSON.stringify(payload),
},
body: JSON.stringify(payload),
signal: controller.signal,
});

clearTimeout(timeout);

if (!response.ok) {
throw new Error(
`TradingView API error: ${response.status} ${response.statusText}`
);
}

return (await response.json()) as ScreenerResponse;
this.transportOptions,
);
return validateScreenerResponse(data, endpoint);
} catch (error) {
clearTimeout(timeout);
if ((error as Error).name === "AbortError") {
throw new Error("Request timeout");
if (!(error instanceof UpstreamError)) throw error;
if (error.kind === "timeout") {
throw new Error("Request timeout", { cause: error });
}
if (error.kind === "http") {
throw new Error(`TradingView API error: ${error.status} ${error.statusText ?? ""}`.trimEnd(), {
cause: error,
});
}
if (error.cause instanceof Error) throw error.cause;
throw error;
}
}
Expand Down
122 changes: 90 additions & 32 deletions src/api/metainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
* Endpoint: POST https://scanner.tradingview.com/{market}/metainfo
*/

import fetch from "node-fetch";
import { createRequire } from "module";
import {
requestJson,
type TransportOptions,
UpstreamError,
} from "./transport.js";

const require = createRequire(import.meta.url);
const pkg = require("../../package.json");

const API_BASE = "https://scanner.tradingview.com";
const METAINFO_TIMEOUT = 10000; // 10 seconds

export interface MetainfoField {
name: string;
Expand All @@ -36,59 +39,114 @@ export interface MetainfoSummaryResponse {
fields: MetainfoField[];
};
}
export interface MetainfoRawResponse {
market: string;
raw: unknown;
}

type MetainfoPayload = Record<string, unknown> | unknown[];

function validateMetainfoResponse(value: unknown): MetainfoPayload {
if (Array.isArray(value)) {
if (
value.length === 0
|| !value.every((field) =>
typeof field === "string"
|| (typeof field === "object" && field !== null && !Array.isArray(field))
)
) {
throw new Error("TradingView returned malformed metainfo response");
}
return value;
}

if (typeof value !== "object" || value === null) {
throw new Error("TradingView returned malformed metainfo response");
}

if ("fields" in value || "columns" in value) {
const fields = "fields" in value ? value.fields : value.columns;
if (typeof fields !== "object" || fields === null) {
throw new Error("TradingView returned malformed metainfo response");
}
} else {
const values = Object.values(value);
if (!values.every((field) => typeof field === "object" && field !== null && !Array.isArray(field))) {
throw new Error("TradingView returned malformed metainfo response");
}
}

return value as Record<string, unknown>;
}

export class MetainfoClient {
constructor(private readonly transportOptions: TransportOptions = {}) {}

/**
* Fetch metainfo for a market.
*/
async getMetainfo(input: MetainfoInput): Promise<MetainfoSummaryResponse | any> {
async getMetainfo(
input: MetainfoInput & { mode: "raw" },
): Promise<MetainfoRawResponse>;
async getMetainfo(
input: MetainfoInput & { mode?: "summary" },
): Promise<MetainfoSummaryResponse>;
async getMetainfo(input: MetainfoInput): Promise<MetainfoSummaryResponse | MetainfoRawResponse>;
async getMetainfo(
input: MetainfoInput,
): Promise<MetainfoSummaryResponse | MetainfoRawResponse> {
const { market, fields, mode = "summary" } = input;

if (!market || market.trim().length < 1) {
throw new Error("Market is required (e.g., 'america', 'uk', 'germany')");
}

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), METAINFO_TIMEOUT);
const trimmedMarket = market.trim();
const url = `${API_BASE}/${encodeURIComponent(trimmedMarket)}/metainfo`;

try {
const url = `${API_BASE}/${encodeURIComponent(market.trim())}/metainfo`;

const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": `tradingview-mcp-server/${pkg.version}`,
const data = await requestJson(
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": `tradingview-mcp-server/${pkg.version}`,
},
body: fields ? JSON.stringify({ fields }) : undefined,
},
body: fields ? JSON.stringify({ fields }) : undefined,
signal: controller.signal,
});

clearTimeout(timeout);

if (!response.ok) {
if (response.status === 404) {
throw new Error(`Invalid market: '${market}'. Use markets like 'america', 'uk', 'germany', etc.`);
}
throw new Error(`Metainfo request failed: ${response.status} ${response.statusText}`);
}

const data = await response.json() as any;
this.transportOptions,
);

if (mode === "raw") {
return {
market: market.trim(),
market: trimmedMarket,
raw: data,
};
}

// Summary mode: normalize the response
return this.normalizeMetainfo(market.trim(), fields, data);
// Summary mode: validate and normalize the response.
return this.normalizeMetainfo(
trimmedMarket,
fields,
validateMetainfoResponse(data),
);
} catch (error) {
clearTimeout(timeout);
if ((error as Error).name === "AbortError") {
throw new Error("Metainfo request timeout");
if (!(error instanceof UpstreamError)) throw error;
if (error.kind === "http" && error.status === 404) {
throw new Error(`Invalid market: '${market}'. Use markets like 'america', 'uk', 'germany', etc.`, {
cause: error,
});
}
if (error.kind === "timeout") {
throw new Error("Metainfo request timeout", { cause: error });
}
if (error.kind === "http") {
throw new Error(`Metainfo request failed: ${error.status} ${error.statusText ?? ""}`.trimEnd(), {
cause: error,
});
}
if (error.cause instanceof Error) throw error.cause;
throw error;
}
}
Expand Down
Loading
Loading