import { Command } from 'commander';
import { formatTable } from '../formatters/table.js';
import { formatJson, formatYamlMultiDoc } from '../formatters/output.js';
import type { Column } from '../formatters/table.js';
import { resolveResource, stripInternalFields } from './shared.js';

export interface GetCommandDeps {
  fetchResource: (resource: string, id?: string, opts?: { project?: string; all?: boolean }) => Promise<unknown[]>;
  log: (...args: string[]) => void;
  getProject?: () => string | undefined;
  mcplocalUrl?: string;
}

interface ServerRow {
  id: string;
  name: string;
  transport: string;
  packageName: string | null;
  dockerImage: string | null;
}

interface ProjectRow {
  id: string;
  name: string;
  description: string;
  proxyModel: string;
  gated?: boolean;
  ownerId: string;
  servers?: Array<{ server: { name: string } }>;
}

interface SecretRow {
  id: string;
  name: string;
  data: Record<string, string>;
  /** Sorted list of key names — populated by mcpd whether the values live in
   *  the row (plaintext) or in a remote backend (openbao). Falls back to
   *  Object.keys(data) for older rows that pre-date the column. */
  keyNames?: string[];
}

interface TemplateRow {
  id: string;
  name: string;
  version: string;
  transport: string;
  packageName: string | null;
  description: string;
}

interface InstanceRow {
  id: string;
  serverId: string;
  server?: { name: string };
  status: string;
  containerId: string | null;
  port: number | null;
  healthStatus: string | null;
}

const serverColumns: Column<ServerRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'TRANSPORT', key: 'transport', width: 16 },
  { header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
  { header: 'IMAGE', key: (r) => r.dockerImage ?? '-' },
  { header: 'ID', key: 'id' },
];

interface UserRow {
  id: string;
  email: string;
  name: string | null;
  provider: string | null;
}

interface GroupRow {
  id: string;
  name: string;
  description: string;
  members?: Array<{ user: { email: string } }>;
}

interface RbacRow {
  id: string;
  name: string;
  subjects: Array<{ kind: string; name: string }>;
  roleBindings: Array<{ role: string; resource?: string; action?: string; name?: string }>;
}

const projectColumns: Column<ProjectRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'PLUGIN', key: (r) => r.proxyModel || 'default', width: 18 },
  { header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
  { header: 'DESCRIPTION', key: 'description', width: 30 },
  { header: 'ID', key: 'id' },
];

const userColumns: Column<UserRow>[] = [
  { header: 'EMAIL', key: 'email' },
  { header: 'NAME', key: (r) => r.name ?? '-' },
  { header: 'PROVIDER', key: (r) => r.provider ?? 'local', width: 10 },
  { header: 'ID', key: 'id' },
];

const groupColumns: Column<GroupRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 },
  { header: 'DESCRIPTION', key: 'description', width: 40 },
  { header: 'ID', key: 'id' },
];

const rbacColumns: Column<RbacRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'SUBJECTS', key: (r) => r.subjects.map((s) => `${s.kind}:${s.name}`).join(', '), width: 30 },
  { header: 'BINDINGS', key: (r) => r.roleBindings.map((b) => {
    if ('action' in b && b.action !== undefined) return `run>${b.action}`;
    if ('resource' in b && b.resource !== undefined) {
      const base = `${b.role}:${b.resource}`;
      return b.name ? `${base}:${b.name}` : base;
    }
    return b.role;
  }).join(', '), width: 40 },
  { header: 'ID', key: 'id' },
];

interface LlmRow {
  id: string;
  name: string;
  type: string;
  model: string;
  tier: string;
  url: string;
  description: string;
  apiKeyRef: { name: string; key: string } | null;
  // Virtual-provider lifecycle (optional for backward compat with older
  // mcpd responses that predate the kind/status columns).
  kind?: 'public' | 'virtual';
  status?: 'active' | 'inactive' | 'hibernating';
  // v4: explicit pool key. NULL = solo Llm (effective pool = its own name).
  poolName?: string | null;
  // v7: visibility scope. Legacy public rows omit it; mcpd defaults missing
  // values to 'public' on serialization.
  visibility?: 'public' | 'private';
  ownerId?: string | null;
}

