Skip to content

Commit 1993394

Browse files
authored
Merge pull request #170 from QuantGeekDev/feat/create-current-dir-78
feat: support `mcp create .` to scaffold in current directory
2 parents b543447 + 879cd8b commit 1993394

File tree

3 files changed

+290
-20
lines changed

3 files changed

+290
-20
lines changed

src/cli/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ program.command('build').description('Build the MCP project').action(buildFramew
4747
program
4848
.command('create')
4949
.description('Create a new MCP server project')
50-
.argument('[name]', 'project name')
50+
.argument('[name]', 'project name (use "." for current directory)')
5151
.option('--http', 'use HTTP transport instead of default stdio')
5252
.option('--cors', 'enable CORS with wildcard (*) access')
5353
.option('--port <number>', 'specify HTTP port (only valid with --http)', (val) =>

src/cli/project/create.ts

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createRequire } from 'module';
22
import { spawnSync } from 'child_process';
3-
import { mkdir, writeFile } from 'fs/promises';
4-
import { join } from 'path';
3+
import { existsSync } from 'fs';
4+
import { mkdir, readdir, writeFile } from 'fs/promises';
5+
import { basename, join } from 'path';
56
import prompts from 'prompts';
67
import { generateReadme } from '../templates/readme.js';
78
import { execa } from 'execa';
@@ -49,17 +50,46 @@ export async function createProject(
4950
projectName = name;
5051
}
5152

53+
const isCurrentDir = name === '.';
54+
55+
if (isCurrentDir) {
56+
// Derive project name from current directory name
57+
projectName = basename(process.cwd())
58+
.toLowerCase()
59+
.replace(/[^a-z0-9-]/g, '-')
60+
.replace(/^-+|-+$/g, '')
61+
.replace(/-{2,}/g, '-');
62+
63+
if (!projectName) {
64+
console.error('❌ Error: Could not derive a valid project name from the current directory name');
65+
console.error(' Please rename the directory or specify a project name: mcp create <name>');
66+
process.exit(1);
67+
}
68+
}
69+
5270
if (!projectName) {
5371
throw new Error('Project name is required');
5472
}
5573

56-
const projectDir = join(process.cwd(), projectName);
74+
const projectDir = isCurrentDir ? process.cwd() : join(process.cwd(), projectName);
5775
const srcDir = join(projectDir, 'src');
5876
const toolsDir = join(srcDir, 'tools');
5977

6078
try {
79+
if (isCurrentDir) {
80+
const entries = await readdir(projectDir);
81+
const conflicts = ['package.json', 'tsconfig.json', 'src'].filter(f => entries.includes(f));
82+
if (conflicts.length > 0) {
83+
console.error(`❌ Error: Current directory already contains: ${conflicts.join(', ')}`);
84+
console.error(' Please use an empty directory or specify a project name: mcp create <name>');
85+
process.exit(1);
86+
}
87+
}
88+
6189
console.log('Creating project structure...');
62-
await mkdir(projectDir);
90+
if (!isCurrentDir) {
91+
await mkdir(projectDir);
92+
}
6393
await mkdir(srcDir);
6494
await mkdir(toolsDir);
6595

@@ -321,14 +351,16 @@ OAUTH_ISSUER=https://auth.example.com
321351

322352
process.chdir(projectDir);
323353

324-
console.log('Initializing git repository...');
325-
const gitInit = spawnSync('git', ['init'], {
326-
stdio: 'inherit',
327-
shell: true,
328-
});
354+
if (!isCurrentDir || !existsSync(join(projectDir, '.git'))) {
355+
console.log('Initializing git repository...');
356+
const gitInit = spawnSync('git', ['init'], {
357+
stdio: 'inherit',
358+
shell: true,
359+
});
329360

330-
if (gitInit.status !== 0) {
331-
throw new Error('Failed to initialize git repository');
361+
if (gitInit.status !== 0) {
362+
throw new Error('Failed to initialize git repository');
363+
}
332364
}
333365

334366
if (shouldInstall) {
@@ -370,10 +402,12 @@ OAUTH_ISSUER=https://auth.example.com
370402
✅ Project ${projectName} created and built successfully with OAuth 2.1!
371403
372404
🔐 OAuth Setup Required:
373-
1. cd ${projectName}
405+
${isCurrentDir ? `1. Copy .env.example to .env
406+
2. Configure your OAuth provider settings in .env
407+
3. See docs/OAUTH.md for provider-specific setup guides` : `1. cd ${projectName}
374408
2. Copy .env.example to .env
375409
3. Configure your OAuth provider settings in .env
376-
4. See docs/OAUTH.md for provider-specific setup guides
410+
4. See docs/OAUTH.md for provider-specific setup guides`}
377411
378412
📖 OAuth Resources:
379413
- Framework docs: https://github.com/QuantGeekDev/mcp-framework/blob/main/docs/OAUTH.md
@@ -385,11 +419,13 @@ OAUTH_ISSUER=https://auth.example.com
385419
} else {
386420
console.log(`
387421
Project ${projectName} created and built successfully!
388-
422+
${isCurrentDir ? `
423+
Add more tools using:
424+
mcp add tool <n>` : `
389425
You can now:
390426
1. cd ${projectName}
391427
2. Add more tools using:
392-
mcp add tool <n>
428+
mcp add tool <n>`}
393429
`);
394430
}
395431
} else {
@@ -398,26 +434,35 @@ You can now:
398434
✅ Project ${projectName} created successfully with OAuth 2.1 (without dependencies)!
399435
400436
Next steps:
401-
1. cd ${projectName}
437+
${isCurrentDir ? `1. Copy .env.example to .env
438+
2. Configure your OAuth provider settings in .env
439+
3. Run 'npm install' to install dependencies
440+
4. Run 'npm run build' to build the project
441+
5. See docs/OAUTH.md for OAuth setup guides` : `1. cd ${projectName}
402442
2. Copy .env.example to .env
403443
3. Configure your OAuth provider settings in .env
404444
4. Run 'npm install' to install dependencies
405445
5. Run 'npm run build' to build the project
406-
6. See docs/OAUTH.md for OAuth setup guides
446+
6. See docs/OAUTH.md for OAuth setup guides`}
407447
408448
🛠️ Add more tools:
409449
mcp add tool <tool-name>
410450
`);
411451
} else {
412452
console.log(`
413453
Project ${projectName} created successfully (without dependencies)!
414-
454+
${isCurrentDir ? `
455+
Next steps:
456+
1. Run 'npm install' to install dependencies
457+
2. Run 'npm run build' to build the project
458+
3. Add more tools using:
459+
mcp add tool <n>` : `
415460
You can now:
416461
1. cd ${projectName}
417462
2. Run 'npm install' to install dependencies
418463
3. Run 'npm run build' to build the project
419464
4. Add more tools using:
420-
mcp add tool <n>
465+
mcp add tool <n>`}
421466
`);
422467
}
423468
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2+
import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
3+
import { join } from 'path';
4+
import { tmpdir } from 'os';
5+
import { spawnSync } from 'child_process';
6+
7+
import { createProject } from '../../src/cli/project/create.js';
8+
9+
describe('mcp create . (current directory)', () => {
10+
let tempDir: string;
11+
const originalCwd = process.cwd();
12+
const originalExit = process.exit;
13+
14+
beforeEach(() => {
15+
tempDir = mkdtempSync(join(tmpdir(), 'mcp-test-'));
16+
});
17+
18+
afterEach(() => {
19+
process.chdir(originalCwd);
20+
process.exit = originalExit;
21+
rmSync(tempDir, { recursive: true, force: true });
22+
});
23+
24+
function mockProcessExit(): { getExitCode: () => number | undefined } {
25+
let exitCode: number | undefined;
26+
process.exit = ((code?: number) => {
27+
exitCode = code;
28+
throw new Error('process.exit called');
29+
}) as never;
30+
return { getExitCode: () => exitCode };
31+
}
32+
33+
describe('scaffolding', () => {
34+
it('should create all project files in the current directory', async () => {
35+
const projectDir = join(tempDir, 'my-test-server');
36+
mkdirSync(projectDir);
37+
process.chdir(projectDir);
38+
39+
await createProject('.', { install: false, example: true });
40+
41+
expect(existsSync(join(projectDir, 'package.json'))).toBe(true);
42+
expect(existsSync(join(projectDir, 'tsconfig.json'))).toBe(true);
43+
expect(existsSync(join(projectDir, 'src', 'index.ts'))).toBe(true);
44+
expect(existsSync(join(projectDir, 'src', 'tools', 'ExampleTool.ts'))).toBe(true);
45+
expect(existsSync(join(projectDir, '.gitignore'))).toBe(true);
46+
expect(existsSync(join(projectDir, 'README.md'))).toBe(true);
47+
});
48+
49+
it('should not create a subdirectory', async () => {
50+
const projectDir = join(tempDir, 'test-server');
51+
mkdirSync(projectDir);
52+
process.chdir(projectDir);
53+
54+
await createProject('.', { install: false, example: false });
55+
56+
// No nested subdirectory should exist with the project name
57+
expect(existsSync(join(projectDir, 'test-server'))).toBe(false);
58+
});
59+
60+
it('should skip example tool when --no-example is used', async () => {
61+
const projectDir = join(tempDir, 'no-example');
62+
mkdirSync(projectDir);
63+
process.chdir(projectDir);
64+
65+
await createProject('.', { install: false, example: false });
66+
67+
expect(existsSync(join(projectDir, 'src', 'tools', 'ExampleTool.ts'))).toBe(false);
68+
expect(existsSync(join(projectDir, 'src', 'index.ts'))).toBe(true);
69+
});
70+
71+
it('should generate HTTP transport config when --http is used', async () => {
72+
const projectDir = join(tempDir, 'http-server');
73+
mkdirSync(projectDir);
74+
process.chdir(projectDir);
75+
76+
await createProject('.', { install: false, http: true, port: 3000 });
77+
78+
const indexTs = readFileSync(join(projectDir, 'src', 'index.ts'), 'utf-8');
79+
expect(indexTs).toContain('http-stream');
80+
expect(indexTs).toContain('3000');
81+
});
82+
});
83+
84+
describe('project name derivation', () => {
85+
it('should derive name from directory name', async () => {
86+
const projectDir = join(tempDir, 'my-test-server');
87+
mkdirSync(projectDir);
88+
process.chdir(projectDir);
89+
90+
await createProject('.', { install: false, example: false });
91+
92+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
93+
expect(pkg.name).toBe('my-test-server');
94+
expect(pkg.description).toBe('my-test-server MCP server');
95+
expect(pkg.bin['my-test-server']).toBe('./dist/index.js');
96+
});
97+
98+
it('should lowercase uppercase directory names', async () => {
99+
const projectDir = join(tempDir, 'MyServer');
100+
mkdirSync(projectDir);
101+
process.chdir(projectDir);
102+
103+
await createProject('.', { install: false, example: false });
104+
105+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
106+
expect(pkg.name).toBe('myserver');
107+
});
108+
109+
it('should replace special characters with hyphens', async () => {
110+
const projectDir = join(tempDir, 'My_Cool.Server');
111+
mkdirSync(projectDir);
112+
process.chdir(projectDir);
113+
114+
await createProject('.', { install: false, example: false });
115+
116+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
117+
expect(pkg.name).toBe('my-cool-server');
118+
});
119+
120+
it('should collapse consecutive hyphens', async () => {
121+
const projectDir = join(tempDir, 'my___server');
122+
mkdirSync(projectDir);
123+
process.chdir(projectDir);
124+
125+
await createProject('.', { install: false, example: false });
126+
127+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
128+
expect(pkg.name).toBe('my-server');
129+
});
130+
131+
it('should strip leading and trailing hyphens', async () => {
132+
const projectDir = join(tempDir, '-my-server-');
133+
mkdirSync(projectDir);
134+
process.chdir(projectDir);
135+
136+
await createProject('.', { install: false, example: false });
137+
138+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
139+
expect(pkg.name).toBe('my-server');
140+
});
141+
});
142+
143+
describe('conflict detection', () => {
144+
it('should reject when package.json exists', async () => {
145+
const projectDir = join(tempDir, 'has-pkg');
146+
mkdirSync(projectDir);
147+
writeFileSync(join(projectDir, 'package.json'), '{}');
148+
process.chdir(projectDir);
149+
150+
const { getExitCode } = mockProcessExit();
151+
152+
await expect(createProject('.', { install: false })).rejects.toThrow('process.exit called');
153+
expect(getExitCode()).toBe(1);
154+
});
155+
156+
it('should reject when tsconfig.json exists', async () => {
157+
const projectDir = join(tempDir, 'has-tsconfig');
158+
mkdirSync(projectDir);
159+
writeFileSync(join(projectDir, 'tsconfig.json'), '{}');
160+
process.chdir(projectDir);
161+
162+
const { getExitCode } = mockProcessExit();
163+
164+
await expect(createProject('.', { install: false })).rejects.toThrow('process.exit called');
165+
expect(getExitCode()).toBe(1);
166+
});
167+
168+
it('should reject when src directory exists', async () => {
169+
const projectDir = join(tempDir, 'has-src');
170+
mkdirSync(projectDir);
171+
mkdirSync(join(projectDir, 'src'));
172+
process.chdir(projectDir);
173+
174+
const { getExitCode } = mockProcessExit();
175+
176+
await expect(createProject('.', { install: false })).rejects.toThrow('process.exit called');
177+
expect(getExitCode()).toBe(1);
178+
});
179+
180+
it('should allow directories with other non-conflicting files', async () => {
181+
const projectDir = join(tempDir, 'has-readme');
182+
mkdirSync(projectDir);
183+
writeFileSync(join(projectDir, 'notes.txt'), 'hello');
184+
process.chdir(projectDir);
185+
186+
await createProject('.', { install: false, example: false });
187+
188+
expect(existsSync(join(projectDir, 'package.json'))).toBe(true);
189+
});
190+
});
191+
192+
describe('git init behavior', () => {
193+
it('should skip git init when .git already exists', async () => {
194+
const projectDir = join(tempDir, 'git-exists');
195+
mkdirSync(projectDir);
196+
process.chdir(projectDir);
197+
198+
// Initialize a git repo before running create
199+
spawnSync('git', ['init'], { cwd: projectDir, stdio: 'ignore' });
200+
expect(existsSync(join(projectDir, '.git'))).toBe(true);
201+
202+
// Capture console output to verify git init was skipped
203+
const logs: string[] = [];
204+
const originalLog = console.log;
205+
console.log = (...args: unknown[]) => logs.push(args.join(' '));
206+
207+
await createProject('.', { install: false, example: false });
208+
209+
console.log = originalLog;
210+
211+
expect(logs.some((l) => l.includes('Initializing git'))).toBe(false);
212+
expect(existsSync(join(projectDir, 'package.json'))).toBe(true);
213+
});
214+
215+
it('should run git init when no .git exists', async () => {
216+
const projectDir = join(tempDir, 'no-git');
217+
mkdirSync(projectDir);
218+
process.chdir(projectDir);
219+
220+
await createProject('.', { install: false, example: false });
221+
222+
expect(existsSync(join(projectDir, '.git'))).toBe(true);
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)