- Add warmup() to LlmProvider interface for eager subprocess startup - ManagedVllmProvider.warmup() starts vLLM in background on project load - ProviderRegistry.warmupAll() triggers all managed providers - NamedProvider proxies warmup() to inner provider - paginate stage generates LLM-powered descriptive page titles when available, cached by content hash, falls back to generic "Page N" - project-mcp-endpoint calls warmupAll() on router creation so vLLM is loading while the session initializes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
227 lines
6.9 KiB
TypeScript
227 lines
6.9 KiB
TypeScript
/**
|
|
* 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)));
|
|
}
|
|
}
|