feat: add @bytelyst/mcp-client package for reusable MCP integration
Add new reusable MCP client package with connection management, tool execution, caching, rate limiting, audit logging, and error handling. This provides a standardized way to integrate with MCP servers across all ByteLyst products. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
c39da91588
commit
0c76a5f358
28
packages/mcp-client/package.json
Normal file
28
packages/mcp-client/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/mcp-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --pool forks",
|
||||||
|
"dev": "tsc --watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
344
packages/mcp-client/src/index.ts
Normal file
344
packages/mcp-client/src/index.ts
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
|
||||||
|
export interface MCPConfig {
|
||||||
|
serverCommand: string;
|
||||||
|
serverArgs: string;
|
||||||
|
envFile: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
cacheTtlSec: number;
|
||||||
|
maxRetries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tool {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
data: any;
|
||||||
|
timestamp: number;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MCPClient {
|
||||||
|
private client: Client | null = null;
|
||||||
|
private transport: StdioClientTransport | null = null;
|
||||||
|
private isConnected: boolean = false;
|
||||||
|
private cache: Map<string, CacheEntry> = new Map();
|
||||||
|
private rateLimitMap: Map<string, RateLimitEntry> = new Map();
|
||||||
|
private config: MCPConfig;
|
||||||
|
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute window
|
||||||
|
private readonly RATE_LIMIT_MAX_REQUESTS = 200; // Max 200 requests per minute
|
||||||
|
|
||||||
|
constructor(config: MCPConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.isConnected) {
|
||||||
|
console.warn('[MCP] Already connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`[MCP] Connecting to server via ${this.config.serverCommand} ${this.config.serverArgs}`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.client = new Client(
|
||||||
|
{
|
||||||
|
name: 'bytelyst-mcp-client',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.transport = new StdioClientTransport({
|
||||||
|
command: this.config.serverCommand,
|
||||||
|
args: [this.config.serverArgs],
|
||||||
|
env: {
|
||||||
|
MCP_ENV_FILE: this.config.envFile,
|
||||||
|
MCP_CLIENT: 'backend',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.connect(this.transport);
|
||||||
|
this.isConnected = true;
|
||||||
|
console.log('[MCP] Successfully connected to MCP server');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[MCP] Failed to connect: ${error.message}`);
|
||||||
|
this.isConnected = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.client) {
|
||||||
|
await this.client.close();
|
||||||
|
}
|
||||||
|
if (this.transport) {
|
||||||
|
// @ts-ignore - StdioClientTransport doesn't have explicit close method
|
||||||
|
if (this.transport.close) {
|
||||||
|
await this.transport.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
this.client = null;
|
||||||
|
this.transport = null;
|
||||||
|
console.log('[MCP] Disconnected from MCP server');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[MCP] Error during disconnect: ${error.message}`);
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(toolName: string, args: any = {}): Promise<any> {
|
||||||
|
if (!this.isConnected || !this.client) {
|
||||||
|
throw new Error('MCP client is not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limits before proceeding
|
||||||
|
this.checkRateLimit();
|
||||||
|
|
||||||
|
// Audit log MCP tool calls with sanitized parameters
|
||||||
|
this.auditLogToolCall(toolName, args);
|
||||||
|
|
||||||
|
// Check cache for read operations
|
||||||
|
const cacheKey = this.getCacheKey(toolName, args);
|
||||||
|
const cached = this.getFromCache(cacheKey);
|
||||||
|
if (cached !== null) {
|
||||||
|
console.debug(`[MCP] Cache hit for ${toolName}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
console.debug(
|
||||||
|
`[MCP] Calling tool ${toolName} (attempt ${attempt + 1}/${this.config.maxRetries})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
this.client.callTool({
|
||||||
|
name: toolName,
|
||||||
|
arguments: args,
|
||||||
|
}),
|
||||||
|
this.createTimeout(this.config.timeoutMs),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = result.content as any[];
|
||||||
|
if (response && response.length > 0) {
|
||||||
|
const data = response[0];
|
||||||
|
|
||||||
|
// Cache successful read operations
|
||||||
|
if (this.isReadOperation(toolName)) {
|
||||||
|
this.setCache(cacheKey, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log successful tool execution
|
||||||
|
this.auditLogToolSuccess(toolName);
|
||||||
|
|
||||||
|
console.log(`[MCP] Tool ${toolName} executed successfully`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Empty response from tool ${toolName}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error;
|
||||||
|
console.warn(`[MCP] Tool ${toolName} attempt ${attempt + 1} failed: ${error.message}`);
|
||||||
|
|
||||||
|
// Audit log failed tool execution
|
||||||
|
this.auditLogToolFailure(toolName, error.message);
|
||||||
|
|
||||||
|
// Don't retry on certain errors
|
||||||
|
if (this.isNonRetriableError(error)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
if (attempt < this.config.maxRetries - 1) {
|
||||||
|
await this.sleep(Math.pow(2, attempt) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[MCP] Tool ${toolName} failed after ${this.config.maxRetries} attempts`);
|
||||||
|
throw lastError || new Error(`Tool ${toolName} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<Tool[]> {
|
||||||
|
if (!this.isConnected || !this.client) {
|
||||||
|
throw new Error('MCP client is not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.listTools();
|
||||||
|
return response.tools.map((tool: any) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[MCP] Failed to list tools: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkRateLimit(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = 'global';
|
||||||
|
|
||||||
|
let entry = this.rateLimitMap.get(key);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetTime) {
|
||||||
|
entry = {
|
||||||
|
count: 0,
|
||||||
|
resetTime: now + this.RATE_LIMIT_WINDOW,
|
||||||
|
};
|
||||||
|
this.rateLimitMap.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= this.RATE_LIMIT_MAX_REQUESTS) {
|
||||||
|
const resetTime = new Date(entry.resetTime).toISOString();
|
||||||
|
throw new Error(
|
||||||
|
`Rate limit exceeded. Maximum ${this.RATE_LIMIT_MAX_REQUESTS} requests per ${this.RATE_LIMIT_WINDOW / 1000} seconds. Resets at ${resetTime}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCacheKey(toolName: string, args: any): string {
|
||||||
|
return `${toolName}:${JSON.stringify(args)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFromCache(key: string): any | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - entry.timestamp > entry.ttl * 1000) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCache(key: string, data: any): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
ttl: this.config.cacheTtlSec,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
console.debug('[MCP] Cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isReadOperation(toolName: string): boolean {
|
||||||
|
const readOperations = [
|
||||||
|
'get_quote',
|
||||||
|
'get_bars',
|
||||||
|
'get_positions',
|
||||||
|
'get_account',
|
||||||
|
'get_news',
|
||||||
|
'get_orders',
|
||||||
|
];
|
||||||
|
return readOperations.some(op => toolName.toLowerCase().includes(op));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNonRetriableError(error: any): boolean {
|
||||||
|
const nonRetriablePatterns = [
|
||||||
|
'unauthorized',
|
||||||
|
'forbidden',
|
||||||
|
'invalid',
|
||||||
|
'not found',
|
||||||
|
'authentication',
|
||||||
|
];
|
||||||
|
|
||||||
|
const message = error.message?.toLowerCase() || '';
|
||||||
|
return nonRetriablePatterns.some(pattern => message.includes(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTimeout(ms: number): Promise<never> {
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to list tools as a simple health check
|
||||||
|
await this.listTools();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[MCP] Health check failed: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionStatus(): boolean {
|
||||||
|
return this.isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheStats(): { size: number; keys: string[] } {
|
||||||
|
return {
|
||||||
|
size: this.cache.size,
|
||||||
|
keys: Array.from(this.cache.keys()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditLogToolCall(toolName: string, args: any): void {
|
||||||
|
// Security: Sanitize sensitive parameters before logging
|
||||||
|
const sanitizedArgs = this.sanitizeArgs(args);
|
||||||
|
console.log(`[MCP-AUDIT] Tool called: ${toolName}, Args: ${JSON.stringify(sanitizedArgs)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditLogToolSuccess(toolName: string): void {
|
||||||
|
console.log(`[MCP-AUDIT] Tool succeeded: ${toolName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditLogToolFailure(toolName: string, error: string): void {
|
||||||
|
console.error(`[MCP-AUDIT] Tool failed: ${toolName}, Error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeArgs(args: any): any {
|
||||||
|
const sensitiveKeys = ['api_key', 'secret', 'password', 'token', 'key'];
|
||||||
|
const sanitized = { ...args };
|
||||||
|
|
||||||
|
for (const key of Object.keys(sanitized)) {
|
||||||
|
if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive))) {
|
||||||
|
sanitized[key] = '[REDACTED]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/mcp-client/tsconfig.json
Normal file
9
packages/mcp-client/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user