Skip to content

v1.3 Plan 1: Shared Package + Orchestrator Core

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the shared types package and orchestrator core — config loading, MCP proxy, tool namespacing/routing, and provider lifecycle management.

Architecture: Monorepo with npm workspaces. packages/shared exports types, role definitions, and config validation. packages/orchestrator spawns child MCP servers, proxies their tools with namespacing, and exposes them as a single MCP server over stdio. The orchestrator replaces the Java fat JAR as the entry point.

Tech Stack: TypeScript, Node 20+, npm workspaces, vitest, MCP protocol over stdio (JSON-RPC 2.0)

Spec: docs/superpowers/specs/2026-03-30-v1.3-plugin-architecture-design.md


File Structure

New files

packages/
  shared/
    src/
      types.ts              # Provider, Role, Config, Workflow, ToolInfo types
      roles.ts              # Role enum, capability patterns per role
      config-schema.ts      # Config validation, v1.2 migration detection
      constants.ts          # Shared constants (config filename, protocol version, etc.)
      index.ts              # Barrel export
    package.json
    tsconfig.json

  orchestrator/
    src/
      mcp-client.ts         # Spawn a child MCP server, connect, send requests
      proxy.ts              # Manage multiple MCP clients (providers)
      router.ts             # Tool namespacing, role aliasing, request dispatch
      config.ts             # Load/save/migrate config file
      server.ts             # MCP server entry point (stdio)
      cli.ts                # CLI entry: parse flags, start server or validate
      index.ts              # Barrel export
    bin/
      cli.ts                # #!/usr/bin/env node shim
    tests/
      mcp-client.test.ts
      proxy.test.ts
      router.test.ts
      config.test.ts
    package.json
    tsconfig.json

package.json                # Workspace root
tsconfig.base.json          # Shared compiler options

Existing files to modify later (Plans 2-5)

  • packages/tui/ — the existing tui/ directory, moved and updated (Plan 3)
  • docs/ — documentation rewrite (Plan 5)
  • README.md — rewrite (Plan 5)

Task 1: Monorepo Scaffold

Files: - Create: package.json (workspace root — replaces current empty root) - Create: tsconfig.base.json - Create: packages/shared/package.json - Create: packages/shared/tsconfig.json - Create: packages/orchestrator/package.json - Create: packages/orchestrator/tsconfig.json

  • [ ] Step 1: Create workspace root package.json
{
  "name": "infrastructure-mcp-monorepo",
  "private": true,
  "workspaces": [
    "packages/shared",
    "packages/orchestrator"
  ],
  "scripts": {
    "build": "npm run build --workspaces",
    "test": "npm run test --workspaces",
    "clean": "rm -rf packages/*/dist"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}

Save to packages/package.json — wait, this is the workspace root. The existing root package.json doesn't exist (it's a Maven project). Create at project root.

Note: The existing pom.xml and Java src/ directory stay in the repo for now. They'll be removed in Plan 4 after the external repos are updated. The monorepo and Java project coexist temporarily.

  • [ ] Step 2: Create tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "sourceMap": true,
    "forceConsistentCasingInFileNames": true
  }
}

