Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Build
- name: Build (tsup)
run: npm run build:tsup

- name: Build (Smithery)
run: npm run build

- name: Lint
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ jobs:
- name: Bundle AXe artifacts
run: npm run bundle:axe

- name: Build TypeScript
- name: Build TypeScript (tsup)
run: npm run build:tsup

- name: Build Smithery bundle
run: npm run build

- name: Run tests
Expand Down
786 changes: 786 additions & 0 deletions .smithery/index.cjs

Large diffs are not rendered by default.

38 changes: 0 additions & 38 deletions Dockerfile

This file was deleted.

11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,21 @@ claude mcp add XcodeBuildMCP npx xcodebuildmcp@latest -e INCREMENTAL_BUILDS_ENAB

##### Smithery

To install XcodeBuildMCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@cameroncooke/XcodeBuildMCP):
XcodeBuildMCP runs as a local (stdio) server when installed via [Smithery](https://smithery.ai/server/@cameroncooke/XcodeBuildMCP). You can configure it in the Smithery session UI or via environment variables.

```bash
# Claude Desktop / Claude Code
npx -y @smithery/cli install @cameroncooke/XcodeBuildMCP --client claude

# Cursor
npx -y @smithery/cli install @cameroncooke/XcodeBuildMCP --client cursor

# VS Code
npx -y @smithery/cli install @cameroncooke/XcodeBuildMCP --client vscode
```

If your client isn't listed, run `npx smithery install --help` to see supported values for `--client`.

> [!IMPORTANT]
> Please note that XcodeBuildMCP will request xcodebuild to skip macro validation. This is to avoid errors when building projects that use Swift Macros.

Expand Down
120 changes: 112 additions & 8 deletions build-plugins/plugin-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@ export function createPluginDiscoveryPlugin(): Plugin {
build.onStart(async () => {
try {
await generateWorkflowLoaders();
await generateResourceLoaders();
} catch (error) {
console.error('Failed to generate workflow loaders:', error);
console.error('Failed to generate loaders:', error);
throw error;
}
});
},
};
}

async function generateWorkflowLoaders(): Promise<void> {
const pluginsDir = path.resolve(process.cwd(), 'src/plugins');
export async function generateWorkflowLoaders(): Promise<void> {
const pluginsDir = path.resolve(process.cwd(), 'src/mcp/tools');

if (!existsSync(pluginsDir)) {
throw new Error(`Plugins directory not found: ${pluginsDir}`);
Expand All @@ -41,7 +42,8 @@ async function generateWorkflowLoaders(): Promise<void> {
const workflowMetadata: Record<string, WorkflowMetadata> = {};

for (const dirName of workflowDirs) {
const indexPath = join(pluginsDir, dirName, 'index.ts');
const dirPath = join(pluginsDir, dirName);
const indexPath = join(dirPath, 'index.ts');

// Check if workflow has index.ts file
if (!existsSync(indexPath)) {
Expand All @@ -55,11 +57,26 @@ async function generateWorkflowLoaders(): Promise<void> {
const metadata = extractWorkflowMetadata(indexContent);

if (metadata) {
// Generate dynamic import for this workflow
workflowLoaders[dirName] = `() => import('../plugins/${dirName}/index.js')`;
// Find all tool files in this workflow directory
const toolFiles = readdirSync(dirPath, { withFileTypes: true })
.filter((dirent) => dirent.isFile())
.map((dirent) => dirent.name)
.filter(
(name) =>
(name.endsWith('.ts') || name.endsWith('.js')) &&
name !== 'index.ts' &&
name !== 'index.js' &&
!name.endsWith('.test.ts') &&
!name.endsWith('.test.js') &&
name !== 'active-processes.ts',
);

workflowLoaders[dirName] = generateWorkflowLoader(dirName, toolFiles);
workflowMetadata[dirName] = metadata;

console.log(`✅ Discovered workflow: ${dirName} - ${metadata.name}`);
console.log(
`✅ Discovered workflow: ${dirName} - ${metadata.name} (${toolFiles.length} tools)`,
);
} else {
console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`);
}
Expand All @@ -80,6 +97,31 @@ async function generateWorkflowLoaders(): Promise<void> {
console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`);
}

function generateWorkflowLoader(workflowName: string, toolFiles: string[]): string {
const toolImports = toolFiles
.map((file, index) => {
const toolName = file.replace(/\.(ts|js)$/, '');
return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.js').then(m => m.default)`;
})
.join(';\n ');

const toolExports = toolFiles
.map((file, index) => {
const toolName = file.replace(/\.(ts|js)$/, '');
return `'${toolName}': tool_${index}`;
})
.join(',\n ');

return `async () => {
const { workflow } = await import('../mcp/tools/${workflowName}/index.js');
${toolImports ? toolImports + ';\n ' : ''}
return {
workflow,
${toolExports ? toolExports : ''}
};
}`;
}

function extractWorkflowMetadata(content: string): WorkflowMetadata | null {
try {
// Simple regex to extract workflow export object
Expand Down Expand Up @@ -114,7 +156,13 @@ function generatePluginsFileContent(
workflowMetadata: Record<string, WorkflowMetadata>,
): string {
const loaderEntries = Object.entries(workflowLoaders)
.map(([key, loader]) => ` '${key}': ${loader}`)
.map(([key, loader]) => {
const indentedLoader = loader
.split('\n')
.map((line, index) => (index === 0 ? ` '${key}': ${line}` : ` ${line}`))
.join('\n');
return indentedLoader;
})
.join(',\n');

const metadataEntries = Object.entries(workflowMetadata)
Expand Down Expand Up @@ -143,3 +191,59 @@ ${metadataEntries}
};
`;
}

export async function generateResourceLoaders(): Promise<void> {
const resourcesDir = path.resolve(process.cwd(), 'src/mcp/resources');

if (!existsSync(resourcesDir)) {
console.log('Resources directory not found, skipping resource generation');
return;
}

const resourceFiles = readdirSync(resourcesDir, { withFileTypes: true })
.filter((dirent) => dirent.isFile())
.map((dirent) => dirent.name)
.filter(
(name) =>
(name.endsWith('.ts') || name.endsWith('.js')) &&
!name.endsWith('.test.ts') &&
!name.endsWith('.test.js') &&
!name.startsWith('__'),
);

const resourceLoaders: Record<string, string> = {};

for (const fileName of resourceFiles) {
const resourceName = fileName.replace(/\.(ts|js)$/, '');
resourceLoaders[resourceName] = `async () => {
const module = await import('../mcp/resources/${resourceName}.js');
return module.default;
}`;

console.log(`✅ Discovered resource: ${resourceName}`);
}

const generatedContent = generateResourcesFileContent(resourceLoaders);
const outputPath = path.resolve(process.cwd(), 'src/core/generated-resources.ts');

const fs = await import('fs');
await fs.promises.writeFile(outputPath, generatedContent, 'utf8');

console.log(`🔧 Generated resource loaders for ${Object.keys(resourceLoaders).length} resources`);
}

function generateResourcesFileContent(resourceLoaders: Record<string, string>): string {
const loaderEntries = Object.entries(resourceLoaders)
.map(([key, loader]) => ` '${key}': ${loader}`)
.join(',\n');

return `// AUTO-GENERATED - DO NOT EDIT
// This file is generated by the plugin discovery esbuild plugin

export const RESOURCE_LOADERS = {
${loaderEntries}
};

export type ResourceName = keyof typeof RESOURCE_LOADERS;
`;
}
Loading
Loading