// v4: POOL column placed right after NAME so an operator can't miss
// which Llms stack into the same dispatcher pool. Solo rows show "-"
// to make the "no pool / pool of 1" case unambiguous.
const llmColumns: Column<LlmRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'POOL', key: (r) => (r.poolName !== null && r.poolName !== undefined && r.poolName !== '') ? r.poolName : '-', width: 18 },
  { header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 },
  { header: 'STATUS', key: (r) => r.status ?? 'active', width: 12 },
  { header: 'VISIBILITY', key: (r) => r.visibility ?? 'public', width: 11 },
  { header: 'TYPE', key: 'type', width: 12 },
  { header: 'MODEL', key: 'model', width: 28 },
  { header: 'TIER', key: 'tier', width: 8 },
  { header: 'KEY', key: (r) => r.apiKeyRef ? `secret://${r.apiKeyRef.name}/${r.apiKeyRef.key}` : '-', width: 34 },
  { header: 'ID', key: 'id' },
];

/**
 * v5 InferenceTask row shape from `GET /api/v1/inference-tasks`. We
 * don't import the Prisma type to keep the CLI build independent of
 * @prisma/client; the field set here mirrors what the API actually
 * returns.
 */
interface InferenceTaskRow {
  id: string;
  status: 'pending' | 'claimed' | 'running' | 'completed' | 'error' | 'cancelled';
  poolName: string;
  llmName: string;
  model: string;
  tier: string | null;
  claimedBy: string | null;
  streaming: boolean;
  createdAt: string;
  claimedAt: string | null;
  completedAt: string | null;
  errorMessage: string | null;
  ownerId: string;
  agentId: string | null;
}

const inferenceTaskColumns: Column<InferenceTaskRow>[] = [
  { header: 'ID', key: 'id' },
  { header: 'STATUS', key: 'status', width: 10 },
  { header: 'POOL', key: 'poolName', width: 18 },
  { header: 'LLM', key: 'llmName', width: 20 },
  { header: 'MODEL', key: 'model', width: 24 },
  { header: 'STREAM', key: (r) => r.streaming ? 'yes' : '-', width: 7 },
  // AGE is more useful than createdAt at the table level — operators
  // are usually scanning for "what's stuck" rather than absolute time.
  { header: 'AGE', key: (r) => formatAge(r.createdAt), width: 8 },
  { header: 'WORKER', key: (r) => r.claimedBy ?? '-', width: 16 },
];

function formatAge(iso: string): string {
  const ms = Date.now() - new Date(iso).getTime();
  if (!Number.isFinite(ms) || ms < 0) return '-';
  const sec = Math.floor(ms / 1000);
  if (sec < 60) return `${String(sec)}s`;
  const min = Math.floor(sec / 60);
  if (min < 60) return `${String(min)}m`;
  const hr = Math.floor(min / 60);
  if (hr < 48) return `${String(hr)}h`;
  return `${String(Math.floor(hr / 24))}d`;
}

interface AgentRow {
  id: string;
  name: string;
  description: string;
  llm: { id: string; name: string };
  project: { id: string; name: string } | null;
  // v3: lifecycle fields. Public agents have kind=public/status=active and
  // these never change — virtuals get them set/updated by mcpd's
  // AgentService as the publishing mcplocal heartbeats and disconnects.
  kind?: 'public' | 'virtual';
  status?: 'active' | 'inactive';
  // v7: visibility — same semantics as Llm. Public legacy agents omit it.
  visibility?: 'public' | 'private';
}

const agentColumns: Column<AgentRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 },
  { header: 'STATUS', key: (r) => r.status ?? 'active', width: 10 },
  { header: 'VISIBILITY', key: (r) => r.visibility ?? 'public', width: 11 },
  { header: 'LLM', key: (r) => r.llm.name, width: 24 },
  { header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 20 },
  { header: 'DESCRIPTION', key: (r) => truncate(r.description, 50) || '-', width: 50 },
  { header: 'ID', key: 'id' },
];

interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string }
const threadColumns: Column<ThreadRow>[] = [
  { header: 'ID', key: 'id' },
  { header: 'TITLE', key: (r) => truncate(r.title, 40) || '(untitled)', width: 40 },
  { header: 'LAST TURN', key: (r) => formatAge(r.lastTurnAt), width: 10 },
  { header: 'CREATED', key: (r) => formatAge(r.createdAt), width: 10 },
];

interface PersonalityRow {
  id: string;
  name: string;
  description: string;
  agentId: string;
  agentName: string;
  priority: number;
  promptCount: number;
}

const personalityColumns: Column<PersonalityRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'AGENT', key: 'agentName', width: 24 },
  { header: 'PROMPTS', key: (r) => String(r.promptCount), width: 8 },
  { header: 'PRIORITY', key: (r) => String(r.priority), width: 8 },
  { header: 'DESCRIPTION', key: (r) => truncate(r.description, 40) || '-', width: 40 },
  { header: 'ID', key: 'id' },
];

function truncate(s: string, max: number): string {
  if (s.length <= max) return s;
  return s.slice(0, max - 1) + '…';
}

interface SecretBackendRow {
  id: string;
  name: string;
  type: string;
  isDefault: boolean;
  description: string;
  config?: Record<string, unknown>;
}

const secretBackendColumns: Column<SecretBackendRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'TYPE', key: 'type', width: 14 },
  { header: 'DEFAULT', key: (r) => r.isDefault ? '*' : '', width: 8 },
  { header: 'DESCRIPTION', key: (r) => r.description || '-', width: 30 },
  { header: 'ID', key: 'id' },
];

interface McpTokenRow {
  id: string;
  name: string;
  projectName: string;
  tokenPrefix: string;
  createdAt: string;
  lastUsedAt: string | null;
  expiresAt: string | null;
  status: 'active' | 'revoked' | 'expired';
}

const mcpTokenColumns: Column<McpTokenRow>[] = [
  { header: 'NAME', key: 'name', width: 24 },
  { header: 'PROJECT', key: 'projectName', width: 20 },
  { header: 'PREFIX', key: 'tokenPrefix', width: 18 },
  { header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
  { header: 'LAST USED', key: (r) => r.lastUsedAt ? new Date(r.lastUsedAt).toLocaleString() : '-', width: 20 },
  { header: 'EXPIRES', key: (r) => r.expiresAt ? new Date(r.expiresAt).toLocaleString() : 'never', width: 20 },
  { header: 'STATUS', key: 'status', width: 10 },
];

const secretColumns: Column<SecretRow>[] = [
  { header: 'NAME', key: 'name' },
  {
    header: 'KEYS',
    key: (r) => {
      // Prefer the dedicated column. It's populated whether the secret lives
      // in the row (plaintext) or behind a backend (openbao); the row's `data`
      // is empty in the latter case.
      const tracked = Array.isArray(r.keyNames) && r.keyNames.length > 0 ? r.keyNames : null;
      const fallback = Object.keys(r.data ?? {});
      const keys = tracked ?? fallback;
      return keys.length > 0 ? keys.join(', ') : '-';
    },
    width: 40,
  },
  { header: 'ID', key: 'id' },
];

const templateColumns: Column<TemplateRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'VERSION', key: 'version', width: 10 },
  { header: 'TRANSPORT', key: 'transport', width: 16 },
  { header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
  { header: 'DESCRIPTION', key: 'description', width: 50 },
];

interface PromptRow {
  id: string;
  name: string;
  projectId: string | null;
  project?: { name: string } | null;
  priority: number;
  linkTarget: string | null;
  linkStatus: 'alive' | 'dead' | null;
  createdAt: string;
}