Save to project root tsconfig.base.json.

  • [ ] Step 3: Create packages/shared/package.json
{
  "name": "@infrastructure-mcp/shared",
  "version": "1.3.0",
  "description": "Shared types, roles, and config schema for infrastructure-mcp",
  "type": "module",
  "license": "MIT",
  "main": "dist/src/index.js",
  "types": "dist/src/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "test": "echo 'no tests yet'"
  },
  "devDependencies": {
    "typescript": "^5.7.0"
  }
}
  • [ ] Step 4: Create packages/shared/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "."
  },
  "include": ["src/**/*"]
}
  • [ ] Step 5: Create packages/orchestrator/package.json
{
  "name": "infrastructure-mcp",
  "version": "1.3.0",
  "description": "MCP orchestrator that proxies tools from provider MCP servers",
  "type": "module",
  "license": "MIT",
  "author": "Matt Hesketh <[email protected]>",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/wrxck/infrastructure-mcp.git",
    "directory": "packages/orchestrator"
  },
  "bin": {
    "infrastructure-mcp": "dist/bin/cli.js"
  },
  "main": "dist/src/index.js",
  "types": "dist/src/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "start": "tsx src/cli.ts",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@infrastructure-mcp/shared": "^1.3.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.7.0",
    "vitest": "^3.0.0"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}
  • [ ] Step 6: Create packages/orchestrator/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "."
  },
  "include": ["bin/**/*", "src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}
  • [ ] Step 7: Install dependencies

Run: cd /home/matt/mcp/infrastructure-mcp && npm install

Expected: Workspace packages linked, node_modules created at root and in packages.

  • [ ] Step 8: Verify workspaces

Run: npm ls --workspaces

Expected: Shows @infrastructure-mcp/shared and infrastructure-mcp as workspaces.

  • [ ] Step 9: Commit
git add package.json tsconfig.base.json packages/shared/package.json packages/shared/tsconfig.json packages/orchestrator/package.json packages/orchestrator/tsconfig.json
git commit -m "chore: scaffold monorepo with shared and orchestrator packages"

Task 2: Shared Types

Files: - Create: packages/shared/src/types.ts - Create: packages/shared/src/constants.ts - Create: packages/shared/src/index.ts

  • [ ] Step 1: Create shared types
// packages/shared/src/types.ts

export type Role = "dns_registrar" | "dns_host" | "cdn" | "security" | "app_platform";

export interface ProviderConfig {
  name: string;
  command: string;
  args?: string[];
  env?: Record<string, string>;
  roles: Role[];
  optional?: boolean;
  timeout?: number;
}

export interface WorkflowConfig {
  enabled: boolean;
}

export interface TuiSettings {
  experienceLevel: "learner" | "comfortable" | "professional";
}

export interface InfraConfig {
  providers: ProviderConfig[];
  workflows: Record<string, WorkflowConfig>;
  tui: TuiSettings;
}

export interface LegacyConfig {
  jarPath: string;
  env: Record<string, string>;
  experienceLevel: "learner" | "comfortable" | "professional";
}

export interface ToolInfo {
  name: string;
  description: string;
}

export interface ToolResult {
  content: string;
  isError: boolean;
}

export interface ProviderStatus {
  name: string;
  roles: Role[];
  connected: boolean;
  toolCount: number;
  error?: string;
}
  • [ ] Step 2: Create shared constants
// packages/shared/src/constants.ts

export const CONFIG_FILENAME = ".infrastructure-mcp.json";
export const MCP_PROTOCOL_VERSION = "2024-11-05";
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000;
export const ORCHESTRATOR_NAME = "infrastructure-mcp";
export const ORCHESTRATOR_VERSION = "1.3.0";

export const REQUIRED_ROLES: readonly string[] = [
  "dns_registrar",
  "dns_host",
  "cdn",
  "security",
] as const;

export const OPTIONAL_ROLES: readonly string[] = [
  "app_platform",
] as const;
  • [ ] Step 3: Create barrel export
// packages/shared/src/index.ts

export * from "./types.js";
export * from "./constants.js";
  • [ ] Step 4: Verify it compiles

Run: cd packages/shared && npx tsc --noEmit

Expected: No errors.

  • [ ] Step 5: Commit
git add packages/shared/src/types.ts packages/shared/src/constants.ts packages/shared/src/index.ts
git commit -m "feat(shared): add core types, roles, and constants"

Task 3: Roles and Capability Patterns

Files: - Create: packages/shared/src/roles.ts - Modify: packages/shared/src/index.ts

  • [ ] Step 1: Create roles module
// packages/shared/src/roles.ts

import { Role } from "./types.js";

export interface RoleDefinition {
  role: Role;
  description: string;
  required: boolean;
  capabilityPatterns: string[];
}

export const ROLE_DEFINITIONS: readonly RoleDefinition[] = [
  {
    role: "dns_registrar",
    description: "Domain registration and nameserver management",
    required: true,
    capabilityPatterns: ["*list_domains*", "*get_nameservers*", "*set_nameservers*"],
  },
  {
    role: "dns_host",
    description: "DNS zones and records",
    required: true,
    capabilityPatterns: ["*list_zones*", "*create_zone*", "*get_dns*", "*create_dns*"],
  },
  {
    role: "cdn",
    description: "Caching, performance, and edge configuration",
    required: true,
    capabilityPatterns: ["*cache*", "*performance*", "*speed*"],
  },
  {
    role: "security",
    description: "WAF, SSL/TLS, bot management, and hardening",
    required: true,
    capabilityPatterns: ["*waf*", "*ssl*", "*protection*", "*firewall*"],
  },
  {
    role: "app_platform",
    description: "Application deployment and services",
    required: false,
    capabilityPatterns: ["*list_apps*", "*deploy*", "*list_domains*"],
  },
] as const;

/**
 * Match a tool name against a glob-like pattern.
 * Patterns use * as a wildcard that matches any substring.
 * Examples: "*list_zones*" matches "cloudflare_list_zones"
 */
export function matchesPattern(toolName: string, pattern: string): boolean {
  const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
  const regex = new RegExp("^" + escaped.replace(/\*/g, ".*") + "$");

  return regex.test(toolName);
}

/**
 * Suggest roles for a provider based on its tool names.
 * Returns roles where at least one tool matches at least one capability pattern.
 */
export function suggestRoles(toolNames: string[]): Role[] {
  const suggested: Role[] = [];

  for (const def of ROLE_DEFINITIONS) {
    const hasMatch = def.capabilityPatterns.some((pattern) =>
      toolNames.some((name) => matchesPattern(name, pattern))
    );

    if (hasMatch) {
      suggested.push(def.role);
    }
  }

  return suggested;
}

/**
 * Validate that a provider's tools cover the expected capabilities for its declared roles.
 * Returns warnings for any role whose capability patterns have zero matches.
 */
export function validateCapabilities(
  toolNames: string[],
  declaredRoles: Role[],
): string[] {
  const warnings: string[] = [];

  for (const role of declaredRoles) {
    const def = ROLE_DEFINITIONS.find((d) => d.role === role);

    if (!def) {
      warnings.push(`Unknown role: ${role}`);
      continue;
    }

    const unmatchedPatterns = def.capabilityPatterns.filter(
      (pattern) => !toolNames.some((name) => matchesPattern(name, pattern))
    );

    if (unmatchedPatterns.length === def.capabilityPatterns.length) {
      warnings.push(
        `Provider declares role '${role}' but no tools match any capability pattern (${def.capabilityPatterns.join(", ")})`
      );
    }
  }

  return warnings;
}
  • [ ] Step 2: Update barrel export
// packages/shared/src/index.ts

export * from "./types.js";
export * from "./constants.js";
export * from "./roles.js";
  • [ ] Step 3: Verify it compiles

Run: cd packages/shared && npx tsc --noEmit

Expected: No errors.

  • [ ] Step 4: Commit
git add packages/shared/src/roles.ts packages/shared/src/index.ts
git commit -m "feat(shared): add role definitions and capability matching"

Task 4: Roles Tests

Files: - Create: packages/shared/tests/roles.test.ts - Modify: packages/shared/package.json (add vitest)

  • [ ] Step 1: Add vitest to shared package

Update packages/shared/package.json — add to devDependencies:

"vitest": "^3.0.0"

And update scripts:

"test": "vitest run"

Run: cd /home/matt/mcp/infrastructure-mcp && npm install

  • [ ] Step 2: Write the tests
// packages/shared/tests/roles.test.ts

import { describe, it, expect } from "vitest";
import { matchesPattern, suggestRoles, validateCapabilities } from "../src/roles.js";

describe("matchesPattern", () => {
  it("matches wildcard at both ends", () => {
    expect(matchesPattern("cloudflare_list_zones", "*list_zones*")).toBe(true);
  });

  it("matches wildcard at start only", () => {
    expect(matchesPattern("my_list_zones", "*list_zones")).toBe(true);
  });

  it("matches wildcard at end only", () => {
    expect(matchesPattern("list_zones_all", "list_zones*")).toBe(true);
  });

  it("rejects non-matching name", () => {
    expect(matchesPattern("create_record", "*list_zones*")).toBe(false);
  });

  it("matches exact name with wildcards", () => {
    expect(matchesPattern("list_zones", "*list_zones*")).toBe(true);
  });
});

describe("suggestRoles", () => {
  it("suggests dns_host for zone-related tools", () => {
    const tools = ["list_zones", "create_zone", "get_dns", "create_dns"];
    const roles = suggestRoles(tools);

    expect(roles).toContain("dns_host");
  });

  it("suggests dns_registrar for domain-related tools", () => {
    const tools = ["list_domains", "get_nameservers", "set_nameservers"];
    const roles = suggestRoles(tools);

    expect(roles).toContain("dns_registrar");
  });

  it("suggests multiple roles when tools span categories", () => {
    const tools = [
      "list_zones", "create_zone", "get_dns", "create_dns",
      "get_waf_rules", "update_ssl",
      "cache_purge",
    ];
    const roles = suggestRoles(tools);

    expect(roles).toContain("dns_host");
    expect(roles).toContain("security");
    expect(roles).toContain("cdn");
  });

  it("returns empty array for unrecognized tools", () => {
    const roles = suggestRoles(["do_something", "other_thing"]);

    expect(roles).toEqual([]);
  });

  it("suggests app_platform for deploy tools", () => {
    const roles = suggestRoles(["list_apps", "deploy_app"]);

    expect(roles).toContain("app_platform");
  });
});

describe("validateCapabilities", () => {
  it("returns no warnings when all patterns match", () => {
    const tools = ["list_zones", "create_zone", "get_dns", "create_dns"];
    const warnings = validateCapabilities(tools, ["dns_host"]);

    expect(warnings).toEqual([]);
  });

  it("warns when no patterns match for a declared role", () => {
    const tools = ["unrelated_tool"];
    const warnings = validateCapabilities(tools, ["dns_host"]);

    expect(warnings).toHaveLength(1);
    expect(warnings[0]).toContain("dns_host");
    expect(warnings[0]).toContain("no tools match");
  });

  it("does not warn when at least one pattern matches", () => {
    const tools = ["list_zones"];
    const warnings = validateCapabilities(tools, ["dns_host"]);

    expect(warnings).toEqual([]);
  });

  it("warns about unknown roles", () => {
    const warnings = validateCapabilities([], ["not_a_role" as any]);

    expect(warnings).toHaveLength(1);
    expect(warnings[0]).toContain("Unknown role");
  });
});
  • [ ] Step 3: Run the tests

Run: cd packages/shared && npx vitest run

Expected: All tests pass.

  • [ ] Step 4: Commit
git add packages/shared/tests/roles.test.ts packages/shared/package.json
git commit -m "test(shared): add role matching and capability validation tests"

Task 5: Config Schema and v1.2 Migration

Files: - Create: packages/shared/src/config-schema.ts - Create: packages/shared/tests/config-schema.test.ts - Modify: packages/shared/src/index.ts

  • [ ] Step 1: Write the config-schema tests
// packages/shared/tests/config-schema.test.ts

import { describe, it, expect } from "vitest";
import {
  isLegacyConfig,
  migrateLegacyConfig,
  validateConfig,
} from "../src/config-schema.js";
import { InfraConfig, LegacyConfig } from "../src/types.js";

describe("isLegacyConfig", () => {
  it("detects v1.2 format with jarPath and flat env", () => {
    const legacy = {
      jarPath: "/path/to/server.jar",
      env: { CLOUDFLARE_API_KEY: "key" },
      experienceLevel: "professional",
    };

    expect(isLegacyConfig(legacy)).toBe(true);
  });

  it("rejects v1.3 format with providers array", () => {
    const modern: InfraConfig = {
      providers: [],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    expect(isLegacyConfig(modern)).toBe(false);
  });

  it("rejects null and non-objects", () => {
    expect(isLegacyConfig(null)).toBe(false);
    expect(isLegacyConfig("string")).toBe(false);
    expect(isLegacyConfig(42)).toBe(false);
  });
});

describe("migrateLegacyConfig", () => {
  it("creates cloudflare provider from CF env vars (global key)", () => {
    const legacy: LegacyConfig = {
      jarPath: "/path/to/jar",
      env: {
        CLOUDFLARE_API_KEY: "cfkey",
        CLOUDFLARE_EMAIL: "[email protected]",
        CLOUDFLARE_ACCOUNT_ID: "acc123",
        NAMECHEAP_API_USER: "ncuser",
        NAMECHEAP_API_KEY: "nckey",
        NAMECHEAP_CLIENT_IP: "1.2.3.4",
      },
      experienceLevel: "professional",
    };

    const result = migrateLegacyConfig(legacy);

    expect(result.providers).toHaveLength(2);

    const cf = result.providers.find((p) => p.name === "cloudflare");
    expect(cf).toBeDefined();
    expect(cf!.roles).toEqual(["dns_host", "cdn", "security"]);
    expect(cf!.env).toEqual({
      CLOUDFLARE_API_KEY: "cfkey",
      CLOUDFLARE_EMAIL: "[email protected]",
      CLOUDFLARE_ACCOUNT_ID: "acc123",
    });

    const nc = result.providers.find((p) => p.name === "namecheap");
    expect(nc).toBeDefined();
    expect(nc!.roles).toEqual(["dns_registrar"]);
    expect(nc!.env).toEqual({
      NAMECHEAP_API_USER: "ncuser",
      NAMECHEAP_API_KEY: "nckey",
      NAMECHEAP_CLIENT_IP: "1.2.3.4",
    });
  });

  it("creates cloudflare provider from CF env vars (api token)", () => {
    const legacy: LegacyConfig = {
      jarPath: "/path/to/jar",
      env: {
        CLOUDFLARE_API_TOKEN: "token123",
        CLOUDFLARE_ACCOUNT_ID: "acc123",
      },
      experienceLevel: "comfortable",
    };

    const result = migrateLegacyConfig(legacy);
    const cf = result.providers.find((p) => p.name === "cloudflare");

    expect(cf!.env).toEqual({
      CLOUDFLARE_API_TOKEN: "token123",
      CLOUDFLARE_ACCOUNT_ID: "acc123",
    });
  });

  it("creates optional fleet provider from FLEET env vars", () => {
    const legacy: LegacyConfig = {
      jarPath: "/path/to/jar",
      env: {
        FLEET_REGISTRY_PATH: "/fleet/registry.json",
        FLEET_BINARY: "/usr/bin/fleet",
      },
      experienceLevel: "professional",
    };

    const result = migrateLegacyConfig(legacy);
    const fleet = result.providers.find((p) => p.name === "fleet");

    expect(fleet).toBeDefined();
    expect(fleet!.roles).toEqual(["app_platform"]);
    expect(fleet!.optional).toBe(true);
  });

  it("preserves experience level in tui settings", () => {
    const legacy: LegacyConfig = {
      jarPath: "",
      env: {},
      experienceLevel: "learner",
    };

    const result = migrateLegacyConfig(legacy);

    expect(result.tui.experienceLevel).toBe("learner");
  });

  it("sets default workflows as enabled", () => {
    const legacy: LegacyConfig = {
      jarPath: "",
      env: {},
      experienceLevel: "professional",
    };

    const result = migrateLegacyConfig(legacy);

    expect(result.workflows.onboard_domain).toEqual({ enabled: true });
    expect(result.workflows.migrate_dns).toEqual({ enabled: true });
    expect(result.workflows.apply_protection).toEqual({ enabled: true });
  });
});

describe("validateConfig", () => {
  function validConfig(): InfraConfig {
    return {
      providers: [
        {
          name: "cloudflare",
          command: "java",
          args: ["-jar", "/path/to/cf.jar"],
          roles: ["dns_host", "cdn", "security"],
        },
        {
          name: "namecheap",
          command: "java",
          args: ["-jar", "/path/to/nc.jar"],
          roles: ["dns_registrar"],
        },
      ],
      workflows: { onboard_domain: { enabled: true } },
      tui: { experienceLevel: "professional" },
    };
  }

  it("accepts valid config with all required roles filled", () => {
    const errors = validateConfig(validConfig());

    expect(errors).toEqual([]);
  });

  it("rejects config with missing required role", () => {
    const config = validConfig();
    config.providers = config.providers.filter((p) => p.name !== "namecheap");

    const errors = validateConfig(config);

    expect(errors.length).toBeGreaterThan(0);
    expect(errors.some((e) => e.includes("dns_registrar"))).toBe(true);
  });

  it("rejects config with duplicate role across providers", () => {
    const config = validConfig();
    config.providers.push({
      name: "other-dns",
      command: "other",
      roles: ["dns_host"],
    });

    const errors = validateConfig(config);

    expect(errors.length).toBeGreaterThan(0);
    expect(errors.some((e) => e.includes("dns_host"))).toBe(true);
  });

  it("rejects config with duplicate provider names", () => {
    const config = validConfig();
    config.providers.push({
      name: "cloudflare",
      command: "other",
      roles: ["app_platform"],
    });

    const errors = validateConfig(config);

    expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
  });

  it("allows missing app_platform (optional role)", () => {
    const config = validConfig();
    // No app_platform provider — should be fine
    const errors = validateConfig(config);

    expect(errors).toEqual([]);
  });

  it("rejects config with empty providers array", () => {
    const config = validConfig();
    config.providers = [];

    const errors = validateConfig(config);

    expect(errors.length).toBeGreaterThan(0);
  });

  it("rejects provider with no command", () => {
    const config = validConfig();
    config.providers[0].command = "";

    const errors = validateConfig(config);

    expect(errors.some((e) => e.includes("command"))).toBe(true);
  });

  it("rejects provider with no roles", () => {
    const config = validConfig();
    config.providers[0].roles = [];

    const errors = validateConfig(config);

    expect(errors.some((e) => e.includes("roles"))).toBe(true);
  });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: cd packages/shared && npx vitest run

Expected: FAIL — config-schema.js does not exist.

  • [ ] Step 3: Implement config-schema.ts
// packages/shared/src/config-schema.ts

import { InfraConfig, LegacyConfig, ProviderConfig } from "./types.js";
import { REQUIRED_ROLES } from "./constants.js";

/**
 * Detect whether a parsed config object is the v1.2 legacy format.
 */
export function isLegacyConfig(obj: unknown): obj is LegacyConfig {
  if (obj === null || typeof obj !== "object") {
    return false;
  }

  const record = obj as Record<string, unknown>;

  return "jarPath" in record && "env" in record && !("providers" in record);
}

/**
 * Migrate a v1.2 legacy config to the v1.3 format.
 */
export function migrateLegacyConfig(legacy: LegacyConfig): InfraConfig {
  const providers: ProviderConfig[] = [];
  const env = legacy.env;

  // Cloudflare provider
  const cfEnv: Record<string, string> = {};
  const cfKeys = [
    "CLOUDFLARE_API_KEY",
    "CLOUDFLARE_EMAIL",
    "CLOUDFLARE_API_TOKEN",
    "CLOUDFLARE_ACCOUNT_ID",
  ];

  for (const key of cfKeys) {
    if (env[key]) {
      cfEnv[key] = env[key];
    }
  }

  if (Object.keys(cfEnv).length > 0) {
    providers.push({
      name: "cloudflare",
      command: "java",
      args: ["-jar", legacy.jarPath],
      env: cfEnv,
      roles: ["dns_host", "cdn", "security"],
    });
  }

  // Namecheap provider
  const ncEnv: Record<string, string> = {};
  const ncKeys = ["NAMECHEAP_API_USER", "NAMECHEAP_API_KEY", "NAMECHEAP_CLIENT_IP"];

  for (const key of ncKeys) {
    if (env[key]) {
      ncEnv[key] = env[key];
    }
  }

  if (Object.keys(ncEnv).length > 0) {
    providers.push({
      name: "namecheap",
      command: "java",
      args: ["-jar", legacy.jarPath],
      env: ncEnv,
      roles: ["dns_registrar"],
    });
  }

  // Fleet provider (optional)
  const fleetEnv: Record<string, string> = {};
  const fleetKeys = ["FLEET_REGISTRY_PATH", "FLEET_BINARY"];

  for (const key of fleetKeys) {
    if (env[key]) {
      fleetEnv[key] = env[key];
    }
  }

  if (Object.keys(fleetEnv).length > 0) {
    providers.push({
      name: "fleet",
      command: "fleet-mcp",
      env: fleetEnv,
      roles: ["app_platform"],
      optional: true,
    });
  }

  return {
    providers,
    workflows: {
      onboard_domain: { enabled: true },
      migrate_dns: { enabled: true },
      apply_protection: { enabled: true },
    },
    tui: {
      experienceLevel: legacy.experienceLevel,
    },
  };
}

/**
 * Validate a v1.3 config. Returns an array of error messages (empty = valid).
 */
export function validateConfig(config: InfraConfig): string[] {
  const errors: string[] = [];

  if (!config.providers || config.providers.length === 0) {
    errors.push("No providers configured");
    return errors;
  }

  // Check for duplicate provider names
  const names = new Set<string>();
  for (const provider of config.providers) {
    if (names.has(provider.name)) {
      errors.push(`Duplicate provider name: '${provider.name}'`);
    }
    names.add(provider.name);
  }

  // Check each provider has command and roles
  for (const provider of config.providers) {
    if (!provider.command) {
      errors.push(`Provider '${provider.name}' has no command`);
    }
    if (!provider.roles || provider.roles.length === 0) {
      errors.push(`Provider '${provider.name}' has no roles`);
    }
  }

  // Check required roles are filled
  const filledRoles = new Map<string, string>();

  for (const provider of config.providers) {
    for (const role of provider.roles) {
      if (filledRoles.has(role)) {
        errors.push(
          `Role '${role}' is filled by both '${filledRoles.get(role)}' and '${provider.name}'`
        );
      } else {
        filledRoles.set(role, provider.name);
      }
    }
  }

  for (const role of REQUIRED_ROLES) {
    if (!filledRoles.has(role)) {
      errors.push(`Required role '${role}' is not filled by any provider`);
    }
  }

  return errors;
}
  • [ ] Step 4: Update barrel export
// packages/shared/src/index.ts

export * from "./types.js";
export * from "./constants.js";
export * from "./roles.js";
export * from "./config-schema.js";
  • [ ] Step 5: Run the tests

Run: cd packages/shared && npx vitest run

Expected: All tests pass.

  • [ ] Step 6: Commit
git add packages/shared/src/config-schema.ts packages/shared/src/index.ts packages/shared/tests/config-schema.test.ts
git commit -m "feat(shared): add config validation and v1.2 migration"

Task 6: Orchestrator MCP Client

Files: - Create: packages/orchestrator/src/mcp-client.ts - Create: packages/orchestrator/tests/mcp-client.test.ts

This is the module that spawns a single child MCP server, connects via stdio, and sends/receives JSON-RPC messages. Very similar to the existing tui/src/mcp-client.ts but generalized — no JAR-specific logic, returns tool list, and exposes provider name.

  • [ ] Step 1: Write the tests
// packages/orchestrator/tests/mcp-client.test.ts

import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "events";

vi.mock("child_process", () => ({
  spawn: vi.fn(),
}));

import { spawn } from "child_process";
import { createProviderClient } from "../src/mcp-client.js";

function makeFakeProcess() {
  const stdin = { write: vi.fn() };
  const stdout = new EventEmitter() as any;
  const proc = new EventEmitter() as any;
  proc.stdin = stdin;
  proc.stdout = stdout;
  proc.stderr = new EventEmitter();
  proc.kill = vi.fn();
  return proc;
}

function sendResponse(proc: any, response: object) {
  const json = JSON.stringify(response);
  const msg = `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`;
  proc.stdout.emit("data", Buffer.from(msg));
}

function getLastRequest(proc: any): any {
  const calls = (proc.stdin.write as any).mock.calls;
  const raw = calls[calls.length - 1][0] as string;
  return JSON.parse(raw.split("\r\n\r\n")[1]);
}

beforeEach(() => {
  vi.clearAllMocks();
});

describe("createProviderClient", () => {
  it("spawns the given command with args and env", async () => {
    const fakeProc = makeFakeProcess();
    vi.mocked(spawn).mockReturnValue(fakeProc as any);

    const client = createProviderClient({
      name: "test-provider",
      command: "my-server",
      args: ["--flag"],
      env: { API_KEY: "secret" },
      roles: ["dns_host"],
    });

    const connectPromise = client.connect();

    const initReq = getLastRequest(fakeProc);
    sendResponse(fakeProc, {
      jsonrpc: "2.0",
      id: initReq.id,
      result: { protocolVersion: "2024-11-05", capabilities: {} },
    });

    await connectPromise;

    expect(spawn).toHaveBeenCalledWith(
      "my-server",
      ["--flag"],
      expect.objectContaining({
        env: expect.objectContaining({ API_KEY: "secret" }),
      })
    );
  });

  it("sends initialize on connect with orchestrator client info", async () => {
    const fakeProc = makeFakeProcess();
    vi.mocked(spawn).mockReturnValue(fakeProc as any);

    const client = createProviderClient({
      name: "test",
      command: "cmd",
      roles: ["dns_host"],
    });

    const connectPromise = client.connect();

    const msg = getLastRequest(fakeProc);
    expect(msg.method).toBe("initialize");
    expect(msg.params.clientInfo.name).toBe("infrastructure-mcp");

    sendResponse(fakeProc, {
      jsonrpc: "2.0",
      id: msg.id,
      result: { protocolVersion: "2024-11-05", capabilities: {} },
    });

    await connectPromise;
  });

  it("listTools returns tool names from provider", async () => {
    const fakeProc = makeFakeProcess();
    vi.mocked(spawn).mockReturnValue(fakeProc as any);

    const client = createProviderClient({
      name: "cf",
      command: "cmd",
      roles: ["dns_host"],
    });

    const connectPromise = client.connect();
    sendResponse(fakeProc, {
      jsonrpc: "2.0",
      id: getLastRequest(fakeProc).id,
      result: { protocolVersion: "2024-11-05", capabilities: {} },
    });
    await connectPromise;

    const toolsPromise = client.listTools();
    sendResponse(fakeProc, {
      jsonrpc: "2.0",
      id: getLastRequest(fakeProc).id,
      result: {
        tools: [
          { name: "list_zones", description: "List zones" },
          { name: "create_zone", description: "Create zone" },
        ],
      },
    });

    const tools = await toolsPromise;
    expect(tools).toHaveLength(2);
    expect(tools[0].name).toBe("list_zones");
    expect(tools[1].name).toBe("create_zone");
  });

  it("callTool sends request and returns result", async () => {
    const fakeProc = makeFakeProcess();
    vi.mocked(spawn).mockReturnValue(fakeProc as any);

    const client = createProviderClient({
      name: "cf",
      command: "cmd",
      roles: ["dns_host"],
    });

    const connectPromise = client.connect();
    sendResponse(fakeProc, {
      jsonrpc: "2.0",
      id: getLastRequest(fakeProc).id,
      result: { protocolVersion: "2024-11-05", capabilities: {} },
    });
    await connectPromise;

    const toolPromise = client.callTool("list_zones", { page: 1 });
    const toolReq = getLastRequest(fakeProc);

    expect(toolReq.method).toBe("tools/call");
    expect(toolReq.params.name).toBe("list_zones");
    expect(toolReq.params.arguments).toEqual({ page: 1 });

    sendResponse(fakeProc, {
      jsonrpc: "2.0",
      id: toolReq.id,
      result: {
        content: [{ type: "text", text: "zone data" }],
        isError: false,
      },
    });

    const result = await toolPromise;
    expect(result.content).toBe("zone data");
    expect(result.isError).toBe(false);
  });

  it("disconnect kills the process", async () => {
    const fakeProc = makeFakeProcess();
    vi.mocked(spawn).mockReturnValue(fakeProc as any);

    const client = createProviderClient({
      name: "cf",
      command: "cmd",
      roles: ["dns_host"],
    });

    const connectPromise = client.connect();
    sendResponse(fakeProc, {
      jsonrpc: "2.0",
      id: getLastRequest(fakeProc).id,
      result: { protocolVersion: "2024-11-05", capabilities: {} },
    });
    await connectPromise;

    expect(client.isConnected()).toBe(true);

    await client.disconnect();

    expect(fakeProc.kill).toHaveBeenCalled();
    expect(client.isConnected()).toBe(false);
  });

  it("exposes provider name", () => {
    const fakeProc = makeFakeProcess();
    vi.mocked(spawn).mockReturnValue(fakeProc as any);

    const client = createProviderClient({
      name: "my-provider",
      command: "cmd",
      roles: ["dns_host"],
    });

    expect(client.name).toBe("my-provider");
  });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: cd packages/orchestrator && npx vitest run tests/mcp-client.test.ts

Expected: FAIL — module does not exist.

  • [ ] Step 3: Implement mcp-client.ts
// packages/orchestrator/src/mcp-client.ts

import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import {
  ProviderConfig,
  ToolInfo,
  ToolResult,
  MCP_PROTOCOL_VERSION,
  ORCHESTRATOR_NAME,
  ORCHESTRATOR_VERSION,
} from "@infrastructure-mcp/shared";

interface PendingRequest {
  resolve: (value: unknown) => void;
  reject: (reason: unknown) => void;
}

interface ContentItem {
  type: string;
  text?: string;
}

interface CallToolRpcResult {
  content: ContentItem[];
  isError: boolean;
}

export interface ProviderClient {
  readonly name: string;
  connect(): Promise<void>;
  listTools(): Promise<ToolInfo[]>;
  callTool(name: string, args?: Record<string, unknown>): Promise<ToolResult>;
  isConnected(): boolean;
  disconnect(): Promise<void>;
}

function buildMessage(obj: object): string {
  const json = JSON.stringify(obj);
  const length = Buffer.byteLength(json);
  return `Content-Length: ${length}\r\n\r\n${json}`;
}

export function createProviderClient(config: ProviderConfig): ProviderClient {
  let proc: ChildProcessWithoutNullStreams | null = null;
  let connected = false;
  let nextId = 1;
  let buffer = Buffer.alloc(0);

  const pending = new Map<number, PendingRequest>();

  function rejectAll(reason: Error): void {
    for (const [, { reject }] of pending) {
      reject(reason);
    }
    pending.clear();
  }

  function processBuffer(): void {
    while (true) {
      const separatorIdx = buffer.indexOf("\r\n\r\n");
      if (separatorIdx === -1) break;

      const header = buffer.subarray(0, separatorIdx).toString("utf8");
      const match = header.match(/Content-Length:\s*(\d+)/i);

      if (!match) {
        buffer = buffer.subarray(separatorIdx + 4);
        continue;
      }

      const contentLength = parseInt(match[1], 10);
      const bodyStart = separatorIdx + 4;

      if (buffer.length < bodyStart + contentLength) break;

      const body = buffer.subarray(bodyStart, bodyStart + contentLength).toString("utf8");
      buffer = buffer.subarray(bodyStart + contentLength);

      let message: any;
      try {
        message = JSON.parse(body);
      } catch {
        continue;
      }

      if (message.id !== undefined && pending.has(message.id)) {
        const { resolve, reject } = pending.get(message.id)!;
        pending.delete(message.id);

        if (message.error) {
          reject(new Error(message.error.message ?? "RPC error"));
        } else {
          resolve(message.result);
        }
      }
    }
  }

  function sendRequest<T>(method: string, params: object): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      if (!proc) {
        reject(new Error("Not connected"));
        return;
      }

      const id = nextId++;
      pending.set(id, { resolve: (v: unknown) => resolve(v as T), reject });
      proc.stdin.write(buildMessage({ jsonrpc: "2.0", id, method, params }));
    });
  }

  return {
    name: config.name,

    async connect(): Promise<void> {
      return new Promise((resolve, reject) => {
        const mergedEnv = { ...process.env, ...(config.env ?? {}) };

        proc = spawn(config.command, config.args ?? [], {
          env: mergedEnv,
          stdio: ["pipe", "pipe", "pipe"],
        }) as unknown as ChildProcessWithoutNullStreams;

        proc.stdout.on("data", (chunk: Buffer) => {
          buffer = Buffer.concat([buffer, chunk]);
          processBuffer();
        });

        proc.stderr.on("data", () => {
          // Ignore stderr from provider
        });

        proc.on("error", (err: Error) => {
          connected = false;
          rejectAll(err);
          reject(err);
        });

        proc.on("exit", () => {
          connected = false;
          rejectAll(new Error(`Provider '${config.name}' process exited`));
        });

        const id = nextId++;
        pending.set(id, {
          resolve: () => {
            connected = true;
            resolve();
          },
          reject: (err: unknown) => reject(err),
        });

        proc.stdin.write(
          buildMessage({
            jsonrpc: "2.0",
            id,
            method: "initialize",
            params: {
              protocolVersion: MCP_PROTOCOL_VERSION,
              clientInfo: { name: ORCHESTRATOR_NAME, version: ORCHESTRATOR_VERSION },
              capabilities: {},
            },
          })
        );
      });
    },

    async listTools(): Promise<ToolInfo[]> {
      const result = (await sendRequest("tools/list", {})) as any;
      const tools: Array<{ name: string; description?: string }> = result?.tools ?? [];

      return tools.map((t) => ({
        name: t.name,
        description: t.description ?? "",
      }));
    },

    async callTool(
      name: string,
      args: Record<string, unknown> = {},
    ): Promise<ToolResult> {
      const result = await sendRequest<CallToolRpcResult>("tools/call", {
        name,
        arguments: args,
      });

      const items: ContentItem[] = result?.content ?? [];
      const content = items
        .filter((item) => item.type === "text" && item.text != null)
        .map((item) => item.text!)
        .join("\n");

      return { content, isError: result?.isError === true };
    },

    isConnected(): boolean {
      return connected;
    },

    async disconnect(): Promise<void> {
      connected = false;
      rejectAll(new Error("Client disconnected"));

      if (proc) {
        proc.kill();
        proc = null;
      }
    },
  };
}
  • [ ] Step 4: Run the tests

Run: cd packages/orchestrator && npx vitest run tests/mcp-client.test.ts

Expected: All tests pass.

  • [ ] Step 5: Commit
git add packages/orchestrator/src/mcp-client.ts packages/orchestrator/tests/mcp-client.test.ts
git commit -m "feat(orchestrator): add MCP client for spawning provider servers"

Task 7: Router — Tool Namespacing and Role Aliasing

Files: - Create: packages/orchestrator/src/router.ts - Create: packages/orchestrator/tests/router.test.ts

The router takes tools from multiple providers, namespaces them, creates role aliases, and routes calls to the correct provider.

  • [ ] Step 1: Write the tests
// packages/orchestrator/tests/router.test.ts

import { describe, it, expect } from "vitest";
import { ToolRouter } from "../src/router.js";
import { ToolInfo } from "@infrastructure-mcp/shared";

describe("ToolRouter", () => {
  function makeRouter() {
    const router = new ToolRouter();

    router.registerProvider("cloudflare", ["dns_host", "cdn", "security"], [
      { name: "list_zones", description: "List zones" },
      { name: "create_zone", description: "Create zone" },
      { name: "get_protection_status", description: "Get protection status" },
    ]);

    router.registerProvider("namecheap", ["dns_registrar"], [
      { name: "list_domains", description: "List domains" },
      { name: "get_nameservers", description: "Get nameservers" },
    ]);

    return router;
  }

  describe("getAllTools", () => {
    it("returns provider-namespaced tools", () => {
      const router = makeRouter();
      const tools = router.getAllTools();
      const names = tools.map((t) => t.name);

      expect(names).toContain("cloudflare.list_zones");
      expect(names).toContain("cloudflare.create_zone");
      expect(names).toContain("namecheap.list_domains");
    });

    it("returns role-aliased tools", () => {
      const router = makeRouter();
      const tools = router.getAllTools();
      const names = tools.map((t) => t.name);

      expect(names).toContain("dns_host.list_zones");
      expect(names).toContain("dns_host.create_zone");
      expect(names).toContain("dns_registrar.list_domains");
    });

    it("does not duplicate when provider name equals role name", () => {
      const router = new ToolRouter();
      router.registerProvider("dns_host", ["dns_host"], [
        { name: "list_zones", description: "List zones" },
      ]);

      const tools = router.getAllTools();
      const names = tools.map((t) => t.name);
      const occurrences = names.filter((n) => n === "dns_host.list_zones");

      expect(occurrences).toHaveLength(1);
    });
  });

  describe("resolve", () => {
    it("resolves provider-namespaced tool to provider and original name", () => {
      const router = makeRouter();
      const result = router.resolve("cloudflare.list_zones");

      expect(result).toEqual({ provider: "cloudflare", toolName: "list_zones" });
    });

    it("resolves role-aliased tool to provider and original name", () => {
      const router = makeRouter();
      const result = router.resolve("dns_host.list_zones");

      expect(result).toEqual({ provider: "cloudflare", toolName: "list_zones" });
    });

    it("returns null for unknown tool", () => {
      const router = makeRouter();
      const result = router.resolve("unknown.tool");

      expect(result).toBeNull();
    });

    it("returns null for tool without namespace", () => {
      const router = makeRouter();
      const result = router.resolve("list_zones");

      expect(result).toBeNull();
    });
  });

  describe("getProviderForRole", () => {
    it("returns provider name for a filled role", () => {
      const router = makeRouter();

      expect(router.getProviderForRole("dns_host")).toBe("cloudflare");
      expect(router.getProviderForRole("dns_registrar")).toBe("namecheap");
    });

    it("returns null for unfilled role", () => {
      const router = makeRouter();

      expect(router.getProviderForRole("app_platform")).toBeNull();
    });
  });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: cd packages/orchestrator && npx vitest run tests/router.test.ts

Expected: FAIL — module does not exist.

  • [ ] Step 3: Implement router.ts
// packages/orchestrator/src/router.ts

import { Role, ToolInfo } from "@infrastructure-mcp/shared";

export interface ResolvedTool {
  provider: string;
  toolName: string;
}

export class ToolRouter {
  /** Map from namespaced tool name -> { provider, originalToolName } */
  private routes = new Map<string, ResolvedTool>();

  /** Map from role -> provider name */
  private roleMap = new Map<string, string>();

  /** All exposed tools (namespaced + role-aliased) */
  private tools: ToolInfo[] = [];

  registerProvider(providerName: string, roles: Role[], tools: ToolInfo[]): void {
    for (const role of roles) {
      this.roleMap.set(role, providerName);
    }

    for (const tool of tools) {
      // Provider-namespaced: cloudflare.list_zones
      const providerKey = `${providerName}.${tool.name}`;

      if (!this.routes.has(providerKey)) {
        this.routes.set(providerKey, { provider: providerName, toolName: tool.name });
        this.tools.push({
          name: providerKey,
          description: `[${providerName}] ${tool.description}`,
        });
      }

      // Role-aliased: dns_host.list_zones
      for (const role of roles) {
        const roleKey = `${role}.${tool.name}`;

        // Skip if role name is the same as provider name (avoid duplicates)
        if (roleKey === providerKey) continue;

        if (!this.routes.has(roleKey)) {
          this.routes.set(roleKey, { provider: providerName, toolName: tool.name });
          this.tools.push({
            name: roleKey,
            description: `[${role}] ${tool.description}`,
          });
        }
      }
    }
  }

  getAllTools(): ToolInfo[] {
    return this.tools;
  }

  resolve(namespacedName: string): ResolvedTool | null {
    return this.routes.get(namespacedName) ?? null;
  }

  getProviderForRole(role: string): string | null {
    return this.roleMap.get(role) ?? null;
  }
}
  • [ ] Step 4: Run the tests

Run: cd packages/orchestrator && npx vitest run tests/router.test.ts

Expected: All tests pass.

  • [ ] Step 5: Commit
git add packages/orchestrator/src/router.ts packages/orchestrator/tests/router.test.ts
git commit -m "feat(orchestrator): add tool router with namespacing and role aliasing"

Task 8: Proxy — Provider Lifecycle Manager

Files: - Create: packages/orchestrator/src/proxy.ts - Create: packages/orchestrator/tests/proxy.test.ts

The proxy manages multiple provider clients: starts them, connects, registers tools in the router, and dispatches calls.

  • [ ] Step 1: Write the tests
// packages/orchestrator/tests/proxy.test.ts

import { describe, it, expect, vi, beforeEach } from "vitest";
import { ProviderProxy } from "../src/proxy.js";
import { ProviderClient } from "../src/mcp-client.js";
import { InfraConfig } from "@infrastructure-mcp/shared";

function mockClient(name: string, tools: Array<{ name: string; description: string }>): ProviderClient {
  return {
    name,
    connect: vi.fn().mockResolvedValue(undefined),
    listTools: vi.fn().mockResolvedValue(tools),
    callTool: vi.fn().mockResolvedValue({ content: "ok", isError: false }),
    isConnected: vi.fn().mockReturnValue(true),
    disconnect: vi.fn().mockResolvedValue(undefined),
  };
}

describe("ProviderProxy", () => {
  it("starts all providers and registers tools", async () => {
    const cfClient = mockClient("cloudflare", [
      { name: "list_zones", description: "List zones" },
    ]);
    const ncClient = mockClient("namecheap", [
      { name: "list_domains", description: "List domains" },
    ]);

    const clientFactory = vi.fn()
      .mockReturnValueOnce(cfClient)
      .mockReturnValueOnce(ncClient);

    const config: InfraConfig = {
      providers: [
        { name: "cloudflare", command: "cmd", roles: ["dns_host", "cdn", "security"] },
        { name: "namecheap", command: "cmd", roles: ["dns_registrar"] },
      ],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    const proxy = new ProviderProxy(config, clientFactory);
    await proxy.startAll();

    expect(cfClient.connect).toHaveBeenCalled();
    expect(ncClient.connect).toHaveBeenCalled();
    expect(cfClient.listTools).toHaveBeenCalled();
    expect(ncClient.listTools).toHaveBeenCalled();

    const tools = proxy.getAllTools();
    const names = tools.map((t) => t.name);

    expect(names).toContain("cloudflare.list_zones");
    expect(names).toContain("namecheap.list_domains");
  });

  it("routes tool calls to correct provider", async () => {
    const cfClient = mockClient("cloudflare", [
      { name: "list_zones", description: "List zones" },
    ]);

    const clientFactory = vi.fn().mockReturnValue(cfClient);

    const config: InfraConfig = {
      providers: [
        { name: "cloudflare", command: "cmd", roles: ["dns_host", "cdn", "security"] },
      ],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    const proxy = new ProviderProxy(config, clientFactory);
    await proxy.startAll();

    await proxy.callTool("cloudflare.list_zones", { page: 1 });

    expect(cfClient.callTool).toHaveBeenCalledWith("list_zones", { page: 1 });
  });

  it("routes role-aliased tool calls to correct provider", async () => {
    const cfClient = mockClient("cloudflare", [
      { name: "list_zones", description: "List zones" },
    ]);

    const clientFactory = vi.fn().mockReturnValue(cfClient);

    const config: InfraConfig = {
      providers: [
        { name: "cloudflare", command: "cmd", roles: ["dns_host", "cdn", "security"] },
      ],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    const proxy = new ProviderProxy(config, clientFactory);
    await proxy.startAll();

    await proxy.callTool("dns_host.list_zones", {});

    expect(cfClient.callTool).toHaveBeenCalledWith("list_zones", {});
  });

  it("returns error for unknown tool", async () => {
    const clientFactory = vi.fn().mockReturnValue(
      mockClient("cf", [{ name: "list_zones", description: "List" }])
    );

    const config: InfraConfig = {
      providers: [{ name: "cf", command: "cmd", roles: ["dns_host", "cdn", "security"] }],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    const proxy = new ProviderProxy(config, clientFactory);
    await proxy.startAll();

    const result = await proxy.callTool("unknown.tool", {});

    expect(result.isError).toBe(true);
    expect(result.content).toContain("unknown.tool");
  });

  it("skips optional providers that fail to connect", async () => {
    const failingClient: ProviderClient = {
      name: "fleet",
      connect: vi.fn().mockRejectedValue(new Error("spawn failed")),
      listTools: vi.fn(),
      callTool: vi.fn(),
      isConnected: vi.fn().mockReturnValue(false),
      disconnect: vi.fn(),
    };

    const goodClient = mockClient("cloudflare", [
      { name: "list_zones", description: "List zones" },
    ]);

    const clientFactory = vi.fn()
      .mockReturnValueOnce(goodClient)
      .mockReturnValueOnce(failingClient);

    const config: InfraConfig = {
      providers: [
        { name: "cloudflare", command: "cmd", roles: ["dns_host", "cdn", "security"] },
        { name: "fleet", command: "cmd", roles: ["app_platform"], optional: true },
      ],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    const proxy = new ProviderProxy(config, clientFactory);
    await proxy.startAll();

    const tools = proxy.getAllTools();
    const names = tools.map((t) => t.name);

    expect(names).toContain("cloudflare.list_zones");
    expect(names).not.toContain("fleet.");
  });

  it("throws when required provider fails to connect", async () => {
    const failingClient: ProviderClient = {
      name: "cloudflare",
      connect: vi.fn().mockRejectedValue(new Error("spawn failed")),
      listTools: vi.fn(),
      callTool: vi.fn(),
      isConnected: vi.fn().mockReturnValue(false),
      disconnect: vi.fn(),
    };

    const clientFactory = vi.fn().mockReturnValue(failingClient);

    const config: InfraConfig = {
      providers: [
        { name: "cloudflare", command: "cmd", roles: ["dns_host", "cdn", "security"] },
      ],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    const proxy = new ProviderProxy(config, clientFactory);

    await expect(proxy.startAll()).rejects.toThrow("cloudflare");
  });

  it("returns provider statuses", async () => {
    const cfClient = mockClient("cloudflare", [
      { name: "list_zones", description: "List zones" },
    ]);

    const clientFactory = vi.fn().mockReturnValue(cfClient);

    const config: InfraConfig = {
      providers: [
        { name: "cloudflare", command: "cmd", roles: ["dns_host", "cdn", "security"] },
      ],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    const proxy = new ProviderProxy(config, clientFactory);
    await proxy.startAll();

    const statuses = proxy.getStatuses();

    expect(statuses).toHaveLength(1);
    expect(statuses[0].name).toBe("cloudflare");
    expect(statuses[0].connected).toBe(true);
    expect(statuses[0].toolCount).toBe(1);
    expect(statuses[0].roles).toEqual(["dns_host", "cdn", "security"]);
  });

  it("disconnects all providers on shutdown", async () => {
    const cfClient = mockClient("cloudflare", []);
    const ncClient = mockClient("namecheap", []);

    const clientFactory = vi.fn()
      .mockReturnValueOnce(cfClient)
      .mockReturnValueOnce(ncClient);

    const config: InfraConfig = {
      providers: [
        { name: "cloudflare", command: "cmd", roles: ["dns_host", "cdn", "security"] },
        { name: "namecheap", command: "cmd", roles: ["dns_registrar"] },
      ],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    const proxy = new ProviderProxy(config, clientFactory);
    await proxy.startAll();
    await proxy.shutdownAll();

    expect(cfClient.disconnect).toHaveBeenCalled();
    expect(ncClient.disconnect).toHaveBeenCalled();
  });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: cd packages/orchestrator && npx vitest run tests/proxy.test.ts

Expected: FAIL — module does not exist.

  • [ ] Step 3: Implement proxy.ts
// packages/orchestrator/src/proxy.ts

import {
  InfraConfig,
  ProviderConfig,
  ProviderStatus,
  Role,
  ToolInfo,
  ToolResult,
} from "@infrastructure-mcp/shared";
import { validateCapabilities } from "@infrastructure-mcp/shared";
import { ProviderClient, createProviderClient } from "./mcp-client.js";
import { ToolRouter } from "./router.js";

type ClientFactory = (config: ProviderConfig) => ProviderClient;

export class ProviderProxy {
  private clients = new Map<string, ProviderClient>();
  private toolCounts = new Map<string, number>();
  private router = new ToolRouter();
  private config: InfraConfig;
  private clientFactory: ClientFactory;

  constructor(config: InfraConfig, clientFactory?: ClientFactory) {
    this.config = config;
    this.clientFactory = clientFactory ?? createProviderClient;
  }

  async startAll(): Promise<void> {
    for (const providerConfig of this.config.providers) {
      const client = this.clientFactory(providerConfig);

      try {
        await client.connect();
        const tools = await client.listTools();

        this.clients.set(providerConfig.name, client);
        this.toolCounts.set(providerConfig.name, tools.length);
        this.router.registerProvider(
          providerConfig.name,
          providerConfig.roles as Role[],
          tools,
        );

        // Validate capabilities (warnings only)
        const toolNames = tools.map((t) => t.name);
        const warnings = validateCapabilities(toolNames, providerConfig.roles as Role[]);
        for (const warning of warnings) {
          console.warn(`[${providerConfig.name}] ${warning}`);
        }
      } catch (err) {
        if (providerConfig.optional) {
          const msg = err instanceof Error ? err.message : String(err);
          console.warn(
            `Optional provider '${providerConfig.name}' failed to start: ${msg}`
          );
        } else {
          throw new Error(
            `Required provider '${providerConfig.name}' failed to start: ${err instanceof Error ? err.message : String(err)}`
          );
        }
      }
    }
  }

  getAllTools(): ToolInfo[] {
    return this.router.getAllTools();
  }

  async callTool(
    namespacedName: string,
    args: Record<string, unknown>,
  ): Promise<ToolResult> {
    const resolved = this.router.resolve(namespacedName);

    if (!resolved) {
      return {
        content: `Unknown tool: ${namespacedName}`,
        isError: true,
      };
    }

    const client = this.clients.get(resolved.provider);

    if (!client || !client.isConnected()) {
      return {
        content: `Provider '${resolved.provider}' is disconnected`,
        isError: true,
      };
    }

    return client.callTool(resolved.toolName, args);
  }

  getStatuses(): ProviderStatus[] {
    return this.config.providers.map((p) => {
      const client = this.clients.get(p.name);

      return {
        name: p.name,
        roles: p.roles as Role[],
        connected: client?.isConnected() ?? false,
        toolCount: this.toolCounts.get(p.name) ?? 0,
        error: client ? undefined : "Not started",
      };
    });
  }

  getProviderForRole(role: string): string | null {
    return this.router.getProviderForRole(role);
  }

  async shutdownAll(): Promise<void> {
    for (const [, client] of this.clients) {
      await client.disconnect();
    }
    this.clients.clear();
  }
}
  • [ ] Step 4: Run the tests

Run: cd packages/orchestrator && npx vitest run tests/proxy.test.ts

Expected: All tests pass.

  • [ ] Step 5: Commit
git add packages/orchestrator/src/proxy.ts packages/orchestrator/tests/proxy.test.ts
git commit -m "feat(orchestrator): add provider proxy with lifecycle management"

Task 9: Config Loading and Migration

Files: - Create: packages/orchestrator/src/config.ts - Create: packages/orchestrator/tests/config.test.ts

  • [ ] Step 1: Write the tests
// packages/orchestrator/tests/config.test.ts

import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("fs", () => ({
  existsSync: vi.fn(),
  readFileSync: vi.fn(),
  writeFileSync: vi.fn(),
  chmodSync: vi.fn(),
}));

vi.mock("os", () => ({
  homedir: vi.fn(() => "/home/testuser"),
}));

import * as fs from "fs";
import { loadInfraConfig, saveInfraConfig } from "../src/config.js";

beforeEach(() => {
  vi.clearAllMocks();
});

describe("loadInfraConfig", () => {
  it("loads v1.3 config directly", () => {
    const config = {
      providers: [
        { name: "cf", command: "cmd", roles: ["dns_host", "cdn", "security"] },
        { name: "nc", command: "cmd", roles: ["dns_registrar"] },
      ],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(config));

    const result = loadInfraConfig();

    expect(result.config).toEqual(config);
    expect(result.migrated).toBe(false);
  });

  it("migrates v1.2 config and returns migrated flag", () => {
    const legacy = {
      jarPath: "/path/to/jar",
      env: {
        CLOUDFLARE_API_KEY: "key",
        CLOUDFLARE_EMAIL: "[email protected]",
        CLOUDFLARE_ACCOUNT_ID: "acc",
        NAMECHEAP_API_USER: "user",
        NAMECHEAP_API_KEY: "key",
        NAMECHEAP_CLIENT_IP: "1.2.3.4",
      },
      experienceLevel: "professional",
    };

    vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(legacy));

    const result = loadInfraConfig();

    expect(result.migrated).toBe(true);
    expect(result.config.providers.length).toBeGreaterThan(0);
    expect(result.config.providers.some((p) => p.name === "cloudflare")).toBe(true);
  });

  it("returns null config when file not found", () => {
    vi.mocked(fs.readFileSync).mockImplementation(() => {
      throw new Error("ENOENT");
    });

    const result = loadInfraConfig();

    expect(result.config).toBeNull();
  });

  it("accepts explicit config path", () => {
    const config = {
      providers: [{ name: "cf", command: "cmd", roles: ["dns_host", "cdn", "security"] }],
      workflows: {},
      tui: { experienceLevel: "professional" },
    };

    vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(config));

    loadInfraConfig("/custom/path.json");

    expect(fs.readFileSync).toHaveBeenCalledWith("/custom/path.json", "utf-8");
  });
});

describe("saveInfraConfig", () => {
  it("writes config with 0600 permissions", () => {
    const config = {
      providers: [],
      workflows: {},
      tui: { experienceLevel: "professional" as const },
    };

    saveInfraConfig(config);

    expect(fs.writeFileSync).toHaveBeenCalledWith(
      "/home/testuser/.infrastructure-mcp.json",
      JSON.stringify(config, null, 2),
      { encoding: "utf-8", mode: 0o600 },
    );
    expect(fs.chmodSync).toHaveBeenCalledWith(
      "/home/testuser/.infrastructure-mcp.json",
      0o600,
    );
  });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: cd packages/orchestrator && npx vitest run tests/config.test.ts

Expected: FAIL — module does not exist.

  • [ ] Step 3: Implement config.ts
// packages/orchestrator/src/config.ts

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
  InfraConfig,
  CONFIG_FILENAME,
  isLegacyConfig,
  migrateLegacyConfig,
} from "@infrastructure-mcp/shared";

interface LoadResult {
  config: InfraConfig | null;
  migrated: boolean;
}

function defaultConfigPath(): string {
  return path.join(os.homedir(), CONFIG_FILENAME);
}

export function loadInfraConfig(configPath?: string): LoadResult {
  const filePath = configPath ?? defaultConfigPath();

  try {
    const raw = fs.readFileSync(filePath, "utf-8");
    const parsed = JSON.parse(raw);

    if (isLegacyConfig(parsed)) {
      const migrated = migrateLegacyConfig(parsed);
      return { config: migrated, migrated: true };
    }

    return { config: parsed as InfraConfig, migrated: false };
  } catch {
    return { config: null, migrated: false };
  }
}

export function saveInfraConfig(config: InfraConfig, configPath?: string): void {
  const filePath = configPath ?? defaultConfigPath();

  fs.writeFileSync(filePath, JSON.stringify(config, null, 2), {
    encoding: "utf-8",
    mode: 0o600,
  });
  fs.chmodSync(filePath, 0o600);
}
  • [ ] Step 4: Run the tests

Run: cd packages/orchestrator && npx vitest run tests/config.test.ts

Expected: All tests pass.

  • [ ] Step 5: Commit
git add packages/orchestrator/src/config.ts packages/orchestrator/tests/config.test.ts
git commit -m "feat(orchestrator): add config loading with v1.2 migration"

Task 10: MCP Server Entry Point

Files: - Create: packages/orchestrator/src/server.ts - Create: packages/orchestrator/src/cli.ts - Create: packages/orchestrator/bin/cli.ts - Create: packages/orchestrator/src/index.ts

This wires everything together: load config, validate, start proxy, expose tools as an MCP server over stdio.

No tests for this task — it's a thin wiring layer. The proxy, router, config, and client are all tested individually.

  • [ ] Step 1: Create server.ts
// packages/orchestrator/src/server.ts

import { InfraConfig, validateConfig, ORCHESTRATOR_NAME, ORCHESTRATOR_VERSION, MCP_PROTOCOL_VERSION } from "@infrastructure-mcp/shared";
import { ProviderProxy } from "./proxy.js";

interface McpRequest {
  jsonrpc: string;
  id: number;
  method: string;
  params?: Record<string, unknown>;
}

function buildMessage(obj: object): string {
  const json = JSON.stringify(obj);
  const length = Buffer.byteLength(json);
  return `Content-Length: ${length}\r\n\r\n${json}`;
}

function sendResponse(id: number, result: object): void {
  process.stdout.write(buildMessage({ jsonrpc: "2.0", id, result }));
}

function sendError(id: number, code: number, message: string): void {
  process.stdout.write(
    buildMessage({ jsonrpc: "2.0", id, error: { code, message } })
  );
}

export async function startServer(config: InfraConfig): Promise<void> {
  const errors = validateConfig(config);
  if (errors.length > 0) {
    console.error("Config validation failed:");
    for (const err of errors) {
      console.error(`  - ${err}`);
    }
    process.exit(1);
  }

  const proxy = new ProviderProxy(config);
  await proxy.startAll();

  const allTools = proxy.getAllTools();

  let buffer = Buffer.alloc(0);

  function processBuffer(): void {
    while (true) {
      const separatorIdx = buffer.indexOf("\r\n\r\n");
      if (separatorIdx === -1) break;

      const header = buffer.subarray(0, separatorIdx).toString("utf8");
      const match = header.match(/Content-Length:\s*(\d+)/i);

      if (!match) {
        buffer = buffer.subarray(separatorIdx + 4);
        continue;
      }

      const contentLength = parseInt(match[1], 10);
      const bodyStart = separatorIdx + 4;

      if (buffer.length < bodyStart + contentLength) break;

      const body = buffer.subarray(bodyStart, bodyStart + contentLength).toString("utf8");
      buffer = buffer.subarray(bodyStart + contentLength);

      let request: McpRequest;
      try {
        request = JSON.parse(body);
      } catch {
        continue;
      }

      handleRequest(request, proxy, allTools);
    }
  }

  function handleRequest(
    request: McpRequest,
    proxy: ProviderProxy,
    tools: Array<{ name: string; description: string }>,
  ): void {
    switch (request.method) {
      case "initialize":
        sendResponse(request.id, {
          protocolVersion: MCP_PROTOCOL_VERSION,
          capabilities: { tools: {} },
          serverInfo: { name: ORCHESTRATOR_NAME, version: ORCHESTRATOR_VERSION },
        });
        break;

      case "tools/list":
        sendResponse(request.id, {
          tools: tools.map((t) => ({
            name: t.name,
            description: t.description,
            inputSchema: { type: "object", properties: {}, required: [] },
          })),
        });
        break;

      case "tools/call": {
        const params = request.params ?? {};
        const toolName = params.name as string;
        const args = (params.arguments as Record<string, unknown>) ?? {};

        proxy.callTool(toolName, args).then((result) => {
          sendResponse(request.id, {
            content: [{ type: "text", text: result.content }],
            isError: result.isError,
          });
        }).catch((err) => {
          sendError(request.id, -32603, err instanceof Error ? err.message : String(err));
        });
        break;
      }

      default:
        sendError(request.id, -32601, `Method not found: ${request.method}`);
    }
  }

  process.stdin.on("data", (chunk: Buffer) => {
    buffer = Buffer.concat([buffer, chunk]);
    processBuffer();
  });

  // Graceful shutdown
  const shutdown = async () => {
    await proxy.shutdownAll();
    process.exit(0);
  };

  process.on("SIGINT", shutdown);
  process.on("SIGTERM", shutdown);

  console.error(
    `${ORCHESTRATOR_NAME} v${ORCHESTRATOR_VERSION} started (${allTools.length} tools from ${config.providers.length} providers)`
  );
}
  • [ ] Step 2: Create cli.ts
// packages/orchestrator/src/cli.ts

import { loadInfraConfig, saveInfraConfig } from "./config.js";
import { startServer } from "./server.js";
import { validateConfig } from "@infrastructure-mcp/shared";

export async function main(args: string[]): Promise<void> {
  const configPath = getFlagValue(args, "--config");

  if (args.includes("--validate")) {
    const { config, migrated } = loadInfraConfig(configPath);

    if (!config) {
      console.error("No config file found");
      process.exit(1);
    }

    if (migrated) {
      console.log("Detected v1.2 config format — would be migrated on startup");
    }

    const errors = validateConfig(config);
    if (errors.length > 0) {
      console.error("Validation errors:");
      for (const err of errors) {
        console.error(`  - ${err}`);
      }
      process.exit(1);
    }

    console.log("Config is valid");
    return;
  }

  const { config, migrated } = loadInfraConfig(configPath);

  if (!config) {
    console.error("No config file found. Run with --setup or create ~/.infrastructure-mcp.json");
    process.exit(1);
  }

  if (migrated) {
    console.error("Migrated v1.2 config to v1.3 format");
    saveInfraConfig(config, configPath);
  }

  await startServer(config);
}

function getFlagValue(args: string[], flag: string): string | undefined {
  const idx = args.indexOf(flag);
  if (idx === -1 || idx + 1 >= args.length) return undefined;
  return args[idx + 1];
}
  • [ ] Step 3: Create bin/cli.ts
#!/usr/bin/env node
// packages/orchestrator/bin/cli.ts

import { main } from "../src/cli.js";

main(process.argv.slice(2)).catch((err) => {
  console.error(err.message ?? err);
  process.exit(1);
});
  • [ ] Step 4: Create index.ts barrel export
// packages/orchestrator/src/index.ts

export { ProviderProxy } from "./proxy.js";
export { ToolRouter } from "./router.js";
export { createProviderClient } from "./mcp-client.js";
export type { ProviderClient } from "./mcp-client.js";
export { loadInfraConfig, saveInfraConfig } from "./config.js";
export { startServer } from "./server.js";
  • [ ] Step 5: Verify it compiles

Run: cd packages/orchestrator && npx tsc --noEmit

Expected: No errors.

  • [ ] Step 6: Run all orchestrator tests

Run: cd packages/orchestrator && npx vitest run

Expected: All tests pass.

  • [ ] Step 7: Commit
git add packages/orchestrator/src/server.ts packages/orchestrator/src/cli.ts packages/orchestrator/bin/cli.ts packages/orchestrator/src/index.ts
git commit -m "feat(orchestrator): add MCP server entry point and CLI"

Task 11: Run Full Test Suite

Files: None (verification only)

  • [ ] Step 1: Build shared package

Run: cd packages/shared && npx tsc

Expected: Compiles with no errors.

  • [ ] Step 2: Build orchestrator package

Run: cd packages/orchestrator && npx tsc

Expected: Compiles with no errors.

  • [ ] Step 3: Run all tests

Run: npm test --workspaces

Expected: All tests pass across both packages.

  • [ ] Step 4: Verify the CLI starts (smoke test)

Create a minimal test config, then verify the CLI at least parses it:

Run: cd packages/orchestrator && echo '{"providers":[{"name":"test","command":"echo","args":["hello"],"roles":["dns_host","cdn","security"]},{"name":"test2","command":"echo","roles":["dns_registrar"]}],"workflows":{},"tui":{"experienceLevel":"professional"}}' > /tmp/test-infra-config.json && node --loader tsx bin/cli.ts --validate --config /tmp/test-infra-config.json

Expected: "Config is valid"

  • [ ] Step 5: Clean up

Run: rm /tmp/test-infra-config.json


Summary

After completing all 11 tasks, you'll have:

  • @infrastructure-mcp/shared — types, roles, constants, config validation, v1.2 migration
  • infrastructure-mcp (orchestrator) — MCP client for providers, tool router with namespacing/role aliasing, provider proxy with lifecycle management, config loading, MCP server entry point, CLI

Next plans to write: - Plan 2: Workflows (onboard_domain, migrate_dns, apply_protection) - Plan 3: TUI updates (provider management, dynamic dashboard) - Plan 4: External repo changes (standalone cloudflare-mcp, namecheap-mcp, fleet plugin) - Plan 5: Documentation and migration guide