-
Notifications
You must be signed in to change notification settings - Fork 137
Expand file tree
/
Copy pathmcp_client.ts
More file actions
201 lines (174 loc) · 6.14 KB
/
mcp_client.ts
File metadata and controls
201 lines (174 loc) · 6.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
/**
* MCP Client wrapper for workflow evaluations
* Handles spawning, connecting, and communicating with the MCP server
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import type { McpTool, McpToolCall, McpToolResult } from './types.js';
export class McpClient {
private client: Client | null = null;
private transport: StdioClientTransport | null = null;
private tools: McpTool[] = [];
private instructions: string | null = null;
private toolTimeoutMs: number;
/**
* Create MCP client
* @param toolTimeoutSeconds - Timeout for tool calls in seconds (default: 60)
*/
constructor(toolTimeoutSeconds = 60) {
this.toolTimeoutMs = toolTimeoutSeconds * 1000;
}
/**
* Start the MCP server and connect the client
* @param apifyToken - Apify API token
* @param tools - Optional list of tools to enable (e.g., ["actors", "docs", "apify/rag-web-browser"])
*/
async start(apifyToken: string, tools?: string[]): Promise<void> {
if (this.client) {
throw new Error('MCP client is already started');
}
// Check that dist/stdio.js exists
const fs = await import('node:fs');
const path = await import('node:path');
const stdioBinPath = path.resolve(process.cwd(), 'dist/stdio.js');
if (!fs.existsSync(stdioBinPath)) {
throw new Error(
'MCP server binary not found at dist/stdio.js. '
+ 'Please run "npm run build" first.',
);
}
// Build args for MCP server
const args = [stdioBinPath];
// Add --tools argument if provided
if (tools && tools.length > 0) {
args.push(`--tools=${tools.join(',')}`);
}
// Create transport with stdio
this.transport = new StdioClientTransport({
command: 'node',
args,
env: {
...process.env,
APIFY_TOKEN: apifyToken,
},
});
// Create and connect client
this.client = new Client(
{
name: 'workflow-eval-client',
version: '1.0.0',
},
{
capabilities: {},
},
);
await this.client.connect(this.transport);
// Load available tools and instructions
await this.loadTools();
this.instructions = this.client.getInstructions() || null;
}
/**
* Load and cache available tools from the server
*/
private async loadTools(): Promise<void> {
if (!this.client) {
throw new Error('MCP client is not started');
}
const response = await this.client.listTools();
this.tools = response.tools as McpTool[];
}
/**
* Get list of available tools
*/
getTools(): McpTool[] {
return this.tools;
}
/**
* Get server instructions (if provided by the server)
*/
getInstructions(): string | null {
return this.instructions;
}
/**
* Call a tool on the MCP server
*/
async callTool(toolCall: McpToolCall): Promise<McpToolResult> {
if (!this.client) {
throw new Error('MCP client is not started');
}
try {
const response = await this.client.callTool(
{
name: toolCall.name,
arguments: toolCall.arguments,
},
undefined, // resultSchema
{
timeout: this.toolTimeoutMs,
resetTimeoutOnProgress: true, // Reset timeout on progress notifications
},
);
// Populate error field when isError is true so LLM receives the error message
return {
toolName: toolCall.name,
success: !response.isError,
result: response.isError ? undefined : response.content,
error: response.isError ? JSON.stringify(response.content) : undefined,
};
} catch (error) {
// Return raw error message from SDK without modification
return {
toolName: toolCall.name,
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Cleanup and shutdown the MCP client
* Uses a timeout to prevent indefinite waiting during cleanup
*/
async cleanup(cleanupTimeoutMs = 2000): Promise<void> {
// Create timeout promise
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), cleanupTimeoutMs);
});
// Attempt graceful cleanup with timeout
const cleanupPromise = (async () => {
if (this.client) {
await this.client.close();
}
if (this.transport) {
await this.transport.close();
}
})();
// Race between cleanup and timeout
await Promise.race([cleanupPromise, timeoutPromise]);
// Force kill transport process if it's still running
if (this.transport) {
try {
// Access the underlying child process and force kill it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const transportAny = this.transport as any;
// eslint-disable-next-line no-underscore-dangle
if (transportAny._process && transportAny._process.kill) {
// eslint-disable-next-line no-underscore-dangle
transportAny._process.kill('SIGKILL');
}
} catch {
// Ignore errors during force kill
}
}
// Always reset state regardless of cleanup success
this.client = null;
this.transport = null;
this.tools = [];
this.instructions = null;
}
/**
* Check if client is connected
*/
isConnected(): boolean {
return this.client !== null;
}
}