index.js•16.4 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 from 'axios';
import dotenv from 'dotenv';
dotenv.config();
// TradeStation API configuration
const TS_API_BASE = 'https://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.TS_CLIENT_ID;
const TS_CLIENT_SECRET = process.env.TS_CLIENT_SECRET;
const TS_REDIRECT_URI = process.env.TS_REDIRECT_URI;
// 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(' - TS_CLIENT_ID');
console.error(' - TS_CLIENT_SECRET');
console.error(' - TS_REDIRECT_URI');
process.exit(1);
}
// Token storage (in-memory for demonstration)
const tokenStore = new Map();
/*
// Define schemas for the requests
const accountsSchema = z.object({});
const accountBalancesSchema = z.object({
accountId: z.string().describe('Account ID')
});
const positionsSchema = z.object({
accountId: z.string().describe('Account ID')
});
const ordersSchema = z.object({
accountId: z.string().describe('Account ID'),
status: z.enum(['open', 'filled', 'canceled', 'rejected', 'all'])
.default('all')
.describe('Filter orders by status')
});
const placeOrderSchema = z.object({
accountId: z.string().describe('Account ID'),
symbol: z.string().describe('Trading symbol'),
quantity: z.number().describe('Order quantity'),
orderType: z.enum(['market', 'limit', 'stop', 'stop_limit']).describe('Order type'),
side: z.enum(['buy', 'sell', 'sell_short', 'buy_to_cover']).describe('Order side'),
limitPrice: z.number().optional().describe('Limit price (required for limit and stop_limit orders)'),
stopPrice: z.number().optional().describe('Stop price (required for stop and stop_limit orders)'),
timeInForce: z.enum(['day', 'gtc', 'gtd', 'ioc', 'fok'])
.default('day')
.describe('Time in force')
});
const cancelOrderSchema = z.object({
accountId: z.string().describe('Account ID'),
orderId: z.string().describe('Order ID to cancel')
});
*/
const marketDataSchema = z.object({
symbols: z.string().describe('Comma-separated list of symbols')
});
const barChartSchema = z.object({
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 = z.object({
criteria: z.string().describe('Search criteria'),
type: z.enum(['stock', 'option', 'future', 'forex', 'all'])
.default('all')
.describe('Symbol type filter')
});
const optionChainSchema = z.object({
symbol: z.string().describe('Underlying symbol'),
strike: z.number().optional().describe('Strike price filter (optional)'),
expiration: z.string().optional().describe('Expiration date in YYYY-MM-DD format (optional)')
});
// Token refresh helper
async function refreshToken(refreshToken) {
try {
const response = await axios.post(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: response.data.refresh_token || refreshToken,
expiresAt: Date.now() + (response.data.expires_in * 1000)
};
} catch (error) {
console.error('Error refreshing token:', error.response?.data || error.message);
throw new Error('Failed to refresh token');
}
}
// Helper for making authenticated API requests
async function makeAuthenticatedRequest(userId, endpoint, method = 'GET', data = null) {
const userTokens = tokenStore.get(userId);
if (!userTokens) {
throw new Error('User not authenticated');
}
// 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(userId, newTokens);
}
try {
const options = {
method,
url: `${TS_API_BASE}${endpoint}`,
headers: {
'Authorization': `Bearer ${tokenStore.get(userId).accessToken}`,
'Content-Type': 'application/json'
}
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
options.data = data;
}
const response = await axios(options);
return response.data;
} catch (error) {
console.error('API request error:', error.response?.data || error.message);
throw error;
}
}
// Initialize MCP Server
const server = new McpServer({
name: "TradeStation",
version: "1.0.0",
},
{
capabilities: {
tools: {}
}
});
/*
// Define available tools
server.tool(
"accounts",
"Get user account information",
accountsSchema,
async ({ arguments: args }) => {
try {
const userId = args.userId; // You might need to pass this differently
const accounts = await makeAuthenticatedRequest(userId, '/brokerage/accounts');
return {
content: [
{
type: "text",
text: JSON.stringify(accounts, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to fetch accounts: ${error.message}`
}
],
isError: true
};
}
}
);
server.tool(
"accountBalances",
"Get account balances",
accountBalancesSchema,
async ({ arguments: args }) => {
try {
const { accountId } = args;
const userId = args.userId; // You might need to pass this differently
const balances = await makeAuthenticatedRequest(
userId,
`/brokerage/accounts/${accountId}/balances`
);
return {
content: [
{
type: "text",
text: JSON.stringify(balances, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to fetch account balances: ${error.message}`
}
],
isError: true
};
}
}
);
server.tool(
"positions",
"Get account positions",
positionsSchema,
async ({ arguments: args }) => {
try {
const { accountId } = args;
const userId = args.userId; // You might need to pass this differently
const positions = await makeAuthenticatedRequest(
userId,
`/brokerage/accounts/${accountId}/positions`
);
return {
content: [
{
type: "text",
text: JSON.stringify(positions, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to fetch positions: ${error.message}`
}
],
isError: true
};
}
}
);
server.tool(
"orders",
"Get account orders",
ordersSchema,
async ({ arguments: args }) => {
try {
const { accountId, status } = args;
const userId = args.userId; // You might need to pass this differently
let endpoint = `/brokerage/accounts/${accountId}/orders`;
if (status && status !== 'all') {
endpoint += `?status=${status}`;
}
const orders = await makeAuthenticatedRequest(userId, endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(orders, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to fetch orders: ${error.message}`
}
],
isError: true
};
}
}
);
server.tool(
"placeOrder",
"Place a new order",
placeOrderSchema,
async ({ arguments: args }) => {
try {
const {
accountId, symbol, quantity, orderType, side,
limitPrice, stopPrice, timeInForce
} = args;
const userId = args.userId; // You might need to pass this differently
// Build order payload
const orderData = {
AccountID: accountId,
Symbol: symbol,
Quantity: quantity,
OrderType: orderType.toUpperCase(),
TradeAction: side.toUpperCase(),
TimeInForce: {
Duration: timeInForce.toUpperCase()
},
Route: "Intelligent"
};
// Add price fields based on order type
if (orderType === 'limit' || orderType === 'stop_limit') {
orderData.LimitPrice = limitPrice;
}
if (orderType === 'stop' || orderType === 'stop_limit') {
orderData.StopPrice = stopPrice;
}
const result = await makeAuthenticatedRequest(
userId,
'/brokerage/orders',
'POST',
orderData
);
return {
content: [
{
type: "text",
text: JSON.stringify({
orderId: result.OrderID,
status: result.Status
}, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to place order: ${error.message}`
}
],
isError: true
};
}
}
);
server.tool(
"cancelOrder",
"Cancel an existing order",
cancelOrderSchema,
async ({ arguments: args }) => {
try {
const { accountId, orderId } = args;
const userId = args.userId; // You might need to pass this differently
await makeAuthenticatedRequest(
userId,
`/brokerage/accounts/${accountId}/orders/${orderId}`,
'DELETE'
);
return {
content: [
{
type: "text",
text: JSON.stringify({
success: true,
message: 'Order canceled successfully'
}, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
success: false,
message: `Failed to cancel order: ${error.message}`
}, null, 2)
}
],
isError: true
};
}
}
);
*/
server.tool(
"marketData",
"Get quotes for symbols",
marketDataSchema,
async ({ arguments: args }) => {
try {
const { symbols } = args;
const userId = args.userId; // You might need to pass this differently
const quotes = await makeAuthenticatedRequest(
userId,
`/marketdata/quotes?symbols=${encodeURIComponent(symbols)}`
);
return {
content: [
{
type: "text",
text: JSON.stringify(quotes, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to fetch market data: ${error.message}`
}
],
isError: true
};
}
}
);
server.tool(
"barChart",
"Get historical price bars/candles",
barChartSchema,
async ({ arguments: args }) => {
try {
const { symbol, interval, unit, beginTime, endTime, barsBack } = args;
const userId = args.userId; // You might need to pass this differently
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(userId, endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(bars, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to fetch bar chart data: ${error.message}`
}
],
isError: true
};
}
}
);
server.tool(
"searchSymbols",
"Search for symbols",
searchSymbolsSchema,
async ({ arguments: args }) => {
try {
const { criteria, type } = args;
const userId = args.userId; // You might need to pass this differently
let endpoint = `/marketdata/symbols/search/${encodeURIComponent(criteria)}`;
if (type && type !== 'all') {
endpoint += `?type=${type}`;
}
const symbols = await makeAuthenticatedRequest(userId, endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(symbols, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to search symbols: ${error.message}`
}
],
isError: true
};
}
}
);
server.tool(
"optionChain",
"Get option chain for a symbol",
optionChainSchema,
async ({ arguments: args }) => {
try {
const { symbol, strike, expiration } = args;
const userId = args.userId; // You might need to pass this differently
let endpoint = `/marketdata/options/chains/${encodeURIComponent(symbol)}`;
const queryParams = [];
if (strike) {
queryParams.push(`strike=${strike}`);
}
if (expiration) {
queryParams.push(`expiration=${encodeURIComponent(expiration)}`);
}
if (queryParams.length > 0) {
endpoint += `?${queryParams.join('&')}`;
}
const options = await makeAuthenticatedRequest(userId, endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(options, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Failed to fetch option chain: ${error.message}`
}
],
isError: true
};
}
}
);
// Start the server
async function startServer() {
// For stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
// Alternative: For HTTP transport (if you want to expose it as an HTTP service)
// const app = express();
// const port = process.env.PORT || 3000;
// app.use(cors());
// app.use(express.json());
// // Authentication routes (like before)
// app.get('/auth', (req, res) => {
// const userId = uuidv4();
// const state = Buffer.from(JSON.stringify({ userId })).toString('base64');
// const authUrl = `${TS_AUTH_URL}?` +
// new URLSearchParams({
// response_type: 'code',
// client_id: TS_CLIENT_ID,
// redirect_uri: TS_REDIRECT_URI,
// audience: 'https://api.tradestation.com',
// state,
// scope: 'openid offline_access profile MarketData ReadAccount Trade'
// }).toString();
// res.redirect(authUrl);
// });
// app.get('/callback', async (req, res) => {
// // ... callback handling logic ...
// });
// app.listen(port, () => {
// console.log(`TradeStation MCP Server listening on port ${port}`);
// });
}
startServer().catch(console.error);