diff --git a/Makefile b/Makefile index d8d8fe7..e4dfc35 100644 --- a/Makefile +++ b/Makefile @@ -29,3 +29,11 @@ test: check-go lint: check-go @echo "$(BLUE)Running linter...$(NC)" golangci-lint run ./... + +dist: + mkdir -p dist + +clean: + rm -rf bin/* dist/* + +release: clean lint test build diff --git a/README.md b/README.md index c09b6cf..921ad3a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A command-line interface for interacting with MCP (Model Context Protocol) serve - [Output Formats](#output-formats) - [Commands](#commands) - [Interactive Shell](#interactive-shell) + - [Project Scaffolding](#project-scaffolding) - [Server Aliases](#server-aliases) - [Server Modes](#server-modes) - [Mock Server Mode](#mock-server-mode) @@ -42,6 +43,7 @@ MCP Tools provides a versatile CLI for working with Model Context Protocol (MCP) - Create mock servers for testing client applications - Proxy MCP requests to shell scripts for easy extensibility - Create interactive shells for exploring and using MCP servers +- Scaffold new MCP projects with TypeScript support - Format output in various styles (JSON, pretty-printed, table) - Support all transport methods (HTTP, stdio) @@ -96,6 +98,7 @@ Available Commands: call Call a tool, resource, or prompt on the MCP server help Help about any command mock Create a mock MCP server with tools, prompts, and resources + new Create a new MCP project from templates proxy Proxy MCP tool requests to shell scripts prompts List available prompts on the MCP server resources List available resources on the MCP server @@ -272,6 +275,50 @@ Special Commands: /q, /quit, exit Exit the shell ``` +### Project Scaffolding + +MCP Tools provides a scaffolding feature to quickly create new MCP servers with TypeScript: + +```bash +mkdir my-mcp-server +cd my-mcp-server + +# Create a project with specific components +mcp new tool:calculate resource:file prompt:greet + +# Create a project with a specific SDK (currently only TypeScript/ts supported) +mcp new tool:calculate --sdk=ts + +# Create a project with a specific transport type +mcp new tool:calculate --transport=stdio +mcp new tool:calculate --transport=sse +``` + +The scaffolding creates a complete project structure with: + +- Server setup with chosen transport (stdio or SSE) +- TypeScript configuration with modern ES modules +- Component implementations with proper MCP interfaces +- Automatic wiring of imports and initialization + +After scaffolding, you can build and run your MCP server: + +```bash +# Install dependencies +npm install + +# Build the TypeScript code +npm run build + +# Test the server with MCP Tools +mcp tools node build/index.js +``` + +Project templates are stored in either: +- Local `./templates/` directory +- User's home directory: `~/.mcputils/templates/` +- Next to the MCP Tools executable + ## Server Aliases MCP Tools allows you to save and reuse server commands with friendly aliases: diff --git a/cmd/mcptools/main.go b/cmd/mcptools/main.go index b235876..f2f0c23 100644 --- a/cmd/mcptools/main.go +++ b/cmd/mcptools/main.go @@ -96,6 +96,7 @@ func main() { newMockCmd(), proxyCmd(), aliasCmd(), + newNewCmd(), ) if err := rootCmd.Execute(); err != nil { @@ -1282,10 +1283,19 @@ func aliasListCmd() *cobra.Command { func aliasRemoveCmd() *cobra.Command { return &cobra.Command{ - Use: "remove [alias]", + Use: "remove ", Short: "Remove an MCP server alias", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + Long: `Remove a registered alias for an MCP server command. + +Example: + mcp alias remove myfs`, + Args: cobra.ExactArgs(1), + RunE: func(thisCmd *cobra.Command, args []string) error { + if len(args) == 1 && (args[0] == flagHelp || args[0] == flagHelpShort) { + _ = thisCmd.Help() + return nil + } + aliasName := args[0] aliases, err := alias.Load() @@ -1294,7 +1304,7 @@ func aliasRemoveCmd() *cobra.Command { } if _, exists := aliases[aliasName]; !exists { - return fmt.Errorf("alias '%s' not found", aliasName) + return fmt.Errorf("alias '%s' does not exist", aliasName) } delete(aliases, aliasName) diff --git a/cmd/mcptools/new.go b/cmd/mcptools/new.go new file mode 100644 index 0000000..3f806c3 --- /dev/null +++ b/cmd/mcptools/new.go @@ -0,0 +1,295 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/cobra" +) + +// Constants for template options. +const ( + sdkTypeScript = "ts" + transportStdio = "stdio" + transportSSE = "sse" +) + +// newNewCmd returns a new 'new' command for scaffolding MCP projects. +func newNewCmd() *cobra.Command { + var sdkFlag string + var transportFlag string + + cmd := &cobra.Command{ + Use: "new [component:name...]", + Short: "Create a new MCP project component", + Long: `Create a new MCP component (tool, resource, or prompt) from a template. + +Examples: + mcp new tool:hello_world resource:file prompt:hello + mcp new tool:hello_world --sdk=ts + mcp new tool:hello_world --transport=stdio|sse`, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("at least one component must be specified (e.g., tool:hello_world)") + } + + // Validate SDK flag + if sdkFlag != "" && sdkFlag != sdkTypeScript { + return fmt.Errorf("unsupported SDK: %s (only ts is currently supported)", sdkFlag) + } + + // Set default SDK if not specified + if sdkFlag == "" { + sdkFlag = sdkTypeScript + } + + // Validate transport flag + if transportFlag != "" && transportFlag != transportStdio && transportFlag != transportSSE { + return fmt.Errorf("unsupported transport: %s (supported options: stdio, sse)", transportFlag) + } + + // Set default transport if not specified + if transportFlag == "" { + transportFlag = transportStdio + } + + // Parse components from args + components := make(map[string]string) + for _, arg := range args { + parts := strings.SplitN(arg, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid component format: %s (expected format: type:name)", arg) + } + + componentType := parts[0] + componentName := parts[1] + + // Validate component type + switch componentType { + case "tool", "resource", "prompt": // nolint + components[componentType] = componentName + default: + return fmt.Errorf("unsupported component type: %s (supported types: tool, resource, prompt)", componentType) + } + } + + // Create project structure + return createProjectStructure(components, sdkFlag, transportFlag) + }, + } + + // Add flags + cmd.Flags().StringVar(&sdkFlag, "sdk", "", "Specify the SDK to use (ts)") + cmd.Flags().StringVar(&transportFlag, "transport", "", "Specify the transport to use (stdio, sse)") + + return cmd +} + +// createProjectStructure creates the project directory and files based on components. +func createProjectStructure(components map[string]string, sdk, transport string) error { + // Create project directory + projectDir := "." + srcDir := filepath.Join(projectDir, "src") + + // Ensure src directory exists + if err := os.MkdirAll(srcDir, 0o750); err != nil { + return fmt.Errorf("error creating src directory: %w", err) + } + + // Look for templates in multiple locations + templatesDir := findTemplatesDir(sdk) + if templatesDir == "" { + return fmt.Errorf("could not find templates directory for SDK: %s", sdk) + } + + // Copy config files + if err := copyFile( + filepath.Join(templatesDir, "package.json"), + filepath.Join(projectDir, "package.json"), + map[string]string{"PROJECT_NAME": filepath.Base(projectDir)}, + ); err != nil { + return err + } + + if err := copyFile( + filepath.Join(templatesDir, "tsconfig.json"), + filepath.Join(projectDir, "tsconfig.json"), + nil, + ); err != nil { + return err + } + + // Create index.ts with the server setup + var serverTemplateFile string + if transport == transportSSE { + serverTemplateFile = filepath.Join(templatesDir, "server_sse.ts") + } else { + // Use stdio by default + serverTemplateFile = filepath.Join(templatesDir, "server_stdio.ts") + } + + if err := copyFile( + serverTemplateFile, + filepath.Join(srcDir, "index.ts"), + map[string]string{"PROJECT_NAME": filepath.Base(projectDir)}, + ); err != nil { + return err + } + + // Create component files + for componentType, componentName := range components { + componentFile := filepath.Join(srcDir, componentName+".ts") + templateFile := filepath.Join(templatesDir, componentType+".ts") + + replacements := map[string]string{ + "TOOL_NAME": componentName, + "RESOURCE_NAME": componentName, + "PROMPT_NAME": componentName, + "TOOL_DESCRIPTION": fmt.Sprintf("The %s tool", componentName), + "RESOURCE_URI": fmt.Sprintf("%s://data", componentName), + } + + if err := copyFile(templateFile, componentFile, replacements); err != nil { + return err + } + + // Add import to index.ts + if err := appendImport(filepath.Join(srcDir, "index.ts"), componentName); err != nil { + return err + } + } + + fmt.Printf("MCP project created successfully with %s SDK and %s transport.\n", sdk, transport) + fmt.Println("Run the following commands to build and start your MCP server:") + fmt.Println("npm install") + fmt.Println("npm run build") + fmt.Println("npm start") + + return nil +} + +// copyFile copies a template file to the destination with replacements. +func copyFile(srcPath, destPath string, replacements map[string]string) error { + // Read template file + + content, err := os.ReadFile(srcPath) //nolint + if err != nil { + return fmt.Errorf("error reading template file %s: %w", srcPath, err) + } + + // Apply replacements + fileContent := string(content) + if replacements != nil { // nolint + for key, value := range replacements { + fileContent = strings.ReplaceAll(fileContent, key, value) + } + } + + // Ensure parent directory exists + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0o755); err != nil { //nolint + return fmt.Errorf("error creating directory %s: %w", destDir, err) + } + + // Write the processed content to the destination file + if err := os.WriteFile(destPath, []byte(fileContent), 0o600); err != nil { //nolint + return fmt.Errorf("error writing to %s: %w", destPath, err) + } + + return nil +} + +// appendImport appends an import statement for the component to the main index.ts file. +func appendImport(indexFile, componentName string) error { + // Read the index file + content, err := os.ReadFile(indexFile) //nolint + if err != nil { + return fmt.Errorf("error reading index file: %w", err) + } + + // Check if import is already present + importRegex := regexp.MustCompile(fmt.Sprintf(`import\s+.*\s+from\s+['"]\.\/%s['"]`, componentName)) + if importRegex.Match(content) { + // Import already exists, no need to add it + return nil + } + + // Add import statement after the last import + fileContent := string(content) + importPattern := regexp.MustCompile(`^import.*$`) + lastImportIndex := 0 + + for _, line := range strings.Split(fileContent, "\n") { + if importPattern.MatchString(line) { + lastImportIndex += len(line) + 1 // +1 for newline character + } + } + + // Insert the new import after the last import + importStatement := fmt.Sprintf("import %s from \"./%s.js\";\n", componentName, componentName) + updatedContent := fileContent[:lastImportIndex] + importStatement + fileContent[lastImportIndex:] + + // Find the position to insert component initialization + transportPattern := regexp.MustCompile(`(?m)^const\s+transport\s*=`) + match := transportPattern.FindStringIndex(updatedContent) + + var finalContent string + if match != nil { + // Insert component initialization before the transport line + componentInit := fmt.Sprintf("// Initialize the %s component\n%s(server);\n\n", componentName, componentName) + finalContent = updatedContent[:match[0]] + componentInit + updatedContent[match[0]:] + } else { + // Fallback: append to the end of the file if transport line not found + fileEnd := len(updatedContent) + for fileEnd > 0 && (updatedContent[fileEnd-1] == '\n' || updatedContent[fileEnd-1] == '\r') { + fileEnd-- + } + componentInit := fmt.Sprintf("\n\n// Initialize the %s component\n%s(server);\n", componentName, componentName) + finalContent = updatedContent[:fileEnd] + componentInit + } + + // Write the updated content back to the file + if err := os.WriteFile(indexFile, []byte(finalContent), 0o644); err != nil { // nolint + return fmt.Errorf("error writing updated index file: %w", err) + } + + return nil +} + +// findTemplatesDir searches for templates in multiple standard locations. +func findTemplatesDir(sdk string) string { + // Check locations in order of preference + possibleLocations := []string{ + // Local directory + filepath.Join("templates", sdk), + + // User home directory + filepath.Join(os.Getenv("HOME"), ".mcpt", "templates", sdk), + + // Executable directory + func() string { + execPath, err := os.Executable() + if err != nil { + return "" + } + return filepath.Join(filepath.Dir(execPath), "..", "templates", sdk) + }(), + } + + for _, location := range possibleLocations { + if location == "" { + continue + } + + // Check if directory exists + if stat, err := os.Stat(location); err == nil && stat.IsDir() { + return location + } + } + + return "" +} diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..cba322f --- /dev/null +++ b/templates/README.md @@ -0,0 +1,65 @@ +# MCP Project Templates + +This directory contains templates for creating MCP (Model Context Protocol) servers with different capabilities. + +## Installation + +The templates will be automatically found if they are in one of these locations: + +1. `./templates/`: Local project directory +2. `~/.mcputils/templates/`: User's home directory +3. Next to the executable in the installed location + +To install templates to your home directory: + +```bash +make templates +``` + +## Usage + +Create a new MCP project with the `mcp new` command: + +```bash +# Create a project with a tool, resource, and prompt +mcp new tool:hello_world resource:file prompt:hello + +# Create a project with a specific SDK (currently only TypeScript/ts supported) +mcp new tool:hello_world --sdk=ts + +# Create a project with a specific transport (stdio or sse) +mcp new tool:hello_world --transport=stdio +mcp new tool:hello_world --transport=sse +``` + +## Available Templates + +### TypeScript (ts) + +- **tool**: Basic tool implementation template +- **resource**: Resource implementation template +- **prompt**: Prompt implementation template +- **server_stdio**: Server with stdio transport +- **server_sse**: Server with SSE transport +- **full_server**: Complete server with all three capabilities + +## Project Structure + +The scaffolding creates the following structure: + +``` +my-project/ +├── package.json +├── tsconfig.json +└── src/ + ├── index.ts + └── [component].ts +``` + +After scaffolding, run: + +```bash +npm install +npm run build +npm start +``` \ No newline at end of file diff --git a/templates/ts/package.json b/templates/ts/package.json new file mode 100644 index 0000000..592464f --- /dev/null +++ b/templates/ts/package.json @@ -0,0 +1,19 @@ +{ + "name": "PROJECT_NAME", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node build/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.1.0", + "zod": "^3.22.4", + "express": "^4.18.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/express": "^4.17.21", + "typescript": "^5.7.2" + } +} \ No newline at end of file diff --git a/templates/ts/prompt.ts b/templates/ts/prompt.ts new file mode 100644 index 0000000..7f47601 --- /dev/null +++ b/templates/ts/prompt.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +// Define a prompt template +export default (server: McpServer) => { + server.prompt( + "PROMPT_NAME", + { + // Define the parameters for your prompt using Zod + name: z.string({ + description: "The name to use in the greeting" + }), + time_of_day: z.enum(["morning", "afternoon", "evening", "night"], { + description: "The time of day for the greeting" + }) + }, + (params) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Hello ${params.name}! Good ${params.time_of_day}. How are you today?` + } + }] + }) + ); +}; \ No newline at end of file diff --git a/templates/ts/resource.ts b/templates/ts/resource.ts new file mode 100644 index 0000000..1674afe --- /dev/null +++ b/templates/ts/resource.ts @@ -0,0 +1,15 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +// Define a resource +export default (server: McpServer) => { + server.resource( + "RESOURCE_NAME", + "RESOURCE_URI", + async (uri) => ({ + contents: [{ + uri: uri.href, + text: "This is a sample resource content. Replace with your actual content." + }] + }) + ); +}; diff --git a/templates/ts/server_sse.ts b/templates/ts/server_sse.ts new file mode 100644 index 0000000..3303a95 --- /dev/null +++ b/templates/ts/server_sse.ts @@ -0,0 +1,31 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import express from "express"; +import { z } from "zod"; + +// Initialize server +const server = new McpServer({ + name: "PROJECT_NAME", + version: "1.0.0" +}); + +// Setup Express app +const app = express(); + +// Create an SSE endpoint that clients can connect to +app.get("/sse", async (req, res) => { + const transport = new SSEServerTransport("/messages", res); + await server.connect(transport); +}); + +// Create an endpoint to receive messages from clients +app.post("/messages", express.json(), async (req, res) => { + // Handle the message and send response + res.json({ success: true }); +}); + +// Start HTTP server +const port = 3000; +app.listen(port, () => { + console.log(`MCP server running on http://localhost:${port}/sse`); +}); \ No newline at end of file diff --git a/templates/ts/server_stdio.ts b/templates/ts/server_stdio.ts new file mode 100644 index 0000000..2e568bc --- /dev/null +++ b/templates/ts/server_stdio.ts @@ -0,0 +1,13 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +// Initialize server +const server = new McpServer({ + name: "PROJECT_NAME", + version: "1.0.0" +}); + +// === Start server with stdio transport === +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/templates/ts/tool.ts b/templates/ts/tool.ts new file mode 100644 index 0000000..7db3338 --- /dev/null +++ b/templates/ts/tool.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export default (server: McpServer) => { + // Define a calculator tool + server.tool( + "TOOL_NAME", + "TOOL_DESCRIPTION", + { + // Define the parameters for your tool using Zod + + someEnum: z.enum(["option1", "option2", "option3"], { + description: "An enum parameter" + }), + aNumber: z.number({ + description: "A number parameter" + }), + aString: z.string({ + description: "A string parameter" + }) + }, + async (params) => { + + // Implement the tool logic here + + return { + content: [{ + type: "text", + text: "This is the tool response to the user's request" + }] + }; + } + ); +} \ No newline at end of file diff --git a/templates/ts/tsconfig.json b/templates/ts/tsconfig.json new file mode 100644 index 0000000..2301685 --- /dev/null +++ b/templates/ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} \ No newline at end of file