index.ts•21.5 kB
// TradeStation MCP Server
// Implementation of Model Context Protocol server for TradeStation APIs
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import axios, { AxiosRequestConfig, AxiosError } from 'axios';
import dotenv from 'dotenv';
dotenv.config();
// TradeStation API configuration
const TS_API_BASE = 'https://sim-api.tradestation.com/v3';
const TS_AUTH_URL = 'https://signin.tradestation.com/authorize';
const TS_TOKEN_URL = 'https://signin.tradestation.com/oauth/token';
// Auth credentials (must be set in .env file)
const TS_CLIENT_ID = process.env.TRADESTATION_CLIENT_ID;
const TS_CLIENT_SECRET = process.env.TRADESTATION_CLIENT_SECRET;
const TS_REDIRECT_URI = process.env.TRADESTATION_REDIRECT_URI;
const TS_ACCOUNT_ID = process.env.TRADESTATION_ACCOUNT_ID;
// Validate required environment variables
if (!TS_CLIENT_ID || !TS_CLIENT_SECRET || !TS_REDIRECT_URI) {
console.error('ERROR: Missing required environment variables!');
console.error('Please ensure the following are set in your .env file:');
console.error(' - TRADESTATION_CLIENT_ID');
console.error(' - TRADESTATION_CLIENT_SECRET');
console.error(' - TRADESTATION_REDIRECT_URI');
process.exit(1);
}
// Warn if account ID is not set
if (!TS_ACCOUNT_ID) {
console.warn('WARNING: TRADESTATION_ACCOUNT_ID not set in .env file');
console.warn('Account-related tools will require accountId parameter');
}
// Types
interface TokenData {
accessToken: string;
refreshToken: string;
expiresAt: number;
}
interface RefreshTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
}
interface ToolArguments {
userId?: string;
[key: string]: any;
}
// Token storage (single user)
const DEFAULT_USER = "default";
const tokenStore = new Map<string, TokenData>();
// Initialize token from environment variable if available
const TS_REFRESH_TOKEN = process.env.TRADESTATION_REFRESH_TOKEN;
if (TS_REFRESH_TOKEN) {
tokenStore.set(DEFAULT_USER, {
accessToken: "",
refreshToken: TS_REFRESH_TOKEN,
expiresAt: -1
});
}
// Define schemas for the requests - including userId in all schemas
const marketDataSchema = {
symbols: z.string().describe('Comma-separated list of symbols')
};
const barChartSchema = {
symbol: z.string().describe('Trading symbol'),
interval: z.number().describe('Bar interval value (e.g., 1, 5, 15)'),
unit: z.enum(['Minute', 'Daily', 'Weekly', 'Monthly']).describe('Bar interval unit'),
beginTime: z.string().optional().describe('Begin time in ISO format (optional)'),
endTime: z.string().optional().describe('End time in ISO format (optional)'),
barsBack: z.number().optional().describe('Number of bars to return (optional)')
};
const searchSymbolsSchema = {
criteria: z.string().describe('Search criteria'),
type: z.enum(['stock', 'option', 'future', 'forex', 'all'])
.default('all')
.describe('Symbol type filter')
};
const optionExpirationsSchema = {
underlying: z.string().describe('Underlying symbol (e.g., AAPL, SPY)')
};
const optionStrikesSchema = {
underlying: z.string().describe('Underlying symbol (e.g., AAPL, SPY)'),
expiration: z.string().optional().describe('Expiration date filter in YYYY-MM-DD format (optional)')
};
// Account-related schemas
const accountsSchema = {};
const balancesSchema = {
accountId: z.string().optional().describe('Account ID (optional, uses TRADESTATION_ACCOUNT_ID from env if not provided)')
};
const positionsSchema = {
accountId: z.string().optional().describe('Account ID (optional, uses TRADESTATION_ACCOUNT_ID from env if not provided)')
};
const ordersSchema = {
accountId: z.string().optional().describe('Account ID (optional, uses TRADESTATION_ACCOUNT_ID from env if not provided)'),
status: z.enum(['Open', 'Filled', 'Canceled', 'Rejected', 'All'])
.default('All')
.describe('Filter orders by status')
};
const orderDetailsSchema = {
accountId: z.string().optional().describe('Account ID (optional, uses TRADESTATION_ACCOUNT_ID from env if not provided)'),
orderId: z.string().describe('Order ID')
};
const executionsSchema = {
accountId: z.string().optional().describe('Account ID (optional, uses TRADESTATION_ACCOUNT_ID from env if not provided)'),
orderId: z.string().describe('Order ID')
};
const symbolDetailsSchema = {
symbols: z.string().describe('Single symbol or comma-separated list of symbols')
};
const confirmOrderSchema = {
accountId: z.string().optional().describe('Account ID (optional, uses TRADESTATION_ACCOUNT_ID from env if not provided)'),
symbol: z.string().describe('Symbol to trade (e.g., SPY, SPY 251121C580)'),
quantity: z.number().describe('Order quantity'),
orderType: z.enum(['Market', 'Limit', 'Stop', 'StopLimit']).describe('Order type'),
tradeAction: z.enum(['BUY', 'SELL', 'BUYTOOPEN', 'BUYTOCLOSE', 'SELLTOOPEN', 'SELLTOCLOSE']).describe('Trade action'),
limitPrice: z.number().optional().describe('Limit price (required for Limit and StopLimit orders)'),
stopPrice: z.number().optional().describe('Stop price (required for Stop and StopLimit orders)'),
duration: z.enum(['DAY', 'GTC', 'GTD', 'DYP', 'GCP']).default('DAY').describe('Time in force duration')
};
// Token refresh helper
async function refreshToken(refreshToken: string): Promise<TokenData> {
try {
const response = await axios.post<RefreshTokenResponse>(TS_TOKEN_URL,
new URLSearchParams({
grant_type: 'refresh_token',
client_id: TS_CLIENT_ID!,
client_secret: TS_CLIENT_SECRET!,
refresh_token: refreshToken
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return {
accessToken: response.data.access_token,
refreshToken: refreshToken,
expiresAt: Date.now() + 3600000 // 1 hour from now
};
} catch (error: unknown) {
if (error instanceof AxiosError) {
console.error('Error refreshing token:', error.response?.data || error.message);
} else if (error instanceof Error) {
console.error('Error refreshing token:', error.message);
} else {
console.error('Unknown error refreshing token:', error);
}
throw new Error('Failed to refresh token');
}
}
// Helper for making authenticated API requests
async function makeAuthenticatedRequest(
endpoint: string,
method: AxiosRequestConfig['method'] = 'GET',
data: any = null
): Promise<any> {
const userTokens = tokenStore.get(DEFAULT_USER);
if (!userTokens) {
throw new Error('User not authenticated. Please set TRADESTATION_REFRESH_TOKEN in .env file.');
}
// Check if token is expired or about to expire (within 60 seconds)
if (userTokens.expiresAt < Date.now() + 60000) {
// Refresh the token
const newTokens = await refreshToken(userTokens.refreshToken);
tokenStore.set(DEFAULT_USER, newTokens);
}
try {
const options: AxiosRequestConfig = {
method,
url: `${TS_API_BASE}${endpoint}`,
headers: {
'Authorization': `Bearer ${tokenStore.get(DEFAULT_USER)?.accessToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
timeout: 60000
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
options.data = data;
}
const response = await axios(options);
return response.data;
} catch (error: unknown) {
if (error instanceof AxiosError) {
const errorMessage = error.response?.data?.Message || error.response?.data?.message || error.message;
const statusCode = error.response?.status;
console.error(`API request error [${statusCode}]: ${errorMessage}`);
console.error('Endpoint:', endpoint);
throw new Error(`API Error (${statusCode}): ${errorMessage}`);
} else if (error instanceof Error) {
console.error('API request error:', error.message);
throw error;
} else {
console.error('Unknown API request error:', error);
throw new Error('Unknown API request error');
}
}
}
// Initialize MCP Server
const server = new McpServer({
name: "TradeStation",
version: "1.0.0",
},
{
capabilities: {
tools: {}
}
});
// Type for tool handler response
interface ToolResponse {
content: Array<{
type: string;
text: string;
}>;
isError?: boolean;
}
// Type for tool handler function
type ToolHandler<T> = (params: { arguments: T & ToolArguments }) => Promise<ToolResponse>;
// Define available tools
server.tool(
"marketData",
"Get quotes for symbols",
marketDataSchema,
async (args) => {
try {
const { symbols } = args;
// Fixed: symbols should be in the path, not query parameter
const quotes = await makeAuthenticatedRequest(
`/marketdata/quotes/${encodeURIComponent(symbols)}`
);
return {
content: [
{
type: "text",
text: JSON.stringify(quotes, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch market data: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"barChart",
"Get historical price bars/candles",
barChartSchema,
async (args) => {
try {
const { symbol, interval, unit, beginTime, endTime, barsBack } = args;
let endpoint = `/marketdata/barcharts/${encodeURIComponent(symbol)}?interval=${interval}&unit=${unit}`;
if (beginTime) {
endpoint += `&begin=${encodeURIComponent(beginTime)}`;
}
if (endTime) {
endpoint += `&end=${encodeURIComponent(endTime)}`;
}
if (barsBack) {
endpoint += `&barsback=${barsBack}`;
}
const bars = await makeAuthenticatedRequest(endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(bars, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch bar chart data: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"searchSymbols",
"Search for symbols (Note: Symbol search not available in TradeStation v3 API - use getSymbolDetails instead with known symbols)",
searchSymbolsSchema,
async (args) => {
try {
const { criteria } = args;
return {
content: [
{
type: "text",
text: `Symbol search is not available in TradeStation API v3.\n\nAlternatives:\n1. Use getSymbolDetails with known symbols: "${criteria}"\n2. Use marketData to get quotes for known symbols\n3. For options, use getOptionExpirations and getOptionStrikes with the underlying symbol`
}
],
isError: true
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to search symbols: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"getOptionExpirations",
"Get available expiration dates for options on an underlying symbol",
optionExpirationsSchema,
async (args) => {
try {
const { underlying } = args;
const expirations = await makeAuthenticatedRequest(
`/marketdata/options/expirations/${encodeURIComponent(underlying)}`
);
return {
content: [
{
type: "text",
text: JSON.stringify(expirations, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch option expirations: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"getOptionStrikes",
"Get available strike prices for options on an underlying symbol",
optionStrikesSchema,
async (args) => {
try {
const { underlying, expiration } = args;
let endpoint = `/marketdata/options/strikes/${encodeURIComponent(underlying)}`;
if (expiration) {
endpoint += `?expiration=${encodeURIComponent(expiration)}`;
}
const strikes = await makeAuthenticatedRequest(endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(strikes, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch option strikes: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
// Account Functions
server.tool(
"getAccounts",
"Get list of brokerage accounts",
accountsSchema,
async (args) => {
try {
const accounts = await makeAuthenticatedRequest('/brokerage/accounts');
return {
content: [
{
type: "text",
text: JSON.stringify(accounts, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch accounts: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"getBalances",
"Get account balances and buying power",
balancesSchema,
async (args) => {
try {
const accountId = args.accountId || TS_ACCOUNT_ID;
if (!accountId) {
throw new Error('Account ID is required. Either provide accountId parameter or set TRADESTATION_ACCOUNT_ID in .env file.');
}
const balances = await makeAuthenticatedRequest(
`/brokerage/accounts/${encodeURIComponent(accountId)}/balances`
);
return {
content: [
{
type: "text",
text: JSON.stringify(balances, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch balances: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"getPositions",
"Get current positions with P&L",
positionsSchema,
async (args) => {
try {
const accountId = args.accountId || TS_ACCOUNT_ID;
if (!accountId) {
throw new Error('Account ID is required. Either provide accountId parameter or set TRADESTATION_ACCOUNT_ID in .env file.');
}
const positions = await makeAuthenticatedRequest(
`/brokerage/accounts/${encodeURIComponent(accountId)}/positions`
);
return {
content: [
{
type: "text",
text: JSON.stringify(positions, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch positions: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"getOrders",
"Get order history with optional status filter",
ordersSchema,
async (args) => {
try {
const accountId = args.accountId || TS_ACCOUNT_ID;
const { status } = args;
if (!accountId) {
throw new Error('Account ID is required. Either provide accountId parameter or set TRADESTATION_ACCOUNT_ID in .env file.');
}
let endpoint = `/brokerage/accounts/${encodeURIComponent(accountId)}/orders`;
if (status && status !== 'All') {
endpoint += `?status=${status}`;
}
const orders = await makeAuthenticatedRequest(endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(orders, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch orders: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"getOrderDetails",
"Get detailed information for a specific order",
orderDetailsSchema,
async (args) => {
try {
const accountId = args.accountId || TS_ACCOUNT_ID;
const { orderId } = args;
if (!accountId) {
throw new Error('Account ID is required. Either provide accountId parameter or set TRADESTATION_ACCOUNT_ID in .env file.');
}
const orderDetails = await makeAuthenticatedRequest(
`/brokerage/accounts/${encodeURIComponent(accountId)}/orders/${encodeURIComponent(orderId)}`
);
return {
content: [
{
type: "text",
text: JSON.stringify(orderDetails, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch order details: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"getExecutions",
"Get fills/executions for a specific order",
executionsSchema,
async (args) => {
try {
const accountId = args.accountId || TS_ACCOUNT_ID;
const { orderId } = args;
if (!accountId) {
throw new Error('Account ID is required. Either provide accountId parameter or set TRADESTATION_ACCOUNT_ID in .env file.');
}
const executions = await makeAuthenticatedRequest(
`/brokerage/accounts/${encodeURIComponent(accountId)}/orders/${encodeURIComponent(orderId)}/executions`
);
return {
content: [
{
type: "text",
text: JSON.stringify(executions, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch executions: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"getSymbolDetails",
"Get detailed symbol information",
symbolDetailsSchema,
async (args) => {
try {
const { symbols } = args;
const symbolDetails = await makeAuthenticatedRequest(
`/marketdata/symbols/${encodeURIComponent(symbols)}`
);
return {
content: [
{
type: "text",
text: JSON.stringify(symbolDetails, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to fetch symbol details: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
server.tool(
"confirmOrder",
"Preview order costs and requirements (READ-ONLY - does not execute trades)",
confirmOrderSchema,
async (args) => {
try {
const accountId = args.accountId || TS_ACCOUNT_ID;
const { symbol, quantity, orderType, tradeAction, limitPrice, stopPrice, duration } = args;
if (!accountId) {
throw new Error('Account ID is required. Either provide accountId parameter or set TRADESTATION_ACCOUNT_ID in .env file.');
}
// Build order confirmation request body
const orderData: any = {
AccountID: accountId,
Symbol: symbol,
Quantity: quantity,
OrderType: orderType,
TradeAction: tradeAction,
TimeInForce: {
Duration: duration
}
};
// Add price fields based on order type
if (orderType === 'Limit' || orderType === 'StopLimit') {
if (!limitPrice) {
throw new Error('limitPrice is required for Limit and StopLimit orders');
}
orderData.LimitPrice = limitPrice;
}
if (orderType === 'Stop' || orderType === 'StopLimit') {
if (!stopPrice) {
throw new Error('stopPrice is required for Stop and StopLimit orders');
}
orderData.StopPrice = stopPrice;
}
const confirmation = await makeAuthenticatedRequest(
'/orderexecution/orderconfirm',
'POST',
orderData
);
return {
content: [
{
type: "text",
text: JSON.stringify(confirmation, null, 2)
}
]
};
} catch (error: unknown) {
return {
content: [
{
type: "text",
text: `Failed to confirm order: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
// Start the server
async function startServer(): Promise<void> {
// For stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
//console.log('TradeStation MCP Server started with stdio transport');
}
startServer().catch(console.error);