aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
376 lines • 12.7 kB
JavaScript
/**
* MCP Server Registry
*
* Single source of truth for MCP server definitions.
* Stores server configs in the user config directory (~/.aiwg/mcp-servers.json)
* and injects them into provider-native config formats.
*
* @implements #554
*/
import { readFile, writeFile, mkdir } from 'fs/promises';
import { resolve } from 'path';
import { resolveConfigDir } from '../config/user-config.js';
// ============================================
// Registry
// ============================================
const REGISTRY_FILENAME = 'mcp-servers.json';
const DEFAULT_REGISTRY = {
apiVersion: 'aiwg.io/v1',
kind: 'McpServerRegistry',
servers: {},
};
export class McpServerRegistry {
configDir;
cache = null;
constructor(configDirOverride) {
this.configDir = resolveConfigDir(configDirOverride);
}
/** Get the registry file path */
getPath() {
return resolve(this.configDir, REGISTRY_FILENAME);
}
/** Load the registry from disk */
async load() {
if (this.cache)
return this.cache;
const filePath = this.getPath();
try {
const content = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(content);
this.cache = {
...DEFAULT_REGISTRY,
...parsed,
servers: parsed.servers || {},
};
}
catch {
this.cache = { ...DEFAULT_REGISTRY, servers: {} };
}
return this.cache;
}
/** Save the registry to disk */
async save() {
if (!this.cache)
return;
await mkdir(this.configDir, { recursive: true });
const filePath = this.getPath();
await writeFile(filePath, JSON.stringify(this.cache, null, 2) + '\n', 'utf-8');
}
/** Add a new MCP server definition */
async add(def) {
const data = await this.load();
if (data.servers[def.name]) {
throw new Error(`Server "${def.name}" already exists. Use "update" to modify it.`);
}
data.servers[def.name] = {
...def,
injectedProviders: [],
addedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await this.save();
}
/** Remove an MCP server definition */
async remove(name) {
const data = await this.load();
if (!data.servers[name]) {
throw new Error(`Server "${name}" not found.`);
}
delete data.servers[name];
await this.save();
}
/** Update an existing MCP server definition */
async update(name, updates) {
const data = await this.load();
if (!data.servers[name]) {
throw new Error(`Server "${name}" not found.`);
}
data.servers[name] = {
...data.servers[name],
...updates,
name, // preserve original name
updatedAt: new Date().toISOString(),
};
await this.save();
}
/** Get a specific server definition */
async get(name) {
const data = await this.load();
return data.servers[name];
}
/** List all server definitions */
async list() {
const data = await this.load();
return Object.values(data.servers);
}
/** Record that a server was injected into a provider */
async recordInjection(name, provider) {
const data = await this.load();
const server = data.servers[name];
if (!server)
return;
if (!server.injectedProviders) {
server.injectedProviders = [];
}
if (!server.injectedProviders.includes(provider)) {
server.injectedProviders.push(provider);
}
await this.save();
}
/** Get all providers that have had servers injected */
async getInjectedProviders() {
const data = await this.load();
const providers = new Set();
for (const server of Object.values(data.servers)) {
for (const p of server.injectedProviders || []) {
providers.add(p);
}
}
return [...providers];
}
/** Clear the in-memory cache */
clearCache() {
this.cache = null;
}
}
// ============================================
// Provider injection logic
// ============================================
/**
* Build the MCP config block for a single server in a given provider's format.
*/
function buildServerConfig(server, provider) {
switch (provider) {
case 'claude-code':
case 'claude': {
if (server.type === 'stdio') {
return {
command: server.command,
args: server.args || [],
...(server.env ? { env: server.env } : {}),
};
}
// http/sse
return {
url: server.url,
...(server.headers ? { headers: server.headers } : {}),
};
}
case 'cursor': {
if (server.type === 'stdio') {
return {
command: server.command,
args: server.args || [],
...(server.env ? { env: server.env } : {}),
};
}
return {
url: server.url,
...(server.headers ? { headers: server.headers } : {}),
};
}
case 'factory': {
if (server.type === 'stdio') {
return {
type: 'stdio',
command: server.command,
args: server.args || [],
disabled: false,
...(server.env ? { env: server.env } : {}),
};
}
return {
type: server.type,
url: server.url,
disabled: false,
...(server.headers ? { headers: server.headers } : {}),
};
}
case 'opencode': {
if (server.type === 'stdio') {
return {
type: 'local',
command: [server.command, ...(server.args || [])],
...(server.env ? { env: server.env } : {}),
};
}
return {
type: 'remote',
url: server.url,
...(server.headers ? { headers: server.headers } : {}),
};
}
case 'windsurf':
case 'warp': {
if (server.type === 'stdio') {
return {
command: server.command,
args: server.args || [],
...(server.env ? { env: server.env } : {}),
};
}
return {
url: server.url,
...(server.headers ? { headers: server.headers } : {}),
};
}
case 'codex':
case 'openai':
// TOML format handled separately
return {};
default:
return {};
}
}
/**
* Build a TOML section for a server (Codex/OpenAI provider).
*/
function buildServerToml(server) {
const lines = [];
lines.push(`[mcp_servers.${server.name}]`);
if (server.type === 'stdio') {
lines.push(`command = "${server.command}"`);
if (server.args && server.args.length > 0) {
const argsStr = server.args.map(a => `"${a}"`).join(', ');
lines.push(`args = [${argsStr}]`);
}
}
else {
lines.push(`url = "${server.url}"`);
}
lines.push(`startup_timeout_sec = 10.0`);
lines.push(`tool_timeout_sec = 60.0`);
return lines.join('\n');
}
/**
* Get the config file path for a provider.
*/
export function getProviderConfigPath(provider, projectDir = '.') {
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const pathMap = {
'claude-code': resolve(projectDir, '.claude/settings.local.json'),
claude: resolve(projectDir, '.claude/settings.local.json'),
cursor: resolve(projectDir, '.cursor/mcp.json'),
factory: resolve(homeDir, '.factory/mcp.json'),
codex: resolve(homeDir, '.codex/config.toml'),
openai: resolve(homeDir, '.codex/config.toml'),
opencode: resolve(projectDir, 'opencode.json'),
windsurf: resolve(homeDir, '.codeium/windsurf/mcp_config.json'),
warp: resolve(homeDir, '.warp/mcp.json'),
};
return pathMap[provider] || '';
}
/**
* Inject MCP servers into a provider's native config format.
*
* Non-destructive: preserves existing provider-specific servers not managed by AIWG.
* Idempotent: updates in place without duplicating.
*/
export async function injectServers(registry, provider, options = {}) {
const { servers: serverFilter, projectDir = '.', dryRun = false } = options;
const configPath = getProviderConfigPath(provider, projectDir);
const result = {
provider,
configPath,
serversInjected: [],
alreadyPresent: [],
};
// Get servers to inject
let allServers = await registry.list();
if (serverFilter && serverFilter.length > 0) {
allServers = allServers.filter(s => serverFilter.includes(s.name));
}
if (allServers.length === 0) {
result.error = 'No servers to inject. Use "aiwg mcp add" first.';
return result;
}
// Handle TOML-based providers (Codex/OpenAI) separately
if (provider === 'codex' || provider === 'openai') {
return injectToml(registry, allServers, configPath, provider, dryRun, result);
}
// JSON-based providers
return injectJson(registry, allServers, configPath, provider, dryRun, result);
}
async function injectJson(registry, servers, configPath, provider, dryRun, result) {
// Load existing config
let existing = {};
try {
const content = await readFile(configPath, 'utf-8');
existing = JSON.parse(content);
}
catch {
// File doesn't exist, start fresh
}
// Determine the MCP servers key for this provider
const mcpKey = provider === 'opencode' ? 'mcp' : 'mcpServers';
const existingServers = existing[mcpKey] || {};
// Build new server entries
const newServers = { ...existingServers };
for (const server of servers) {
if (existingServers[server.name]) {
// Update existing entry in place
result.alreadyPresent.push(server.name);
}
newServers[server.name] = buildServerConfig(server, provider);
result.serversInjected.push(server.name);
}
// Merge back
const merged = { ...existing, [mcpKey]: newServers };
if (!dryRun) {
await mkdir(resolve(configPath, '..'), { recursive: true });
await writeFile(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
// Record injection in registry
for (const server of servers) {
await registry.recordInjection(server.name, provider);
}
}
return result;
}
async function injectToml(registry, servers, configPath, provider, dryRun, result) {
let existing = '';
try {
existing = await readFile(configPath, 'utf-8');
}
catch {
// File doesn't exist
}
const sectionsToAdd = [];
for (const server of servers) {
const sectionHeader = `[mcp_servers.${server.name}]`;
if (existing.includes(sectionHeader)) {
// Replace the existing section
const sectionRegex = new RegExp(`\\[mcp_servers\\.${escapeRegex(server.name)}\\][\\s\\S]*?(?=\\n\\[|$)`);
existing = existing.replace(sectionRegex, buildServerToml(server));
result.alreadyPresent.push(server.name);
}
else {
sectionsToAdd.push(buildServerToml(server));
}
result.serversInjected.push(server.name);
}
if (sectionsToAdd.length > 0) {
existing = existing.trimEnd() + '\n\n' + sectionsToAdd.join('\n\n') + '\n';
}
if (!dryRun) {
await mkdir(resolve(configPath, '..'), { recursive: true });
await writeFile(configPath, existing, 'utf-8');
for (const server of servers) {
await registry.recordInjection(server.name, provider);
}
}
return result;
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** All supported provider names for injection */
export const SUPPORTED_PROVIDERS = [
'claude-code',
'cursor',
'factory',
'codex',
'opencode',
'windsurf',
'warp',
];
//# sourceMappingURL=registry.js.map