diff --git a/CLAUDE.md b/CLAUDE.md index c93fa17..cacd35c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,12 +30,17 @@ create-mcp-server/ │ │ │ ├── index.ts # Barrel export + getIndexTemplate │ │ │ ├── readme.ts # README.md template │ │ │ └── templates.test.ts -│ │ └── stateful/ # Stateful HTTP template with OAuth option +│ │ ├── stateful/ # Stateful HTTP template with OAuth option +│ │ │ ├── server.ts # Re-exports from stateless +│ │ │ ├── index.ts # Barrel export + getIndexTemplate +│ │ │ ├── readme.ts # README.md template +│ │ │ ├── auth.ts # OAuth authentication template +│ │ │ ├── auth.test.ts # Tests for auth template +│ │ │ └── templates.test.ts +│ │ └── stdio/ # stdio transport template │ │ ├── server.ts # Re-exports from stateless -│ │ ├── index.ts # Barrel export + getIndexTemplate -│ │ ├── readme.ts # README.md template -│ │ ├── auth.ts # OAuth authentication template -│ │ ├── auth.test.ts # Tests for auth template +│ │ ├── index.ts # Barrel export + getIndexTemplate (StdioServerTransport) +│ │ ├── readme.ts # README.md template (for local clients) │ │ └── templates.test.ts │ └── fastmcp/ # FastMCP templates │ ├── server.ts # FastMCP server definition template @@ -108,8 +113,9 @@ npx @agentailor/create-mcp-server --name=my-server [options] | `--name` | `-n` | (required) | alphanumeric, hyphens, underscores | | `--package-manager` | `-p` | `npm` | npm, pnpm, yarn | | `--framework` | `-f` | `sdk` | sdk, fastmcp | -| `--template` | `-t` | `stateless` | stateless, stateful | -| `--oauth` | — | `false` | flag (sdk+stateful only) | +| `--stdio` | — | `false` | flag; uses stdio transport instead of HTTP | +| `--template` | `-t` | `stateless` | stateless, stateful (HTTP only, ignored with --stdio) | +| `--oauth` | — | `false` | flag (sdk+stateful only, incompatible with --stdio) | | `--no-git` | — | `false` | flag | ## Frameworks @@ -163,17 +169,28 @@ When OAuth is enabled for the stateful template: - Server startup validation ensures OAuth provider is reachable - See [docs/oauth-setup.md](docs/oauth-setup.md) for provider-specific setup instructions +#### sdk/stdio + +A stdio MCP server using the official SDK. Uses `StdioServerTransport` — for local clients like Claude Desktop. + +Features: +- `StdioServerTransport` (no HTTP server, no Express) +- Same example prompt, tool, and resource as stateless +- No PORT/ALLOWED_HOSTS environment variables +- No Dockerfile generated (stdio servers are run directly) +- MCP Inspector CLI mode (`mcp-inspector --cli node dist/index.js`) + ### FastMCP Templates -A single template that supports both stateless and stateful modes via the `stateless` configuration option. Uses the FastMCP framework for simpler server setup. +A single template that supports both stateless and stateful HTTP modes via the `stateless` configuration option, plus stdio transport. Uses the FastMCP framework for simpler server setup. Features: - Declarative tool/prompt/resource registration -- Built-in HTTP server (no Express setup required) -- Supports both stateless and stateful modes via config +- Built-in HTTP server (no Express setup required) or stdio transport +- Supports stateless/stateful HTTP modes and stdio via config - Example prompt, tool, and resource -Generated project structure (same for all templates, +auth.ts when OAuth enabled for SDK stateful): +Generated project structure for HTTP templates (+auth.ts when OAuth enabled for SDK stateful): ``` {project-name}/ ├── src/ @@ -189,6 +206,19 @@ Generated project structure (same for all templates, +auth.ts when OAuth enabled └── README.md ``` +Generated project structure for stdio templates (no Dockerfile): +``` +{project-name}/ +├── src/ +│ ├── server.ts # MCP server with tools/prompts/resources +│ └── index.ts # stdio transport startup +├── package.json +├── tsconfig.json +├── .gitignore +├── .env.example +└── README.md +``` + ## Deployment All generated projects include deployment configuration by default: diff --git a/README.md b/README.md index 4c15ecd..1068f92 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ npx @agentailor/create-mcp-server --name=my-server | `--name` | `-n` | — | Project name (required in CLI mode) | | `--package-manager` | `-p` | `npm` | Package manager: npm, pnpm, yarn | | `--framework` | `-f` | `sdk` | Framework: sdk, fastmcp | -| `--template` | `-t` | `stateless` | Server mode: stateless, stateful | -| `--oauth` | — | `false` | Enable OAuth (sdk+stateful only) | +| `--stdio` | — | `false` | Use stdio transport (for local clients) | +| `--template` | `-t` | `stateless` | Server mode: stateless, stateful (HTTP only) | +| `--oauth` | — | `false` | Enable OAuth (sdk+stateful only, incompatible with --stdio) | | `--no-git` | — | `false` | Skip git initialization | | `--help` | `-h` | — | Show help | | `--version` | `-V` | — | Show version | @@ -39,10 +40,16 @@ npx @agentailor/create-mcp-server --name=my-server **Examples:** ```bash -# Minimal - uses all defaults +# Minimal - uses all defaults (HTTP streamable) npx @agentailor/create-mcp-server --name=my-server -# Full options +# stdio server (for local clients) +npx @agentailor/create-mcp-server --name=my-server --stdio + +# stdio with FastMCP +npx @agentailor/create-mcp-server --name=my-server --stdio --framework=fastmcp + +# Full HTTP options npx @agentailor/create-mcp-server \ --name=my-auth-server \ --package-manager=pnpm \ @@ -57,11 +64,12 @@ npx @agentailor/create-mcp-server -n my-server -p yarn -f fastmcp ## Features - **Two frameworks** — Official MCP SDK or FastMCP -- **Two server modes** — stateless or stateful with session management -- **Optional OAuth** — OIDC-compliant authentication (SDK only) ([setup guide](docs/oauth-setup.md)) +- **Two transport types** — HTTP (streamable) or stdio (for local cllients) +- **Two HTTP server modes** — stateless or stateful with session management +- **Optional OAuth** — OIDC-compliant authentication (SDK HTTP only) ([setup guide](docs/oauth-setup.md)) - **Package manager choice** — npm, pnpm, or yarn - **TypeScript ready** — ready to customize -- **Docker ready** — production Dockerfile included +- **Docker ready** — production Dockerfile included (HTTP transport) - **MCP Inspector** — built-in debugging with `npm run inspect` ## Frameworks @@ -93,7 +101,22 @@ server.start({ transportType: "httpStream", httpStream: { port: 3000 } }); Learn more: [FastMCP Documentation](https://github.com/punkpeye/fastmcp) -## Server Modes +## Transport Types + +| Feature | HTTP (Streamable HTTP) | stdio | +|---------|------------------------|-------| +| Use case | Remote access, cloud deployment | Local clients (Claude Desktop) | +| Protocol | HTTP/SSE | stdin/stdout | +| Session management | ✓ (stateful mode) | — | +| OAuth support | ✓ (SDK stateful) | — | +| Docker deployment | ✓ | — | +| Port configuration | ✓ | — | + +**HTTP**: Deploy as an HTTP server accessible remotely. Choose stateless or stateful mode. + +**stdio**: Run as a local process. Communicates over stdin/stdout. Ideal for local clients. No HTTP server, no port, no Dockerfile generated. + +## Server Modes (HTTP only) | Feature | Stateless (default) | Stateful | |---------|---------------------|----------| diff --git a/package.json b/package.json index 60a0d60..aee6828 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentailor/create-mcp-server", - "version": "0.5.3", + "version": "0.6.0", "description": "Create a new MCP (Model Context Protocol) server project", "type": "module", "bin": { diff --git a/src/cli.test.ts b/src/cli.test.ts index b8c2987..d054b36 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -39,6 +39,7 @@ describe('CLI argument parsing', () => { const result = parseArguments(); expect(result.options?.packageManager).toBe('npm'); expect(result.options?.framework).toBe('sdk'); + expect(result.options?.transport).toBe('http'); expect(result.options?.template).toBe('stateless'); expect(result.options?.oauth).toBe(false); expect(result.options?.git).toBe(true); @@ -60,6 +61,7 @@ describe('CLI argument parsing', () => { name: 'test-project', packageManager: 'pnpm', framework: 'fastmcp', + transport: 'http', template: 'stateful', oauth: false, git: false, @@ -172,4 +174,77 @@ describe('CLI argument parsing', () => { // No args = interactive mode expect(result.mode).toBe('interactive'); }); + + it('--stdio sets transport to stdio', async () => { + process.argv = ['node', 'create-mcp-server', '--name=my-stdio-server', '--stdio']; + const { parseArguments } = await import('./cli.js'); + const result = parseArguments(); + expect(result.mode).toBe('cli'); + expect(result.options?.transport).toBe('stdio'); + expect(result.options?.oauth).toBe(false); + }); + + it('--stdio with --framework=fastmcp is valid', async () => { + process.argv = [ + 'node', + 'create-mcp-server', + '--name=my-server', + '--stdio', + '--framework=fastmcp', + ]; + const { parseArguments } = await import('./cli.js'); + const result = parseArguments(); + expect(result.options?.transport).toBe('stdio'); + expect(result.options?.framework).toBe('fastmcp'); + }); + + it('exits with error when --stdio combined with --oauth', async () => { + process.argv = [ + 'node', + 'create-mcp-server', + '--name=test', + '--stdio', + '--oauth', + '--framework=sdk', + '--template=stateful', + ]; + + let exitCode: number | undefined; + process.exit = vi.fn((code) => { + exitCode = code as number; + throw new Error('process.exit called'); + }) as never; + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { parseArguments } = await import('./cli.js'); + expect(() => parseArguments()).toThrow('process.exit called'); + expect(exitCode).toBe(1); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('--stdio cannot be combined with --oauth') + ); + + consoleError.mockRestore(); + }); + + it('exits with error when --stdio combined with --template=stateful', async () => { + process.argv = ['node', 'create-mcp-server', '--name=test', '--stdio', '--template=stateful']; + + let exitCode: number | undefined; + process.exit = vi.fn((code) => { + exitCode = code as number; + throw new Error('process.exit called'); + }) as never; + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { parseArguments } = await import('./cli.js'); + expect(() => parseArguments()).toThrow('process.exit called'); + expect(exitCode).toBe(1); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('--template=stateful is not applicable with --stdio') + ); + + consoleError.mockRestore(); + }); }); diff --git a/src/cli.ts b/src/cli.ts index abed2cf..b5afa3e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ import { Command, Option, InvalidArgumentError } from 'commander'; -import type { PackageManager, Framework } from './templates/common/types.js'; +import type { PackageManager, Framework, TransportType } from './templates/common/types.js'; export type TemplateType = 'stateless' | 'stateful'; @@ -7,6 +7,7 @@ export interface CLIOptions { name: string; packageManager: PackageManager; framework: Framework; + transport: TransportType; template: TemplateType; oauth: boolean; git: boolean; @@ -52,6 +53,7 @@ export function parseArguments(): ParseResult { .choices(['stateless', 'stateful']) .default('stateless') ) + .option('--stdio', 'Use stdio transport instead of HTTP', false) .option('--oauth', 'Enable OAuth authentication (sdk+stateful only)', false) .option('--no-git', 'Skip git repository initialization'); @@ -78,6 +80,19 @@ export function parseArguments(): ParseResult { process.exit(1); } + // Validate stdio constraints + if (opts.stdio && opts.oauth) { + console.error('\nError: --stdio cannot be combined with --oauth\n'); + process.exit(1); + } + + if (opts.stdio && opts.template === 'stateful') { + console.error( + '\nError: --template=stateful is not applicable with --stdio (stdio is inherently stateless)\n' + ); + process.exit(1); + } + // Validate OAuth constraint if (opts.oauth && (opts.framework !== 'sdk' || opts.template !== 'stateful')) { console.error('\nError: --oauth is only valid with --framework=sdk and --template=stateful\n'); @@ -90,6 +105,7 @@ export function parseArguments(): ParseResult { name: opts.name, packageManager: opts.packageManager as PackageManager, framework: opts.framework as Framework, + transport: (opts.stdio ? 'stdio' : 'http') as TransportType, template: opts.template as TemplateType, oauth: opts.oauth, git: opts.git, // Commander handles --no-git -> git: false diff --git a/src/index.ts b/src/index.ts index 4e4e237..910c198 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ async function main() { projectName: options!.name, packageManager: options!.packageManager, framework: options!.framework, + transport: options!.transport, templateType: options!.template, withOAuth: options!.oauth, withGitInit: options!.git, diff --git a/src/interactive.ts b/src/interactive.ts index 5037646..f3b41b7 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -1,5 +1,5 @@ import prompts from 'prompts'; -import type { PackageManager, Framework } from './templates/common/types.js'; +import type { PackageManager, Framework, TransportType } from './templates/common/types.js'; import type { TemplateType } from './cli.js'; import { generateProject } from './project-generator.js'; @@ -77,18 +77,22 @@ export async function runInteractiveMode(): Promise { const framework: Framework = frameworkResponse.framework || 'sdk'; - // Server mode selection - const templateTypeResponse = await prompts( + // Transport selection + const transportResponse = await prompts( { type: 'select', - name: 'templateType', - message: 'Server mode:', + name: 'transport', + message: 'Transport:', choices: [ - { title: 'Stateless', value: 'stateless', description: 'Simple HTTP server' }, { - title: 'Stateful', - value: 'stateful', - description: 'Session-based server with SSE support', + title: 'HTTP (Streamable HTTP)', + value: 'http', + description: 'Deploy as an HTTP server (recommended for remote access)', + }, + { + title: 'stdio', + value: 'stdio', + description: 'For local use with local clients like Claude Desktop', }, ], initial: 0, @@ -96,11 +100,35 @@ export async function runInteractiveMode(): Promise { { onCancel } ); - const templateType: TemplateType = templateTypeResponse.templateType || 'stateless'; + const transport: TransportType = transportResponse.transport || 'http'; + + // Server mode selection - only for HTTP transport + let templateType: TemplateType = 'stateless'; + if (transport === 'http') { + const templateTypeResponse = await prompts( + { + type: 'select', + name: 'templateType', + message: 'Server mode:', + choices: [ + { title: 'Stateless', value: 'stateless', description: 'Simple HTTP server' }, + { + title: 'Stateful', + value: 'stateful', + description: 'Session-based server with SSE support', + }, + ], + initial: 0, + }, + { onCancel } + ); + + templateType = templateTypeResponse.templateType || 'stateless'; + } - // OAuth prompt - only for SDK stateful template + // OAuth prompt - only for SDK stateful HTTP template let withOAuth = false; - if (framework === 'sdk' && templateType === 'stateful') { + if (transport === 'http' && framework === 'sdk' && templateType === 'stateful') { const oauthResponse = await prompts( { type: 'confirm', @@ -129,6 +157,7 @@ export async function runInteractiveMode(): Promise { projectName, packageManager, framework, + transport, templateType, withOAuth, withGitInit, diff --git a/src/project-generator.ts b/src/project-generator.ts index 6fd148a..6085cf9 100644 --- a/src/project-generator.ts +++ b/src/project-generator.ts @@ -10,6 +10,7 @@ import type { SdkTemplateOptions, Framework, PackageManager, + TransportType, } from './templates/common/types.js'; import { getServerTemplate as getSdkStatelessServerTemplate, @@ -27,6 +28,11 @@ import { getIndexTemplate as getFastMCPIndexTemplate, getReadmeTemplate as getFastMCPReadmeTemplate, } from './templates/fastmcp/index.js'; +import { + getServerTemplate as getSdkStdioServerTemplate, + getIndexTemplate as getSdkStdioIndexTemplate, + getReadmeTemplate as getSdkStdioReadmeTemplate, +} from './templates/sdk/stdio/index.js'; import { getDockerfileTemplate, getDockerignoreTemplate } from './templates/deployment/index.js'; import type { TemplateType } from './cli.js'; @@ -68,19 +74,29 @@ export interface ProjectConfig { projectName: string; packageManager: PackageManager; framework: Framework; + transport: TransportType; templateType: TemplateType; withOAuth: boolean; withGitInit: boolean; } export async function generateProject(config: ProjectConfig): Promise { - const { projectName, packageManager, framework, templateType, withOAuth, withGitInit } = config; + const { + projectName, + packageManager, + framework, + transport, + templateType, + withOAuth, + withGitInit, + } = config; const templateOptions: CommonTemplateOptions = { - withOAuth, + withOAuth: transport === 'http' ? withOAuth : false, packageManager, framework, - stateless: templateType === 'stateless', + stateless: transport === 'stdio' ? true : templateType === 'stateless', + transport, }; const projectPath = join(process.cwd(), projectName); @@ -108,8 +124,18 @@ export async function generateProject(config: ProjectConfig): Promise { fastmcpTemplateFunctions.getReadmeTemplate(projectName, templateOptions) ) ); + } else if (transport === 'stdio') { + // SDK stdio templates + filesToWrite.push( + writeFile(join(srcPath, 'server.ts'), getSdkStdioServerTemplate(projectName)), + writeFile(join(srcPath, 'index.ts'), getSdkStdioIndexTemplate(templateOptions)), + writeFile( + join(projectPath, 'README.md'), + getSdkStdioReadmeTemplate(projectName, templateOptions) + ) + ); } else { - // SDK templates + // SDK HTTP templates (stateless or stateful) const templates = sdkTemplateFunctions[templateType]; filesToWrite.push( writeFile(join(srcPath, 'server.ts'), templates.getServerTemplate(projectName)), @@ -137,11 +163,13 @@ export async function generateProject(config: ProjectConfig): Promise { writeFile(join(projectPath, '.env.example'), getEnvExampleTemplate(templateOptions)) ); - // Deployment files for all templates - filesToWrite.push( - writeFile(join(projectPath, 'Dockerfile'), getDockerfileTemplate(templateOptions)), - writeFile(join(projectPath, '.dockerignore'), getDockerignoreTemplate()) - ); + // Deployment files for HTTP transport only (stdio servers are not HTTP services) + if (transport === 'http') { + filesToWrite.push( + writeFile(join(projectPath, 'Dockerfile'), getDockerfileTemplate(templateOptions)), + writeFile(join(projectPath, '.dockerignore'), getDockerignoreTemplate()) + ); + } // Write all template files await Promise.all(filesToWrite); @@ -158,7 +186,7 @@ export async function generateProject(config: ProjectConfig): Promise { const commands = packageManagerCommands[packageManager]; const frameworkName = framework === 'fastmcp' ? 'FastMCP' : 'MCP SDK'; - console.log(`\nCreated ${projectName} with ${frameworkName} at ${projectPath}`); + console.log(`\nCreated ${projectName} with ${frameworkName} (${transport}) at ${projectPath}`); console.log(`\nNext steps:`); console.log(` cd ${projectName}`); console.log(` ${commands.install}`); diff --git a/src/templates/common/env.example.ts b/src/templates/common/env.example.ts index ab092a5..a4e4697 100644 --- a/src/templates/common/env.example.ts +++ b/src/templates/common/env.example.ts @@ -1,6 +1,12 @@ import type { CommonTemplateOptions } from './types.js'; export function getEnvExampleTemplate(options?: CommonTemplateOptions): string { + const transport = options?.transport ?? 'http'; + + if (transport === 'stdio') { + return `# No environment variables required for stdio transport\n`; + } + const withOAuth = options?.withOAuth ?? false; const isSdk = options?.framework === 'sdk' || !options?.framework; diff --git a/src/templates/common/package.json.ts b/src/templates/common/package.json.ts index f3b2614..873df40 100644 --- a/src/templates/common/package.json.ts +++ b/src/templates/common/package.json.ts @@ -6,6 +6,7 @@ export function getPackageJsonTemplate( ): string { const withOAuth = options?.withOAuth ?? false; const framework = options?.framework ?? 'sdk'; + const transport = options?.transport ?? 'http'; let dependencies: Record; let devDependencies: Record; @@ -26,11 +27,22 @@ export function getPackageJsonTemplate( ...dotEnvDependency, }; + devDependencies = { + ...commonDevDependencies, + }; + } else if (transport === 'stdio') { + // Official SDK stdio - no express needed + dependencies = { + '@modelcontextprotocol/sdk': '^1.26.0', + ...zodDependency, + ...dotEnvDependency, + }; + devDependencies = { ...commonDevDependencies, }; } else { - // Official SDK dependencies + // Official SDK HTTP dependencies dependencies = { '@modelcontextprotocol/sdk': '^1.26.0', express: '^5.2.1', @@ -48,6 +60,15 @@ export function getPackageJsonTemplate( }; } + const inspectScripts = + transport === 'stdio' + ? { + 'inspect:tools': 'mcp-inspector --cli node dist/index.js --method tools/list', + 'inspect:prompts': 'mcp-inspector --cli node dist/index.js --method prompts/list', + 'inspect:resources': 'mcp-inspector --cli node dist/index.js --method resources/list', + } + : { inspect: 'mcp-inspector http://localhost:3000/mcp' }; + const packageJson = { name: projectName, version: '0.1.0', @@ -57,7 +78,7 @@ export function getPackageJsonTemplate( build: 'tsc', dev: 'tsc && node dist/index.js', start: 'node dist/index.js', - inspect: 'mcp-inspector http://localhost:3000/mcp', + ...inspectScripts, }, dependencies, devDependencies, diff --git a/src/templates/common/templates.test.ts b/src/templates/common/templates.test.ts index 54c5733..5f333a4 100644 --- a/src/templates/common/templates.test.ts +++ b/src/templates/common/templates.test.ts @@ -59,6 +59,35 @@ describe('common templates', () => { expect(pkg.devDependencies['@types/express']).toBeDefined(); }); + it('should NOT include express or @types/express for SDK stdio', () => { + const template = getPackageJsonTemplate(projectName, { + framework: 'sdk', + transport: 'stdio', + }); + const pkg = JSON.parse(template); + expect(pkg.dependencies['express']).toBeUndefined(); + expect(pkg.devDependencies['@types/express']).toBeUndefined(); + expect(pkg.dependencies['@modelcontextprotocol/sdk']).toBeDefined(); + }); + + it('should use inspect:tools, inspect:prompts, inspect:resources scripts for stdio transport', () => { + const template = getPackageJsonTemplate(projectName, { + framework: 'sdk', + transport: 'stdio', + }); + const pkg = JSON.parse(template); + expect(pkg.scripts['inspect:tools']).toContain('--method tools/list'); + expect(pkg.scripts['inspect:prompts']).toContain('--method prompts/list'); + expect(pkg.scripts['inspect:resources']).toContain('--method resources/list'); + expect(pkg.scripts['inspect']).toBeUndefined(); + }); + + it('should use http inspect script for http transport (default)', () => { + const template = getPackageJsonTemplate(projectName, { framework: 'sdk', transport: 'http' }); + const pkg = JSON.parse(template); + expect(pkg.scripts.inspect).toContain('http://localhost:3000/mcp'); + }); + it('should include required scripts', () => { const template = getPackageJsonTemplate(projectName); const pkg = JSON.parse(template); @@ -120,5 +149,15 @@ describe('common templates', () => { const template = getEnvExampleTemplate({ framework: 'fastmcp' }); expect(template).not.toContain('ALLOWED_HOSTS'); }); + + it('should NOT include PORT for stdio transport', () => { + const template = getEnvExampleTemplate({ transport: 'stdio' }); + expect(template).not.toContain('PORT='); + }); + + it('should NOT include ALLOWED_HOSTS for stdio transport', () => { + const template = getEnvExampleTemplate({ transport: 'stdio' }); + expect(template).not.toContain('ALLOWED_HOSTS'); + }); }); }); diff --git a/src/templates/common/types.ts b/src/templates/common/types.ts index 17764de..389c4b5 100644 --- a/src/templates/common/types.ts +++ b/src/templates/common/types.ts @@ -1,5 +1,6 @@ export type PackageManager = 'npm' | 'pnpm' | 'yarn'; export type Framework = 'sdk' | 'fastmcp'; +export type TransportType = 'http' | 'stdio'; /** * Base template options shared across all templates @@ -13,6 +14,7 @@ export interface BaseTemplateOptions { */ export interface SdkTemplateOptions extends BaseTemplateOptions { withOAuth?: boolean; + transport?: TransportType; } /** @@ -20,6 +22,7 @@ export interface SdkTemplateOptions extends BaseTemplateOptions { */ export interface FastMCPTemplateOptions extends BaseTemplateOptions { stateless?: boolean; + transport?: TransportType; } /** @@ -30,4 +33,5 @@ export interface CommonTemplateOptions extends BaseTemplateOptions { withOAuth?: boolean; framework?: Framework; stateless?: boolean; + transport?: TransportType; } diff --git a/src/templates/fastmcp/index.ts b/src/templates/fastmcp/index.ts index 1d3bc60..29072ad 100644 --- a/src/templates/fastmcp/index.ts +++ b/src/templates/fastmcp/index.ts @@ -2,6 +2,17 @@ export type { FastMCPTemplateOptions as TemplateOptions } from '../common/types. import type { FastMCPTemplateOptions } from '../common/types.js'; export function getIndexTemplate(options?: FastMCPTemplateOptions): string { + const transport = options?.transport ?? 'http'; + + if (transport === 'stdio') { + return `import { server } from './server.js'; + +server.start({ + transportType: "stdio", +}); +`; + } + const stateless = options?.stateless ?? false; const statelessConfig = stateless ? '\n stateless: true,' : ''; diff --git a/src/templates/fastmcp/readme.ts b/src/templates/fastmcp/readme.ts index c34586c..fb39dcb 100644 --- a/src/templates/fastmcp/readme.ts +++ b/src/templates/fastmcp/readme.ts @@ -3,10 +3,20 @@ import type { TemplateOptions } from './index.js'; export function getReadmeTemplate(projectName: string, options?: TemplateOptions): string { const packageManager = options?.packageManager ?? 'npm'; const stateless = options?.stateless ?? false; + const transport = options?.transport ?? 'http'; const commands: Record< string, - { install: string; dev: string; build: string; start: string; inspect: string } + { + install: string; + dev: string; + build: string; + start: string; + inspect: string; + inspectTools: string; + inspectPrompts: string; + inspectResources: string; + } > = { npm: { install: 'npm install', @@ -14,6 +24,9 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions build: 'npm run build', start: 'npm start', inspect: 'npm run inspect', + inspectTools: 'npm run inspect:tools', + inspectPrompts: 'npm run inspect:prompts', + inspectResources: 'npm run inspect:resources', }, pnpm: { install: 'pnpm install', @@ -21,6 +34,9 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions build: 'pnpm build', start: 'pnpm start', inspect: 'pnpm inspect', + inspectTools: 'pnpm inspect:tools', + inspectPrompts: 'pnpm inspect:prompts', + inspectResources: 'pnpm inspect:resources', }, yarn: { install: 'yarn', @@ -28,11 +44,104 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions build: 'yarn build', start: 'yarn start', inspect: 'yarn inspect', + inspectTools: 'yarn inspect:tools', + inspectPrompts: 'yarn inspect:prompts', + inspectResources: 'yarn inspect:resources', }, }; const cmd = commands[packageManager]; + if (transport === 'stdio') { + return `# ${projectName} + +A stdio MCP server built with FastMCP. + +## About + +This project was created with [@agentailor/create-mcp-server](https://www.npmjs.com/package/@agentailor/create-mcp-server) using [FastMCP](https://github.com/punkpeye/fastmcp). + +## Getting Started + +\`\`\`bash +# Install dependencies +${cmd.install} + +# Build and run +${cmd.dev} + +# Or build and start separately +${cmd.build} +${cmd.start} +\`\`\` + +## Testing with MCP Inspector + +This project includes [MCP Inspector](https://github.com/modelcontextprotocol/inspector) as a dev dependency. Build the project first (\`${cmd.build}\`), then use the inspect scripts: + +\`\`\`bash +# List tools +${cmd.inspectTools} + +# List prompts +${cmd.inspectPrompts} + +# List resources +${cmd.inspectResources} +\`\`\` + +You can also call tools directly: + +\`\`\`bash +# Call a tool +npx @modelcontextprotocol/inspector --cli node dist/index.js --method tools/call --tool-name start-notification-stream --tool-arg interval=100 --tool-arg count=5 + +# Call a tool with JSON arguments +npx @modelcontextprotocol/inspector --cli node dist/index.js --method tools/call --tool-name start-notification-stream --tool-arg 'options={"interval": 100, "count": 5}' +\`\`\` + +## Included Examples + +This server comes with example implementations to help you get started: + +### Prompts + +- **greeting-template** - A simple greeting prompt that takes a name parameter + +### Tools + +- **start-notification-stream** - Sends periodic notifications for testing. Parameters: + - \`interval\`: Milliseconds between notifications (default: 100) + - \`count\`: Number of notifications to send (default: 10, use 0 for unlimited) + +### Resources + +- **greeting-resource** - A simple text resource at \`https://example.com/greetings/default\` + +## Project Structure + +\`\`\` +${projectName}/ +├── src/ +│ ├── server.ts # FastMCP server definition (tools, prompts, resources) +│ └── index.ts # Server startup configuration +├── package.json +├── tsconfig.json +└── README.md +\`\`\` + +## Customization + +- Add new tools, prompts, and resources in \`src/server.ts\` +- Modify transport configuration in \`src/index.ts\` + +## Learn More + +- [FastMCP](https://github.com/punkpeye/fastmcp) - The framework powering this server +- [Model Context Protocol](https://modelcontextprotocol.io/) +`; + } + const modeDescription = stateless ? 'A stateless streamable HTTP MCP server built with FastMCP.' : 'A stateful streamable HTTP MCP server built with FastMCP.'; diff --git a/src/templates/fastmcp/templates.test.ts b/src/templates/fastmcp/templates.test.ts index d0c0cfb..43f58a9 100644 --- a/src/templates/fastmcp/templates.test.ts +++ b/src/templates/fastmcp/templates.test.ts @@ -66,6 +66,13 @@ describe('fastmcp templates', () => { const template = getIndexTemplate({ stateless: true }); expect(template).toContain('stateless: true'); }); + + it('should use stdio transportType when transport is stdio', () => { + const template = getIndexTemplate({ transport: 'stdio' }); + expect(template).toContain('transportType: "stdio"'); + expect(template).not.toContain('httpStream'); + expect(template).not.toContain('PORT'); + }); }); describe('getReadmeTemplate', () => { @@ -101,5 +108,24 @@ describe('fastmcp templates', () => { const template = getReadmeTemplate(projectName, { stateless: true }); expect(template).toContain('stateless'); }); + + it('should NOT include HTTP endpoint section for stdio', () => { + const template = getReadmeTemplate(projectName, { transport: 'stdio' }); + expect(template).not.toContain('POST /mcp'); + expect(template).not.toContain('GET /health'); + }); + + it('should include MCP Inspector CLI commands for stdio', () => { + const template = getReadmeTemplate(projectName, { transport: 'stdio' }); + expect(template).toContain('inspect:tools'); + expect(template).toContain('inspect:prompts'); + expect(template).toContain('inspect:resources'); + }); + + it('should NOT include Docker section for stdio', () => { + const template = getReadmeTemplate(projectName, { transport: 'stdio' }); + expect(template).not.toContain('docker build'); + expect(template).not.toContain('Dockerfile'); + }); }); }); diff --git a/src/templates/sdk/stdio/index.ts b/src/templates/sdk/stdio/index.ts new file mode 100644 index 0000000..27e0c21 --- /dev/null +++ b/src/templates/sdk/stdio/index.ts @@ -0,0 +1,25 @@ +export type { SdkTemplateOptions as TemplateOptions } from '../../common/types.js'; +import type { SdkTemplateOptions } from '../../common/types.js'; + +// Options parameter kept for type consistency with other SDK templates +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function getIndexTemplate(_options?: SdkTemplateOptions): string { + return `import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { getServer } from './server.js'; + +async function main() { + const server = getServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("MCP Server running on stdio"); +} + +main().catch((error) => { + console.error("Fatal error in main():", error); + process.exit(1); +}); +`; +} + +export { getServerTemplate } from './server.js'; +export { getReadmeTemplate } from './readme.js'; diff --git a/src/templates/sdk/stdio/readme.ts b/src/templates/sdk/stdio/readme.ts new file mode 100644 index 0000000..338f266 --- /dev/null +++ b/src/templates/sdk/stdio/readme.ts @@ -0,0 +1,123 @@ +import type { TemplateOptions } from './index.js'; + +export function getReadmeTemplate(projectName: string, options?: TemplateOptions): string { + const packageManager = options?.packageManager ?? 'npm'; + + const commands = { + npm: { + install: 'npm install', + dev: 'npm run dev', + build: 'npm run build', + start: 'npm start', + inspectTools: 'npm run inspect:tools', + inspectPrompts: 'npm run inspect:prompts', + inspectResources: 'npm run inspect:resources', + }, + pnpm: { + install: 'pnpm install', + dev: 'pnpm dev', + build: 'pnpm build', + start: 'pnpm start', + inspectTools: 'pnpm inspect:tools', + inspectPrompts: 'pnpm inspect:prompts', + inspectResources: 'pnpm inspect:resources', + }, + yarn: { + install: 'yarn', + dev: 'yarn dev', + build: 'yarn build', + start: 'yarn start', + inspectTools: 'yarn inspect:tools', + inspectPrompts: 'yarn inspect:prompts', + inspectResources: 'yarn inspect:resources', + }, + }[packageManager]; + + return `# ${projectName} + +A stdio MCP (Model Context Protocol) server using the official MCP SDK. + +## About + +This project was created with [@agentailor/create-mcp-server](https://www.npmjs.com/package/@agentailor/create-mcp-server). + +## Getting Started + +\`\`\`bash +# Install dependencies +${commands.install} + +# Build and run +${commands.dev} + +# Or build and start separately +${commands.build} +${commands.start} +\`\`\` + +## Testing with MCP Inspector + +This project includes [MCP Inspector](https://github.com/modelcontextprotocol/inspector) as a dev dependency. Build the project first (\`${commands.build}\`), then use the inspect scripts: + +\`\`\`bash +# List tools +${commands.inspectTools} + +# List prompts +${commands.inspectPrompts} + +# List resources +${commands.inspectResources} +\`\`\` + +You can also call tools directly: + +\`\`\`bash +# Call a tool +npx @modelcontextprotocol/inspector --cli node dist/index.js --method tools/call --tool-name start-notification-stream --tool-arg interval=100 --tool-arg count=5 + +# Call a tool with JSON arguments +npx @modelcontextprotocol/inspector --cli node dist/index.js --method tools/call --tool-name start-notification-stream --tool-arg 'options={"interval": 100, "count": 5}' +\`\`\` + +## Included Examples + +This server comes with example implementations to help you get started: + +### Prompts + +- **greeting-template** - A simple greeting prompt that takes a name parameter + +### Tools + +- **start-notification-stream** - Sends periodic notifications for testing. Parameters: + - \`interval\`: Milliseconds between notifications (default: 100) + - \`count\`: Number of notifications to send (default: 10, use 0 for unlimited) + +### Resources + +- **greeting-resource** - A simple text resource at \`https://example.com/greetings/default\` + +## Project Structure + +\`\`\` +${projectName}/ +├── src/ +│ ├── server.ts # MCP server definition (tools, prompts, resources) +│ └── index.ts # stdio transport startup +├── package.json +├── tsconfig.json +└── README.md +\`\`\` + +## Customization + +- Add new tools, prompts, and resources in \`src/server.ts\` +- Modify transport configuration in \`src/index.ts\` + +## Learn More + +- [Model Context Protocol](https://modelcontextprotocol.io/) +- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) +`; +} diff --git a/src/templates/sdk/stdio/server.ts b/src/templates/sdk/stdio/server.ts new file mode 100644 index 0000000..ccd220e --- /dev/null +++ b/src/templates/sdk/stdio/server.ts @@ -0,0 +1,2 @@ +// Re-export from stateless template - server logic is identical for stdio +export { getServerTemplate } from '../stateless/server.js'; diff --git a/src/templates/sdk/stdio/templates.test.ts b/src/templates/sdk/stdio/templates.test.ts new file mode 100644 index 0000000..a73b88e --- /dev/null +++ b/src/templates/sdk/stdio/templates.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { getServerTemplate, getIndexTemplate, getReadmeTemplate } from './index.js'; + +describe('sdk/stdio templates', () => { + const projectName = 'test-project'; + + describe('getServerTemplate', () => { + it('should include project name in server config', () => { + const template = getServerTemplate(projectName); + expect(template).toContain(`name: '${projectName}'`); + }); + + it('should use correct SDK imports', () => { + const template = getServerTemplate(projectName); + expect(template).toContain("from '@modelcontextprotocol/sdk/types.js'"); + expect(template).toContain("from '@modelcontextprotocol/sdk/server/mcp.js'"); + }); + + it('should include example prompt', () => { + const template = getServerTemplate(projectName); + expect(template).toContain('greeting-template'); + expect(template).toContain('registerPrompt'); + }); + + it('should include example tool', () => { + const template = getServerTemplate(projectName); + expect(template).toContain('start-notification-stream'); + expect(template).toContain('registerTool'); + }); + + it('should include example resource', () => { + const template = getServerTemplate(projectName); + expect(template).toContain('greeting-resource'); + expect(template).toContain('registerResource'); + }); + }); + + describe('getIndexTemplate', () => { + it('should import StdioServerTransport', () => { + const template = getIndexTemplate(); + expect(template).toContain('StdioServerTransport'); + expect(template).toContain('@modelcontextprotocol/sdk/server/stdio.js'); + }); + + it('should NOT import express or createMcpExpressApp', () => { + const template = getIndexTemplate(); + expect(template).not.toContain('express'); + expect(template).not.toContain('createMcpExpressApp'); + }); + + it('should NOT reference PORT', () => { + const template = getIndexTemplate(); + expect(template).not.toContain('PORT'); + expect(template).not.toContain('process.env.PORT'); + }); + + it('should connect server to StdioServerTransport', () => { + const template = getIndexTemplate(); + expect(template).toContain('new StdioServerTransport()'); + expect(template).toContain('server.connect(transport)'); + }); + + it('should log to stderr, not stdout', () => { + const template = getIndexTemplate(); + expect(template).toContain('console.error("MCP Server running on stdio")'); + }); + + it('should wrap startup in async main with error handling', () => { + const template = getIndexTemplate(); + expect(template).toContain('async function main()'); + expect(template).toContain('main().catch'); + expect(template).toContain('process.exit(1)'); + }); + }); + + describe('getReadmeTemplate', () => { + it('should include project name', () => { + const template = getReadmeTemplate(projectName); + expect(template).toContain(`# ${projectName}`); + }); + + it('should describe stdio transport', () => { + const template = getReadmeTemplate(projectName); + expect(template).toContain('stdio'); + }); + + it('should include MCP Inspector CLI commands', () => { + const template = getReadmeTemplate(projectName); + expect(template).toContain('inspect:tools'); + expect(template).toContain('inspect:prompts'); + expect(template).toContain('inspect:resources'); + }); + + it('should NOT mention HTTP API endpoints', () => { + const template = getReadmeTemplate(projectName); + expect(template).not.toContain('POST /mcp'); + expect(template).not.toContain('GET /health'); + }); + + it('should NOT mention PORT or ALLOWED_HOSTS', () => { + const template = getReadmeTemplate(projectName); + expect(template).not.toContain('PORT'); + expect(template).not.toContain('ALLOWED_HOSTS'); + }); + + it('should NOT include Docker deployment section', () => { + const template = getReadmeTemplate(projectName); + expect(template).not.toContain('docker build'); + expect(template).not.toContain('Dockerfile'); + }); + + it('should use npm commands by default', () => { + const template = getReadmeTemplate(projectName); + expect(template).toContain('npm install'); + expect(template).toContain('npm run dev'); + }); + + it('should use pnpm commands when specified', () => { + const template = getReadmeTemplate(projectName, { packageManager: 'pnpm' }); + expect(template).toContain('pnpm install'); + expect(template).toContain('pnpm dev'); + expect(template).not.toContain('npm run'); + }); + + it('should use yarn commands when specified', () => { + const template = getReadmeTemplate(projectName, { packageManager: 'yarn' }); + expect(template).toContain('yarn\n'); + expect(template).toContain('yarn dev'); + expect(template).not.toContain('npm run'); + }); + }); +});