Files
mcpctl/src/mcplocal/tests/smoke/mcp-client.ts

227 lines
6.9 KiB
TypeScript
Raw Normal View History

/**
* Lightweight MCP HTTP client for smoke tests.
* Sends JSON-RPC messages to mcplocal's HTTP endpoint and parses SSE responses.
*/
import http from 'node:http';
export interface McpResponse {
status: number;
sessionId?: string;
messages: unknown[];
}
const MCPLOCAL_URL = process.env.MCPLOCAL_URL ?? 'http://localhost:3200';
const MCPD_URL = process.env.MCPD_URL ?? 'http://localhost:3100';
export function getMcplocalUrl(): string {
return MCPLOCAL_URL;
}
export function getMcpdUrl(): string {
return MCPD_URL;
}
function httpRequest(opts: {
url: string;
method: string;
headers?: Record<string, string>;
body?: string;
timeout?: number;
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(opts.url);
const req = http.request(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
method: opts.method,
headers: opts.headers,
timeout: opts.timeout ?? 30_000,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
resolve({
status: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks).toString('utf-8'),
});
});
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
if (opts.body) req.write(opts.body);
req.end();
});
}
function parseSSE(body: string): unknown[] {
const messages: unknown[] = [];
for (const line of body.split('\n')) {
if (line.startsWith('data: ')) {
try {
messages.push(JSON.parse(line.slice(6)));
} catch {
// skip
}
}
}
return messages;
}
/**
* MCP session for smoke tests.
* Manages session ID and sends JSON-RPC requests.
*/
export class SmokeMcpSession {
private sessionId?: string;
private nextId = 1;
constructor(
private readonly projectName: string,
private readonly token?: string,
) {}
get endpoint(): string {
return `${MCPLOCAL_URL}/projects/${encodeURIComponent(this.projectName)}/mcp`;
}
async send(method: string, params: Record<string, unknown> = {}, timeout?: number): Promise<unknown> {
const id = this.nextId++;
const request = { jsonrpc: '2.0', id, method, params };
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
};
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
const result = await httpRequest({
url: this.endpoint,
method: 'POST',
headers,
body: JSON.stringify(request),
timeout,
});
// Capture session ID
if (!this.sessionId) {
const sid = result.headers['mcp-session-id'];
if (typeof sid === 'string') this.sessionId = sid;
}
// Handle HTTP-level errors (e.g. 502 for nonexistent project)
if (result.status >= 400) {
let errorMsg = `HTTP ${result.status}`;
try {
const body = JSON.parse(result.body) as { error?: string };
if (body.error) errorMsg = body.error;
} catch {
errorMsg = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;
}
throw new Error(errorMsg);
}
// Parse response — handle SSE with multiple messages (notifications + response)
const messages = result.headers['content-type']?.includes('text/event-stream')
? parseSSE(result.body)
: [JSON.parse(result.body)];
// Find the response matching our request ID (skip notifications)
const response = messages.find((m) => {
const msg = m as { id?: unknown };
return msg.id === id;
}) as { result?: unknown; error?: { code: number; message: string } } | undefined;
// Fall back to first message if no ID match (e.g. error responses)
const parsed = response ?? messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined;
if (!parsed) throw new Error(`No response for ${method}`);
if (parsed.error) throw new Error(`MCP error ${parsed.error.code}: ${parsed.error.message}`);
return parsed.result;
}
async initialize(): Promise<unknown> {
return this.send('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-smoke-test', version: '1.0.0' },
});
}
async sendNotification(method: string, params: Record<string, unknown> = {}): Promise<void> {
const notification = { jsonrpc: '2.0', method, params };
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
};
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
await httpRequest({
url: this.endpoint,
method: 'POST',
headers,
body: JSON.stringify(notification),
}).catch(() => {});
}
async listTools(): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
const result = await this.send('tools/list') as { tools: Array<{ name: string; description?: string; inputSchema?: unknown }> };
return result.tools ?? [];
}
async callTool(name: string, args: Record<string, unknown> = {}, timeout?: number): Promise<{ content: Array<{ type: string; text?: string }>; isError?: boolean }> {
return await this.send('tools/call', { name, arguments: args }, timeout) as { content: Array<{ type: string; text?: string }>; isError?: boolean };
}
async close(): Promise<void> {
if (this.sessionId) {
const headers: Record<string, string> = { 'mcp-session-id': this.sessionId };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
await httpRequest({
url: this.endpoint,
method: 'DELETE',
headers,
timeout: 5_000,
}).catch(() => {});
this.sessionId = undefined;
}
}
}
/**
* Check if mcplocal is reachable.
*/
export async function isMcplocalRunning(): Promise<boolean> {
try {
const result = await httpRequest({
url: `${MCPLOCAL_URL}/health`,
method: 'GET',
timeout: 3_000,
});
return result.status < 500;
} catch {
return false;
}
}
/**
* Run an mcpctl CLI command and return stdout.
*/
export function mcpctl(args: string): Promise<string> {
const { execSync } = require('node:child_process') as typeof import('node:child_process');
try {
return Promise.resolve(execSync(`mcpctl ${args}`, { encoding: 'utf-8', timeout: 30_000 }).trim());
} catch (err) {
const e = err as { stderr?: string; stdout?: string };
return Promise.reject(new Error(e.stderr ?? e.stdout ?? String(err)));
}
}