diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index bce35e5..b15d8d5 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -91,7 +91,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Build + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + - name: Build for multiple platforms run: | # if workflow_dispatch is used, use the version input if [ -n "${{ github.event.inputs.version }}" ]; then @@ -100,10 +104,28 @@ jobs: export VERSION=$(echo "$GITHUB_REF" | cut -c11-) fi echo "Building release artifacts with version: ${VERSION}" - make build-cli VERSION=${VERSION} + + # Build for Linux amd64 + GOOS=linux GOARCH=amd64 make build-cli VERSION=${VERSION} + mv dist/kmcp dist/kmcp-linux-amd64 + + # Build for Linux arm64 + GOOS=linux GOARCH=arm64 make build-cli VERSION=${VERSION} + mv dist/kmcp dist/kmcp-linux-arm64 + + # Build for macOS amd64 + GOOS=darwin GOARCH=amd64 make build-cli VERSION=${VERSION} + mv dist/kmcp dist/kmcp-darwin-amd64 + + # Build for macOS arm64 + GOOS=darwin GOARCH=arm64 make build-cli VERSION=${VERSION} + mv dist/kmcp dist/kmcp-darwin-arm64 - name: Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: files: | - dist/kmcp + dist/kmcp-linux-amd64 + dist/kmcp-linux-arm64 + dist/kmcp-darwin-amd64 + dist/kmcp-darwin-arm64 diff --git a/cmd/kmcp/cmd/build.go b/cmd/kmcp/cmd/build.go index a7d5bbd..75def91 100644 --- a/cmd/kmcp/cmd/build.go +++ b/cmd/kmcp/cmd/build.go @@ -19,8 +19,8 @@ MCP server package or Docker image. Examples: kmcp build # Build from current directory - kmcp build --dir /path/to/project # Build from specific directory - kmcp build --docker --dir ./my-project # Build Docker image from specific directory`, + kmcp build --project-dir /path/to/project # Build from specific directory + kmcp build --docker --project-dir ./my-project # Build Docker image from specific directory`, RunE: runBuild, } @@ -39,7 +39,7 @@ func init() { buildCmd.Flags().StringVarP(&buildOutput, "output", "o", "", "Output directory or image name") buildCmd.Flags().StringVarP(&buildTag, "tag", "t", "", "Docker image tag") buildCmd.Flags().StringVar(&buildPlatform, "platform", "", "Target platform (e.g., linux/amd64,linux/arm64)") - buildCmd.Flags().StringVarP(&buildDir, "dir", "d", "", "Build directory (default: current directory)") + buildCmd.Flags().StringVarP(&buildDir, "project-dir", "d", "", "Build directory (default: current directory)") } func runBuild(_ *cobra.Command, _ []string) error { diff --git a/cmd/kmcp/cmd/init.go b/cmd/kmcp/cmd/init.go index 488749f..e44c44b 100644 --- a/cmd/kmcp/cmd/init.go +++ b/cmd/kmcp/cmd/init.go @@ -119,6 +119,9 @@ func runInitFramework( return fmt.Errorf("failed to generate project: %w", err) } + fmt.Printf(" To run the server locally:\n") + fmt.Printf(" kmcp run local --project-dir %s\n", projectPath) + return manifest.NewManager(projectPath).Save(projectManifest) } diff --git a/cmd/kmcp/cmd/run.go b/cmd/kmcp/cmd/run.go new file mode 100644 index 0000000..bbfd107 --- /dev/null +++ b/cmd/kmcp/cmd/run.go @@ -0,0 +1,245 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" + "kagent.dev/kmcp/pkg/manifest" +) + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Run MCP server locally", + Long: `Run an MCP server locally using the Model Context Protocol inspector. + +This command will: +1. Load the kmcp.yaml configuration from the project directory +2. Determine the framework type and create appropriate configuration +3. Run the MCP server using the Model Context Protocol inspector + +Supported frameworks: +- fastmcp-python: Requires uv to be installed +- mcp-go: Requires Go to be installed + +Examples: + kmcp run --project-dir ./my-project # Run from specific directory`, + RunE: executeRun, +} + +var ( + projectDir string +) + +func init() { + rootCmd.AddCommand(runCmd) + + runCmd.Flags().StringVarP( + &projectDir, + "project-dir", + "d", + "", + "Project directory to use (default: current directory)", + ) +} + +func executeRun(_ *cobra.Command, _ []string) error { + projectDir, err := getProjectDir() + if err != nil { + return err + } + + manifest, err := getProjectManifest(projectDir) + if err != nil { + return err + } + + // Check if npx is installed + if err := checkNpxInstalled(); err != nil { + return err + } + + // Determine framework and create configuration + switch manifest.Framework { + case "fastmcp-python": + return runFastMCPPython(projectDir, manifest) + case "mcp-go": + return runMCPGo(projectDir, manifest) + default: + return fmt.Errorf("unsupported framework: %s", manifest.Framework) + } +} + +func checkNpxInstalled() error { + cmd := exec.Command("npx", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("npx is required to run the modelcontextinstaller. Please install Node.js and npm to get npx") + } + return nil +} + +// createMCPInspectorConfig creates an MCP inspector configuration file +func createMCPInspectorConfig(serverName string, serverConfig map[string]interface{}, configPath string) error { + config := map[string]interface{}{ + "mcpServers": map[string]interface{}{ + serverName: serverConfig, + }, + } + + configData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, configData, 0644); err != nil { + return fmt.Errorf("failed to write mcp-server-config.json: %w", err) + } + + if verbose { + fmt.Printf("Created mcp-server-config.json: %s\n", configPath) + } + + return nil +} + +// runMCPInspector runs the MCP inspector with the given configuration +func runMCPInspector(configPath, serverName string, workingDir string) error { + args := []string{ + "@modelcontextprotocol/inspector", + "--config", configPath, + "--server", serverName, + } + + if verbose { + fmt.Printf("Running: npx %s\n", args) + } + + cmd := exec.Command("npx", args...) + if workingDir != "" { + cmd.Dir = workingDir + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Run synchronously + return cmd.Run() +} + +func runFastMCPPython(projectDir string, manifest *manifest.ProjectManifest) error { + // Check if uv is available + if _, err := exec.LookPath("uv"); err != nil { + uvInstallURL := "https://docs.astral.sh/uv/getting-started/installation/" + return fmt.Errorf( + "uv is required for this command to run fastmcp-python projects locally. Please install uv: %s", uvInstallURL, + ) + } + + // Run uv sync first + if verbose { + fmt.Printf("Running uv sync in: %s\n", projectDir) + } + syncCmd := exec.Command("uv", "sync") + syncCmd.Dir = projectDir + syncCmd.Stdout = os.Stdout + syncCmd.Stderr = os.Stderr + if err := syncCmd.Run(); err != nil { + return fmt.Errorf("failed to run uv sync: %w", err) + } + + // Create server configuration for local execution + serverConfig := map[string]interface{}{ + "command": "uv", + "args": []string{"run", "python", "src/main.py"}, + } + + // Create MCP inspector config + configPath := filepath.Join(projectDir, "mcp-server-config.json") + if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath); err != nil { + return err + } + + // Run the inspector + return runMCPInspector(configPath, manifest.Name, projectDir) +} + +func runMCPGo(projectDir string, manifest *manifest.ProjectManifest) error { + // Check if go is available + if _, err := exec.LookPath("go"); err != nil { + goInstallURL := "https://golang.org/doc/install" + return fmt.Errorf("go is required to run mcp-go projects locally. Please install Go: %s", goInstallURL) + } + + // Run go mod tidy first to ensure dependencies are up to date + if verbose { + fmt.Printf("Running go mod tidy in: %s\n", projectDir) + } + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = projectDir + tidyCmd.Stdout = os.Stdout + tidyCmd.Stderr = os.Stderr + if err := tidyCmd.Run(); err != nil { + return fmt.Errorf("failed to run go mod tidy: %w", err) + } + + // Create server configuration for local execution + serverConfig := map[string]interface{}{ + "command": "go", + "args": []string{"run", "main.go"}, + } + + // Create MCP inspector config + configPath := filepath.Join(projectDir, "mcp-server-config.json") + if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath); err != nil { + return err + } + + // Run the inspector + return runMCPInspector(configPath, manifest.Name, projectDir) +} + +func getProjectDir() (string, error) { + // Determine project directory + dir := projectDir + if dir == "" { + // Use current working directory + var err error + dir, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + } else { + // Convert relative path to absolute path + if !filepath.IsAbs(dir) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + dir = filepath.Join(cwd, dir) + } + } + + if verbose { + fmt.Printf("Using project directory: %s\n", dir) + } + + return dir, nil +} + +func getProjectManifest(projectDir string) (*manifest.ProjectManifest, error) { + // Check if kmcp.yaml exists + manager := manifest.NewManager(projectDir) + if !manager.Exists() { + return nil, fmt.Errorf("this directory is not an mcp-server directory: kmcp.yaml not found") + } + + // Load the manifest + manifest, err := manager.Load() + if err != nil { + return nil, fmt.Errorf("failed to load kmcp.yaml: %w", err) + } + + return manifest, nil +} diff --git a/cmd/kmcp/cmd/secrets.go b/cmd/kmcp/cmd/secrets.go index 5bf4e00..536e252 100644 --- a/cmd/kmcp/cmd/secrets.go +++ b/cmd/kmcp/cmd/secrets.go @@ -12,7 +12,6 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" - _ "k8s.io/client-go/plugin/pkg/client/auth" "sigs.k8s.io/controller-runtime/pkg/client/config" "sigs.k8s.io/yaml" ) @@ -50,7 +49,7 @@ Examples: kmcp secrets sync staging --from-file .env.staging # Sync secrets from a specific project directory - kmcp secrets sync staging --dir ./my-project + kmcp secrets sync staging --project-dir ./my-project # Perform a dry run to see the generated secret without applying it kmcp secrets sync production --dry-run @@ -68,10 +67,10 @@ func init() { // create-k8s-secret-from-env flags syncCmd.Flags().StringVar(&secretSourceFile, "from-file", ".env", "Source .env file to sync from") syncCmd.Flags().BoolVar(&secretDryRun, "dry-run", false, "Output the generated secret YAML instead of applying it") - syncCmd.Flags().StringVarP(&secretDir, "dir", "d", "", "Project directory (default: current directory)") + syncCmd.Flags().StringVarP(&secretDir, "project-dir", "d", "", "Project directory (default: current directory)") } -func runSync(cmd *cobra.Command, args []string) error { +func runSync(_ *cobra.Command, args []string) error { environment := args[0] // Determine project root diff --git a/pkg/frameworks/golang/templates/tools/echo.go.tmpl b/pkg/frameworks/golang/templates/tools/echo.go.tmpl index 914c36b..29daca9 100644 --- a/pkg/frameworks/golang/templates/tools/echo.go.tmpl +++ b/pkg/frameworks/golang/templates/tools/echo.go.tmpl @@ -22,11 +22,15 @@ func Echo() MCPTool[EchoParams, EchoResult] { Name: "echo", Description: "Echoes a message back to the user.", Handler: func(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[EchoParams]) (*mcp.CallToolResultFor[EchoResult], error) { - return &mcp.CallToolResultFor[EchoResult]{ - StructuredContent: EchoResult{ - Echo: "Echo: " + params.Arguments.Message, + echoMessage := "Echo: " + params.Arguments.Message + result := &mcp.CallToolResultFor[EchoResult]{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: echoMessage, + }, }, - }, nil + } + return result, nil }, } } diff --git a/pkg/frameworks/golang/templates/tools/tool.go.tmpl b/pkg/frameworks/golang/templates/tools/tool.go.tmpl index ee4dd6d..0227b80 100644 --- a/pkg/frameworks/golang/templates/tools/tool.go.tmpl +++ b/pkg/frameworks/golang/templates/tools/tool.go.tmpl @@ -10,8 +10,13 @@ func init() { Name: "{{.ToolName}}", Description: "{{.Description}}", Handler: func(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[{{.ClassName}}Params]) (*mcp.CallToolResultFor[{{.ClassName}}Result], error) { + result := run{{.ClassName}}(params.Arguments) return &mcp.CallToolResultFor[{{.ClassName}}Result]{ - StructuredContent: run{{.ClassName}}(params.Arguments), + Content: []mcp.Content{ + &mcp.TextContent{ + Text: result.Result, + }, + }, }, nil }, }) diff --git a/pkg/frameworks/python/templates/src/core/server.py.tmpl b/pkg/frameworks/python/templates/src/core/server.py.tmpl index 91338e9..70c2da4 100644 --- a/pkg/frameworks/python/templates/src/core/server.py.tmpl +++ b/pkg/frameworks/python/templates/src/core/server.py.tmpl @@ -69,23 +69,37 @@ class DynamicMCPServer: return loaded_count = 0 + has_errors = False for tool_file in tool_files: try: + # Get the number of tools before importing + tools_before = len(self.mcp._tool_manager._tools) + # Simply import the module - tools auto-register via @mcp.tool() # decorator tool_name = tool_file.stem if self._import_tool_module(tool_file, tool_name): - self.loaded_tools.append(tool_name) - loaded_count += 1 - logging.info(f"Loaded tool module: {tool_name}") + # Check if any tools were actually registered + tools_after = len(self.mcp._tool_manager._tools) + if tools_after > tools_before: + self.loaded_tools.append(tool_name) + loaded_count += 1 + logging.info(f"Loaded tool module: {tool_name}") + else: + logging.error(f"Tool file {tool_name} did not register any tools") + has_errors = True else: - logging.warning(f"Failed to load tool module: {tool_name}") + logging.error(f"Failed to load tool module: {tool_name}") + has_errors = True except Exception as e: - logging.warning(f"Error loading tool {tool_file.name}: {e}") - # Fail fast - if any tool fails to load, stop the server - sys.exit(1) + logging.error(f"Error loading tool {tool_file.name}: {e}") + has_errors = True + + # Fail fast - if any tool fails to load, stop the server + if has_errors: + sys.exit(1) logging.info(f"📦 Successfully loaded {loaded_count} tools") @@ -122,6 +136,11 @@ class DynamicMCPServer: print(f"Error importing {tool_file}: {e}") return False + def get_tools_sync(self): + """Get tools synchronously for testing purposes.""" + # This is a simplified version for testing - in real usage, use get_tools() async + return self.mcp._tool_manager._tools + def run(self) -> None: """Run the FastMCP server.""" if not self.loaded_tools: diff --git a/pkg/frameworks/python/templates/tests/test_tools.py.tmpl b/pkg/frameworks/python/templates/tests/test_tools.py.tmpl index 9eae4b6..dd5eea8 100644 --- a/pkg/frameworks/python/templates/tests/test_tools.py.tmpl +++ b/pkg/frameworks/python/templates/tests/test_tools.py.tmpl @@ -49,7 +49,9 @@ class TestToolLoading: server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") server.load_tools() - for tool_name, tool in server.mcp.tools.items(): + tools = server.get_tools_sync() + for tool_name, tool in tools.items(): + assert hasattr(tool, 'fn'), f"Tool {tool_name} has no fn attribute" assert callable(tool.fn), f"Tool {tool_name} is not callable" @@ -65,9 +67,15 @@ class TestEchoTool: def test_echo_tool_function(self): """Test that the echo tool function works.""" - # Import the echo function directly - from tools.echo import echo - - result = echo("Hello, World!") + # Get the tool from the server + server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") + server.load_tools() + + tools = server.get_tools_sync() + assert "echo" in tools + + # Call the tool function + echo_tool = tools["echo"] + result = echo_tool.fn("Hello, World!") assert isinstance(result, str) assert "Hello, World!" in result diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 537d729..48e63ba 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -205,22 +205,31 @@ var _ = ginkgo.Describe("Manager", ginkgo.Ordered, func() { cmd = exec.Command("rm", "-f", fmt.Sprintf("%s/kmcp.yaml.bak", projectDir)) _, _ = utils.Run(cmd) - ginkgo.By("creating a dummy .env.local file for testing secrets") - envFilePath := fmt.Sprintf("%s/.env.local", projectDir) + ginkgo.By("creating a dummy .env.staging file for testing secrets") + envFilePath := fmt.Sprintf("%s/.env.staging", projectDir) envContent := []byte("DATABASE_URL=postgres://user:pass@host:port/db\n" + "OPENAI_API_KEY=dummy-key\n" + "WEATHER_API_KEY=dummy-key\n") err = os.WriteFile(envFilePath, envContent, 0644) - gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create dummy .env.local file") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create dummy .env.staging file") - ginkgo.By("creating Kubernetes secret from existing .env.local file") + ginkgo.By("creating Kubernetes secret from existing .env.staging file") - cmd = exec.Command("dist/kmcp", "secrets", "sync", "staging", "--from-file", envFilePath, "--dir", projectDir) + cmd = exec.Command( + "dist/kmcp", + "secrets", + "sync", + "staging", + "--from-file", + envFilePath, + "--project-dir", + projectDir, + ) _, err = utils.Run(cmd) gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create secret from .env.local file") ginkgo.By("building the Docker image for the knowledge-assistant project") - cmd = exec.Command("dist/kmcp", "build", "--docker", "--verbose", "--dir", projectDir) + cmd = exec.Command("dist/kmcp", "build", "--docker", "--verbose", "--project-dir", projectDir) _, err = utils.Run(cmd) gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to build Docker image")