Skip to main content
Glama
examples-smoke.ts11.4 kB
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import * as net from 'node:net'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; type JsonRpcResponse = { jsonrpc: '2.0'; id: number | string | null; result?: unknown; error?: { code: number; message: string; data?: unknown }; }; function repoRoot(): string { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); return path.resolve(__dirname, '..', '..'); } async function getFreePort(): Promise<number> { return await new Promise<number>((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const addr = server.address(); if (!addr || typeof addr === 'string') { server.close(() => reject(new Error('Failed to allocate an ephemeral TCP port'))); return; } const port = addr.port; server.close((err) => (err ? reject(err) : resolve(port))); }); }); } function parseSseJsonRpcEvents(bodyText: string): JsonRpcResponse[] { const events: JsonRpcResponse[] = []; const lines = bodyText.split('\n'); for (const line of lines) { if (!line.startsWith('data: ')) continue; const json = line.slice('data: '.length).trim(); if (!json) continue; events.push(JSON.parse(json) as JsonRpcResponse); } return events; } async function waitForHealth(port: number, timeoutMs: number): Promise<void> { const start = Date.now(); // eslint-disable-next-line no-constant-condition while (true) { try { const res = await fetch(`http://127.0.0.1:${port}/health`); if (res.ok) { const json = await res.json().catch(() => null); if (json && typeof json === 'object' && (json as { status?: unknown }).status === 'ok') { return; } } } catch { // ignore and retry } if (Date.now() - start > timeoutMs) { throw new Error(`Timed out waiting for /health on port ${port}`); } await new Promise((r) => setTimeout(r, 250)); } } async function mcpRequest(options: { port: number; sessionId?: string; id: number; method: string; params?: unknown; }): Promise<JsonRpcResponse> { const res = await fetch(`http://127.0.0.1:${options.port}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', // Required by StreamableHTTPServerTransport Accept: 'application/json, text/event-stream', ...(options.sessionId ? { 'mcp-session-id': options.sessionId } : {}), }, body: JSON.stringify({ jsonrpc: '2.0', id: options.id, method: options.method, params: options.params ?? {}, }), }); const text = await res.text(); const events = parseSseJsonRpcEvents(text); if (events.length === 0) { throw new Error(`No SSE JSON-RPC events returned for ${options.method}. HTTP ${res.status}. Body:\n${text}`); } const last = events[events.length - 1]!; if (last.error) { throw new Error(`JSON-RPC error for ${options.method}: ${last.error.message}`); } return last; } async function mcpInitialize(port: number): Promise<string> { const res = await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'examples-smoke', version: '1.0.0' }, }, }), }); const sessionId = res.headers.get('mcp-session-id'); const body = await res.text(); if (!res.ok || !sessionId) { throw new Error(`Failed to initialize MCP session. HTTP ${res.status}. sessionId=${sessionId}\n${body}`); } // Also sanity-check the JSON-RPC response exists. const events = parseSseJsonRpcEvents(body); if (events.length === 0 || events[0]?.error) { throw new Error(`Initialize returned no/invalid JSON-RPC SSE event. Body:\n${body}`); } return sessionId; } async function assertToolsList(port: number, sessionId: string): Promise<void> { const resp = await mcpRequest({ port, sessionId, id: 2, method: 'tools/list', params: {}, }); const result = resp.result as { tools?: Array<{ name?: unknown }> } | undefined; const tools = result?.tools ?? []; const names = tools.map((t) => String(t.name)); const required = ['prodisco.searchTools', 'prodisco.runSandbox']; for (const name of required) { if (!names.includes(name)) { throw new Error(`tools/list missing "${name}". Got: ${names.join(', ')}`); } } } async function assertSearchWorks(port: number, sessionId: string, args: Record<string, unknown>, expectNameContains: string): Promise<void> { const resp = await mcpRequest({ port, sessionId, id: 3, method: 'tools/call', params: { name: 'prodisco.searchTools', arguments: args }, }); const result = resp.result as { structuredContent?: unknown } | undefined; const structured = result?.structuredContent as { results?: Array<{ name?: string }> } | undefined; const names = (structured?.results ?? []).map((r) => r.name ?? ''); if (names.length === 0) { throw new Error(`searchTools returned 0 results for args=${JSON.stringify(args)}`); } const hit = names.some((n) => n.toLowerCase().includes(expectNameContains.toLowerCase())); if (!hit) { throw new Error(`searchTools results did not include "${expectNameContains}". Got: ${names.join(', ')}`); } } async function assertRunSandboxWorks(port: number, sessionId: string, code: string, expectOutputContains: string): Promise<void> { const resp = await mcpRequest({ port, sessionId, id: 4, method: 'tools/call', params: { name: 'prodisco.runSandbox', arguments: { code } }, }); const result = resp.result as { structuredContent?: unknown } | undefined; const structured = result?.structuredContent as { success?: boolean; output?: string; error?: string } | undefined; if (!structured || structured.success !== true) { throw new Error(`runSandbox failed. error=${structured?.error ?? 'unknown'}`); } const output = structured.output ?? ''; if (!output.includes(expectOutputContains)) { throw new Error(`runSandbox output did not include "${expectOutputContains}". Output:\n${output}`); } } type ServerHandle = { port: number; proc: ChildProcessWithoutNullStreams; logs: string[]; }; async function startServerWithConfig(options: { configPath: string; installMissing?: boolean; startupTimeoutMs: number; }): Promise<ServerHandle> { const root = repoRoot(); const port = await getFreePort(); const socketPath = `/tmp/prodisco-ci-sandbox-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.sock`; const scriptsDir = `/tmp/prodisco-ci-scripts-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; await fs.mkdir(scriptsDir, { recursive: true }); const args: string[] = [ path.join(root, 'dist', 'server.js'), '--transport', 'http', '--host', '127.0.0.1', '--port', String(port), '--config', options.configPath, ]; if (options.installMissing) { args.push('--install-missing'); } const proc = spawn('node', args, { cwd: root, env: { ...process.env, SANDBOX_SOCKET_PATH: socketPath, SCRIPTS_CACHE_DIR: scriptsDir, }, stdio: ['ignore', 'pipe', 'pipe'], }); const logs: string[] = []; const pushLog = (line: string) => { logs.push(line); if (logs.length > 5000) logs.shift(); }; proc.stdout.on('data', (d) => pushLog(d.toString())); proc.stderr.on('data', (d) => pushLog(d.toString())); await waitForHealth(port, options.startupTimeoutMs); return { port, proc, logs }; } async function stopServer(handle: ServerHandle): Promise<void> { if (handle.proc.exitCode !== null) return; handle.proc.kill('SIGTERM'); const start = Date.now(); while (handle.proc.exitCode === null) { if (Date.now() - start > 5000) { handle.proc.kill('SIGKILL'); break; } await new Promise((r) => setTimeout(r, 100)); } } async function runScenario(label: string, configRelPath: string, options: { installMissing?: boolean; startupTimeoutMs: number; }): Promise<void> { const root = repoRoot(); const configPath = path.join(root, configRelPath); const server = await startServerWithConfig({ configPath, installMissing: options.installMissing, startupTimeoutMs: options.startupTimeoutMs, }); try { const sessionId = await mcpInitialize(server.port); await assertToolsList(server.port, sessionId); if (label === 'kubernetes') { await assertSearchWorks(server.port, sessionId, { methodName: 'listNamespace', documentType: 'method', library: '@kubernetes/client-node', limit: 5, }, 'listNamespace'); await assertRunSandboxWorks( server.port, sessionId, ` const k8s = require("@kubernetes/client-node"); const kc = new k8s.KubeConfig(); kc.loadFromOptions({ clusters: [{ name: "cluster", server: "https://example.invalid" }], users: [{ name: "user" }], contexts: [{ name: "context", user: "user", cluster: "cluster" }], currentContext: "context", }); const api = kc.makeApiClient(k8s.CoreV1Api); console.log("CoreV1Api:", typeof api.listNamespace); `, 'CoreV1Api: function', ); } else if (label === 'pg-mem') { await assertSearchWorks(server.port, sessionId, { methodName: 'newDb', library: 'pg-mem', limit: 5, }, 'newDb'); await assertRunSandboxWorks( server.port, sessionId, ` const { newDb } = require("pg-mem"); const db = newDb(); db.public.none("create table users(id int primary key, name text);"); db.public.none("insert into users values (1, 'ada'), (2, 'grace');"); const rows = db.public.many("select * from users order by id;"); console.log(JSON.stringify(rows)); `, '"name":"ada"', ); } else { throw new Error(`Unknown scenario label: ${label}`); } } catch (err) { // Print captured logs to aid debugging in CI // eslint-disable-next-line no-console console.error(`\n[examples-smoke] Scenario "${label}" failed. Last logs:\n${server.logs.slice(-200).join('')}\n`); throw err; } finally { await stopServer(server); } } async function main(): Promise<void> { // Kubernetes config should work without install-missing (dependency is already in the repo). await runScenario('kubernetes', 'examples/prodisco.kubernetes.yaml', { installMissing: false, startupTimeoutMs: 120_000, }); // pg-mem is not a repo dependency; exercise the startup-time auto-install path. await runScenario('pg-mem', 'examples/prodisco.postgres.yaml', { installMissing: true, startupTimeoutMs: 300_000, }); } main().catch((err) => { // eslint-disable-next-line no-console console.error(err); process.exitCode = 1; });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/harche/ProDisco'

If you have feedback or need assistance with the MCP directory API, please join our Discord server