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 existingtui/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
- [ ] 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:
And update scripts:
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 migrationinfrastructure-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