interface PromptRequestRow {
  id: string;
  name: string;
  projectId: string | null;
  project?: { name: string } | null;
  createdBySession: string | null;
  createdAt: string;
}

const promptColumns: Column<PromptRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'PROJECT', key: (r) => r.project?.name ?? (r.projectId ? r.projectId : '(global)'), width: 20 },
  { header: 'PRI', key: (r) => String(r.priority), width: 4 },
  { header: 'LINK', key: (r) => r.linkTarget ? r.linkTarget.split(':')[0]! : '-', width: 20 },
  { header: 'STATUS', key: (r) => r.linkStatus ?? '-', width: 6 },
  { header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
  { header: 'ID', key: 'id' },
];

const promptRequestColumns: Column<PromptRequestRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'PROJECT', key: (r) => r.project?.name ?? (r.projectId ? r.projectId : '(global)'), width: 20 },
  { header: 'SESSION', key: (r) => r.createdBySession ? r.createdBySession.slice(0, 12) : '-', width: 14 },
  { header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
  { header: 'ID', key: 'id' },
];

const instanceColumns: Column<InstanceRow>[] = [
  { header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
  { header: 'STATUS', key: 'status', width: 10 },
  { header: 'HEALTH', key: (r) => r.healthStatus ?? 'unknown', width: 10 },
  { header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
  { header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
  { header: 'ID', key: 'id' },
];

interface ServerAttachmentRow {
  project: string;
  server: string;
}

const serverAttachmentColumns: Column<ServerAttachmentRow>[] = [
  { header: 'SERVER', key: 'server', width: 25 },
  { header: 'PROJECT', key: 'project', width: 25 },
];

interface ProxymodelRow {
  name: string;
  source: string;
  type?: string;
  controller?: string;
  stages?: string[];
  cacheable?: boolean;
  extends?: readonly string[];
  hooks?: string[];
  description?: string;
}

const proxymodelColumns: Column<ProxymodelRow>[] = [
  { header: 'NAME', key: 'name' },
  { header: 'TYPE', key: (r) => r.type ?? 'pipeline', width: 10 },
  { header: 'SOURCE', key: 'source', width: 10 },
  { header: 'DETAIL', key: (r) => {
    if (r.type === 'plugin') {
      const ext = r.extends?.length ? `extends: ${[...r.extends].join(', ')}` : '';
      const hooks = r.hooks?.length ? `hooks: ${r.hooks.length}` : '';
      return [ext, hooks].filter(Boolean).join(' | ') || '-';
    }
    return r.stages?.join(', ') ?? '-';
  }, width: 45 },
];

function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
  switch (resource) {
    case 'servers':
      return serverColumns as unknown as Column<Record<string, unknown>>[];
    case 'projects':
      return projectColumns as unknown as Column<Record<string, unknown>>[];
    case 'secrets':
      return secretColumns as unknown as Column<Record<string, unknown>>[];
    case 'templates':
      return templateColumns as unknown as Column<Record<string, unknown>>[];
    case 'instances':
      return instanceColumns as unknown as Column<Record<string, unknown>>[];
    case 'users':
      return userColumns as unknown as Column<Record<string, unknown>>[];
    case 'groups':
      return groupColumns as unknown as Column<Record<string, unknown>>[];
    case 'rbac':
      return rbacColumns as unknown as Column<Record<string, unknown>>[];
    case 'prompts':
      return promptColumns as unknown as Column<Record<string, unknown>>[];
    case 'promptrequests':
      return promptRequestColumns as unknown as Column<Record<string, unknown>>[];
    case 'serverattachments':
      return serverAttachmentColumns as unknown as Column<Record<string, unknown>>[];
    case 'proxymodels':
      return proxymodelColumns as unknown as Column<Record<string, unknown>>[];
    case 'mcptokens':
      return mcpTokenColumns as unknown as Column<Record<string, unknown>>[];
    case 'secretbackends':
      return secretBackendColumns as unknown as Column<Record<string, unknown>>[];
    case 'llms':
      return llmColumns as unknown as Column<Record<string, unknown>>[];
    case 'agents':
      return agentColumns as unknown as Column<Record<string, unknown>>[];
    case 'personalities':
      return personalityColumns as unknown as Column<Record<string, unknown>>[];
    case 'threads':
      return threadColumns as unknown as Column<Record<string, unknown>>[];
    case 'inference-tasks':
      return inferenceTaskColumns as unknown as Column<Record<string, unknown>>[];
    default:
      return [
        { header: 'ID', key: 'id' as keyof Record<string, unknown> },
        { header: 'NAME', key: 'name' as keyof Record<string, unknown> },
      ];
  }
}

/** Map plural resource name → singular kind for YAML documents */
const RESOURCE_KIND: Record<string, string> = {
  servers: 'server',
  projects: 'project',
  secrets: 'secret',
  templates: 'template',
  instances: 'instance',
  users: 'user',
  groups: 'group',
  rbac: 'rbac',
  prompts: 'prompt',
  promptrequests: 'promptrequest',
  serverattachments: 'serverattachment',
  mcptokens: 'mcptoken',
  secretbackends: 'secretbackend',
  llms: 'llm',
  agents: 'agent',
  personalities: 'personality',
};

/**
 * Transform API response items into apply-compatible multi-doc format.
 * Each item gets a `kind` field and internal fields stripped.
 */
function toApplyDocs(resource: string, items: unknown[]): Array<{ kind: string } & Record<string, unknown>> {
  const kind = RESOURCE_KIND[resource] ?? resource;
  return items.map((item) => {
    const cleaned = stripInternalFields(item as Record<string, unknown>);
    // Llm-specific: the virtual-provider lifecycle fields collide with the
    // apply-doc `kind` envelope (the schema uses `kind: public|virtual`)
    // and aren't apply-able anyway — they're derived runtime state managed
    // by VirtualLlmService. Drop them so YAML round-trips stay clean.
    if (resource === 'llms') {
      delete cleaned['kind'];
      delete cleaned['status'];
      delete cleaned['lastHeartbeatAt'];
      delete cleaned['inactiveSince'];
      delete cleaned['providerSessionId'];
    }
    // Agent-specific: same shape as Llm — Agent gained kind/status/etc. in
    // v3 Stage 1 (virtual agent lifecycle) and the schema-`kind` field
    // shadows the apply-envelope `kind: agent`. Strip the same set so
    // `get agent X -o yaml | apply -f -` round-trips without diff.
    if (resource === 'agents') {
      delete cleaned['kind'];
      delete cleaned['status'];
      delete cleaned['lastHeartbeatAt'];
      delete cleaned['inactiveSince'];
      delete cleaned['providerSessionId'];
    }
    return { kind, ...cleaned };
  });
}

export function createGetCommand(deps: GetCommandDeps): Command {
  return new Command('get')
    .description('List resources (servers, projects, instances, all)')
    .argument('<resource>', 'resource type (servers, projects, instances, all)')
    .argument('[id]', 'specific resource ID or name')
    .option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
    .option('-p, --project <name>', 'Filter by project')
    .option('-A, --all', 'Show all (including project-scoped) resources')
    .action(async (resourceArg: string, id: string | undefined, opts: { output: string; project?: string; all?: true }) => {
      const resource = resolveResource(resourceArg);
      // Merge parent --project with local --project
      const project = opts.project ?? deps.getProject?.();

      // Handle `get all --project X` composite export
      if (resource === 'all') {
        await handleGetAll(deps, { ...opts, project });
        return;
      }

      // ProxyModels are served by mcplocal, not mcpd
      if (resource === 'proxymodels') {
        const mcplocalUrl = deps.mcplocalUrl ?? 'http://localhost:3200';
        const items = await fetchProxymodels(mcplocalUrl, id);
        if (opts.output === 'json') {
          deps.log(formatJson(items));
        } else if (opts.output === 'yaml') {
          deps.log(formatYamlMultiDoc(items.map((i) => ({ kind: 'proxymodel', ...(i as Record<string, unknown>) }))));
        } else {
          if (items.length === 0) {
            deps.log('No proxymodels found.');
            return;
          }
          const columns = getColumnsForResource(resource);
          deps.log(formatTable(items as Record<string, unknown>[], columns));
        }
        return;
      }

      const fetchOpts: { project?: string; all?: boolean } = {};
      if (project) fetchOpts.project = project;
      if (opts.all) fetchOpts.all = true;
      const items = await deps.fetchResource(resource, id, Object.keys(fetchOpts).length > 0 ? fetchOpts : undefined);

      if (opts.output === 'json') {
        deps.log(formatJson(toApplyDocs(resource, items)));
      } else if (opts.output === 'yaml') {
        deps.log(formatYamlMultiDoc(toApplyDocs(resource, items)));
      } else {
        if (items.length === 0) {
          deps.log(`No ${resource} found.`);
          return;
        }
        const columns = getColumnsForResource(resource);
        deps.log(formatTable(items as Record<string, unknown>[], columns));
      }
    });
}

async function handleGetAll(
  deps: GetCommandDeps,
  opts: { output: string; project?: string },
): Promise<void> {
  if (!opts.project) {
    throw new Error('--project is required with "get all". Usage: mcpctl get all --project <name>');
  }

  const docs: Array<{ kind: string } & Record<string, unknown>> = [];

  // 1. Fetch the project
  const projects = await deps.fetchResource('projects', opts.project);
  if (projects.length === 0) {
    deps.log(`Project '${opts.project}' not found.`);
    return;
  }

  // 2. Add the project itself
  for (const p of projects) {
    docs.push({ kind: 'project', ...stripInternalFields(p as Record<string, unknown>) });
  }

  // 3. Extract serverattachments from project's server list
  const project = projects[0] as ProjectRow;
  let attachmentCount = 0;
  if (project.servers && project.servers.length > 0) {
    for (const ps of project.servers) {
      docs.push({
        kind: 'serverattachment',
        server: typeof ps === 'string' ? ps : ps.server.name,
        project: project.name,
      });
      attachmentCount++;
    }
  }

  // 4. Fetch prompts owned by this project (exclude global prompts)
  const prompts = await deps.fetchResource('prompts', undefined, { project: opts.project });
  const projectPrompts = prompts.filter((p) => (p as { projectId?: string }).projectId != null);
  for (const p of projectPrompts) {
    docs.push({ kind: 'prompt', ...stripInternalFields(p as Record<string, unknown>) });
  }

  if (opts.output === 'json') {
    deps.log(formatJson(docs));
  } else if (opts.output === 'yaml') {
    deps.log(formatYamlMultiDoc(docs));
  } else {
    // Table output: show summary
    deps.log(`Project: ${opts.project}`);
    deps.log(`  Server Attachments:   ${attachmentCount}`);
    deps.log(`  Prompts:              ${projectPrompts.length}`);
    deps.log(`\nUse -o yaml or -o json for apply-compatible output.`);
  }
}

async function fetchProxymodels(mcplocalUrl: string, name?: string): Promise<unknown[]> {
  const http = await import('node:http');
  const url = name
    ? `${mcplocalUrl}/proxymodels/${encodeURIComponent(name)}`
    : `${mcplocalUrl}/proxymodels`;

  return new Promise<unknown[]>((resolve, reject) => {
    const req = http.get(url, { timeout: 5000 }, (res) => {
      let data = '';
      res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
      res.on('end', () => {
        try {
          const parsed = JSON.parse(data) as unknown;
          resolve(Array.isArray(parsed) ? parsed : [parsed]);
        } catch {
          reject(new Error('Invalid response from mcplocal'));
        }
      });
    });
    req.on('error', () => reject(new Error(`Cannot connect to mcplocal at ${mcplocalUrl}`)));
    req.on('timeout', () => { req.destroy(); reject(new Error('mcplocal request timed out')); });
  });
}
