diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index 8275d56..def00ec 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -100,13 +100,10 @@ jobs: export VERSION=$(echo "$GITHUB_REF" | cut -c12-) fi echo "Building release artifacts with version: ${VERSION}" - make build VERSION=${VERSION} - make helm-package VERSION=${VERSION} + make build-cli VERSION=${VERSION} - name: Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') - # TODO: Add kmcp binary to the release with: files: | - dist/kmcp-*.tgz - + dist/kmcp diff --git a/.gitignore b/.gitignore index 1e52bb9..9e55764 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + # Binaries for programs and plugins *.exe *.exe~ diff --git a/Makefile b/Makefile index bff6dae..1ad4e52 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,6 @@ DOCKER_REGISTRY ?= ghcr.io BASE_IMAGE_REGISTRY ?= cgr.dev DOCKER_REPO ?= kagent-dev/kmcp HELM_REPO ?= oci://ghcr.io/kagent-dev -HELM_DIST_FOLDER ?= dist BUILD_DATE := $(shell date -u '+%Y-%m-%d') GIT_COMMIT := $(shell git rev-parse --short HEAD || echo "unknown") @@ -71,6 +70,21 @@ manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and Cust helm-lint: helm lint helm/kmcp +.PHONY: helm-crd +helm-crd: manifests ## Generate Helm CRD template from the generated CRD definition. + @echo "Generating Helm CRD template from config/crd/bases/kagent.dev_mcpservers.yaml" + @mkdir -p helm/kmcp/templates/crds + @echo '{{- if .Values.crd.create }}' > helm/kmcp/templates/crds/mcpserver-crd.yaml + @awk '/^ name: mcpservers.kagent.dev$$/ { \ + print; \ + print " labels:"; \ + print " {{- include \"kmcp.labels\" . | nindent 4 }}"; \ + next; \ + } \ + { print }' config/crd/bases/kagent.dev_mcpservers.yaml >> helm/kmcp/templates/crds/mcpserver-crd.yaml + @echo '{{- end }}' >> helm/kmcp/templates/crds/mcpserver-crd.yaml + @echo "Helm CRD template generated at helm/kmcp/templates/crds/mcpserver-crd.yaml" + .PHONY: helm-package helm-package: mkdir -p $(DIST_FOLDER) @@ -79,6 +93,7 @@ helm-package: @sed "s/^version: .*/version: $(VERSION)/" helm/kmcp/Chart.yaml.bak > helm/kmcp/Chart.yaml @helm package helm/kmcp --version $(VERSION) -d $(DIST_FOLDER) @mv helm/kmcp/Chart.yaml.bak helm/kmcp/Chart.yaml + @echo "Helm package created: $(DIST_FOLDER)/kmcp-$(VERSION).tgz" .PHONY: helm-cleanup helm-cleanup: ## Clean up Helm chart packages @@ -91,7 +106,7 @@ helm-build: helm-lint helm-package ## Build and package the Helm chart .PHONY: helm-publish helm-publish: helm-package ## Publish Helm chart to OCI registry @echo "Publishing Helm chart to $(HELM_REPO)/kmcp/helm..." - @helm push $(HELM_DIST_FOLDER)/kmcp-$(VERSION).tgz $(HELM_REPO)/kmcp/helm + @helm push $(DIST_FOLDER)/kmcp-$(VERSION).tgz $(HELM_REPO)/kmcp/helm @echo "Helm chart published successfully" .PHONY: helm-test @@ -143,7 +158,7 @@ test-e2e: manifests generate fmt vet ## Run the e2e tests. Expected an isolated echo "No Kind cluster is running. Please start a Kind cluster before running the e2e tests."; \ exit 1; \ } - go test ./test/e2e/ -v -ginkgo.v + go test ./test/e2e/ -v .PHONY: lint lint: golangci-lint ## Run golangci-lint linter @@ -163,6 +178,11 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration build: manifests generate fmt vet ## Build manager binary. go build -o bin/manager cmd/main.go +.PHONY: build-cli +build-cli: fmt vet ## Build kmcp CLI binary. + mkdir -p $(DIST_FOLDER) + go build -ldflags="-X 'kagent.dev/kmcp/cmd/kmcp/cmd.Version=$(VERSION)'" -o $(DIST_FOLDER)/kmcp cmd/kmcp/main.go + .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. go run ./cmd/main.go diff --git a/README.md b/README.md index c60135b..58ee8c4 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,352 @@ -# kmcp -// TODO(user): Add simple overview of use/purpose +# KMCP - Model Context Protocol Development & Deployment Platform -## Description -// TODO(user): An in-depth paragraph about your project and overview of use +KMCP is a comprehensive platform for developing and deploying Model Context Protocol (MCP) servers. It provides both a powerful CLI tool for local development and a Kubernetes controller for cloud-native deployment. -## Getting Started +## Table of Contents -### Prerequisites -- go version v1.23.0+ -- docker version 17.03+. -- kubectl version v1.11.3+. -- Access to a Kubernetes v1.11.3+ cluster. +- [What is MCP?](#what-is-mcp) +- [Quick Start](#quick-start) +- [CLI Tool](#cli-tool) + - [Installation](#installation) + - [Commands](#commands) + - [Examples](#examples) +- [Kubernetes Controller](#kubernetes-controller) +- [Supported Frameworks](#supported-frameworks) +- [Contributing](#contributing) +- [License](#license) +- [Resources](#resources) -### To Deploy on the cluster -**Build and push your image to the location specified by `IMG`:** +## What is MCP? -```sh -make docker-build docker-push IMG=/kmcp:tag +The Model Context Protocol (MCP) is an open standard developed by Anthropic that standardizes how AI applications provide context to Large Language Models (LLMs). Think of MCP as a universal adapter that allows AI models to seamlessly connect to various data sources and external tools. + +### Key Benefits: + +- **Standardized Data Access**: Consistent way for AI models to access external data +- **Tool Integration**: Connect AI assistants to business tools and internal systems +- **Structured Responses**: Ensure consistent, structured outputs from AI models +- **Improved Context**: Give AI models the context they need for better responses +- **Vendor Independence**: Open standard that works across different AI providers + +### MCP Architecture: + +- **MCP Clients**: AI applications (Claude Desktop, Cursor, etc.) that consume MCP services +- **MCP Servers**: Lightweight programs that expose capabilities through standardized interfaces +- **Transport Protocols**: Communication via stdio (standard input/output) and HTTP with SSE + +## Quick Start + +Get started with KMCP in under 5 minutes: + +```bash +# Install the CLI +go install github.com/kagent-dev/kmcp/cmd/kmcp@latest + +# Create a new MCP server project +kmcp init my-mcp-server + +# Build and test your server +cd my-mcp-server +kmcp build --docker --tag my-mcp-server:latest + +# Your MCP server is ready to use! ``` -**NOTE:** This image ought to be published in the personal registry you specified. -And it is required to have access to pull the image from the working environment. -Make sure you have the proper permission to the registry if the above commands don’t work. +## CLI Tool + +The KMCP CLI provides a complete development experience for MCP servers with project scaffolding, build automation, and deployment tools. -**Install the CRDs into the cluster:** +### Installation -```sh -make install +#### Option 1: Go Install (Recommended) +```bash +go install github.com/kagent-dev/kmcp/cmd/kmcp@latest ``` -**Deploy the Manager to the cluster with the image specified by `IMG`:** +#### Option 2: Build from Source +```bash +git clone https://github.com/kagent-dev/kmcp.git +cd kmcp +make build-cli +``` + +#### Option 3: Download Binary +Download the latest binary from the [releases page](https://github.com/kagent-dev/kmcp/releases). + +## CLI Commands + +### `kmcp init` - Initialize New MCP Server Project + +Create a new MCP server project with interactive prompts: -```sh -make deploy IMG=/kmcp:tag +```bash +kmcp init [project-name] [flags] ``` -> **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin -privileges or be logged in as admin. +**Flags:** +- `--framework, -f` - Choose framework (fastmcp-python) +- `--force` - Overwrite existing directory +- `--no-git` - Skip git initialization +- `--author` - Set project author +- `--email` - Set author email +- `--version` - MCP server version (defaults to kmcp version) +- `--non-interactive` - Use defaults without prompts +- `--verbose, -v` - Show detailed output -**Create instances of your solution** -You can apply the samples (examples) from the config/sample: +#### `kmcp build` - Build MCP Servers -```sh -kubectl apply -k config/samples/ +Build your MCP server with Docker support: + +```bash +kmcp build [flags] ``` ->**NOTE**: Ensure that the samples has default values to test it out. +**Flags:** +- `--docker` - Build Docker image +- `--output, -o` - Output directory or image name +- `--tag, -t` - Docker image tag +- `--platform` - Target platform (e.g., linux/amd64, linux/arm64) +- `--dir, -d` - Build directory (default: current directory) +- `--verbose, -v` - Show detailed build output + +### `kmcp deploy` - Deploy to Kubernetes -### To Uninstall -**Delete the instances (CRs) from the cluster:** +Deploy components to Kubernetes clusters: -```sh -kubectl delete -k config/samples/ +```bash +kmcp deploy [command] [flags] ``` -**Delete the APIs(CRDs) from the cluster:** +**Subcommands:** +- `mcp [name]` - Deploy MCP server to Kubernetes +- `controller` - Deploy KMCP controller to cluster + +#### `kmcp deploy mcp` - Deploy MCP Server + +Deploy an MCP server to Kubernetes by generating MCPServer CRDs: + +```bash +kmcp deploy mcp [name] [flags] +``` -```sh -make uninstall +**Flags:** +- `--namespace, -n` - Kubernetes namespace (default: "default") +- `--dry-run` - Generate manifest without applying to cluster +- `--output, -o` - Output file for the generated YAML +- `--image` - Docker image to deploy (overrides build image) +- `--transport` - Transport type (stdio, http) +- `--port` - Container port (default: from project config) +- `--target-port` - Target port for HTTP transport +- `--command` - Command to run (overrides project config) +- `--args` - Command arguments +- `--env` - Environment variables (KEY=VALUE) +- `--force` - Force deployment even if validation fails +- `--file, -f` - Path to kmcp.yaml file (default: current directory) +- `--verbose, -v` - Show detailed output + +#### `kmcp deploy controller` - Deploy KMCP Controller + +Deploy the KMCP controller to a Kubernetes cluster using Helm: + +```bash +kmcp deploy controller [flags] ``` -**UnDeploy the controller from the cluster:** +**Flags:** +- `--version` - Version of the controller to deploy (defaults to kmcp version) +- `--namespace` - Namespace for the KMCP controller (defaults to kmcp-system) +- `--registry-config` - Path to Docker registry config file +- `--verbose, -v` - Show detailed output + + +### Examples -```sh -make undeploy +#### Create a FastMCP Python Project + +```bash +# Interactive creation +kmcp init my-python-server + +# Non-interactive with specific options +kmcp init my-python-server \ + --framework fastmcp-python \ + --template database \ + --author "John Doe" \ + --email "john@example.com" \ + --non-interactive ``` -## Project Distribution +## Kubernetes Controller + +KMCP also includes a Kubernetes controller for cloud-native MCP server deployment. -Following the options to release and provide this solution to the users. +### Controller Features -### By providing a bundle with all YAML files +- **Declarative Management**: Deploy MCP servers using Kubernetes Custom Resources +- **Container-based Servers**: Run MCP servers as containerized workloads +- **Service Discovery**: Automatic Service creation for HTTP-based MCP servers +- **Configuration Management**: ConfigMap-based configuration with environment variables -1. Build the installer for the image built and published in the registry: +### Controller Installation -```sh -make build-installer IMG=/kmcp:tag +```bash +# Install CRDs +kubectl apply -f https://raw.githubusercontent.com/kagent-dev/kmcp/main/config/crd/bases/kagent.dev_mcpservers.yaml + +# Deploy controller +kubectl apply -f https://raw.githubusercontent.com/kagent-dev/kmcp/main/config/default/ + +# Or use Helm +helm repo add kmcp https://charts.kagent.dev +helm install kmcp kmcp/kmcp --namespace kmcp-system --create-namespace ``` -**NOTE:** The makefile target mentioned above generates an 'install.yaml' -file in the dist directory. This file contains all the resources built -with Kustomize, which are necessary to install this project without its -dependencies. +### MCPServer Custom Resource + +```yaml +apiVersion: kagent.dev/v1alpha1 +kind: MCPServer +metadata: + name: my-mcp-server +spec: + deployment: + image: "my-mcp-server:latest" + port: 3000 + cmd: "python" + args: ["-m", "my_mcp_server"] + transportType: "stdio" +``` + +## Supported Frameworks + +KMCP supports the most popular MCP frameworks: -2. Using the installer +### FastMCP Python ⭐ (Recommended) +- **Best for**: Production Python applications +- **Features**: Comprehensive toolkit, official SDK integration +- **Use cases**: Database integration, API clients, complex workflows -Users can just run 'kubectl apply -f ' to install -the project, i.e.: +## Project Structure -```sh -kubectl apply -f https://raw.githubusercontent.com//kmcp//dist/install.yaml +Generated projects follow MCP best practices: + +``` +my-mcp-server/ +├── src/ +│ ├── tools/ # Tool implementations +│ ├── resources/ # Resource handlers +│ ├── prompts/ # Prompt templates +│ └── main.py # Server entry point +├── tests/ # Test suite +├── Dockerfile # Container definition +├── pyproject.toml # Python dependencies +├── .env.example # Environment variables +└── README.md # Project documentation ``` -### By providing a Helm Chart +## Development + +### Building from Source + +```bash +git clone https://github.com/kagent-dev/kmcp.git +cd kmcp +make build-cli +``` -1. Build the chart using the optional helm plugin +### Running Tests -```sh -kubebuilder edit --plugins=helm/v1-alpha +```bash +make test +make test-e2e ``` -2. See that a chart was generated under 'dist/chart', and users -can obtain this solution from there. +### Development Mode -**NOTE:** If you change the project, you need to update the Helm Chart -using the same command above to sync the latest changes. Furthermore, -if you create webhooks, you need to use the above command with -the '--force' flag and manually ensure that any custom configuration -previously added to 'dist/chart/values.yaml' or 'dist/chart/manager/manager.yaml' -is manually re-applied afterwards. +```bash +# Run CLI in development mode +go run cmd/kmcp/main.go init test-project --verbose + +# Build and test +make build-cli +./bin/kmcp build --help +``` + +## TODO - Initial Phase Completion + +The following tasks remain to complete the initial phase of KMCP: + +### CLI Tool Enhancements +- [ ] **Add `dev` command** - Local development server with hot reload +- [ ] **Add `validate` command** - Protocol compliance checking +- [ ] **Add `test` command** - Run MCP server tests with inspector +- [ ] **Add `list` command** - List available frameworks and templates +- [ ] **Add `migrate` command** - Upgrade projects between framework versions + +### Template System Improvements +- [ ] **🚀 MAJOR: Template System Refactoring** - See [TEMPLATE_REFACTOR.md](TEMPLATE_REFACTOR.md) for comprehensive plan + - [ ] Opinionated modular architecture with plugin-based tools + - [ ] Project manifest system (`kmcp.yaml`) + - [ ] Auto-generated boilerplate separation + - [ ] CLI tool management commands (`add-tool`, `remove-tool`, etc.) + - [ ] **Kubernetes-native secret management** - Built-in secret handling and sanitization + - [ ] **Multi-environment support** - Local development to production workflows +- [ ] **Multi-tool template** - Advanced template with multiple tools/resources +- [ ] **API client template** - Template for REST/GraphQL API integration +- [ ] **Template validation** - Ensure all templates build and run correctly + +### Build System Enhancements +- [ ] **Multi-platform builds** - Support ARM64 and x86_64 architectures +- [ ] **Build optimization** - Improve Docker build caching and layer optimization +- [ ] **Security scanning** - Integrate vulnerability scanning in build process +- [ ] **Build profiles** - Development vs production build configurations + +### Testing & Validation +- [ ] **Integration tests** - End-to-end testing of generated projects +- [ ] **Framework compliance** - Validate generated servers work with MCP clients +- [ ] **Template testing** - Automated testing of all templates +- [ ] **CLI testing** - Comprehensive test suite for CLI commands + +### Documentation & Examples +- [ ] **Framework comparison guide** - Help users choose the right framework +- [ ] **Advanced usage examples** - Complex MCP server implementations +- [ ] **Troubleshooting guide** - Common issues and solutions +- [ ] **Video tutorials** - Getting started and advanced workflows + +### Distribution & Packaging +- [ ] **GitHub releases** - Automated binary releases for all platforms +- [ ] **Homebrew formula** - Easy installation on macOS +- [ ] **Docker image** - Containerized CLI tool +- [ ] **Package managers** - Consider APT, YUM, Chocolatey support + +### IDE & Editor Integration +- [ ] **VS Code extension** - Project templates and debugging support +- [ ] **Cursor integration** - Enhanced MCP development experience +- [ ] **Language server** - MCP protocol awareness in editors + +### Performance & Reliability +- [ ] **Error handling** - Comprehensive error messages and recovery +- [ ] **Progress indicators** - Better UX for long-running operations +- [ ] **Configuration caching** - Speed up repeated operations +- [ ] **Interrupt handling** - Graceful handling of Ctrl+C + +### Community & Ecosystem +- [ ] **Contributing guide** - Detailed guide for contributors +- [ ] **Issue templates** - Structured bug reports and feature requests +- [ ] **Community templates** - Accept and maintain community-contributed templates +- [ ] **Plugin system** - Allow third-party framework extensions ## Contributing -// TODO(user): Add detailed information on how you would like others to contribute to this project -**NOTE:** Run `make help` for more information on all potential `make` targets +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. -More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) +### Development Setup + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Submit a pull request ## License @@ -133,3 +364,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +## Resources + +- [Model Context Protocol Specification](https://spec.modelcontextprotocol.io/) +- [MCP Documentation](https://modelcontextprotocol.io/) +- [Anthropic's MCP Announcement](https://www.anthropic.com/news/model-context-protocol) +- [FastMCP Python Documentation](https://github.com/jlowin/fastmcp) diff --git a/api/v1alpha1/mcpserver_types.go b/api/v1alpha1/mcpserver_types.go index 8007bc2..a966697 100644 --- a/api/v1alpha1/mcpserver_types.go +++ b/api/v1alpha1/mcpserver_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -202,6 +203,11 @@ type MCPServerDeployment struct { // Env defines the environment variables to set in the container. Env map[string]string `json:"env,omitempty"` + + // SecretRefs defines the list of Kubernetes secrets to reference. + // These secrets will be mounted as volumes to the MCP server container. + // +optional + SecretRefs []corev1.ObjectReference `json:"secretRefs,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index e77cdab..575ed69 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -82,6 +83,11 @@ func (in *MCPServerDeployment) DeepCopyInto(out *MCPServerDeployment) { (*out)[key] = val } } + if in.SecretRefs != nil { + in, out := &in.SecretRefs, &out.SecretRefs + *out = make([]corev1.ObjectReference, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerDeployment. diff --git a/cmd/kmcp/cmd/add_tool.go b/cmd/kmcp/cmd/add_tool.go new file mode 100644 index 0000000..a1e2563 --- /dev/null +++ b/cmd/kmcp/cmd/add_tool.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "kagent.dev/kmcp/pkg/frameworks" + "kagent.dev/kmcp/pkg/manifest" +) + +var addToolCmd = &cobra.Command{ + Use: "add-tool [tool-name]", + Short: "Add a new MCP tool to your project", + Long: `Generate a new MCP tool that will be automatically loaded by the server. + +This command creates a new tool file in src/tools/ with a generic template. +The tool will be automatically discovered and loaded when the server starts. + +Each tool is a Python file containing a function decorated with @mcp.tool(). +The function should use the @mcp.tool() decorator from FastMCP. + +Examples: + kmcp add-tool weather + kmcp add-tool database --description "Database operations tool" + kmcp add-tool weather --force # Overwrite existing tool +`, + Args: cobra.ExactArgs(1), + RunE: runAddTool, +} + +var ( + addToolDescription string + addToolForce bool + addToolInteractive bool +) + +func init() { + rootCmd.AddCommand(addToolCmd) + + addToolCmd.Flags().StringVarP(&addToolDescription, "description", "d", "", "Tool description") + addToolCmd.Flags().BoolVarP(&addToolForce, "force", "f", false, "Overwrite existing tool file") + addToolCmd.Flags().BoolVarP(&addToolInteractive, "interactive", "i", false, "Interactive tool creation") +} + +func runAddTool(_ *cobra.Command, args []string) error { + toolName := args[0] + + // Validate tool name + if err := validateToolName(toolName); err != nil { + return fmt.Errorf("invalid tool name: %w", err) + } + + // Get project root and framework + projectRoot, err := findProjectRoot() + if err != nil { + return err + } + manifestManager := manifest.NewManager(projectRoot) + projectManifest, err := manifestManager.Load() + if err != nil { + return fmt.Errorf("failed to load project manifest: %w", err) + } + framework := projectManifest.Framework + + // Check if tool already exists + toolPath := filepath.Join("src", "tools", toolName+".py") + toolExists := fileExists(toolPath) + + if verbose { + fmt.Printf("Tool file path: %s\n", toolPath) + fmt.Printf("Tool exists: %v\n", toolExists) + } + + if toolExists && !addToolForce { + return fmt.Errorf("tool '%s' already exists. Use --force to overwrite", toolName) + } + + if addToolInteractive { + return createToolInteractive(toolName, projectRoot, framework) + } + + return createTool(toolName, projectRoot, framework) +} + +func validateToolName(name string) error { + if name == "" { + return fmt.Errorf("tool name cannot be empty") + } + + // Check for valid Python identifier + if !isValidPythonIdentifier(name) { + return fmt.Errorf("tool name must be a valid Python identifier") + } + + // Check for reserved names + reservedNames := []string{"server", "main", "core", "utils", "init", "test"} + for _, reserved := range reservedNames { + if strings.ToLower(name) == reserved { + return fmt.Errorf("'%s' is a reserved name", name) + } + } + + return nil +} + +func isValidPythonIdentifier(name string) bool { + if len(name) == 0 { + return false + } + + // First character must be letter or underscore + firstChar := name[0] + if firstChar < 'a' || firstChar > 'z' { + if firstChar < 'A' || firstChar > 'Z' { + if firstChar != '_' { + return false + } + } + } + + // Remaining characters must be letters, digits, or underscores + for i := 1; i < len(name); i++ { + c := name[i] + if c < 'a' || c > 'z' { + if c < 'A' || c > 'Z' { + if c < '0' || c > '9' { + if c != '_' { + return false + } + } + } + } + } + + return true +} + +func createToolInteractive(toolName, projectRoot, framework string) error { + fmt.Printf("Creating tool '%s' interactively...\n", toolName) + + // Get tool description + if addToolDescription == "" { + fmt.Printf("Enter tool description (optional): ") + var desc string + _, err := fmt.Scanln(&desc) + if err != nil { + return fmt.Errorf("failed to read description: %w", err) + } + addToolDescription = desc + } + + return generateTool(toolName, projectRoot, framework) +} + +func createTool(toolName, projectRoot, framework string) error { + if verbose { + fmt.Printf("Creating tool: %s\n", toolName) + } + + return generateTool(toolName, projectRoot, framework) +} + +func generateTool(toolName, projectRoot, framework string) error { + generator, err := frameworks.GetGenerator(framework) + if err != nil { + return err + } + + config := map[string]interface{}{ + "description": addToolDescription, + } + + if err := generator.GenerateTool(projectRoot, toolName, config); err != nil { + return fmt.Errorf("failed to generate tool file: %w", err) + } + + fmt.Printf("✅ Successfully created tool: %s\n", toolName) + fmt.Printf("📁 Generated file: src/tools/%s.py\n", toolName) + fmt.Printf("🔄 Updated tools/__init__.py with new tool import\n") + + fmt.Printf("\nNext steps:\n") + fmt.Printf("1. Edit src/tools/%s.py to implement your tool logic\n", toolName) + fmt.Printf("2. Configure any required environment variables in kmcp.yaml\n") + fmt.Printf("3. Run 'uv run python src/main.py' to start the server\n") + fmt.Printf("4. Run 'uv run pytest tests/' to test your tool\n") + + return nil +} diff --git a/cmd/kmcp/cmd/build.go b/cmd/kmcp/cmd/build.go new file mode 100644 index 0000000..a7d5bbd --- /dev/null +++ b/cmd/kmcp/cmd/build.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "kagent.dev/kmcp/pkg/build" +) + +var buildCmd = &cobra.Command{ + Use: "build", + Short: "Build MCP server", + Long: `Build an MCP server from the current project. + +This command will detect the project type and build the appropriate +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`, + RunE: runBuild, +} + +var ( + buildDocker bool + buildOutput string + buildTag string + buildPlatform string + buildDir string +) + +func init() { + rootCmd.AddCommand(buildCmd) + + buildCmd.Flags().BoolVar(&buildDocker, "docker", false, "Build Docker image") + 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)") +} + +func runBuild(_ *cobra.Command, _ []string) error { + // Determine build directory + buildDirectory := buildDir + if buildDirectory == "" { + var err error + buildDirectory, 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(buildDirectory) { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + buildDirectory = filepath.Join(cwd, buildDirectory) + } + } + + if verbose { + fmt.Printf("Building MCP server in: %s\n", buildDirectory) + } + + // Check if this is a valid MCP project + if err := validateMCPProject(buildDirectory); err != nil { + return fmt.Errorf("invalid MCP project: %w", err) + } + + // Create build options + opts := build.Options{ + ProjectDir: buildDirectory, + Docker: buildDocker, + Output: buildOutput, + Tag: buildTag, + Platform: buildPlatform, + Verbose: verbose, + } + + // Execute build + builder := build.New() + return builder.Build(opts) +} + +// validateMCPProject checks if the current directory contains a valid MCP project +func validateMCPProject(dir string) error { + // Check for common MCP project indicators + indicators := []string{ + "pyproject.toml", // Python projects + "package.json", // Node.js projects + "go.mod", // Go projects + ".mcpbuilder.yaml", // Our project config + } + + for _, indicator := range indicators { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + if verbose { + fmt.Printf("Detected project file: %s\n", indicator) + } + return nil + } + } + + return fmt.Errorf("no MCP project detected. Expected one of: %v", indicators) +} diff --git a/cmd/kmcp/cmd/deploy.go b/cmd/kmcp/cmd/deploy.go new file mode 100644 index 0000000..22b283d --- /dev/null +++ b/cmd/kmcp/cmd/deploy.go @@ -0,0 +1,460 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "kagent.dev/kmcp/api/v1alpha1" + "kagent.dev/kmcp/pkg/manifest" + "kagent.dev/kmcp/pkg/wellknown" + "sigs.k8s.io/yaml" +) + +const ( + transportHTTP = "http" + transportStdio = "stdio" +) + +var deployCmd = &cobra.Command{ + Use: "deploy", + Short: "Deploy to Kubernetes", + Long: `Deploy components to Kubernetes clusters. + +This command provides functionality to deploy MCP servers and the KMCP controller +to Kubernetes clusters.`, +} + +// deployMCPCmd deploys an MCP server to Kubernetes +var deployMCPCmd = &cobra.Command{ + Use: "mcp [name]", + Short: "Deploy MCP server to Kubernetes", + Long: `Deploy an MCP server to Kubernetes by generating MCPServer CRDs. + +This command generates MCPServer Custom Resource Definitions (CRDs) based on: +- Project configuration from kmcp.yaml +- Docker image built with 'kmcp build --docker' +- Deployment configuration options + +The generated MCPServer will include: +- Docker image reference from the build +- Transport configuration (stdio/http) +- Port and command configuration +- Environment variables and secrets + +The command can also apply Kubernetes secret YAML files to the cluster before deploying the MCPServer. +The secrets will be referenced in the MCPServer CRD for mounting as volumes to the MCP server container. +Secret namespace will be overridden with the deployment namespace to avoid the need for reference grants +to enable cross-namespace references. + +Examples: + kmcp deploy mcp # Deploy with project name to cluster + kmcp deploy mcp my-server # Deploy with custom name + kmcp deploy mcp --namespace staging # Deploy to staging namespace + kmcp deploy mcp --dry-run # Generate manifest without applying to cluster + kmcp deploy mcp --image custom:tag # Use custom image + kmcp deploy mcp --transport http # Use HTTP transport + kmcp deploy mcp --output deploy.yaml # Save to file + kmcp deploy mcp --file /path/to/kmcp.yaml # Use custom kmcp.yaml file + kmcp deploy mcp --environment staging # Target environment for deployment (e.g., staging, production)`, + Args: cobra.MaximumNArgs(1), + RunE: runDeployMCP, +} + +var ( + // MCP deployment flags + deployNamespace string + deployDryRun bool + deployOutput string + deployImage string + deployTransport string + deployPort int + deployTargetPort int + deployCommand string + deployArgs []string + deployEnv []string + deployForce bool + deployFile string + deployEnvironment string +) + +func init() { + rootCmd.AddCommand(deployCmd) + + // Add subcommands + deployCmd.AddCommand(deployMCPCmd) + + // MCP deployment flags + deployMCPCmd.Flags().StringVarP(&deployNamespace, "namespace", "n", "default", "Kubernetes namespace") + deployMCPCmd.Flags().BoolVar(&deployDryRun, "dry-run", false, "Generate manifest without applying to cluster") + deployMCPCmd.Flags().StringVarP(&deployOutput, "output", "o", "", "Output file for the generated YAML") + deployMCPCmd.Flags().StringVar(&deployImage, "image", "", "Docker image to deploy (overrides build image)") + deployMCPCmd.Flags().StringVar(&deployTransport, "transport", "", "Transport type (stdio, http)") + deployMCPCmd.Flags().IntVar(&deployPort, "port", 0, "Container port (default: from project config)") + deployMCPCmd.Flags().IntVar(&deployTargetPort, "target-port", 0, "Target port for HTTP transport") + deployMCPCmd.Flags().StringVar(&deployCommand, "command", "", "Command to run (overrides project config)") + deployMCPCmd.Flags().StringSliceVar(&deployArgs, "args", []string{}, "Command arguments") + deployMCPCmd.Flags().StringSliceVar(&deployEnv, "env", []string{}, "Environment variables (KEY=VALUE)") + deployMCPCmd.Flags().BoolVar(&deployForce, "force", false, "Force deployment even if validation fails") + deployMCPCmd.Flags().StringVarP(&deployFile, "file", "f", "", "Path to kmcp.yaml file (default: current directory)") + deployMCPCmd.Flags().StringVar( + &deployEnvironment, + "environment", + "staging", + "Target environment for deployment (e.g., staging, production)", + ) +} + +func runDeployMCP(_ *cobra.Command, args []string) error { + // Determine project directory + var projectDir string + var err error + + if deployFile != "" { + // Use specified file path + projectDir, err = getProjectDirFromFile(deployFile) + if err != nil { + return fmt.Errorf("failed to get project directory from file: %w", err) + } + } else { + // Use current working directory + projectDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + } + + // Load project manifest + manifestManager := manifest.NewManager(projectDir) + if !manifestManager.Exists() { + return fmt.Errorf("kmcp.yaml not found in %s. Run 'kmcp init' first or specify a valid path with --file", projectDir) + } + + projectManifest, err := manifestManager.Load() + if err != nil { + return fmt.Errorf("failed to load project manifest: %w", err) + } + + // Determine deployment name + deploymentName := projectManifest.Name + if len(args) > 0 { + deploymentName = args[0] + } + + // Generate MCPServer resource + mcpServer, err := generateMCPServer(projectManifest, deploymentName, deployEnvironment) + if err != nil { + return fmt.Errorf("failed to generate MCPServer: %w", err) + } + + // Set namespace + mcpServer.Namespace = deployNamespace + + if verbose { + fmt.Printf("Generated MCPServer: %s/%s\n", mcpServer.Namespace, mcpServer.Name) + } + + // Convert to YAML + yamlData, err := yaml.Marshal(mcpServer) + if err != nil { + return fmt.Errorf("failed to marshal MCPServer to YAML: %w", err) + } + + // Add YAML document separator and standard header + yamlContent := fmt.Sprintf( + "---\n# MCPServer deployment generated by kmcp deploy mcp\n# Project: %s\n# Framework: %s\n%s", + projectManifest.Name, + projectManifest.Framework, + string(yamlData), + ) + + // Handle output + if deployOutput != "" { + // Write to file + if err := os.WriteFile(deployOutput, []byte(yamlContent), 0644); err != nil { + return fmt.Errorf("failed to write to file: %w", err) + } + fmt.Printf("✅ MCPServer manifest written to: %s\n", deployOutput) + } + + if deployDryRun { + // Print to stdout + fmt.Print(yamlContent) + } else { + // Apply MCPServer to cluster + if err := applyToCluster(yamlContent, deploymentName); err != nil { + return fmt.Errorf("failed to apply to cluster: %w", err) + } + } + + return nil +} + +// getProjectDirFromFile extracts the project directory from a file path +func getProjectDirFromFile(filePath string) (string, error) { + // Get absolute path of the file + absPath, err := filepath.Abs(filePath) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + // Get the directory containing the file + projectDir := filepath.Dir(absPath) + + // Verify the file exists + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return "", fmt.Errorf("file does not exist: %s", absPath) + } + + return projectDir, nil +} + +func generateMCPServer( + projectManifest *manifest.ProjectManifest, + deploymentName, + environment string, +) (*v1alpha1.MCPServer, error) { + // Determine image name + imageName := deployImage + if imageName == "" { + // Use image from build config or generate default + if projectManifest.Build.Docker.Image != "" { + imageName = projectManifest.Build.Docker.Image + } else { + // Generate default image name + imageName = fmt.Sprintf("%s:latest", strings.ToLower(strings.ReplaceAll(projectManifest.Name, "_", "-"))) + } + } + + // Determine transport type + transportType := v1alpha1.TransportTypeStdio + if deployTransport != "" { + switch deployTransport { + case transportHTTP: + transportType = v1alpha1.TransportTypeHTTP + case transportStdio: + transportType = v1alpha1.TransportTypeStdio + default: + return nil, fmt.Errorf("invalid transport type: %s (must be 'stdio' or 'http')", deployTransport) + } + } + + // Determine port + port := deployPort + if port == 0 { + if projectManifest.Build.Docker.Port != 0 { + port = projectManifest.Build.Docker.Port + } else { + port = 3000 // Default port + } + } + + // Determine command and args + command := deployCommand + args := deployArgs + if command == "" { + // Set default command based on framework + command = getDefaultCommand(projectManifest.Framework) + if len(args) == 0 { + args = getDefaultArgs(projectManifest.Framework) + } + } + + // Parse environment variables + envVars := parseEnvVars(deployEnv) + + // Add framework-specific environment variables + for k, v := range projectManifest.Build.Docker.Environment { + if envVars[k] == "" { // Don't override user-provided values + envVars[k] = v + } + } + + // Get secret reference from manifest for the specified environment + secretRef, err := getSecretRefFromManifest(projectManifest, environment) + if err != nil { + return nil, fmt.Errorf("failed to get secret reference: %w", err) + } + var secretRefs []corev1.ObjectReference + if secretRef != nil { + secretRefs = append(secretRefs, *secretRef) + } + + // Create MCPServer spec + mcpServer := &v1alpha1.MCPServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kagent.dev/v1alpha1", + Kind: "MCPServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Labels: map[string]string{ + "app.kubernetes.io/name": deploymentName, + "app.kubernetes.io/instance": deploymentName, + "app.kubernetes.io/component": "mcp-server", + "app.kubernetes.io/part-of": "kmcp", + "app.kubernetes.io/managed-by": "kmcp", + "kmcp.dev/framework": projectManifest.Framework, + "kmcp.dev/version": sanitizeLabelValue(projectManifest.Version), + }, + Annotations: map[string]string{ + "kmcp.dev/project-name": projectManifest.Name, + "kmcp.dev/description": projectManifest.Description, + }, + }, + Spec: v1alpha1.MCPServerSpec{ + Deployment: v1alpha1.MCPServerDeployment{ + Image: imageName, + Port: uint16(port), + Cmd: command, + Args: args, + Env: envVars, + SecretRefs: secretRefs, + }, + TransportType: transportType, + }, + } + + // Configure transport-specific settings + if transportType == v1alpha1.TransportTypeHTTP { + targetPort := deployTargetPort + if targetPort == 0 { + targetPort = port + } + mcpServer.Spec.HTTPTransport = &v1alpha1.HTTPTransport{ + TargetPort: uint32(targetPort), + TargetPath: "/mcp", + } + } else { + mcpServer.Spec.StdioTransport = &v1alpha1.StdioTransport{} + } + + return mcpServer, nil +} + +func getSecretRefFromManifest( + projectManifest *manifest.ProjectManifest, + environment string, +) (*corev1.ObjectReference, error) { + if environment == "" { + return nil, nil // No environment specified + } + + secretProvider, ok := projectManifest.Secrets[environment] + if !ok { + return nil, fmt.Errorf("environment '%s' not found in secrets config", environment) + } + + if secretProvider.Provider == manifest.SecretProviderKubernetes && secretProvider.Enabled { + secretName := secretProvider.SecretName + if secretName == "" { + return nil, fmt.Errorf("secretName not found in secret provider config for environment %s", environment) + } + namespace := secretProvider.Namespace + if namespace == "" { + return nil, fmt.Errorf("namespace not found in secret provider config for environment %s", environment) + } + + return &corev1.ObjectReference{ + Kind: wellknown.SecretKind, + Name: secretName, + Namespace: namespace, + }, nil + } + + return nil, nil +} + +func sanitizeLabelValue(value string) string { + return strings.ReplaceAll(value, "+", "_") +} + +func getDefaultCommand(framework string) string { + switch framework { + default: + return "python" + } +} + +func getDefaultArgs(framework string) []string { + switch framework { + case manifest.FrameworkFastMCPPython: + return []string{"src/main.py"} + default: + return []string{"src/main.py"} + } +} + +func parseEnvVars(envVars []string) map[string]string { + result := make(map[string]string) + for _, env := range envVars { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + result[parts[0]] = parts[1] + } + } + return result +} + +func applyToCluster(yamlContent, deploymentName string) error { + fmt.Printf("🚀 Applying MCPServer to cluster...\n") + + // Check if kubectl is available + if err := checkKubectlAvailable(); err != nil { + return fmt.Errorf("kubectl is required for cluster deployment: %w", err) + } + + // Create temporary file for kubectl apply + tmpFile, err := os.CreateTemp("", "mcpserver-*.yaml") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + + // Write YAML content to temp file + if _, err := tmpFile.Write([]byte(yamlContent)); err != nil { + return fmt.Errorf("failed to write to temp file: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // Apply using kubectl + if err := runKubectl("apply", "-f", tmpFile.Name()); err != nil { + return fmt.Errorf("kubectl apply failed: %w", err) + } + + fmt.Printf("✅ MCPServer '%s' applied successfully\n", deploymentName) + fmt.Printf("💡 Check status with: kubectl get mcpserver %s -n %s\n", deploymentName, deployNamespace) + fmt.Printf("💡 View logs with: kubectl logs -l app.kubernetes.io/name=%s -n %s\n", deploymentName, deployNamespace) + + if err := os.Remove(tmpFile.Name()); err != nil { + fmt.Printf("failed to remove temp file: %v\n", err) + } + return nil +} + +func runKubectl(args ...string) error { + if verbose { + fmt.Printf("Running: kubectl %s\n", strings.Join(args, " ")) + } + + cmd := exec.Command("kubectl", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// checkKubectlAvailable checks if kubectl is available in the system +func checkKubectlAvailable() error { + cmd := exec.Command("kubectl", "version", "--client") + if err := cmd.Run(); err != nil { + return fmt.Errorf("kubectl not found or not working: %w", err) + } + return nil +} diff --git a/cmd/kmcp/cmd/init.go b/cmd/kmcp/cmd/init.go new file mode 100644 index 0000000..a744474 --- /dev/null +++ b/cmd/kmcp/cmd/init.go @@ -0,0 +1,284 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "kagent.dev/kmcp/pkg/frameworks" + "kagent.dev/kmcp/pkg/manifest" + "kagent.dev/kmcp/pkg/templates" +) + +const ( + frameworkFastMCPPython = "fastmcp-python" + frameworkMCPGo = "mcp-go" + templateBasic = "basic" +) + +var initCmd = &cobra.Command{ + Use: "init [project-name]", + Short: "Initialize a new MCP server project", + Long: `Initialize a new MCP server project with dynamic tool loading. + +This command creates a new MCP server project using one of the supported frameworks: +- FastMCP Python (recommended) - Dynamic tool loading with FastMCP + +The recommended approach is FastMCP Python which provides: +- Automatic tool discovery and loading +- One file per tool with clear structure +- Built-in configuration management +- Comprehensive testing framework`, + Args: cobra.MaximumNArgs(1), + RunE: runInit, +} + +var ( + initFramework string + initForce bool + initNoGit bool + initAuthor string + initEmail string + initDescription string + initNonInteractive bool + initNamespace string +) + +func init() { + rootCmd.AddCommand(initCmd) + + initCmd.Flags().StringVarP(&initFramework, "framework", "f", "", "Framework to use (fastmcp-python)") + initCmd.Flags().BoolVar(&initForce, "force", false, "Overwrite existing directory") + initCmd.Flags().BoolVar(&initNoGit, "no-git", false, "Skip git initialization") + initCmd.Flags().StringVar(&initAuthor, "author", "", "Author name for the project") + initCmd.Flags().StringVar(&initEmail, "email", "", "Author email for the project") + initCmd.Flags().StringVar(&initDescription, "description", "", "Description for the project") + initCmd.Flags().BoolVar(&initNonInteractive, "non-interactive", false, "Run in non-interactive mode") + initCmd.Flags().StringVar(&initNamespace, "namespace", "default", "Default namespace for project resources") +} + +func runInit(_ *cobra.Command, args []string) error { + var projectName string + + // Get project name from args or prompt + if len(args) > 0 { + projectName = args[0] + } else if !initNonInteractive { + name, err := promptForProjectName() + if err != nil { + return fmt.Errorf("failed to get project name: %w", err) + } + projectName = name + } else { + return fmt.Errorf("project name is required in non-interactive mode") + } + + // Validate project name + if err := validateProjectName(projectName); err != nil { + return fmt.Errorf("invalid project name: %w", err) + } + + // Check if directory exists + projectPath := filepath.Join(".", projectName) + if _, err := os.Stat(projectPath); err == nil && !initForce { + return fmt.Errorf("directory '%s' already exists. Use --force to overwrite", projectName) + } + + // Get framework selection + framework := initFramework + if framework == "" && !initNonInteractive { + framework = promptForFramework() + } else if framework == "" { + framework = frameworkFastMCPPython // Default framework + } + + // Get author information + author := initAuthor + email := initEmail + description := initDescription + if !initNonInteractive { + if author == "" { + author = promptForAuthor() + } + if email == "" { + email = promptForEmail() + } + if description == "" { + description = promptForDescription() + } + } + + if verbose { + fmt.Printf("Creating %s project using %s framework\n", projectName, framework) + fmt.Printf("Project directory: %s\n", projectPath) + } + + // Create project directory first + if err := os.MkdirAll(projectPath, 0755); err != nil { + return fmt.Errorf("failed to create project directory: %w", err) + } + + // Create the project manifest object + projectManifest := manifest.GetDefault(projectName, framework, description, author, email, initNamespace) + + // Create project configuration + projectConfig := templates.ProjectConfig{ + ProjectName: projectManifest.Name, + Framework: projectManifest.Framework, + Version: projectManifest.Version, + Description: projectManifest.Description, + Author: projectManifest.Author, + Email: projectManifest.Email, + Tools: projectManifest.Tools, + Secrets: projectManifest.Secrets, + Build: projectManifest.Build, + Directory: projectPath, + NoGit: initNoGit, + Verbose: verbose, + } + + // Use the generator to create the project files + fmt.Println("✅ Creating project structure...") + generator, err := frameworks.GetGenerator(framework) + if err != nil { + return fmt.Errorf("failed to get generator: %w", err) + } + + if err := generator.GenerateProject(projectConfig); err != nil { + return fmt.Errorf("failed to generate project files: %w", err) + } + + // Create kmcp.yaml + manifestManager := manifest.NewManager(projectPath) + if err := manifestManager.Save(projectManifest); err != nil { + return fmt.Errorf("failed to save project manifest: %w", err) + } + + // Get absolute path for output + absProjectPath, err := filepath.Abs(projectPath) + if err != nil { + absProjectPath = projectPath // Fallback to relative path if absolute fails + } + + // Success message + fmt.Printf("\n✓ Successfully created MCP server project: %s\n", projectName) + fmt.Printf("✓ Generated project manifest: kmcp.yaml\n") + fmt.Printf("\nNext steps for local development:\n") + + switch framework { + case frameworkFastMCPPython: + fmt.Printf(" Tools will be automatically discovered from the src/tools/ directory.\n") + fmt.Printf(" Use 'kmcp add-tool ' to add new tools after project creation.\n") + fmt.Printf("\n") + fmt.Printf(" To connect to the server using the inspector:\n") + fmt.Printf(" run npx @modelcontextprotocol/inspector\n") + fmt.Printf(" open the inspector on localhost:6274 and set transport type to STDIO\n") + fmt.Printf(" copy the `MCP_PROXY_AUTH_TOKEN` into the Proxy Session Token input under configuration\n") + fmt.Printf(" paste the following command into the inspector to connect to the server using the inspector\n") + fmt.Printf(" %s\n", filepath.Join(absProjectPath, "run_server.sh")) + fmt.Printf("\n") + fmt.Printf(" alternatively, run the following commands to start the server\n") + fmt.Printf(" cd %s\n", projectName) + fmt.Printf(" uv sync\n") + fmt.Printf(" uv run python src/main.py\n") + case frameworkMCPGo: + fmt.Printf(" go mod tidy\n") + fmt.Printf(" go run main.go\n") + } + + fmt.Printf("\nTo build a Docker image:\n") + fmt.Printf(" kmcp build --docker\n") + + fmt.Printf("\nTo build a Docker image in a specific directory:\n") + fmt.Printf(" kmcp build --docker --dir ./my-project\n") + + fmt.Printf("\nTo develop using Docker only (no local Python/uv required):\n") + fmt.Printf(" kmcp build --docker --verbose # Build and test\n") + fmt.Printf(" kmcp deploy mcp --apply # Deploy MCP server to Kubernetes\n") + + fmt.Printf("\nTo manage secrets:\n") + fmt.Printf(" kmcp secrets add-secret API_KEY --environment local\n") + fmt.Printf(" kmcp secrets generate-k8s-secrets --environment staging\n") + fmt.Printf("\nNote: Default namespace for secrets and deployments is '%s'\n", initNamespace) + + return nil +} + +func validateProjectName(name string) error { + if name == "" { + return fmt.Errorf("project name cannot be empty") + } + + // Check for invalid characters + if strings.ContainsAny(name, " \t\n\r/\\:*?\"<>|") { + return fmt.Errorf("project name contains invalid characters") + } + + // Check if it starts with a dot + if strings.HasPrefix(name, ".") { + return fmt.Errorf("project name cannot start with a dot") + } + + return nil +} + +// Prompts for user input + +func promptForProjectName() (string, error) { + fmt.Print("Enter project name: ") + var name string + if _, err := fmt.Scanln(&name); err != nil { + return "", err + } + return strings.TrimSpace(name), nil +} + +func promptForFramework() string { + fmt.Println("\nSelect a framework:") + fmt.Println("1. FastMCP Python (recommended) - Dynamic tool loading with FastMCP") + fmt.Print("Enter choice [1]: ") + + var choice string + if _, err := fmt.Scanln(&choice); err != nil { + // Default to FastMCP Python on any scan error (e.g., empty input) + return frameworkFastMCPPython + } + + switch strings.TrimSpace(choice) { + case "1", "": + return frameworkFastMCPPython + case "2": + return frameworkMCPGo + default: + return frameworkFastMCPPython // Default to recommended + } +} + +func promptForAuthor() string { + fmt.Print("Enter author name (optional): ") + var author string + if _, err := fmt.Scanln(&author); err != nil { + return "" + } + return strings.TrimSpace(author) +} + +func promptForEmail() string { + fmt.Print("Enter author email (optional): ") + var email string + if _, err := fmt.Scanln(&email); err != nil { + return "" + } + return strings.TrimSpace(email) +} + +func promptForDescription() string { + fmt.Print("Enter description (optional): ") + var description string + if _, err := fmt.Scanln(&description); err != nil { + return "" // Ignore error, treat as empty + } + return strings.TrimSpace(description) +} diff --git a/cmd/kmcp/cmd/install.go b/cmd/kmcp/cmd/install.go new file mode 100644 index 0000000..0ca08d9 --- /dev/null +++ b/cmd/kmcp/cmd/install.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +var ( + // Controller deployment flags + controllerVersion string + controllerNamespace string + controllerRegistryConfig string +) + +// installCmd represents the install command +var installCmd = &cobra.Command{ + Use: "install", + Short: "Install the KMCP controller on a Kubernetes cluster", + Long: `Install the KMCP controller and its required Custom Resource Definitions (CRDs) +on a Kubernetes cluster. + +This command should be run once per cluster to set up the necessary infrastructure +for deploying MCP servers. + +It will install the following resources: +- MCPServer Custom Resource Definition +- ClusterRole and ClusterRoleBinding for RBAC +- The KMCP controller Deployment`, + RunE: runInstall, +} + +func init() { + rootCmd.AddCommand(installCmd) + + // Controller deployment flags + installCmd.Flags().StringVar( + &controllerVersion, + "version", + "", + "Version of the controller to deploy (defaults to kmcp version)", + ) + installCmd.Flags().StringVar( + &controllerNamespace, + "namespace", + "kmcp-system", + "Namespace for the KMCP controller (defaults to kmcp-system)", + ) + installCmd.Flags().StringVar( + &controllerRegistryConfig, + "registry-config", + "", + "Path to docker registry config file", + ) +} + +func runInstall(_ *cobra.Command, _ []string) error { + fmt.Printf("🚀 Deploying KMCP controller to cluster...\n") + + // Check if helm is available + if err := checkHelmAvailable(); err != nil { + return fmt.Errorf("helm is required for controller deployment: %w", err) + } + + // Determine controller version + version := controllerVersion + if version == "" { + version = getKMCPVersion() + } + + // Validate controller version format + if version == "" { + return fmt.Errorf("invalid controller version: version cannot be empty") + } + + // Determine registry config file + registryConfig := controllerRegistryConfig + if registryConfig == "" { + fmt.Print("Docker registry config must be set use --registry-config\n") + } + if registryConfig != "" && verbose { + fmt.Printf("Using registry config: %s\n", registryConfig) + } + + // Build helm install command + args := []string{ + "install", "kmcp", "oci://ghcr.io/kagent-dev/kmcp/helm/kmcp", + "--version", version, + "--namespace", controllerNamespace, + "--create-namespace", + } + + // Add registry config if found + if registryConfig != "" { + args = append(args, "--registry-config", registryConfig) + } + + // Run helm install + if err := runHelm(args...); err != nil { + return fmt.Errorf("helm install failed: %w", err) + } + + fmt.Printf( + "✅ KMCP controller deployed successfully with version %s\n", + version, + ) + fmt.Printf( + "💡 Check controller status with: kubectl get pods -n %s\n", + controllerNamespace, + ) + fmt.Printf( + "💡 View controller logs with: kubectl logs -l app.kubernetes.io/name=kmcp-controller-manager -n %s\n", + controllerNamespace, + ) + + return nil +} + +// runHelm executes helm commands +func runHelm(args ...string) error { + if verbose { + fmt.Printf("Running: helm %s\n", strings.Join(args, " ")) + } + + cmd := exec.Command("helm", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// checkHelmAvailable checks if helm is available in the system +func checkHelmAvailable() error { + cmd := exec.Command("helm", "version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("helm not found or not working: %w", err) + } + return nil +} + +// getKMCPVersion returns the current kmcp version +func getKMCPVersion() string { + return Version +} diff --git a/cmd/kmcp/cmd/root.go b/cmd/kmcp/cmd/root.go new file mode 100644 index 0000000..50b6fec --- /dev/null +++ b/cmd/kmcp/cmd/root.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// Version will be set by ldflags during build +var Version = "" + +var rootCmd = &cobra.Command{ + Use: "kmcp", + Short: "KMCP - Kubernetes Model Context Protocol CLI", + Long: `KMCP is a CLI tool for building and managing Model Context Protocol (MCP) servers. + +It provides a unified development experience for creating, building, and deploying +MCP servers locally and to Kubernetes clusters.`, + Version: Version, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() error { + return rootCmd.Execute() +} + +func init() { + // Global flags can be added here + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") +} + +var verbose bool diff --git a/cmd/kmcp/cmd/secrets.go b/cmd/kmcp/cmd/secrets.go new file mode 100644 index 0000000..5bf4e00 --- /dev/null +++ b/cmd/kmcp/cmd/secrets.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kagent-dev/kmcp/pkg/manifest" + "github.com/spf13/cobra" + 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" +) + +// secretsCmd represents the secrets command +var secretsCmd = &cobra.Command{ + Use: "secrets", + Short: "Manage project secrets", + Long: `Manage secrets for MCP server projects.`, +} + +var ( + secretSourceFile string + secretDryRun bool + secretDir string +) + +// syncCmd creates or updates a Kubernetes secret from an environment file +var syncCmd = &cobra.Command{ + Use: "sync [environment]", + Short: "Sync secrets to a Kubernetes environment from a local .env file", + Long: `Sync secrets from a local .env file to a Kubernetes secret. + +This command reads a .env file and the project's kmcp.yaml file to determine +the correct secret name and namespace for the specified environment. It then +creates or updates the Kubernetes secret directly in the cluster. + +The command will look for a ".env" file in the project root by default. + +Examples: + # Sync secrets to the "staging" environment defined in kmcp.yaml + kmcp secrets sync staging + + # Sync secrets from a custom .env file + kmcp secrets sync staging --from-file .env.staging + + # Sync secrets from a specific project directory + kmcp secrets sync staging --dir ./my-project + + # Perform a dry run to see the generated secret without applying it + kmcp secrets sync production --dry-run +`, + Args: cobra.ExactArgs(1), + RunE: runSync, +} + +func init() { + rootCmd.AddCommand(secretsCmd) + + // Add subcommands + secretsCmd.AddCommand(syncCmd) + + // 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)") +} + +func runSync(cmd *cobra.Command, args []string) error { + environment := args[0] + + // Determine project root + projectRoot := secretDir + if projectRoot == "" { + var err error + projectRoot, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + } else { + // Convert relative path to absolute path + if !filepath.IsAbs(projectRoot) { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + projectRoot = filepath.Join(cwd, projectRoot) + } + } + + // Load manifest + manifestManager := manifest.NewManager(projectRoot) + if !manifestManager.Exists() { + return fmt.Errorf("kmcp.yaml not found in %s. Please run 'kmcp init' or navigate to a valid project", projectRoot) + } + projectManifest, err := manifestManager.Load() + if err != nil { + return fmt.Errorf("failed to load project manifest: %w", err) + } + + // Get secret config for the environment + secretConfig, ok := projectManifest.Secrets[environment] + if !ok { + return fmt.Errorf("environment '%s' not found in kmcp.yaml secrets configuration", environment) + } + + if secretConfig.Provider != manifest.SecretProviderKubernetes { + return fmt.Errorf( + "the 'secrets sync' command only supports the 'kubernetes' provider, but environment '%s' uses '%s'", + environment, + secretConfig.Provider, + ) + } + + // Load .env file + envVars, err := loadEnvFile(secretSourceFile) + if err != nil { + return err + } + if len(envVars) == 0 { + return fmt.Errorf("no variables found in source file '%s'", secretSourceFile) + } + + // Create Kubernetes secret object + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secretConfig.SecretName, + Namespace: secretConfig.Namespace, + }, + Type: corev1.SecretTypeOpaque, + Data: make(map[string][]byte), + } + + for key, value := range envVars { + secret.Data[key] = []byte(value) + } + + if secretDryRun { + yamlData, err := yaml.Marshal(secret) + if err != nil { + return fmt.Errorf("failed to marshal secret to YAML: %w", err) + } + fmt.Print(string(yamlData)) + return nil + } + + // Apply to cluster + return applySecretToCluster(secret) +} + +func applySecretToCluster(secret *corev1.Secret) error { + // Get kubeconfig + cfg, err := config.GetConfig() + if err != nil { + return fmt.Errorf("failed to get kubernetes config: %w", err) + } + + // Create clientset + clientset, err := kubernetes.NewForConfig(cfg) + if err != nil { + return fmt.Errorf("failed to create kubernetes clientset: %w", err) + } + + // Check if secret exists + _, err = clientset.CoreV1().Secrets(secret.Namespace).Get(context.TODO(), secret.Name, metav1.GetOptions{}) + if err != nil { + // Create if it doesn't exist + _, err = clientset.CoreV1().Secrets(secret.Namespace).Create(context.TODO(), secret, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + fmt.Printf("✅ Secret '%s' created in namespace '%s'.\n", secret.Name, secret.Namespace) + } else { + // Update if it exists + _, err = clientset.CoreV1().Secrets(secret.Namespace).Update(context.TODO(), secret, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update secret: %w", err) + } + fmt.Printf("✅ Secret '%s' updated in namespace '%s'.\n", secret.Name, secret.Namespace) + } + + return nil +} + +// loadEnvFile reads environment variables from a file and returns them as a map +func loadEnvFile(filename string) (map[string]string, error) { + if _, err := os.Stat(filename); os.IsNotExist(err) { + return nil, fmt.Errorf("source secret file not found: %s", filename) + } + + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + envVars := make(map[string]string) + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue // Skip empty lines and comments + } + + if idx := strings.Index(line, "="); idx != -1 { + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if key != "" { + envVars[key] = value + } + } + } + + return envVars, nil +} diff --git a/cmd/kmcp/cmd/utils.go b/cmd/kmcp/cmd/utils.go new file mode 100644 index 0000000..8c2e2c0 --- /dev/null +++ b/cmd/kmcp/cmd/utils.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" +) + +// findProjectRoot finds the root of the KMCP project by looking for kmcp.yaml. +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "kmcp.yaml")); err == nil { + return dir, nil + } + if _, err := os.Stat(filepath.Join(dir, "kmcp.yml")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("not in a KMCP project directory") + } + dir = parent + } +} + +// fileExists checks if a file exists at the given path. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/cmd/kmcp/main.go b/cmd/kmcp/main.go new file mode 100644 index 0000000..6a1b3ef --- /dev/null +++ b/cmd/kmcp/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "kagent.dev/kmcp/cmd/kmcp/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/config/crd/bases/kagent.dev_mcpservers.yaml b/config/crd/bases/kagent.dev_mcpservers.yaml index 4e15374..33a413e 100644 --- a/config/crd/bases/kagent.dev_mcpservers.yaml +++ b/config/crd/bases/kagent.dev_mcpservers.yaml @@ -66,6 +66,55 @@ spec: description: Port defines the port on which the MCP server will listen. type: integer + secretRefs: + description: |- + SecretRefs defines the list of Kubernetes secrets to reference. + These secrets will be mounted as volumes to the MCP server container. + items: + description: ObjectReference contains enough information to + let you inspect or modify the referred object. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + type: array type: object httpTransport: description: HTTPTransport defines the configuration for a Streamable diff --git a/examples/fastmcp-python-basic/.gitignore b/examples/fastmcp-python-basic/.gitignore new file mode 100644 index 0000000..359a664 --- /dev/null +++ b/examples/fastmcp-python-basic/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ \ No newline at end of file diff --git a/examples/fastmcp-python-basic/Dockerfile b/examples/fastmcp-python-basic/Dockerfile new file mode 100644 index 0000000..96097b2 --- /dev/null +++ b/examples/fastmcp-python-basic/Dockerfile @@ -0,0 +1,67 @@ +# Multi-stage build for FastMCP Python server +FROM python:3.11-slim as builder + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for layer caching +COPY pyproject.toml README.md ./ + +# Install build tools +RUN pip install --no-cache-dir --upgrade pip setuptools wheel + +# Copy source code +COPY fastmcp_python_basic/ ./fastmcp_python_basic/ + +# Install the application +RUN pip install --no-cache-dir -e . + +# Production stage +FROM python:3.11-slim + +# Create non-root user +RUN groupadd -r mcpuser && useradd -r -g mcpuser mcpuser + +# Set working directory +WORKDIR /app + +# Install runtime dependencies only +RUN apt-get update && apt-get install -y \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Copy built application from builder stage +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Copy application code +COPY . . + +# Install the application +RUN pip install --no-cache-dir --no-deps -e . + +# Change ownership to non-root user +RUN chown -R mcpuser:mcpuser /app + +# Switch to non-root user +USER mcpuser + +# Expose port (if needed for HTTP transport) +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import sys; sys.exit(0)" + +# Set environment variables +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Default command +CMD ["python", "-m", "fastmcp_python_basic.server"] \ No newline at end of file diff --git a/examples/fastmcp-python-basic/README.md b/examples/fastmcp-python-basic/README.md new file mode 100644 index 0000000..f1e2c32 --- /dev/null +++ b/examples/fastmcp-python-basic/README.md @@ -0,0 +1,219 @@ +# FastMCP Python Basic Example + +This is a basic Model Context Protocol (MCP) server implementation using the FastMCP framework. It demonstrates common MCP patterns and provides several example tools that can be used by AI assistants. + +## Overview + +This MCP server provides four basic tools: +- **calculator**: Perform arithmetic operations (add, subtract, multiply, divide) +- **list_files**: List files in a directory with optional pattern matching +- **system_info**: Get system information and environment details +- **echo**: Echo messages back to the client (useful for testing) + +## Features + +- **FastMCP Framework**: Uses the high-level FastMCP library for simplified development +- **Type Safety**: Pydantic models for request/response validation +- **Error Handling**: Comprehensive error handling with informative messages +- **Docker Support**: Multi-stage Dockerfile for containerized deployment +- **Security**: Non-root user execution and minimal attack surface + +## Installation + +### Local Development + +1. **Clone or navigate to this directory**: + ```bash + cd examples/fastmcp-python-basic + ``` + +2. **Install dependencies**: + ```bash + pip install -e . + ``` + +3. **Run the server**: + ```bash + python -m fastmcp_python_basic.server + ``` + +### Docker Deployment + +1. **Build the Docker image**: + ```bash + docker build -t fastmcp-python-basic . + ``` + +2. **Run the container**: + ```bash + docker run -i fastmcp-python-basic + ``` + +## Usage Examples + +### Calculator Tool + +Perform basic arithmetic operations: + +```json +{ + "operation": "add", + "a": 10, + "b": 5 +} +``` + +Response: +```json +{ + "result": 15, + "operation": "add", + "inputs": {"a": 10, "b": 5} +} +``` + +### List Files Tool + +List files in a directory: + +```json +{ + "directory": ".", + "pattern": "*.py" +} +``` + +Response: +```json +{ + "directory": ".", + "pattern": "*.py", + "files": [ + { + "name": "server.py", + "path": "./server.py", + "size": 1024, + "modified": "2024-01-01T12:00:00", + "is_file": true + } + ], + "count": 1 +} +``` + +### System Info Tool + +Get system information: + +```json +{} +``` + +Response: +```json +{ + "timestamp": "2024-01-01T12:00:00", + "python_version": "3.11.0", + "platform": "Darwin-21.6.0-x86_64-i386-64bit", + "working_directory": "/app", + "environment_variables": { + "PATH": "/usr/local/bin:/usr/bin:/bin", + "HOME": "/home/mcpuser" + } +} +``` + +### Echo Tool + +Echo a message: + +```json +{ + "message": "Hello, MCP!" +} +``` + +Response: +```json +{ + "message": "Hello, MCP!", + "timestamp": "2024-01-01T12:00:00", + "length": 11 +} +``` + +## Development + +### Running Tests + +```bash +pip install -e ".[dev]" +pytest +``` + +### Code Formatting + +```bash +black . +ruff check . +``` + +### Type Checking + +```bash +mypy . +``` + +## Project Structure + +``` +fastmcp-python-basic/ +├── fastmcp_python_basic/ +│ ├── __init__.py # Package initialization +│ └── server.py # Main server implementation +├── Dockerfile # Multi-stage Docker build +├── pyproject.toml # Python project configuration +└── README.md # This file +``` + +## MCP Protocol Details + +This server implements the Model Context Protocol (MCP) specification: + +- **Transport**: STDIO (standard input/output) +- **Protocol**: JSON-RPC 2.0 +- **Capabilities**: Tools (functions that can be called by AI assistants) + +## Integration + +This server can be integrated with various MCP clients including: + +- **Claude Desktop**: Add to your MCP configuration +- **Cursor**: Use with MCP extension +- **VS Code**: MCP protocol support +- **Custom Applications**: Any application supporting MCP + +### Example MCP Client Configuration + +```json +{ + "mcpServers": { + "basic-server": { + "command": "python", + "args": ["-m", "fastmcp_python_basic.server"], + "cwd": "/path/to/project" + } + } +} +``` + +## Security Considerations + +- Server runs as non-root user in Docker +- Limited environment variable exposure +- Input validation through Pydantic models +- No network access from tools (local operation only) + +## License + +This example is part of the KMCP project and follows the same license terms. \ No newline at end of file diff --git a/examples/fastmcp-python-basic/fastmcp_python_basic/__init__.py b/examples/fastmcp-python-basic/fastmcp_python_basic/__init__.py new file mode 100644 index 0000000..47928f8 --- /dev/null +++ b/examples/fastmcp-python-basic/fastmcp_python_basic/__init__.py @@ -0,0 +1,6 @@ +"""FastMCP Python Basic Example Server. + +A basic MCP server implementation using FastMCP framework. +""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/examples/fastmcp-python-basic/fastmcp_python_basic/server.py b/examples/fastmcp-python-basic/fastmcp_python_basic/server.py new file mode 100644 index 0000000..584422a --- /dev/null +++ b/examples/fastmcp-python-basic/fastmcp_python_basic/server.py @@ -0,0 +1,191 @@ +"""Main FastMCP server implementation with example tools.""" + +import asyncio +import json +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastmcp import FastMCP +from pydantic import BaseModel, Field + + +class CalculationRequest(BaseModel): + """Request model for calculation operations.""" + operation: str = Field(..., description="The operation to perform: add, subtract, multiply, divide") + a: float = Field(..., description="First number") + b: float = Field(..., description="Second number") + + +class FileListRequest(BaseModel): + """Request model for listing files.""" + directory: str = Field(".", description="Directory to list files from") + pattern: str = Field("*", description="File pattern to match") + + +class SystemInfoResponse(BaseModel): + """Response model for system information.""" + timestamp: str + python_version: str + platform: str + working_directory: str + environment_variables: Dict[str, str] + + +# Initialize FastMCP server +mcp = FastMCP("Basic MCP Server") + + +@mcp.tool() +def calculator(request: CalculationRequest) -> Dict[str, Any]: + """Perform basic arithmetic calculations. + + This tool can perform addition, subtraction, multiplication, and division + operations on two numbers. + """ + try: + result = 0.0 + + if request.operation == "add": + result = request.a + request.b + elif request.operation == "subtract": + result = request.a - request.b + elif request.operation == "multiply": + result = request.a * request.b + elif request.operation == "divide": + if request.b == 0: + return { + "error": "Division by zero is not allowed", + "operation": request.operation, + "inputs": {"a": request.a, "b": request.b} + } + result = request.a / request.b + else: + return { + "error": f"Unknown operation: {request.operation}", + "supported_operations": ["add", "subtract", "multiply", "divide"] + } + + return { + "result": result, + "operation": request.operation, + "inputs": {"a": request.a, "b": request.b} + } + except Exception as e: + return { + "error": f"Calculation error: {str(e)}", + "operation": request.operation, + "inputs": {"a": request.a, "b": request.b} + } + + +@mcp.tool() +def list_files(request: FileListRequest) -> Dict[str, Any]: + """List files in a directory with optional pattern matching. + + This tool lists files in the specified directory and can filter + results using glob patterns. + """ + try: + directory = Path(request.directory) + + if not directory.exists(): + return { + "error": f"Directory does not exist: {request.directory}", + "directory": request.directory + } + + if not directory.is_dir(): + return { + "error": f"Path is not a directory: {request.directory}", + "directory": request.directory + } + + # List files matching pattern + files = [] + for file_path in directory.glob(request.pattern): + if file_path.is_file(): + stat = file_path.stat() + files.append({ + "name": file_path.name, + "path": str(file_path), + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "is_file": True + }) + elif file_path.is_dir(): + files.append({ + "name": file_path.name, + "path": str(file_path), + "is_file": False + }) + + return { + "directory": request.directory, + "pattern": request.pattern, + "files": files, + "count": len(files) + } + except Exception as e: + return { + "error": f"File listing error: {str(e)}", + "directory": request.directory, + "pattern": request.pattern + } + + +@mcp.tool() +def system_info() -> SystemInfoResponse: + """Get system information and environment details. + + This tool provides information about the current system environment, + Python version, and other runtime details. + """ + import platform + + # Get a subset of environment variables (excluding sensitive ones) + env_vars = {} + safe_vars = ["PATH", "HOME", "USER", "SHELL", "LANG", "PWD"] + for var in safe_vars: + if var in os.environ: + env_vars[var] = os.environ[var] + + return SystemInfoResponse( + timestamp=datetime.now().isoformat(), + python_version=sys.version, + platform=platform.platform(), + working_directory=os.getcwd(), + environment_variables=env_vars + ) + + +@mcp.tool() +def echo(message: str) -> Dict[str, Any]: + """Echo a message back to the client. + + This is a simple tool that returns the input message along with + a timestamp, useful for testing connectivity and basic functionality. + """ + return { + "message": message, + "timestamp": datetime.now().isoformat(), + "length": len(message) + } + + +def main() -> None: + """Main entry point for the MCP server.""" + try: + # Run the FastMCP server + mcp.run() + except KeyboardInterrupt: + print("\nShutting down server...") + except Exception as e: + print(f"Server error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/fastmcp-python-basic/pyproject.toml b/examples/fastmcp-python-basic/pyproject.toml new file mode 100644 index 0000000..f6d1bb0 --- /dev/null +++ b/examples/fastmcp-python-basic/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "fastmcp-python-basic" +version = "0.1.0" +description = "A basic FastMCP Python server example" +authors = [ + {name = "KMCP CLI", email = "noreply@kagent.dev"} +] +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "mcp>=1.0.0", + "fastmcp>=0.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=22.0.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.scripts] +basic-server = "fastmcp_python_basic.server:main" + +[tool.black] +line-length = 88 +target-version = ['py39'] + +[tool.ruff] +line-length = 88 +target-version = "py39" +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.mypy] +python_version = "3.9" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true \ No newline at end of file diff --git a/go.mod b/go.mod index bc4ee79..5179dd4 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,13 @@ toolchain go1.24.3 godebug default=go1.23 require ( + github.com/kagent-dev/kmcp v0.0.0-00010101000000-000000000000 github.com/mark3labs/mcp-go v0.33.0 github.com/onsi/ginkgo/v2 v2.21.0 github.com/onsi/gomega v1.35.1 + github.com/spf13/cobra v1.8.1 + golang.org/x/text v0.19.0 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.32.0 k8s.io/apimachinery v0.32.0 k8s.io/client-go v0.32.0 @@ -61,7 +65,6 @@ require ( github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -82,7 +85,6 @@ require ( golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/term v0.25.0 // indirect - golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.26.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect @@ -92,7 +94,6 @@ require ( google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.32.0 // indirect k8s.io/apiserver v0.32.0 // indirect k8s.io/component-base v0.32.0 // indirect @@ -103,3 +104,5 @@ require ( sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect ) + +replace github.com/kagent-dev/kmcp => ./ diff --git a/helm/kmcp/.helmignore b/helm/kmcp/.helmignore index 1c2ee74..3da8c1d 100644 --- a/helm/kmcp/.helmignore +++ b/helm/kmcp/.helmignore @@ -19,4 +19,4 @@ Makefile # Exclude build artifacts bin/ dist/ -*.tgz \ No newline at end of file +*.tgz diff --git a/helm/kmcp/templates/mcpserver-crd.yaml b/helm/kmcp/templates/mcpserver-crd.yaml index 3692a16..14dad1f 100644 --- a/helm/kmcp/templates/mcpserver-crd.yaml +++ b/helm/kmcp/templates/mcpserver-crd.yaml @@ -68,6 +68,55 @@ spec: description: Port defines the port on which the MCP server will listen. type: integer + secretRefs: + description: |- + SecretRefs defines the list of Kubernetes secrets to reference. + These secrets will be mounted as volumes to the MCP server container. + items: + description: ObjectReference contains enough information to + let you inspect or modify the referred object. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + type: array type: object httpTransport: description: HTTPTransport defines the configuration for a Streamable diff --git a/internal/controller/mcpserver_controller.go b/internal/controller/mcpserver_controller.go index 5c01da5..faeeb58 100644 --- a/internal/controller/mcpserver_controller.go +++ b/internal/controller/mcpserver_controller.go @@ -96,7 +96,7 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -func (r *MCPServerReconciler) reconcileOutputs(ctx context.Context, outputs *agentgateway.AgentGatewayOutputs) error { +func (r *MCPServerReconciler) reconcileOutputs(ctx context.Context, outputs *agentgateway.Outputs) error { // upsert the outputs to the cluster if outputs.Deployment != nil { if err := upsertOutput(ctx, r.Client, outputs.Deployment); err != nil { @@ -117,7 +117,11 @@ func (r *MCPServerReconciler) reconcileOutputs(ctx context.Context, outputs *age return nil } -func (r *MCPServerReconciler) reconcileStatus(ctx context.Context, server *kagentdevv1alpha1.MCPServer, reconcileErr error) { +func (r *MCPServerReconciler) reconcileStatus( + ctx context.Context, + server *kagentdevv1alpha1.MCPServer, + reconcileErr error, +) { // Update ObservedGeneration server.Status.ObservedGeneration = server.Generation @@ -125,21 +129,59 @@ func (r *MCPServerReconciler) reconcileStatus(ctx context.Context, server *kagen if err := r.validateMCPServer(server); err != nil { setAcceptedCondition(server, false, kagentdevv1alpha1.MCPServerReasonInvalidConfig, err.Error()) // If validation fails, set other conditions as unknown/false - setResolvedRefsCondition(server, false, kagentdevv1alpha1.MCPServerReasonImageNotFound, "Configuration validation failed") - setProgrammedCondition(server, false, kagentdevv1alpha1.MCPServerReasonDeploymentFailed, "Configuration validation failed") - setReadyCondition(server, false, kagentdevv1alpha1.MCPServerReasonPodsNotReady, "Configuration validation failed") + setResolvedRefsCondition( + server, + false, + kagentdevv1alpha1.MCPServerReasonImageNotFound, + "Configuration validation failed", + ) + setProgrammedCondition( + server, + false, + kagentdevv1alpha1.MCPServerReasonDeploymentFailed, + "Configuration validation failed", + ) + setReadyCondition( + server, + false, + kagentdevv1alpha1.MCPServerReasonPodsNotReady, + "Configuration validation failed", + ) } else { - setAcceptedCondition(server, true, kagentdevv1alpha1.MCPServerReasonAccepted, "MCPServer configuration is valid") + setAcceptedCondition( + server, + true, + kagentdevv1alpha1.MCPServerReasonAccepted, + "MCPServer configuration is valid", + ) // Set ResolvedRefs condition (for now, assume image exists - could be enhanced later) - setResolvedRefsCondition(server, true, kagentdevv1alpha1.MCPServerReasonResolvedRefs, "All references resolved successfully") + setResolvedRefsCondition( + server, + true, + kagentdevv1alpha1.MCPServerReasonResolvedRefs, + "All references resolved successfully", + ) // Set Programmed condition based on reconcile result if reconcileErr != nil { - setProgrammedCondition(server, false, kagentdevv1alpha1.MCPServerReasonDeploymentFailed, reconcileErr.Error()) - setReadyCondition(server, false, kagentdevv1alpha1.MCPServerReasonPodsNotReady, "Resources failed to be created") + setProgrammedCondition( + server, + false, + kagentdevv1alpha1.MCPServerReasonDeploymentFailed, + reconcileErr.Error(), + ) + setReadyCondition(server, + false, + kagentdevv1alpha1.MCPServerReasonPodsNotReady, + "Resources failed to be created", + ) } else { - setProgrammedCondition(server, true, kagentdevv1alpha1.MCPServerReasonProgrammed, "All resources created successfully") + setProgrammedCondition(server, + true, + kagentdevv1alpha1.MCPServerReasonProgrammed, + "All resources created successfully", + ) // Check Ready condition by examining deployment status r.checkReadyCondition(ctx, server) @@ -155,7 +197,8 @@ func (r *MCPServerReconciler) reconcileStatus(ctx context.Context, server *kagen // validateMCPServer validates the MCPServer configuration func (r *MCPServerReconciler) validateMCPServer(server *kagentdevv1alpha1.MCPServer) error { // Check if transport type is supported - if server.Spec.TransportType != kagentdevv1alpha1.TransportTypeStdio && server.Spec.TransportType != kagentdevv1alpha1.TransportTypeHTTP { + if server.Spec.TransportType != kagentdevv1alpha1.TransportTypeStdio && + server.Spec.TransportType != kagentdevv1alpha1.TransportTypeHTTP { return fmt.Errorf("unsupported transport type: %s", server.Spec.TransportType) } @@ -177,22 +220,39 @@ func (r *MCPServerReconciler) checkReadyCondition(ctx context.Context, server *k if client.IgnoreNotFound(err) == nil { setReadyCondition(server, false, kagentdevv1alpha1.MCPServerReasonPodsNotReady, "Deployment not found") } else { - setReadyCondition(server, false, kagentdevv1alpha1.MCPServerReasonPodsNotReady, fmt.Sprintf("Error getting deployment: %s", err.Error())) + setReadyCondition( + server, + false, + kagentdevv1alpha1.MCPServerReasonPodsNotReady, + fmt.Sprintf("Error getting deployment: %s", err.Error()), + ) } return } // Check if deployment is available if deployment.Status.ReadyReplicas > 0 && deployment.Status.ReadyReplicas == deployment.Status.Replicas { - setReadyCondition(server, true, kagentdevv1alpha1.MCPServerReasonReady, "Deployment is ready and all pods are running") + setReadyCondition( + server, + true, + kagentdevv1alpha1.MCPServerReasonReady, + "Deployment is ready and all pods are running", + ) } else { - message := fmt.Sprintf("Deployment not ready: %d/%d replicas ready", deployment.Status.ReadyReplicas, deployment.Status.Replicas) + message := fmt.Sprintf("Deployment not ready: %d/%d replicas ready", + deployment.Status.ReadyReplicas, deployment.Status.Replicas) setReadyCondition(server, false, kagentdevv1alpha1.MCPServerReasonPodsNotReady, message) } } // setCondition sets the given condition on the MCPServer status. -func setCondition(server *kagentdevv1alpha1.MCPServer, conditionType kagentdevv1alpha1.MCPServerConditionType, status metav1.ConditionStatus, reason kagentdevv1alpha1.MCPServerConditionReason, message string) { +func setCondition( + server *kagentdevv1alpha1.MCPServer, + conditionType kagentdevv1alpha1.MCPServerConditionType, + status metav1.ConditionStatus, + reason kagentdevv1alpha1.MCPServerConditionReason, + message string, +) { now := metav1.Now() condition := metav1.Condition{ Type: string(conditionType), @@ -223,7 +283,12 @@ func setCondition(server *kagentdevv1alpha1.MCPServer, conditionType kagentdevv1 } // setAcceptedCondition sets the Accepted condition on the MCPServer. -func setAcceptedCondition(server *kagentdevv1alpha1.MCPServer, accepted bool, reason kagentdevv1alpha1.MCPServerConditionReason, message string) { +func setAcceptedCondition( + server *kagentdevv1alpha1.MCPServer, + accepted bool, + reason kagentdevv1alpha1.MCPServerConditionReason, + message string, +) { status := metav1.ConditionTrue if !accepted { status = metav1.ConditionFalse @@ -232,7 +297,12 @@ func setAcceptedCondition(server *kagentdevv1alpha1.MCPServer, accepted bool, re } // setResolvedRefsCondition sets the ResolvedRefs condition on the MCPServer. -func setResolvedRefsCondition(server *kagentdevv1alpha1.MCPServer, resolved bool, reason kagentdevv1alpha1.MCPServerConditionReason, message string) { +func setResolvedRefsCondition( + server *kagentdevv1alpha1.MCPServer, + resolved bool, + reason kagentdevv1alpha1.MCPServerConditionReason, + message string, +) { status := metav1.ConditionTrue if !resolved { status = metav1.ConditionFalse @@ -241,7 +311,12 @@ func setResolvedRefsCondition(server *kagentdevv1alpha1.MCPServer, resolved bool } // setProgrammedCondition sets the Programmed condition on the MCPServer. -func setProgrammedCondition(server *kagentdevv1alpha1.MCPServer, programmed bool, reason kagentdevv1alpha1.MCPServerConditionReason, message string) { +func setProgrammedCondition( + server *kagentdevv1alpha1.MCPServer, + programmed bool, + reason kagentdevv1alpha1.MCPServerConditionReason, + message string, +) { status := metav1.ConditionTrue if !programmed { status = metav1.ConditionFalse @@ -250,7 +325,12 @@ func setProgrammedCondition(server *kagentdevv1alpha1.MCPServer, programmed bool } // setReadyCondition sets the Ready condition on the MCPServer. -func setReadyCondition(server *kagentdevv1alpha1.MCPServer, ready bool, reason kagentdevv1alpha1.MCPServerConditionReason, message string) { +func setReadyCondition( + server *kagentdevv1alpha1.MCPServer, + ready bool, + reason kagentdevv1alpha1.MCPServerConditionReason, + message string, +) { status := metav1.ConditionTrue if !ready { status = metav1.ConditionFalse diff --git a/internal/controller/mcpserver_controller_test.go b/internal/controller/mcpserver_controller_test.go index 55819c0..c8d86b0 100644 --- a/internal/controller/mcpserver_controller_test.go +++ b/internal/controller/mcpserver_controller_test.go @@ -19,8 +19,9 @@ package controller import ( "context" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + "github.com/onsi/gomega" + + ginkgo "github.com/onsi/ginkgo/v2" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -30,8 +31,8 @@ import ( kagentdevv1alpha1 "kagent.dev/kmcp/api/v1alpha1" ) -var _ = Describe("MCPServer Controller", func() { - Context("When reconciling a resource", func() { +var _ = ginkgo.Describe("MCPServer Controller", func() { + ginkgo.Context("When reconciling a resource", func() { const resourceName = "test-resource" ctx := context.Background() @@ -42,8 +43,8 @@ var _ = Describe("MCPServer Controller", func() { } mcpserver := &kagentdevv1alpha1.MCPServer{} - BeforeEach(func() { - By("creating the custom resource for the Kind MCPServer") + ginkgo.BeforeEach(func() { + ginkgo.By("creating the custom resource for the Kind MCPServer") err := k8sClient.Get(ctx, typeNamespacedName, mcpserver) if err != nil && errors.IsNotFound(err) { resource := &kagentdevv1alpha1.MCPServer{ @@ -61,24 +62,24 @@ var _ = Describe("MCPServer Controller", func() { TransportType: "stdio", }, } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + gomega.Expect(k8sClient.Create(ctx, resource)).To(gomega.Succeed()) } }) - AfterEach(func() { + ginkgo.AfterEach(func() { // TODO(user): Cleanup logic after each test, like removing the resource instance. resource := &kagentdevv1alpha1.MCPServer{} err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) - By("Cleanup the specific resource instance MCPServer") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + ginkgo.By("Cleanup the specific resource instance MCPServer") + gomega.Expect(k8sClient.Delete(ctx, resource)).To(gomega.Succeed()) }) - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") + ginkgo.It("should successfully reconcile the resource", func() { + ginkgo.By("Reconciling the created resource") scheme := k8sClient.Scheme() err := kagentdevv1alpha1.AddToScheme(scheme) - Expect(err).NotTo(HaveOccurred()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) controllerReconciler := &MCPServerReconciler{ Client: k8sClient, @@ -88,7 +89,7 @@ var _ = Describe("MCPServer Controller", func() { _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: typeNamespacedName, }) - Expect(err).NotTo(HaveOccurred()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) }) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index a9c1951..73ecaff 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -22,8 +22,8 @@ import ( "path/filepath" "testing" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -48,23 +48,23 @@ var ( ) func TestControllers(t *testing.T) { - RegisterFailHandler(Fail) + gomega.RegisterFailHandler(ginkgo.Fail) - RunSpecs(t, "Controller Suite") + ginkgo.RunSpecs(t, "Controller Suite") } -var _ = BeforeSuite(func() { - logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) +var _ = ginkgo.BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(ginkgo.GinkgoWriter), zap.UseDevMode(true))) ctx, cancel = context.WithCancel(context.TODO()) var err error err = kagentdevv1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) // +kubebuilder:scaffold:scheme - By("bootstrapping test environment") + ginkgo.By("bootstrapping test environment") testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, @@ -77,19 +77,19 @@ var _ = BeforeSuite(func() { // cfg is defined in this file globally. cfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg).NotTo(BeNil()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(cfg).NotTo(gomega.BeNil()) k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient).NotTo(BeNil()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(k8sClient).NotTo(gomega.BeNil()) }) -var _ = AfterSuite(func() { - By("tearing down the test environment") +var _ = ginkgo.AfterSuite(func() { + ginkgo.By("tearing down the test environment") cancel() err := testEnv.Stop() - Expect(err).NotTo(HaveOccurred()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) // getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. diff --git a/pkg/agentgateway/agentgateway_translator.go b/pkg/agentgateway/agentgateway_translator.go index a286d62..f9dd826 100644 --- a/pkg/agentgateway/agentgateway_translator.go +++ b/pkg/agentgateway/agentgateway_translator.go @@ -19,7 +19,7 @@ const ( agentGatewayContainerImage = "howardjohn/agentgateway:1752179558" ) -type AgentGatewayOutputs struct { +type Outputs struct { // AgentGateway Deployment Deployment *appsv1.Deployment // AgentGateway Service @@ -28,15 +28,15 @@ type AgentGatewayOutputs struct { ConfigMap *corev1.ConfigMap } -type AgentGatewayTranslator interface { - TranslateAgentGatewayOutputs(server *v1alpha1.MCPServer) (*AgentGatewayOutputs, error) +type Translator interface { + TranslateAgentGatewayOutputs(server *v1alpha1.MCPServer) (*Outputs, error) } type agentGatewayTranslator struct { scheme *runtime.Scheme } -func NewAgentGatewayTranslator(scheme *runtime.Scheme) AgentGatewayTranslator { +func NewAgentGatewayTranslator(scheme *runtime.Scheme) Translator { return &agentGatewayTranslator{ scheme: scheme, } @@ -44,7 +44,7 @@ func NewAgentGatewayTranslator(scheme *runtime.Scheme) AgentGatewayTranslator { func (t *agentGatewayTranslator) TranslateAgentGatewayOutputs( server *v1alpha1.MCPServer, -) (*AgentGatewayOutputs, error) { +) (*Outputs, error) { deployment, err := t.translateAgentGatewayDeployment(server) if err != nil { return nil, fmt.Errorf("failed to translate AgentGateway deployment: %w", err) @@ -57,7 +57,7 @@ func (t *agentGatewayTranslator) TranslateAgentGatewayOutputs( if err != nil { return nil, fmt.Errorf("failed to translate AgentGateway config map: %w", err) } - return &AgentGatewayOutputs{ + return &Outputs{ Deployment: deployment, Service: service, ConfigMap: configMap, @@ -72,15 +72,19 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment( return nil, fmt.Errorf("deployment image must be specified for MCPServer %s", server.Name) } + // Create environment variables from secrets for envFrom + secretEnvFrom := t.createSecretEnvFrom(server.Spec.Deployment.SecretRefs) + var template corev1.PodSpec switch server.Spec.TransportType { case v1alpha1.TransportTypeStdio: // copy the binary into the container when running with stdio template = corev1.PodSpec{ InitContainers: []corev1.Container{{ - Name: "copy-binary", - Image: agentGatewayContainerImage, - Command: []string{"sh"}, + Name: "copy-binary", + Image: agentGatewayContainerImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"sh"}, Args: []string{ "-c", "cp /usr/bin/agentgateway /agentbin/agentgateway", @@ -92,8 +96,9 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment( SecurityContext: getSecurityContext(), }}, Containers: []corev1.Container{{ - Name: "mcp-server", - Image: image, + Name: "mcp-server", + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{ "sh", }, @@ -101,6 +106,7 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment( "-c", "/agentbin/agentgateway -f /config/local.yaml", }, + EnvFrom: secretEnvFrom, VolumeMounts: []corev1.VolumeMount{ { Name: "config", @@ -141,9 +147,10 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment( template = corev1.PodSpec{ Containers: []corev1.Container{ { - Name: "agent-gateway", - Image: agentGatewayContainerImage, - Command: []string{"sh"}, + Name: "agent-gateway", + Image: agentGatewayContainerImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"sh"}, Args: []string{ "-c", "/agentbin/agentgateway -f /config/local.yaml", @@ -157,9 +164,11 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment( { Name: "mcp-server", Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, Command: cmd, Args: server.Spec.Deployment.Args, Env: convertEnvVars(server.Spec.Deployment.Env), + EnvFrom: secretEnvFrom, SecurityContext: getSecurityContext(), }}, Volumes: []corev1.Volume{ @@ -209,6 +218,30 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment( return deployment, controllerutil.SetOwnerReference(server, deployment, t.scheme) } +// createSecretEnvFrom creates envFrom references from secret references +func (t *agentGatewayTranslator) createSecretEnvFrom( + secretRefs []corev1.ObjectReference, +) []corev1.EnvFromSource { + envFrom := make([]corev1.EnvFromSource, 0, len(secretRefs)) + + for _, secretRef := range secretRefs { + // Skip empty secret references + if secretRef.Name == "" { + continue + } + + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretRef.Name, + }, + }, + }) + } + + return envFrom +} + // getSecurityContext returns a SecurityContext that meets Pod Security Standards "restricted" policy func getSecurityContext() *corev1.SecurityContext { return &corev1.SecurityContext{ @@ -229,7 +262,7 @@ func convertEnvVars(env map[string]string) []corev1.EnvVar { if env == nil { return nil } - envVars := make([]corev1.EnvVar, 0, len(env)) + envVars := make([]corev1.EnvVar, len(env)) for key, value := range env { envVars = append(envVars, corev1.EnvVar{ Name: key, diff --git a/pkg/build/build.go b/pkg/build/build.go new file mode 100644 index 0000000..9492e20 --- /dev/null +++ b/pkg/build/build.go @@ -0,0 +1,284 @@ +package build + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Options contains configuration for building MCP servers +type Options struct { + ProjectDir string + Docker bool + Output string + Tag string + Platform string + Verbose bool +} + +// Builder handles building MCP servers +type Builder struct { + // Future: Add fields for template handling, etc. +} + +// New creates a new Builder instance +func New() *Builder { + return &Builder{} +} + +// Build executes the build process for an MCP server +func (b *Builder) Build(opts Options) error { + if opts.Verbose { + fmt.Printf("Starting build process...\n") + } + + // Detect project type + projectType, err := b.detectProjectType(opts.ProjectDir) + if err != nil { + return fmt.Errorf("failed to detect project type: %w", err) + } + + if opts.Verbose { + fmt.Printf("Detected project type: %s\n", projectType) + } + + // Build based on project type + switch projectType { + case "python": + return b.buildPython(opts) + case "node": + return b.buildNode(opts) + case "go": + return b.buildGo(opts) + default: + return fmt.Errorf("unsupported project type: %s", projectType) + } +} + +// detectProjectType determines the project type based on files present +func (b *Builder) detectProjectType(dir string) (string, error) { + // Check for Python project + if b.fileExists(filepath.Join(dir, "pyproject.toml")) || + b.fileExists(filepath.Join(dir, ".python-version")) || + b.fileExists(filepath.Join(dir, "requirements.txt")) || + b.fileExists(filepath.Join(dir, "setup.py")) { + return "python", nil + } + + // Check for Node.js project + if b.fileExists(filepath.Join(dir, "package.json")) { + return "node", nil + } + + // Check for Go project + if b.fileExists(filepath.Join(dir, "go.mod")) { + return "go", nil + } + + return "", fmt.Errorf("unknown project type") +} + +// fileExists checks if a file exists +func (b *Builder) fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// buildPython handles building Python MCP servers +func (b *Builder) buildPython(opts Options) error { + if opts.Docker { + return b.buildDockerImage(opts, "python") + } + + // For now, just validate that we can build + fmt.Println("✓ Python project validation passed") + fmt.Println("Note: Native Python builds will be implemented in future iterations") + + return nil +} + +// buildNode handles building Node.js MCP servers +func (b *Builder) buildNode(opts Options) error { + fmt.Println("Building Node.js MCP server...") + + if opts.Docker { + return b.buildDockerImage(opts, "node") + } + + // For now, just validate that we can build + fmt.Println("✓ Node.js project validation passed") + fmt.Println("Note: Native Node.js builds will be implemented in future iterations") + + return nil +} + +// buildGo handles building Go MCP servers +func (b *Builder) buildGo(opts Options) error { + fmt.Println("Building Go MCP server...") + + if opts.Docker { + return b.buildDockerImage(opts, "go") + } + + // For now, just validate that we can build + fmt.Println("✓ Go project validation passed") + fmt.Println("Note: Native Go builds will be implemented in future iterations") + + return nil +} + +// buildDockerImage builds a Docker image for the MCP server +func (b *Builder) buildDockerImage(opts Options, projectType string) error { + fmt.Printf("Building Docker image for %s project...\n", projectType) + + // Check if Docker is available + if err := b.checkDockerAvailable(); err != nil { + return fmt.Errorf("docker not available: %w", err) + } + + // Check if Dockerfile exists + dockerfilePath := filepath.Join(opts.ProjectDir, "Dockerfile") + if !b.fileExists(dockerfilePath) { + return fmt.Errorf("dockerfile not found at %s", dockerfilePath) + } + + // Generate image name if not provided + imageName := opts.Output + if imageName == "" { + dirName := filepath.Base(opts.ProjectDir) + imageName = strings.ToLower(dirName) + } + + // Add tag if provided + if opts.Tag != "" { + imageName = imageName + ":" + opts.Tag + } else { + imageName = imageName + ":latest" + } + + // Prepare docker build command + args := []string{"build", "-t", imageName} + + // Add platform if specified + if opts.Platform != "" { + args = append(args, "--platform", opts.Platform) + } + + // Add context (current directory) + args = append(args, ".") + + if opts.Verbose { + fmt.Printf("Running: docker %s\n", strings.Join(args, " ")) + } + + // Create docker command + cmd := exec.Command("docker", args...) + cmd.Dir = opts.ProjectDir + + if opts.Verbose { + // Show real-time output for verbose mode + return b.runCommandWithOutput(cmd, imageName) + } + // Capture output and show progress for non-verbose mode + return b.runCommandWithProgress(cmd, imageName) +} + +// checkDockerAvailable verifies that Docker is available and running +func (b *Builder) checkDockerAvailable() error { + cmd := exec.Command("docker", "version", "--format", "{{.Server.Version}}") + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker is not available or not running. Please ensure Docker is installed and running") + } + return nil +} + +// runCommandWithOutput runs a command and streams output in real-time +func (b *Builder) runCommandWithOutput(cmd *exec.Cmd, imageName string) error { + // Create pipes for stdout and stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Start the command + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start docker build: %w", err) + } + + // Stream output + go b.streamOutput(stdout, "") + go b.streamOutput(stderr, "") + + // Wait for command to complete + if err := cmd.Wait(); err != nil { + return fmt.Errorf("docker build failed: %w", err) + } + + fmt.Printf("✓ Successfully built Docker image: %s\n", imageName) + return nil +} + +// runCommandWithProgress runs a command and shows progress without streaming all output +func (b *Builder) runCommandWithProgress(cmd *exec.Cmd, imageName string) error { + // Start the command + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start docker build: %w", err) + } + + // Show progress indicator + done := make(chan bool) + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + chars := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + i := 0 + + for { + select { + case <-done: + return + case <-ticker.C: + fmt.Printf("\r%s Building Docker image...", chars[i%len(chars)]) + i++ + } + } + }() + + // Wait for command to complete + err := cmd.Wait() + done <- true + fmt.Print("\r") + + if err != nil { + return fmt.Errorf("docker build failed: %w", err) + } + + fmt.Printf("✓ Successfully built Docker image: %s\n", imageName) + return nil +} + +// streamOutput reads from a pipe and outputs lines with optional prefix +func (b *Builder) streamOutput(pipe io.ReadCloser, _ string) { + defer func() { + if err := pipe.Close(); err != nil { + fmt.Printf("Error closing pipe: %v\n", err) + } + }() + + scanner := bufio.NewScanner(pipe) + for scanner.Scan() { + line := scanner.Text() + fmt.Println(line) + } +} diff --git a/pkg/frameworks/frameworks.go b/pkg/frameworks/frameworks.go new file mode 100644 index 0000000..70aa971 --- /dev/null +++ b/pkg/frameworks/frameworks.go @@ -0,0 +1,28 @@ +package frameworks + +import ( + "fmt" + + "kagent.dev/kmcp/pkg/frameworks/golang" + "kagent.dev/kmcp/pkg/frameworks/python" + "kagent.dev/kmcp/pkg/templates" +) + +// Generator defines the interface for a framework-specific generator. +type Generator interface { + GenerateProject(config templates.ProjectConfig) error + GenerateTool(projectPath string, toolName string, config map[string]interface{}) error +} + +// GetGenerator returns a generator for the specified framework. +func GetGenerator(framework string) (Generator, error) { + switch framework { + case "fastmcp-python": + return python.NewGenerator(), nil + case "mcp-go": + // TODO: Implement the Go generator. + return golang.NewGenerator(), nil + default: + return nil, fmt.Errorf("unsupported framework: %s", framework) + } +} diff --git a/pkg/frameworks/golang/generator.go b/pkg/frameworks/golang/generator.go new file mode 100644 index 0000000..0a996ff --- /dev/null +++ b/pkg/frameworks/golang/generator.go @@ -0,0 +1,25 @@ +package golang + +import ( + "fmt" + + "kagent.dev/kmcp/pkg/templates" +) + +// Generator is the Go-specific generator. +type Generator struct{} + +// NewGenerator creates a new Go generator. +func NewGenerator() *Generator { + return &Generator{} +} + +// GenerateProject generates a new Go project. +func (g *Generator) GenerateProject(config templates.ProjectConfig) error { + return fmt.Errorf("go project generation not yet implemented") +} + +// GenerateTool generates a new tool for a Go project. +func (g *Generator) GenerateTool(projectPath string, toolName string, config map[string]interface{}) error { + return fmt.Errorf("go tool generation not yet implemented") +} diff --git a/pkg/frameworks/python/generator.go b/pkg/frameworks/python/generator.go new file mode 100644 index 0000000..b6589d5 --- /dev/null +++ b/pkg/frameworks/python/generator.go @@ -0,0 +1,274 @@ +package python + +import ( + "embed" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + "kagent.dev/kmcp/pkg/templates" +) + +//go:embed all:templates +var templateFiles embed.FS + +// Generator for Python projects +type Generator struct{} + +// NewGenerator creates a new Python generator +func NewGenerator() *Generator { + return &Generator{} +} + +// GenerateProject generates a new Python project +func (g *Generator) GenerateProject(config templates.ProjectConfig) error { + if config.Framework == "fastmcp-python" { + // Generate project from embedded templates + return g.generateFastMCPPython(config) + } + return fmt.Errorf("unsupported python framework: %s", config.Framework) +} + +// GenerateTool generates a new tool for a Python project. +func (g *Generator) GenerateTool(projectPath string, toolName string, config map[string]interface{}) error { + toolPath := filepath.Join(projectPath, "src", "tools", toolName+".py") + if err := g.GenerateToolFile(toolPath, toolName, config); err != nil { + return fmt.Errorf("failed to generate tool file: %w", err) + } + + // After generating the tool file, regenerate the __init__.py file + toolsDir := filepath.Dir(toolPath) + if err := g.RegenerateToolsInit(toolsDir); err != nil { + return fmt.Errorf("failed to regenerate __init__.py: %w", err) + } + return nil +} + +func (g *Generator) generateFastMCPPython(config templates.ProjectConfig) error { + if config.Verbose { + fmt.Println("Generating FastMCP Python project...") + } + + templateRoot, err := fs.Sub(templateFiles, "templates") + if err != nil { + return fmt.Errorf("failed to get templates subdirectory: %w", err) + } + + err = fs.WalkDir(templateRoot, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip tool.py.tmpl during project generation - it's for individual tool generation + if path == "tool.py.tmpl" { + return nil + } + + destPath := filepath.Join(config.Directory, strings.TrimSuffix(path, ".tmpl")) + + if d.IsDir() { + // Create the directory if it doesn't exist + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", destPath, err) + } + return nil + } + + // Read template file + templateContent, err := fs.ReadFile(templateRoot, path) + if err != nil { + return fmt.Errorf("failed to read template file %s: %w", path, err) + } + + // Render template content + renderedContent, err := g.renderTemplate(string(templateContent), config) + if err != nil { + return fmt.Errorf("failed to render template for %s: %w", path, err) + } + + // Create file + if err := os.WriteFile(destPath, []byte(renderedContent), 0644); err != nil { + return fmt.Errorf("failed to write file %s: %w", destPath, err) + } + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk templates: %w", err) + } + + // Initialize git repository + if !config.NoGit { + if err := g.initGitRepo(config.Directory, config.Verbose); err != nil { + // Don't fail the whole operation if git init fails + if config.Verbose { + fmt.Printf("Warning: failed to initialize git repository: %v\n", err) + } + } + } + + return nil +} + +// GenerateToolFile generates a new Python tool file from the unified template +func (g *Generator) GenerateToolFile(filePath, toolName string, config map[string]interface{}) error { + // Prepare template data + data := map[string]interface{}{ + "ToolName": toolName, + "ToolNameTitle": cases.Title(language.English).String(toolName), + "ToolNameUpper": strings.ToUpper(toolName), + "ToolNameLower": strings.ToLower(toolName), + "ClassName": cases.Title(language.English).String(toolName) + "Tool", + "Config": config, + } + + // Add config values to template data + for key, value := range config { + data[key] = value + } + + // Create the directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Parse and execute the template + templateContent, err := fs.ReadFile(templateFiles, "templates/tool.py.tmpl") + if err != nil { + return fmt.Errorf("failed to read tool template: %w", err) + } + + tmpl, err := template.New("tool").Parse(string(templateContent)) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + // Create the output file + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + // Execute the template + err = tmpl.Execute(file, data) + + // Close the file and check for errors + if closeErr := file.Close(); err == nil { + err = closeErr + } + return err +} + +// RegenerateToolsInit regenerates the __init__.py file in the tools directory +func (g *Generator) RegenerateToolsInit(toolsDir string) error { + // Scan the tools directory for Python files + tools, err := g.ScanToolsDirectory(toolsDir) + if err != nil { + return fmt.Errorf("failed to scan tools directory: %w", err) + } + + // Generate the __init__.py content + content := g.generateInitContent(tools) + + // Write the __init__.py file + initPath := filepath.Join(toolsDir, "__init__.py") + return os.WriteFile(initPath, []byte(content), 0644) +} + +// ScanToolsDirectory scans the tools directory and returns a list of tool names +func (g *Generator) ScanToolsDirectory(toolsDir string) ([]string, error) { + var tools []string + + // Read the directory + entries, err := os.ReadDir(toolsDir) + if err != nil { + return nil, fmt.Errorf("failed to read tools directory: %w", err) + } + + // Find all Python files (excluding __init__.py) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if strings.HasSuffix(name, ".py") && name != "__init__.py" { + // Extract tool name (filename without .py extension) + toolName := strings.TrimSuffix(name, ".py") + tools = append(tools, toolName) + } + } + + return tools, nil +} + +// generateInitContent generates the content for the __init__.py file +func (g *Generator) generateInitContent(tools []string) string { + var content strings.Builder + + // Add the header comment + content.WriteString(`"""Tools package for knowledge-assistant MCP server. + +This file is automatically generated by the dynamic loading system. +Do not edit manually - it will be overwritten when tools are loaded. +""" + +`) + + // Add import statements + for _, tool := range tools { + content.WriteString(fmt.Sprintf("from .%s import %s\n", tool, tool)) + } + + // Add empty line + content.WriteString("\n") + + // Add __all__ list + content.WriteString("__all__ = [") + for i, tool := range tools { + if i > 0 { + content.WriteString(", ") + } + content.WriteString(fmt.Sprintf(`"%s"`, tool)) + } + content.WriteString("]\n") + + return content.String() +} + +// renderTemplate renders a template string with the provided data +func (g *Generator) renderTemplate(tmplContent string, data interface{}) (string, error) { + tmpl, err := template.New("template").Parse(tmplContent) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + var result strings.Builder + if err := tmpl.Execute(&result, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return result.String(), nil +} + +// initGitRepo initializes a git repository in the specified directory +func (g *Generator) initGitRepo(dir string, verbose bool) error { + cmd := exec.Command("git", "init") + cmd.Dir = dir + + if verbose { + fmt.Printf(" Initializing git repository...\n") + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to run git init: %w", err) + } + + return nil +} diff --git a/pkg/frameworks/python/templates/.env.example.tmpl b/pkg/frameworks/python/templates/.env.example.tmpl new file mode 100644 index 0000000..3ee4e5c --- /dev/null +++ b/pkg/frameworks/python/templates/.env.example.tmpl @@ -0,0 +1,18 @@ +# {{.ProjectName}} Environment Variables +# Copy this file to .env.local and fill in actual values + +# Example API keys (configure these in kmcp.yaml under tools) +# WEATHER_API_KEY=your-weather-api-key-here +# DATABASE_URL=postgresql://user:password@localhost:5432/database +# OPENAI_API_KEY=your-openai-api-key-here + +# Server configuration +# MCP_SERVER_HOST=127.0.0.1 +# MCP_SERVER_PORT=8080 +# MCP_LOG_LEVEL=INFO +# MCP_DEBUG=false + +# Tool-specific configuration +# WEATHER_TIMEOUT=30 +# DB_MAX_CONNECTIONS=10 +# FILE_MAX_SIZE=10485760 # 10MB in bytes \ No newline at end of file diff --git a/pkg/frameworks/python/templates/.env.local.tmpl b/pkg/frameworks/python/templates/.env.local.tmpl new file mode 100644 index 0000000..4492bd7 --- /dev/null +++ b/pkg/frameworks/python/templates/.env.local.tmpl @@ -0,0 +1,17 @@ +# {{.ProjectName}} Environment Variables + +# Example API keys (configure these in kmcp.yaml under tools) +WEATHER_API_KEY=your-weather-api-key-here +DATABASE_URL=postgresql://user:password@localhost:5432/database +OPENAI_API_KEY=your-openai-api-key-here + +# Server configuration +MCP_SERVER_HOST=127.0.0.1 +MCP_SERVER_PORT=8080 +MCP_LOG_LEVEL=INFO +MCP_DEBUG=false + +# Tool-specific configuration +WEATHER_TIMEOUT=30 +DB_MAX_CONNECTIONS=10 +FILE_MAX_SIZE=10485760 # 10MB in bytes \ No newline at end of file diff --git a/pkg/frameworks/python/templates/.gitignore.tmpl b/pkg/frameworks/python/templates/.gitignore.tmpl new file mode 100644 index 0000000..33f7e53 --- /dev/null +++ b/pkg/frameworks/python/templates/.gitignore.tmpl @@ -0,0 +1,127 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.env.local +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# KMCP specific +.mcpbuilder.yaml \ No newline at end of file diff --git a/pkg/frameworks/python/templates/.python-version.tmpl b/pkg/frameworks/python/templates/.python-version.tmpl new file mode 100644 index 0000000..4b7e483 --- /dev/null +++ b/pkg/frameworks/python/templates/.python-version.tmpl @@ -0,0 +1 @@ +3.11 \ No newline at end of file diff --git a/pkg/frameworks/python/templates/Dockerfile.tmpl b/pkg/frameworks/python/templates/Dockerfile.tmpl new file mode 100644 index 0000000..6cf2c41 --- /dev/null +++ b/pkg/frameworks/python/templates/Dockerfile.tmpl @@ -0,0 +1,72 @@ +# Multi-stage build for {{.ProjectName}} MCP server using uv +FROM python:3.11-slim as builder + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Set working directory +WORKDIR /app + +# Copy dependency files first for layer caching +COPY pyproject.toml .python-version ./ +COPY README.md ./ + +# Copy lockfile if it exists, otherwise generate it +COPY uv.loc[k] ./ +RUN if [ -f uv.lock ]; then \ + echo "Using existing lockfile"; \ + uv sync --frozen --no-dev --no-cache; \ + else \ + echo "Generating lockfile and installing dependencies"; \ + uv sync --no-dev --no-cache; \ + fi + +# Copy source code +COPY src/ ./src/ +COPY kmcp.yaml ./ + +# Production stage +FROM python:3.11-slim + +# Install uv in production +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Create non-root user +RUN groupadd -r mcpuser && useradd -r -g mcpuser mcpuser + +# Set working directory +WORKDIR /app + +# Copy virtual environment and application from builder +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/src /app/src +COPY --from=builder /app/kmcp.yaml /app/kmcp.yaml +COPY --from=builder /app/pyproject.toml /app/pyproject.toml + +# Make sure scripts in .venv are usable +ENV PATH="/app/.venv/bin:$PATH" + +# Install runtime dependencies only +RUN apt-get update && apt-get install -y \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Change ownership to non-root user +RUN chown -R mcpuser:mcpuser /app + +# Switch to non-root user +USER mcpuser + +# Expose port (if needed for HTTP transport) +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import src.main; print('healthy')" + +# Set environment variables +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Default command +CMD ["python", "src/main.py"] \ No newline at end of file diff --git a/pkg/frameworks/python/templates/README.md.tmpl b/pkg/frameworks/python/templates/README.md.tmpl new file mode 100644 index 0000000..eb59dcd --- /dev/null +++ b/pkg/frameworks/python/templates/README.md.tmpl @@ -0,0 +1,185 @@ +# {{.ProjectName}} + +{{.ProjectName}} is a Model Context Protocol (MCP) server built with FastMCP featuring dynamic tool loading. + +## Features + +- **Dynamic Tool Loading**: Tools are automatically discovered and loaded from `src/tools/` +- **One Tool Per File**: Each tool is a single file with a function matching the filename +- **FastMCP Integration**: Leverages FastMCP for robust MCP protocol handling +- **Configuration Management**: Tool-specific configuration via `kmcp.yaml` +- **Fail-Fast**: Server won't start if any tool fails to load +- **Auto-Generated Tests**: Automatic test generation for tool validation + +## Project Structure + +``` +src/ +├── tools/ # Tool implementations (one file per tool) +│ ├── echo.py # Example echo tool +│ └── __init__.py # Auto-generated tool registry +├── core/ # Dynamic loading framework +│ ├── server.py # Dynamic MCP server +│ └── utils.py # Shared utilities +└── main.py # Entry point +kmcp.yaml # Configuration file +tests/ # Generated tests +``` + +## Quick Start + +### Option 1: Local Development (with Python/uv) + +1. **Install Dependencies**: + ```bash + uv sync + ``` + +2. **Run the Server**: + ```bash + uv run python src/main.py + ``` + +3. **Add New Tools**: + ```bash + # Create a new tool (no tool types needed!) + kmcp add-tool weather + + # The tool file will be created at src/tools/weather.py + # Edit it to implement your tool logic + ``` + +### Option 2: Docker-Only Development (no local Python/uv required) + +1. **Build Docker Image**: + ```bash + kmcp build --docker --verbose + ``` + +2. **Run in Container**: + ```bash + docker run -i {{.ProjectName}}:latest + ``` + +3. **Deploy to Kubernetes**: + ```bash + kmcp deploy mcp --apply + ``` + +4. **Add New Tools**: + ```bash + # Create a new tool + kmcp add-tool weather + + # Edit the tool file, then rebuild + kmcp build --docker + ``` + +## Creating Tools + +### Basic Tool Structure + +Each tool is a Python file in `src/tools/` containing a function decorated with `@mcp.tool()`: + +```python +# src/tools/weather.py +from core.server import mcp +from core.utils import get_tool_config, get_env_var + +@mcp.tool() +def weather(location: str) -> str: + """Get weather information for a location.""" + + # Get tool configuration + config = get_tool_config("weather") + api_key = get_env_var(config.get("api_key_env", "WEATHER_API_KEY")) + base_url = config.get("base_url", "https://api.openweathermap.org/data/2.5") + + # TODO: Implement weather API call + return f"Weather for {location}: Sunny, 72°F" +``` + +### Tool Examples + +The generated tool template includes commented examples for common patterns: + +```python +# HTTP API calls +# async with httpx.AsyncClient() as client: +# response = await client.get(f"{base_url}/weather?q={location}&appid={api_key}") +# return response.json() + +# Database operations +# async with asyncpg.connect(connection_string) as conn: +# result = await conn.fetchrow("SELECT * FROM weather WHERE location = $1", location) +# return dict(result) + +# File processing +# with open(file_path, 'r') as f: +# content = f.read() +# return {"content": content, "size": len(content)} +``` + +## Configuration + +Configure tools in `kmcp.yaml`: + +```yaml +tools: + weather: + api_key_env: "WEATHER_API_KEY" + base_url: "https://api.openweathermap.org/data/2.5" + timeout: 30 + + database: + connection_string_env: "DATABASE_URL" + max_connections: 10 +``` + +## Testing + +Run the generated tests to verify your tools load correctly: + +```bash +uv run pytest tests/ +``` + +## Development + +### Adding Dependencies + +Update `pyproject.toml` and run: + +```bash +uv sync +``` + +### Code Quality + +```bash +uv run black . +uv run ruff check . +uv run mypy . +``` + +## Deployment + +### Docker + +```bash +# Build image (handles lockfile automatically) +kmcp build --docker + +# Run container +docker run -i {{.ProjectName}}:latest +``` + +### Kubernetes + +```bash +# Deploy to Kubernetes +kmcp deploy mcp --apply + +# Check deployment status +kubectl get mcpserver {{.ProjectName}} +``` \ No newline at end of file diff --git a/pkg/frameworks/python/templates/pyproject.toml.tmpl b/pkg/frameworks/python/templates/pyproject.toml.tmpl new file mode 100644 index 0000000..9e0e94b --- /dev/null +++ b/pkg/frameworks/python/templates/pyproject.toml.tmpl @@ -0,0 +1,55 @@ +[project] +name = "{{.ProjectName}}" +version = "0.1.0" +description = "{{.ProjectName}} MCP server built with FastMCP" +{{if .Author}}{{if .Email}}authors = [ + {name = "{{.Author}}", email = "{{.Email}}"} +]{{else}}authors = [ + {name = "{{.Author}}"} +]{{end}}{{else}}{{if .Email}}authors = [ + {email = "{{.Email}}"} +]{{else}}authors = [ + {name = "Unknown Author"} +]{{end}}{{end}} +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "fastmcp>=0.2.0", + "pydantic>=2.0.0", + "pyyaml>=6.0", + "python-dotenv>=1.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[project.scripts] +"{{.ProjectName}}-server" = "src.main:main" + +[tool.uv] +dev-dependencies = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "black>=22.0.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[tool.black] +line-length = 88 +target-version = ['py310'] + +[tool.ruff] +line-length = 88 +target-version = "py310" +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true \ No newline at end of file diff --git a/pkg/frameworks/python/templates/run_server.sh.tmpl b/pkg/frameworks/python/templates/run_server.sh.tmpl new file mode 100644 index 0000000..40e96d4 --- /dev/null +++ b/pkg/frameworks/python/templates/run_server.sh.tmpl @@ -0,0 +1,4 @@ +#!/bin/bash +cd "$(dirname "$0")" +source .venv/bin/activate +python src/main.py \ No newline at end of file diff --git a/pkg/frameworks/python/templates/src/core/__init__.py.tmpl b/pkg/frameworks/python/templates/src/core/__init__.py.tmpl new file mode 100644 index 0000000..dfee826 --- /dev/null +++ b/pkg/frameworks/python/templates/src/core/__init__.py.tmpl @@ -0,0 +1,9 @@ +"""Core framework for {{.ProjectName}} MCP server. + +This package provides the dynamic tool loading system that automatically +discovers and registers tools from the src/tools/ directory. +""" + +from .server import DynamicMCPServer + +__all__ = ["DynamicMCPServer"] diff --git a/pkg/frameworks/python/templates/src/core/server.py.tmpl b/pkg/frameworks/python/templates/src/core/server.py.tmpl new file mode 100644 index 0000000..91338e9 --- /dev/null +++ b/pkg/frameworks/python/templates/src/core/server.py.tmpl @@ -0,0 +1,130 @@ +"""Dynamic MCP server implementation with automatic tool discovery. + +This server automatically discovers and loads tools from the tools directory. +Each tool file should contain a function decorated with @mcp.tool(). +""" + +import importlib.util +import logging +import sys +from pathlib import Path + +from dotenv import load_dotenv +from fastmcp import FastMCP + +from .utils import load_config + +# Global FastMCP instance for tools to import +mcp = FastMCP(name="Dynamic Server") + + +class DynamicMCPServer: + """MCP server with dynamic tool loading capabilities.""" + + def __init__(self, name: str, tools_dir: str = "src/tools"): + """Initialize the dynamic MCP server. + + Args: + name: Server name + tools_dir: Directory containing tool files + """ + global mcp + self.name = name + self.tools_dir = Path(tools_dir) + self.config = self._load_config() + + # Load local environment variables if configured + self._load_local_env() + + # Update global FastMCP instance + mcp = FastMCP(name=self.name) + self.mcp = mcp + + # Track loaded tools + self.loaded_tools: list[str] = [] + + def _load_config(self) -> dict[str, any]: + """Load configuration from kmcp.yaml.""" + return load_config("kmcp.yaml") + + def _load_local_env(self) -> None: + """Load environment variables from a .env file if it exists.""" + # load_dotenv will search for a .env file and load it. + # It does not fail if the file is not found. + if load_dotenv(override=True): + logging.info("Loaded environment variables from .env file") + + def load_tools(self) -> None: + """Discover and load all tools from the tools directory.""" + if not self.tools_dir.exists(): + print(f"Tools directory {self.tools_dir} does not exist") + return + + # Find all Python files in tools directory + tool_files = list(self.tools_dir.glob("*.py")) + tool_files = [f for f in tool_files if f.name != "__init__.py"] + + if not tool_files: + logging.warning(f"No tool files found in {self.tools_dir}") + return + + loaded_count = 0 + + for tool_file in tool_files: + try: + # 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}") + else: + logging.warning(f"Failed to load tool module: {tool_name}") + + 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.info(f"📦 Successfully loaded {loaded_count} tools") + + if loaded_count == 0: + logging.warning("No tools loaded. Server starting without tools.") + + def _import_tool_module(self, tool_file: Path, tool_name: str) -> bool: + """Import a tool module, which auto-registers tools via decorators. + + Args: + tool_file: Path to the tool file + tool_name: Name of the tool (same as filename) + + Returns: + True if module was imported successfully + """ + try: + # Load the module + spec = importlib.util.spec_from_file_location(tool_name, tool_file) + if spec is None or spec.loader is None: + return False + + module = importlib.util.module_from_spec(spec) + + # Add to sys.modules so it can be imported by other modules + sys.modules[f"tools.{tool_name}"] = module + + # Execute the module - this will trigger @mcp.tool() decorators + spec.loader.exec_module(module) + + return True + + except Exception as e: + print(f"Error importing {tool_file}: {e}") + return False + + def run(self) -> None: + """Run the FastMCP server.""" + if not self.loaded_tools: + print("⚠️ No tools loaded. Server starting without tools.") + + self.mcp.run() diff --git a/pkg/frameworks/python/templates/src/core/utils.py.tmpl b/pkg/frameworks/python/templates/src/core/utils.py.tmpl new file mode 100644 index 0000000..0394c23 --- /dev/null +++ b/pkg/frameworks/python/templates/src/core/utils.py.tmpl @@ -0,0 +1,61 @@ +"""Shared utilities for {{.ProjectName}} MCP server.""" + +import os +from typing import Any + +import yaml + + +def load_config(config_path: str) -> dict[str, Any]: + """Load configuration from YAML file. + + Args: + config_path: Path to the configuration file + + Returns: + Configuration dictionary + """ + try: + with open(config_path) as f: + return yaml.safe_load(f) or {} + except FileNotFoundError: + return {} + except Exception as e: + print(f"Error loading config from {config_path}: {e}") + return {} + + +def get_shared_config() -> dict[str, Any]: + """Get shared configuration that tools can access. + + Returns: + Shared configuration dictionary + """ + config = load_config("kmcp.yaml") + return config.get("tools", {}) + + +def get_tool_config(tool_name: str) -> dict[str, Any]: + """Get configuration for a specific tool. + + Args: + tool_name: Name of the tool + + Returns: + Tool-specific configuration + """ + shared_config = get_shared_config() + return shared_config.get(tool_name, {}) + + +def get_env_var(key: str, default: str = "") -> str: + """Get environment variable with fallback. + + Args: + key: Environment variable key + default: Default value if not found + + Returns: + Environment variable value or default + """ + return os.environ.get(key, default) diff --git a/pkg/frameworks/python/templates/src/main.py.tmpl b/pkg/frameworks/python/templates/src/main.py.tmpl new file mode 100644 index 0000000..1aa905d --- /dev/null +++ b/pkg/frameworks/python/templates/src/main.py.tmpl @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""{{.ProjectName}} MCP server with dynamic tool loading. + +This server automatically discovers and loads tools from the src/tools/ directory. +Each tool file should contain a function decorated with @mcp.tool(). +""" + +import logging +import sys +from pathlib import Path + +# Add src to Python path +sys.path.insert(0, str(Path(__file__).parent)) + +from core.server import DynamicMCPServer # noqa: E402 + + +def main() -> None: + """Main entry point for the MCP server.""" + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stderr) + ] + ) + + try: + # Create server with dynamic tool loading + server = DynamicMCPServer( + name="{{.ProjectName}}", + tools_dir="src/tools" + ) + + # Load tools and start server + server.load_tools() + server.run() + + except KeyboardInterrupt: + print("\nShutting down server...") + except Exception as e: + print(f"Server error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/pkg/frameworks/python/templates/src/tools/__init__.py.tmpl b/pkg/frameworks/python/templates/src/tools/__init__.py.tmpl new file mode 100644 index 0000000..ef8aa36 --- /dev/null +++ b/pkg/frameworks/python/templates/src/tools/__init__.py.tmpl @@ -0,0 +1,9 @@ +"""Tools package for {{.ProjectName}} MCP server. + +This file is automatically generated by the dynamic loading system. +Do not edit manually - it will be overwritten when tools are loaded. +""" + +from .echo import echo + +__all__ = ["echo"] diff --git a/pkg/frameworks/python/templates/src/tools/echo.py.tmpl b/pkg/frameworks/python/templates/src/tools/echo.py.tmpl new file mode 100644 index 0000000..6c09d6d --- /dev/null +++ b/pkg/frameworks/python/templates/src/tools/echo.py.tmpl @@ -0,0 +1,26 @@ +"""Example echo tool for {{.ProjectName}} MCP server. + +This is an example tool showing the basic structure for FastMCP tools. +Each tool file should contain a function decorated with @mcp.tool(). +""" + +from core.server import mcp +from core.utils import get_tool_config + + +@mcp.tool() +def echo(message: str) -> str: + """Echo a message back to the client. + + Args: + message: The message to echo + + Returns: + The echoed message with any configured prefix + """ + # Get tool-specific configuration + config = get_tool_config("echo") + prefix = config.get("prefix", "") + + # Return the message with optional prefix + return f"{prefix}{message}" if prefix else message diff --git a/pkg/frameworks/python/templates/tests/test_discovery.py.tmpl b/pkg/frameworks/python/templates/tests/test_discovery.py.tmpl new file mode 100644 index 0000000..2efe0a0 --- /dev/null +++ b/pkg/frameworks/python/templates/tests/test_discovery.py.tmpl @@ -0,0 +1,94 @@ +"""Tests for tool discovery and loading mechanism.""" + +import sys +import tempfile +from pathlib import Path + +import pytest + +# Add src to Python path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from core.server import DynamicMCPServer # noqa: E402 + + +class TestToolDiscovery: + """Test the tool discovery mechanism.""" + + def test_discover_tools_in_directory(self): + """Test discovering tools in a directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + tools_dir = Path(temp_dir) / "tools" + tools_dir.mkdir() + + # Create a test tool file + tool_file = tools_dir / "test_tool.py" + tool_content = ''' +from core.server import mcp + +@mcp.tool() +def test_tool(message: str) -> str: + return f"Test: {message}" +''' + tool_file.write_text(tool_content) + + # Test discovery + server = DynamicMCPServer(name="Test", tools_dir=str(tools_dir)) + + # Load tools - this should work without raising SystemExit + try: + server.load_tools() + # If we get here, it means loading succeeded + assert True + except SystemExit: + pytest.fail("Tool loading failed") + + def test_invalid_tool_fails_fast(self): + """Test that invalid tools cause the server to exit.""" + with tempfile.TemporaryDirectory() as temp_dir: + tools_dir = Path(temp_dir) / "tools" + tools_dir.mkdir() + + # Create an invalid tool file (syntax error) + tool_file = tools_dir / "invalid_tool.py" + # This has a syntax error + tool_content = 'syntax error' + tool_file.write_text(tool_content) + + server = DynamicMCPServer(name="Test", tools_dir=str(tools_dir)) + + # This should cause SystemExit due to fail-fast behavior + with pytest.raises(SystemExit): + server.load_tools() + + def test_tool_without_matching_function(self): + """Test tool file without matching function name.""" + with tempfile.TemporaryDirectory() as temp_dir: + tools_dir = Path(temp_dir) / "tools" + tools_dir.mkdir() + + # Create a tool file without matching function name + tool_file = tools_dir / "mismatch.py" + tool_content = ''' +def wrong_name(message: str) -> str: + return f"Wrong: {message}" +''' + tool_file.write_text(tool_content) + + server = DynamicMCPServer(name="Test", tools_dir=str(tools_dir)) + + # This should cause SystemExit due to fail-fast behavior + with pytest.raises(SystemExit): + server.load_tools() + + def test_empty_tools_directory(self): + """Test behavior with empty tools directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + tools_dir = Path(temp_dir) / "tools" + tools_dir.mkdir() + + server = DynamicMCPServer(name="Test", tools_dir=str(tools_dir)) + + # Should not raise exception + server.load_tools() + assert len(server.loaded_tools) == 0 diff --git a/pkg/frameworks/python/templates/tests/test_server.py.tmpl b/pkg/frameworks/python/templates/tests/test_server.py.tmpl new file mode 100644 index 0000000..282018e --- /dev/null +++ b/pkg/frameworks/python/templates/tests/test_server.py.tmpl @@ -0,0 +1,81 @@ +"""Tests for {{.ProjectName}} MCP server core functionality.""" + +import sys +from pathlib import Path +from unittest.mock import mock_open, patch + +# Add src to Python path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from core.server import DynamicMCPServer # noqa: E402 +from core.utils import get_tool_config, load_config # noqa: E402 + + +class TestDynamicMCPServer: + """Test the dynamic MCP server functionality.""" + + def test_server_initialization(self): + """Test server initialization.""" + server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") + assert server.name == "Test Server" + assert server.tools_dir == Path("src/tools") + + def test_server_with_nonexistent_tools_dir(self): + """Test server behavior with non-existent tools directory.""" + server = DynamicMCPServer(name="Test Server", tools_dir="nonexistent") + + # Should not raise exception, just print message + server.load_tools() + assert len(server.loaded_tools) == 0 + + def test_load_config(self): + """Test configuration loading.""" + config_data = """ + server: + name: "Test Server" + tools: + echo: + prefix: "[TEST] " + """ + + with patch("builtins.open", mock_open(read_data=config_data)): + config = load_config("test.yaml") + assert config["server"]["name"] == "Test Server" + assert config["tools"]["echo"]["prefix"] == "[TEST] " + + def test_get_tool_config(self): + """Test tool-specific configuration retrieval.""" + with patch("core.utils.load_config") as mock_load: + mock_load.return_value = { + "tools": { + "echo": {"prefix": "[TEST] "}, + "weather": {"api_key_env": "WEATHER_API_KEY"} + } + } + + echo_config = get_tool_config("echo") + assert echo_config["prefix"] == "[TEST] " + + weather_config = get_tool_config("weather") + assert weather_config["api_key_env"] == "WEATHER_API_KEY" + + # Test non-existent tool + empty_config = get_tool_config("nonexistent") + assert empty_config == {} + + +class TestToolLoading: + """Test the tool loading mechanism.""" + + def test_tool_function_detection(self): + """Test that tool functions are properly detected.""" + server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") + + # This should load actual tools from the tools directory + server.load_tools() + + # Verify that tools were loaded + assert len(server.loaded_tools) > 0 + + # Verify that echo tool specifically was loaded + assert "echo" in server.loaded_tools diff --git a/pkg/frameworks/python/templates/tests/test_tools.py.tmpl b/pkg/frameworks/python/templates/tests/test_tools.py.tmpl new file mode 100644 index 0000000..9eae4b6 --- /dev/null +++ b/pkg/frameworks/python/templates/tests/test_tools.py.tmpl @@ -0,0 +1,73 @@ +"""Generated tests for {{.ProjectName}} MCP server tools. + +This file is automatically generated to test that all tools can be loaded +and executed successfully. +""" + +import sys +from pathlib import Path + +import pytest + +# Add src to Python path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from core.server import DynamicMCPServer # noqa: E402 + + +class TestToolLoading: + """Test that all tools can be loaded successfully.""" + + def test_server_initialization(self): + """Test that the server can be initialized.""" + server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") + assert server is not None + assert server.name == "Test Server" + + def test_tool_discovery(self): + """Test that tools can be discovered.""" + server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") + + # Load tools without failing + try: + server.load_tools() + assert True # If we get here, loading succeeded + except SystemExit: + pytest.fail("Tool loading failed - server exited") + + def test_loaded_tools_count(self): + """Test that expected tools are loaded.""" + server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") + server.load_tools() + + # At minimum, we should have the echo tool + assert len(server.loaded_tools) >= 1 + assert "echo" in server.loaded_tools + + def test_tool_functions_callable(self): + """Test that loaded tool functions are callable.""" + server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") + server.load_tools() + + for tool_name, tool in server.mcp.tools.items(): + assert callable(tool.fn), f"Tool {tool_name} is not callable" + + +class TestEchoTool: + """Test the example echo tool.""" + + def test_echo_tool_exists(self): + """Test that the echo tool exists and can be loaded.""" + server = DynamicMCPServer(name="Test Server", tools_dir="src/tools") + server.load_tools() + + assert "echo" in server.loaded_tools + + 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!") + assert isinstance(result, str) + assert "Hello, World!" in result diff --git a/pkg/frameworks/python/templates/tool.py.tmpl b/pkg/frameworks/python/templates/tool.py.tmpl new file mode 100644 index 0000000..85134d0 --- /dev/null +++ b/pkg/frameworks/python/templates/tool.py.tmpl @@ -0,0 +1,29 @@ +"""{{if .ToolNameTitle}}{{.ToolNameTitle}} tool{{else}}Example tool{{end}} for MCP server.{{if .description}} + +{{.description}}{{end}} +""" + +from core.server import mcp +from core.utils import get_tool_config + + +@mcp.tool() +def {{if .ToolName}}{{.ToolName}}{{else}}tool{{end}}(message: str) -> str: + """{{if .ToolNameTitle}}{{.ToolNameTitle}}{{else}}Example{{end}} tool implementation. + + This is a template function. Replace this implementation with your tool logic. + + Args: + message: Input message (replace with your actual parameters) + + Returns: + str: Result of the tool operation (replace with your actual return type) + """ + # Get tool-specific configuration from kmcp.yaml + config = get_tool_config("{{if .ToolName}}{{.ToolName}}{{else}}tool{{end}}") + + # TODO: Replace this basic implementation with your tool logic + + # Example: Basic text processing + prefix = config.get("prefix", "echo: ") + return f"{prefix}{message}" diff --git a/pkg/manifest/manager.go b/pkg/manifest/manager.go new file mode 100644 index 0000000..2070e0c --- /dev/null +++ b/pkg/manifest/manager.go @@ -0,0 +1,236 @@ +package manifest + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ManifestFileName = "kmcp.yaml" + +// Manager handles project manifest operations +type Manager struct { + projectRoot string +} + +// NewManager creates a new manifest manager +func NewManager(projectRoot string) *Manager { + return &Manager{ + projectRoot: projectRoot, + } +} + +// Load reads and parses the kmcp.yaml file +func (m *Manager) Load() (*ProjectManifest, error) { + manifestPath := filepath.Join(m.projectRoot, ManifestFileName) + + data, err := os.ReadFile(manifestPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("kmcp.yaml not found in %s", m.projectRoot) + } + return nil, fmt.Errorf("failed to read kmcp.yaml: %w", err) + } + + var manifest ProjectManifest + if err := yaml.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse kmcp.yaml: %w", err) + } + + // Validate the manifest + if err := m.Validate(&manifest); err != nil { + return nil, fmt.Errorf("invalid kmcp.yaml: %w", err) + } + + return &manifest, nil +} + +// Save writes the manifest to kmcp.yaml +func (m *Manager) Save(manifest *ProjectManifest) error { + // Update timestamp + manifest.UpdatedAt = time.Now() + + // Validate before saving + if err := m.Validate(manifest); err != nil { + return fmt.Errorf("invalid manifest: %w", err) + } + + data, err := yaml.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to marshal manifest: %w", err) + } + + manifestPath := filepath.Join(m.projectRoot, ManifestFileName) + if err := os.WriteFile(manifestPath, data, 0644); err != nil { + return fmt.Errorf("failed to write kmcp.yaml: %w", err) + } + + return nil +} + +// Exists checks if a kmcp.yaml file exists in the project root +func (m *Manager) Exists() bool { + manifestPath := filepath.Join(m.projectRoot, ManifestFileName) + _, err := os.Stat(manifestPath) + return err == nil +} + +// GetDefault returns a new ProjectManifest with default values +func GetDefault(name, framework, description, author, email, namespace string) *ProjectManifest { + if description == "" { + description = fmt.Sprintf("MCP server built with %s", framework) + } + return &ProjectManifest{ + Name: name, + Framework: framework, + Version: "0.1.0", + Description: description, + Author: author, + Email: email, + Tools: make(map[string]ToolConfig), + Secrets: SecretsConfig{ + "local": { + Enabled: false, + Provider: SecretProviderKubernetes, + Namespace: namespace, + SecretName: fmt.Sprintf("%s-secrets-local", strings.ReplaceAll(name, "_", "-")), + }, + "staging": { + Enabled: false, + Provider: SecretProviderKubernetes, + Namespace: namespace, + SecretName: fmt.Sprintf("%s-secrets-staging", strings.ReplaceAll(name, "_", "-")), + }, + "production": { + Enabled: false, + Provider: SecretProviderKubernetes, + Namespace: namespace, + SecretName: fmt.Sprintf("%s-secrets-production", strings.ReplaceAll(name, "_", "-")), + }, + }, + Build: BuildConfig{ + Output: name, + Docker: DockerConfig{ + Image: fmt.Sprintf("%s:latest", strings.ToLower(strings.ReplaceAll(name, "_", "-"))), + Dockerfile: "Dockerfile", + }, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +// Validate checks if the manifest is valid +func (m *Manager) Validate(manifest *ProjectManifest) error { + // Basic validation + if manifest.Name == "" { + return fmt.Errorf("project name is required") + } + + if manifest.Framework == "" { + return fmt.Errorf("framework is required") + } + + if !isValidFramework(manifest.Framework) { + return fmt.Errorf("unsupported framework: %s", manifest.Framework) + } + + // Validate tools + for toolName, tool := range manifest.Tools { + if err := m.validateTool(toolName, tool); err != nil { + return fmt.Errorf("invalid tool %s: %w", toolName, err) + } + } + + // Validate secrets + if err := m.validateSecrets(manifest.Secrets); err != nil { + return fmt.Errorf("invalid secrets config: %w", err) + } + + return nil +} + +// AddTool adds a new tool to the manifest +func (m *Manager) AddTool(manifest *ProjectManifest, name string, config ToolConfig) error { + if name == "" { + return fmt.Errorf("tool name is required") + } + + if err := m.validateTool(name, config); err != nil { + return err + } + + if manifest.Tools == nil { + manifest.Tools = make(map[string]ToolConfig) + } + + manifest.Tools[name] = config + return nil +} + +// RemoveTool removes a tool from the manifest +func (m *Manager) RemoveTool(manifest *ProjectManifest, name string) error { + if manifest.Tools == nil { + return fmt.Errorf("tool %s not found", name) + } + + if _, exists := manifest.Tools[name]; !exists { + return fmt.Errorf("tool %s not found", name) + } + + delete(manifest.Tools, name) + return nil +} + +// Private validation methods + +func (m *Manager) validateTool(_ string, tool ToolConfig) error { + if tool.Name == "" { + return fmt.Errorf("tool name is required") + } + return nil +} + +func (m *Manager) validateSecrets(secrets SecretsConfig) error { + // Validate each secret provider configuration + for env, config := range secrets { + if config.Provider != "" && !isValidSecretProvider(config.Provider) { + return fmt.Errorf("invalid secret provider for environment %s: %s", env, config.Provider) + } + } + + return nil +} + +// Helper functions + +func isValidFramework(framework string) bool { + validFrameworks := []string{ + FrameworkFastMCPPython, + } + + for _, valid := range validFrameworks { + if framework == valid { + return true + } + } + return false +} + +func isValidSecretProvider(provider string) bool { + validProviders := []string{ + SecretProviderEnv, + SecretProviderKubernetes, + } + + for _, valid := range validProviders { + if provider == valid { + return true + } + } + return false +} diff --git a/pkg/manifest/types.go b/pkg/manifest/types.go new file mode 100644 index 0000000..a83657f --- /dev/null +++ b/pkg/manifest/types.go @@ -0,0 +1,94 @@ +package manifest + +import ( + "time" +) + +// ProjectManifest represents the complete kmcp.yaml configuration +type ProjectManifest struct { + // Project metadata + Name string `yaml:"name" json:"name"` + Framework string `yaml:"framework" json:"framework"` + Version string `yaml:"version" json:"version"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Author string `yaml:"author,omitempty" json:"author,omitempty"` + Email string `yaml:"email,omitempty" json:"email,omitempty"` + + // Project configuration + Tools map[string]ToolConfig `yaml:"tools,omitempty" json:"tools,omitempty"` + Secrets SecretsConfig `yaml:"secrets,omitempty" json:"secrets,omitempty"` + + // Build configuration + Build BuildConfig `yaml:"build,omitempty" json:"build,omitempty"` + + // Metadata + CreatedAt time.Time `yaml:"created_at,omitempty" json:"created_at,omitempty"` + UpdatedAt time.Time `yaml:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// ToolConfig represents configuration for an MCP tool +type ToolConfig struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Handler string `yaml:"handler,omitempty" json:"handler,omitempty"` + Enabled bool `yaml:"enabled" json:"enabled"` + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Config map[string]interface{} `yaml:"config,omitempty" json:"config,omitempty"` +} + +// SecretsConfig defines the secret management configuration +type SecretsConfig map[string]SecretProviderConfig + +// SecretProviderConfig represents configuration for a secret provider +type SecretProviderConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Provider string `yaml:"provider" json:"provider"` // env, kubernetes + + // For environment provider + File string `yaml:"file,omitempty" json:"file,omitempty"` // .env.local + + // For kubernetes provider + SecretName string `yaml:"secretName,omitempty" json:"secretName,omitempty"` + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` +} + +// DependencyConfig represents dependency management configuration +type DependencyConfig struct { + AutoManage bool `yaml:"auto_manage" json:"auto_manage"` + Runtime []string `yaml:"runtime,omitempty" json:"runtime,omitempty"` + Dev []string `yaml:"dev,omitempty" json:"dev,omitempty"` + Extra []string `yaml:"extra,omitempty" json:"extra,omitempty"` +} + +// BuildConfig represents build configuration +type BuildConfig struct { + Output string `yaml:"output,omitempty" json:"output,omitempty"` + Docker DockerConfig `yaml:"docker,omitempty" json:"docker,omitempty"` + Target string `yaml:"target,omitempty" json:"target,omitempty"` + Platform string `yaml:"platform,omitempty" json:"platform,omitempty"` +} + +// DockerConfig represents Docker build configuration +type DockerConfig struct { + Image string `yaml:"image,omitempty" json:"image,omitempty"` + Dockerfile string `yaml:"dockerfile,omitempty" json:"dockerfile,omitempty"` + Platform []string `yaml:"platform,omitempty" json:"platform,omitempty"` + BaseImage string `yaml:"base_image,omitempty" json:"base_image,omitempty"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + Environment map[string]string `yaml:"environment,omitempty" json:"environment,omitempty"` + HealthCheck string `yaml:"health_check,omitempty" json:"health_check,omitempty"` +} + +// Supported frameworks +const ( + FrameworkFastMCPPython = "fastmcp-python" +) + +// Supported secret providers +const ( + SecretProviderEnv = "env" + SecretProviderKubernetes = "kubernetes" +) + +// Tool types are imported from the tools package to eliminate redundancy +// Use tools.ToolTypeBasic, tools.ToolTypeAPIClient, etc. diff --git a/pkg/plugins/registry.go b/pkg/plugins/registry.go new file mode 100644 index 0000000..f973148 --- /dev/null +++ b/pkg/plugins/registry.go @@ -0,0 +1,211 @@ +package plugins + +import ( + "fmt" + "sync" +) + +// DefaultRegistry provides the default plugin registry implementation +type DefaultRegistry struct { + toolFactories map[string]func() Tool + metadata map[string]*PluginMetadata + mu sync.RWMutex +} + +// NewRegistry creates a new plugin registry +func NewRegistry() *DefaultRegistry { + return &DefaultRegistry{ + toolFactories: make(map[string]func() Tool), + metadata: make(map[string]*PluginMetadata), + } +} + +// RegisterTool registers a tool type with a factory function +func (r *DefaultRegistry) RegisterTool(toolType string, factory func() Tool) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.toolFactories[toolType]; exists { + return fmt.Errorf("tool type %s is already registered", toolType) + } + + r.toolFactories[toolType] = factory + return nil +} + +// GetTool creates a tool instance by type +func (r *DefaultRegistry) GetTool(toolType string) (Tool, error) { + r.mu.RLock() + factory, exists := r.toolFactories[toolType] + r.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("tool type %s not found", toolType) + } + + return factory(), nil +} + +// ListTools returns all registered tool types +func (r *DefaultRegistry) ListTools() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + tools := make([]string, len(r.toolFactories)) + for toolType := range r.toolFactories { + tools = append(tools, toolType) + } + + return tools +} + +// RegisterMetadata registers metadata for a plugin +func (r *DefaultRegistry) RegisterMetadata(pluginName string, metadata *PluginMetadata) { + r.mu.Lock() + defer r.mu.Unlock() + + r.metadata[pluginName] = metadata +} + +// GetMetadata returns plugin metadata +func (r *DefaultRegistry) GetMetadata(pluginName string) (*PluginMetadata, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + metadata, exists := r.metadata[pluginName] + if !exists { + return nil, fmt.Errorf("metadata for plugin %s not found", pluginName) + } + + return metadata, nil +} + +// Manager manages plugin lifecycle and provides runtime services +type Manager struct { + registry PluginRegistry + context *Context + tools map[string]Tool + mu sync.RWMutex +} + +// NewManager creates a new plugin manager +func NewManager(registry PluginRegistry, context *Context) *Manager { + return &Manager{ + registry: registry, + context: context, + tools: make(map[string]Tool), + } +} + +// LoadTool loads and initializes a tool +func (m *Manager) LoadTool(name, toolType string, config map[string]interface{}) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if tool is already loaded + if _, exists := m.tools[name]; exists { + return fmt.Errorf("tool %s is already loaded", name) + } + + // Create tool instance + tool, err := m.registry.GetTool(toolType) + if err != nil { + return fmt.Errorf("failed to create tool %s: %w", name, err) + } + + // Initialize tool with configuration + if err := tool.Initialize(config); err != nil { + return fmt.Errorf("failed to initialize tool %s: %w", name, err) + } + + m.tools[name] = tool + return nil +} + +// GetTool returns a loaded tool by name +func (m *Manager) GetTool(name string) (Tool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + tool, exists := m.tools[name] + if !exists { + return nil, fmt.Errorf("tool %s not found", name) + } + + return tool, nil +} + +// ListLoadedTools returns all loaded tool names +func (m *Manager) ListLoadedTools() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + tools := make([]string, len(m.tools)) + for name := range m.tools { + if m.tools[name].IsEnabled() { + tools = append(tools, name) + } + } + + return tools +} + +// UnloadTool removes a tool from the manager +func (m *Manager) UnloadTool(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.tools[name]; !exists { + return fmt.Errorf("tool %s not found", name) + } + + delete(m.tools, name) + return nil +} + +// EnableTool enables a tool +func (m *Manager) EnableTool(name string) error { + tool, err := m.GetTool(name) + if err != nil { + return err + } + + tool.SetEnabled(true) + return nil +} + +// DisableTool disables a tool +func (m *Manager) DisableTool(name string) error { + tool, err := m.GetTool(name) + if err != nil { + return err + } + + tool.SetEnabled(false) + return nil +} + +// GetContext returns the plugin context +func (m *Manager) GetContext() *Context { + return m.context +} + +// Global registry instance +var globalRegistry PluginRegistry = NewRegistry() + +// GetGlobalRegistry returns the global plugin registry +func GetGlobalRegistry() PluginRegistry { + return globalRegistry +} + +// RegisterGlobalTool registers a tool in the global registry +func RegisterGlobalTool(toolType string, factory func() Tool) error { + return globalRegistry.RegisterTool(toolType, factory) +} + +// RegisterGlobalMetadata registers metadata in the global registry +func RegisterGlobalMetadata(pluginName string, metadata *PluginMetadata) { + if dr, ok := globalRegistry.(*DefaultRegistry); ok { + dr.RegisterMetadata(pluginName, metadata) + } +} diff --git a/pkg/plugins/types.go b/pkg/plugins/types.go new file mode 100644 index 0000000..d4c9039 --- /dev/null +++ b/pkg/plugins/types.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "context" +) + +// Tool represents an MCP tool plugin +type Tool interface { + // Name returns the unique name of this tool + Name() string + + // Description returns a human-readable description of the tool + Description() string + + // Methods returns the list of available methods for this tool + Methods() []MethodInfo + + // Call executes a method with the given parameters + Call(ctx context.Context, method string, params map[string]interface{}) (*CallResult, error) + + // Dependencies returns the list of dependencies this tool requires + Dependencies() []string + + // Config returns the configuration schema for this tool + Config() ToolConfig + + // Initialize sets up the tool with the given configuration + Initialize(config map[string]interface{}) error + + // IsEnabled returns whether this tool is currently enabled + IsEnabled() bool + + // SetEnabled sets the enabled state of this tool + SetEnabled(enabled bool) +} + +// MethodInfo describes a tool method +type MethodInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters,omitempty"` + Required []string `json:"required,omitempty"` +} + +// CallResult represents the result of a tool method call +type CallResult struct { + Content interface{} `json:"content"` + IsText bool `json:"isText"` + Error string `json:"error,omitempty"` +} + +// ToolConfig represents tool configuration +type ToolConfig struct { + Type string `json:"type"` + Schema map[string]interface{} `json:"schema,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Enabled bool `json:"enabled"` +} + +// Context provides shared services to plugins +type Context struct { + // SecretManager provides access to secrets + SecretManager SecretManager + + // Logger provides logging capabilities + Logger Logger + + // Config provides access to plugin configuration + Config map[string]interface{} + + // ProjectRoot is the root directory of the project + ProjectRoot string +} + +// SecretManager interface for accessing secrets +type SecretManager interface { + Get(key string) (string, error) + GetAll() (map[string]string, error) + Exists(key string) bool + ListKeys() ([]string, error) + SanitizeForMCP(data interface{}) interface{} +} + +// Logger interface for plugin logging +type Logger interface { + Debug(msg string, args ...interface{}) + Info(msg string, args ...interface{}) + Warn(msg string, args ...interface{}) + Error(msg string, args ...interface{}) +} + +// Plugin metadata +type PluginMetadata struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author,omitempty"` + Tags []string `json:"tags,omitempty"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + License string `json:"license,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` +} + +// PluginRegistry manages plugin registration and discovery +type PluginRegistry interface { + // RegisterTool registers a tool type + RegisterTool(toolType string, factory func() Tool) error + + // GetTool creates a tool instance by type + GetTool(toolType string) (Tool, error) + + // ListTools returns all registered tool types + ListTools() []string + + // GetMetadata returns plugin metadata + GetMetadata(pluginName string) (*PluginMetadata, error) +} diff --git a/pkg/security/sanitizer/sanitizer.go b/pkg/security/sanitizer/sanitizer.go new file mode 100644 index 0000000..8eb85f1 --- /dev/null +++ b/pkg/security/sanitizer/sanitizer.go @@ -0,0 +1,334 @@ +package sanitizer + +import ( + "fmt" + "reflect" + "regexp" + "strings" +) + +// Sanitizer removes sensitive information from data structures +type Sanitizer struct { + patterns []Pattern +} + +// Pattern represents a secret detection pattern +type Pattern struct { + Name string + Regex *regexp.Regexp + Replacement string +} + +// NewSanitizer creates a new sanitizer with default patterns +func NewSanitizer() *Sanitizer { + return &Sanitizer{ + patterns: getDefaultPatterns(), + } +} + +// Sanitize recursively sanitizes data structures, replacing detected secrets +func (s *Sanitizer) Sanitize(data interface{}) interface{} { + return s.sanitizeValue(reflect.ValueOf(data)).Interface() +} + +// sanitizeValue recursively processes different types of values +func (s *Sanitizer) sanitizeValue(v reflect.Value) reflect.Value { + if !v.IsValid() { + return v + } + + switch v.Kind() { + case reflect.String: + return reflect.ValueOf(s.sanitizeString(v.String())) + case reflect.Map: + return s.sanitizeMap(v) + case reflect.Slice, reflect.Array: + return s.sanitizeSlice(v) + case reflect.Struct: + return s.sanitizeStruct(v) + case reflect.Ptr: + if v.IsNil() { + return v + } + elem := s.sanitizeValue(v.Elem()) + newPtr := reflect.New(elem.Type()) + newPtr.Elem().Set(elem) + return newPtr + case reflect.Interface: + if v.IsNil() { + return v + } + return s.sanitizeValue(v.Elem()) + default: + // For basic types (int, bool, etc.), return as-is + return v + } +} + +// sanitizeString applies all patterns to a string value +func (s *Sanitizer) sanitizeString(str string) string { + for _, pattern := range s.patterns { + str = pattern.Regex.ReplaceAllString(str, pattern.Replacement) + } + return str +} + +// sanitizeMap processes map values +func (s *Sanitizer) sanitizeMap(v reflect.Value) reflect.Value { + if v.IsNil() { + return v + } + + newMap := reflect.MakeMap(v.Type()) + for _, key := range v.MapKeys() { + sanitizedKey := s.sanitizeValue(key) + sanitizedValue := s.sanitizeValue(v.MapIndex(key)) + newMap.SetMapIndex(sanitizedKey, sanitizedValue) + } + return newMap +} + +// sanitizeSlice processes slice/array elements +func (s *Sanitizer) sanitizeSlice(v reflect.Value) reflect.Value { + newSlice := reflect.MakeSlice(v.Type(), v.Len(), v.Cap()) + for i := 0; i < v.Len(); i++ { + sanitizedElem := s.sanitizeValue(v.Index(i)) + newSlice.Index(i).Set(sanitizedElem) + } + return newSlice +} + +// sanitizeStruct processes struct fields +func (s *Sanitizer) sanitizeStruct(v reflect.Value) reflect.Value { + newStruct := reflect.New(v.Type()).Elem() + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if field.CanInterface() { + sanitizedField := s.sanitizeValue(field) + if newStruct.Field(i).CanSet() { + newStruct.Field(i).Set(sanitizedField) + } + } + } + + return newStruct +} + +// AddPattern adds a custom pattern to the sanitizer +func (s *Sanitizer) AddPattern(name, pattern, replacement string) error { + regex, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("invalid regex pattern for %s: %w", name, err) + } + + s.patterns = append(s.patterns, Pattern{ + Name: name, + Regex: regex, + Replacement: replacement, + }) + + return nil +} + +// getDefaultPatterns returns common secret patterns +func getDefaultPatterns() []Pattern { + patterns := []Pattern{ + // GitHub tokens + { + Name: "GitHub Personal Access Token", + Regex: regexp.MustCompile(`ghp_[A-Za-z0-9]{36}`), + Replacement: "[REDACTED-GITHUB-TOKEN]", + }, + { + Name: "GitHub App Token", + Regex: regexp.MustCompile(`ghs_[A-Za-z0-9]{36}`), + Replacement: "[REDACTED-GITHUB-APP-TOKEN]", + }, + { + Name: "GitHub OAuth Token", + Regex: regexp.MustCompile(`gho_[A-Za-z0-9]{36}`), + Replacement: "[REDACTED-GITHUB-OAUTH-TOKEN]", + }, + { + Name: "GitHub User-to-Server Token", + Regex: regexp.MustCompile(`ghu_[A-Za-z0-9]{36}`), + Replacement: "[REDACTED-GITHUB-USER-TOKEN]", + }, + { + Name: "GitHub Server-to-Server Token", + Regex: regexp.MustCompile(`ghr_[A-Za-z0-9]{36}`), + Replacement: "[REDACTED-GITHUB-SERVER-TOKEN]", + }, + { + Name: "GitHub Refresh Token", + Regex: regexp.MustCompile(`ghs_[A-Za-z0-9]{36}`), + Replacement: "[REDACTED-GITHUB-REFRESH-TOKEN]", + }, + + // OpenAI API Keys + { + Name: "OpenAI API Key", + Regex: regexp.MustCompile(`sk-[A-Za-z0-9]{48}`), + Replacement: "[REDACTED-OPENAI-KEY]", + }, + + // Anthropic API Keys + { + Name: "Anthropic API Key", + Regex: regexp.MustCompile(`sk-ant-[A-Za-z0-9\-_]{95}`), + Replacement: "[REDACTED-ANTHROPIC-KEY]", + }, + + // Slack tokens + { + Name: "Slack Bot Token", + Regex: regexp.MustCompile(`xoxb-[0-9]+-[0-9]+-[A-Za-z0-9]+`), + Replacement: "[REDACTED-SLACK-BOT-TOKEN]", + }, + { + Name: "Slack User Token", + Regex: regexp.MustCompile(`xoxp-[0-9]+-[0-9]+-[0-9]+-[A-Za-z0-9]+`), + Replacement: "[REDACTED-SLACK-USER-TOKEN]", + }, + { + Name: "Slack App Token", + Regex: regexp.MustCompile(`xapp-[0-9]+-[A-Za-z0-9]+-[A-Za-z0-9]+`), + Replacement: "[REDACTED-SLACK-APP-TOKEN]", + }, + + // AWS tokens + { + Name: "AWS Access Key", + Regex: regexp.MustCompile(`AKIA[0-9A-Z]{16}`), + Replacement: "[REDACTED-AWS-ACCESS-KEY]", + }, + { + Name: "AWS Secret Key", + Regex: regexp.MustCompile(`[A-Za-z0-9/+=]{40}`), + Replacement: "[REDACTED-AWS-SECRET-KEY]", + }, + + // Google API Keys + { + Name: "Google API Key", + Regex: regexp.MustCompile(`AIza[0-9A-Za-z\-_]{35}`), + Replacement: "[REDACTED-GOOGLE-API-KEY]", + }, + + // JWT tokens + { + Name: "JWT Token", + Regex: regexp.MustCompile(`eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+`), + Replacement: "[REDACTED-JWT-TOKEN]", + }, + + // Bearer tokens (generic) + { + Name: "Bearer Token", + Regex: regexp.MustCompile(`Bearer\s+[A-Za-z0-9\-_]+`), + Replacement: "Bearer [REDACTED-TOKEN]", + }, + + // Database URLs + { + Name: "PostgreSQL URL", + Regex: regexp.MustCompile(`postgresql://[^:]+:[^@]+@[^/]+/[^?\s]+`), + Replacement: "postgresql://[USER]:[REDACTED]@[HOST]/[DB]", + }, + { + Name: "MySQL URL", + Regex: regexp.MustCompile(`mysql://[^:]+:[^@]+@[^/]+/[^?\s]+`), + Replacement: "mysql://[USER]:[REDACTED]@[HOST]/[DB]", + }, + { + Name: "MongoDB URL", + Regex: regexp.MustCompile(`mongodb://[^:]+:[^@]+@[^/]+/[^?\s]+`), + Replacement: "mongodb://[USER]:[REDACTED]@[HOST]/[DB]", + }, + { + Name: "Redis URL", + Regex: regexp.MustCompile(`redis://[^:]+:[^@]+@[^/]+/[^?\s]+`), + Replacement: "redis://[USER]:[REDACTED]@[HOST]/[DB]", + }, + + // Generic password patterns + { + Name: "Password in URL", + Regex: regexp.MustCompile(`://[^:]+:([^@]+)@`), + Replacement: "://[USER]:[REDACTED]@", + }, + + // Credit card numbers (basic pattern) + { + Name: "Credit Card Number", + Regex: regexp.MustCompile(`\b(?:\d{4}[-\s]?){3}\d{4}\b`), + Replacement: "[REDACTED-CREDIT-CARD]", + }, + + // Email addresses (in some contexts) + { + Name: "Email in Auth Context", + Regex: regexp.MustCompile(`"email":\s*"[^"]+"`), + Replacement: `"email": "[REDACTED-EMAIL]"`, + }, + + // SSH private keys + { + Name: "SSH Private Key", + Regex: regexp.MustCompile(`-----BEGIN [A-Z ]+PRIVATE KEY-----[^-]+-----END [A-Z ]+PRIVATE KEY-----`), + Replacement: "[REDACTED-SSH-PRIVATE-KEY]", + }, + + // Generic high-entropy strings (potential secrets) + { + Name: "High Entropy String", + Regex: regexp.MustCompile(`\b[A-Za-z0-9+/]{32,}={0,2}\b`), + Replacement: "[REDACTED-HIGH-ENTROPY-STRING]", + }, + } + + return patterns +} + +// SanitizeString is a convenience function for sanitizing a single string +func SanitizeString(input string) string { + sanitizer := NewSanitizer() + return sanitizer.sanitizeString(input) +} + +// IsLikelySecret performs a heuristic check if a string looks like a secret +func IsLikelySecret(input string) bool { + sanitizer := NewSanitizer() + sanitized := sanitizer.sanitizeString(input) + + // If the sanitized version is different, it likely contained a secret + return sanitized != input +} + +// GetRedactedFieldNames returns common field names that often contain secrets +func GetRedactedFieldNames() []string { + return []string{ + "password", "pass", "passwd", + "secret", "key", "token", "auth", + "credential", "cred", "authorization", + "api_key", "apikey", "access_token", + "refresh_token", "client_secret", + "private_key", "cert", "certificate", + "webhook", "webhook_url", + } +} + +// ShouldRedactField checks if a field name suggests it contains sensitive data +func ShouldRedactField(fieldName string) bool { + fieldName = strings.ToLower(fieldName) + redactedFields := GetRedactedFieldNames() + + for _, pattern := range redactedFields { + if strings.Contains(fieldName, pattern) { + return true + } + } + + return false +} diff --git a/pkg/templates/generator.go b/pkg/templates/generator.go new file mode 100644 index 0000000..058ef0c --- /dev/null +++ b/pkg/templates/generator.go @@ -0,0 +1,19 @@ +package templates + +import "kagent.dev/kmcp/pkg/manifest" + +// ProjectConfig contains configuration for generating a new project +type ProjectConfig struct { + ProjectName string + Framework string + Author string + Email string + Directory string + NoGit bool + Verbose bool + Version string + Description string + Tools map[string]manifest.ToolConfig + Secrets manifest.SecretsConfig + Build manifest.BuildConfig +} diff --git a/pkg/wellknown/kubernetes.go b/pkg/wellknown/kubernetes.go new file mode 100644 index 0000000..3e7248e --- /dev/null +++ b/pkg/wellknown/kubernetes.go @@ -0,0 +1,5 @@ +package wellknown + +const ( + SecretKind = "Secret" +) diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 9ee6116..4a2f132 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -21,8 +21,8 @@ import ( "os/exec" "testing" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + ginkgo "github.com/onsi/ginkgo/v2" + gomega "github.com/onsi/gomega" "kagent.dev/kmcp/test/utils" ) @@ -39,22 +39,22 @@ var ( // The default setup requires Kind, builds/loads the Manager Docker image locally, and installs // CertManager and Prometheus. func TestE2E(t *testing.T) { - RegisterFailHandler(Fail) - _, _ = fmt.Fprintf(GinkgoWriter, "Starting kmcp integration test suite\n") - RunSpecs(t, "e2e suite") + gomega.RegisterFailHandler(ginkgo.Fail) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "Starting kmcp integration test suite\n") + ginkgo.RunSpecs(t, "e2e suite") } -var _ = BeforeSuite(func() { +var _ = ginkgo.BeforeSuite(func() { - By("building the manager(Operator) image") + ginkgo.By("building the manager(Operator) image") cmd := exec.Command("make", "docker-build", fmt.Sprintf("CONTROLLER_IMG=%s", projectImage)) _, err := utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") + gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred(), "Failed to build the manager(Operator) image") // TODO(user): If you want to change the e2e test vendor from Kind, ensure the image is // built and available before running the tests. Also, remove the following block. - By("loading the manager(Operator) image on Kind") + ginkgo.By("loading the manager(Operator) image on Kind") err = utils.LoadImageToKindClusterWithName(projectImage) - ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind") + gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred(), "Failed to load the manager(Operator) image into Kind") }) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 70748fa..367a955 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -25,99 +25,99 @@ import ( "strings" "time" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "kagent.dev/kmcp/api/v1alpha1" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "kagent.dev/kmcp/test/utils" - "sigs.k8s.io/yaml" ) // namespace where the project is deployed in const namespace = "kmcp-system" -var _ = Describe("Manager", Ordered, func() { +var _ = ginkgo.Describe("Manager", ginkgo.Ordered, func() { var controllerPodName string // Before running the tests, set up the environment by creating the namespace, // enforce the restricted security policy to the namespace, and deploying the controller using Helm. - BeforeAll(func() { - By("creating manager namespace") + ginkgo.BeforeAll(func() { + ginkgo.By("creating manager namespace") cmd := exec.Command("kubectl", "create", "ns", namespace) _, err := utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create namespace") - By("labeling the namespace to enforce the restricted security policy") + ginkgo.By("labeling the namespace to enforce the restricted security policy") cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace, "pod-security.kubernetes.io/enforce=restricted") _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to label namespace with restricted policy") - By("deploying the controller-manager using Helm") + ginkgo.By("deploying the controller-manager using Helm") cmd = exec.Command("helm", "install", "kmcp", "helm/kmcp", "--namespace", namespace, "--wait", "--timeout=5m", "--set", fmt.Sprintf("image.repository=%s", getImageRepository(projectImage)), "--set", fmt.Sprintf("image.tag=%s", getImageTag(projectImage))) _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager using Helm") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to deploy the controller-manager using Helm") }) // After all tests have been executed, clean up by undeploying the controller using Helm // and deleting the namespace. - AfterAll(func() { - By("cleaning up the curl pod for metrics") + ginkgo.AfterAll(func() { + ginkgo.By("cleaning up the curl pod for metrics") cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace) _, _ = utils.Run(cmd) - By("undeploying the controller-manager using Helm") + ginkgo.By("undeploying the controller-manager using Helm") cmd = exec.Command("helm", "uninstall", "kmcp", "--namespace", namespace) _, _ = utils.Run(cmd) - By("removing manager namespace") + ginkgo.By("cleaning up the knowledge-assistant project directory") + cmd = exec.Command("rm", "-rf", "knowledge-assistant") + _, _ = utils.Run(cmd) + + ginkgo.By("removing manager namespace") cmd = exec.Command("kubectl", "delete", "ns", namespace) _, _ = utils.Run(cmd) }) // After each test, check for failures and collect logs, events, // and pod descriptions for debugging. - AfterEach(func() { - specReport := CurrentSpecReport() + ginkgo.AfterEach(func() { + specReport := ginkgo.CurrentSpecReport() if specReport.Failed() { - By("Fetching controller manager pod logs") + ginkgo.By("Fetching controller manager pod logs") cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) controllerLogs, err := utils.Run(cmd) if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "Controller logs:\n %s", controllerLogs) } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "Failed to get Controller logs: %s", err) } - By("Fetching Kubernetes events") + ginkgo.By("Fetching Kubernetes events") cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") eventsOutput, err := utils.Run(cmd) if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "Failed to get Kubernetes events: %s", err) } - By("Fetching curl-metrics logs") + ginkgo.By("Fetching curl-metrics logs") cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) metricsOutput, err := utils.Run(cmd) if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "Metrics logs:\n %s", metricsOutput) } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "Failed to get curl-metrics logs: %s", err) } - By("Fetching controller manager pod description") + ginkgo.By("Fetching controller manager pod description") cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace) podDescription, err := utils.Run(cmd) if err == nil { @@ -128,13 +128,13 @@ var _ = Describe("Manager", Ordered, func() { } }) - SetDefaultEventuallyTimeout(2 * time.Minute) - SetDefaultEventuallyPollingInterval(time.Second) + gomega.SetDefaultEventuallyTimeout(2 * time.Minute) + gomega.SetDefaultEventuallyPollingInterval(time.Second) - Context("Manager", func() { - It("should run successfully", func() { - By("validating that the controller-manager pod is running as expected") - verifyControllerUp := func(g Gomega) { + ginkgo.Context("Manager", func() { + ginkgo.It("should run successfully", func() { + ginkgo.By("validating that the controller-manager pod is running as expected") + verifyControllerUp := func(g gomega.Gomega) { // Get the name of the controller-manager pod cmd := exec.Command("kubectl", "get", "pods", "-l", "control-plane=controller-manager", @@ -146,11 +146,11 @@ var _ = Describe("Manager", Ordered, func() { ) podOutput, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") + g.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to retrieve controller-manager pod information") podNames := utils.GetNonEmptyLines(podOutput) - g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") + g.Expect(podNames).To(gomega.HaveLen(1), "expected 1 controller pod running") controllerPodName = podNames[0] - g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) + g.Expect(controllerPodName).To(gomega.ContainSubstring("controller-manager")) // Validate the pod's status cmd = exec.Command("kubectl", "get", @@ -158,97 +158,142 @@ var _ = Describe("Manager", Ordered, func() { "-n", namespace, ) output, err := utils.Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(output).To(gomega.Equal("Running"), "Incorrect controller-manager pod status") } - Eventually(verifyControllerUp).Should(Succeed()) + gomega.Eventually(verifyControllerUp).Should(gomega.Succeed()) }) - - // +kubebuilder:scaffold:e2e-webhooks-checks - - // TODO: Customize the e2e test suite with scenarios specific to your project. - // Consider applying sample/CR(s) and check their status and/or verifying - // the reconciliation by using the metrics, i.e.: - // metricsOutput := getMetricsOutput() - // Expect(metricsOutput).To(ContainSubstring( - // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`, - // strings.ToLower(), - // )) }) - Context("MCPServer CRD", func() { - It("deploy a working MCP server", func() { - mcpServerName := "test-mcp-client-server" + ginkgo.Context("MCPServer CRD", func() { + ginkgo.It("deploy a working MCP server with mounted secrets", func() { + mcpServerName := "knowledge-assistant" var portForwardCmd *exec.Cmd localPort := 8080 + projectDir := "knowledge-assistant" + imageName := "knowledge-assistant:latest" - By("creating an MCPServer for client testing") - mcpServer := &v1alpha1.MCPServer{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "kagent.dev/v1alpha1", - Kind: "MCPServer", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: mcpServerName, - Namespace: namespace, - }, - Spec: v1alpha1.MCPServerSpec{ - Deployment: v1alpha1.MCPServerDeployment{ - Image: "docker.io/mcp/everything", - Port: 3000, - Cmd: "npx", - Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/"}, - }, - TransportType: "stdio", - }, - } - - cmd := exec.Command("kubectl", "apply", "-f", "-") - cmd.Stdin = strings.NewReader(mcpServerToYAML(mcpServer)) + ginkgo.By("building the kmcp CLI") + cmd := exec.Command("make", "build-cli") _, err := utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to create MCPServer") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to build kmcp CLI") + + ginkgo.By("creating a knowledge-assistant project using kmcp CLI") + cmd = exec.Command( + "dist/kmcp", + "init", projectDir, + "--framework", + "fastmcp-python", + "--force", + "--namespace", + namespace, + ) + _, err = utils.Run(cmd) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create knowledge-assistant project") + + ginkgo.By("updating kmcp.yaml to enable all secrets") + cmd = exec.Command("sed", "-i.bak", "s/enabled: false/enabled: true/", fmt.Sprintf("%s/kmcp.yaml", projectDir)) + _, err = utils.Run(cmd) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to update kmcp.yaml to enable local secrets") - By("waiting for the deployment to be ready") - Eventually(func(g Gomega) { + // clean up kmcp yaml backup file + cmd = exec.Command("rm", "-f", fmt.Sprintf("%s/kmcp.yaml.bak", projectDir)) + _, _ = utils.Run(cmd) + + ginkgo.By("creating Kubernetes secret from existing .env.local file") + envFilePath := fmt.Sprintf("%s/.env.local", projectDir) + cmd = exec.Command("dist/kmcp", "secrets", "sync", "local", "--from-file", envFilePath, "--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) + _, err = utils.Run(cmd) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to build Docker image") + + ginkgo.By("loading the Docker image into the Kind cluster") + cmd = exec.Command("kind", "load", "docker-image", imageName) + _, err = utils.Run(cmd) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to load Docker image into Kind cluster") + + ginkgo.By("deploying the knowledge-assistant MCP server using kmcp CLI") + cmd = exec.Command( + "dist/kmcp", + "deploy", + "mcp", + "-f", + fmt.Sprintf("%s/kmcp.yaml", projectDir), + "-n", + namespace, + "--environment", + "local", + ) + _, err = utils.Run(cmd) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to deploy knowledge-assistant MCP server") + + ginkgo.By("waiting for the deployment to be ready") + gomega.Eventually(func(g gomega.Gomega) { deployment := getDeployment(mcpServerName, namespace) - g.Expect(deployment).NotTo(BeNil()) - g.Expect(deployment.Status.ReadyReplicas).To(Equal(int32(1))) - }, 3*time.Minute).Should(Succeed()) + g.Expect(deployment).NotTo(gomega.BeNil()) + g.Expect(deployment.Status.ReadyReplicas).To(gomega.Equal(int32(1))) + }, 3*time.Minute).Should(gomega.Succeed()) - By("waiting for the service to be ready") - Eventually(func(g Gomega) { + ginkgo.By("waiting for the service to be ready") + gomega.Eventually(func(g gomega.Gomega) { service := getService(mcpServerName, namespace) - g.Expect(service).NotTo(BeNil()) - g.Expect(service.Spec.Ports).To(HaveLen(1)) - g.Expect(service.Spec.Ports[0].Port).To(Equal(int32(3000))) - }).Should(Succeed()) + g.Expect(service).NotTo(gomega.BeNil()) + g.Expect(service.Spec.Ports).To(gomega.HaveLen(1)) + g.Expect(service.Spec.Ports[0].Port).To(gomega.Equal(int32(3000))) + }).Should(gomega.Succeed()) + + ginkgo.By("verifying that environment variables are loaded via envFrom") + gomega.Eventually(func(g gomega.Gomega) { + // Get the pod name + cmd := exec.Command("kubectl", "get", "pods", "-l", + fmt.Sprintf("app.kubernetes.io/name=%s", mcpServerName), "-n", namespace, + "-o", "jsonpath={.items[0].metadata.name}") + podName, err := utils.Run(cmd) + g.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to get pod name") + g.Expect(podName).NotTo(gomega.BeEmpty(), "Pod name should not be empty") + + // Verify that environment variables are set in the container + expectedVars := []string{"DATABASE_URL", "OPENAI_API_KEY", "WEATHER_API_KEY"} + for _, envVar := range expectedVars { + cmd = exec.Command("kubectl", "exec", strings.TrimSpace(podName), "-n", namespace, "--", "sh", "-c", + fmt.Sprintf("echo $%s", envVar)) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(gomega.HaveOccurred(), fmt.Sprintf("Failed to check environment variable %s", envVar)) + g.Expect(strings.TrimSpace(output)).NotTo(gomega.BeEmpty(), + fmt.Sprintf("Environment variable %s should be set", envVar)) + } + }, 30*time.Second, 1*time.Second).Should(gomega.Succeed()) - By("setting up kubectl port-forward to access the MCP server") + ginkgo.By("setting up kubectl port-forward to access the MCP server") portForwardCmd = exec.Command("kubectl", "port-forward", fmt.Sprintf("service/%s", mcpServerName), fmt.Sprintf("%d:3000", localPort), "-n", namespace) err = portForwardCmd.Start() - Expect(err).NotTo(HaveOccurred(), "Failed to start port-forward") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to start port-forward") // Wait for port-forward to be ready - Eventually(func() error { + gomega.Eventually(func() error { resp, err := http.Get(fmt.Sprintf("http://localhost:%d", localPort)) if err != nil { return err } _ = resp.Body.Close() return nil - }, 30*time.Second, 1*time.Second).Should(Succeed()) + }, 30*time.Second, 1*time.Second).Should(gomega.Succeed()) - By("creating MCP client and testing connection") + ginkgo.By("creating MCP client and testing connection") mcpClient, err := client.NewStreamableHttpClient(fmt.Sprintf("http://localhost:%d/mcp", localPort)) - Expect(err).NotTo(HaveOccurred(), "Failed to create MCP client") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create MCP client") ctx := context.Background() - By("initializing the MCP client") + ginkgo.By("initializing the MCP client") initResponse, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{ Params: mcp.InitializeParams{ ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, @@ -258,30 +303,30 @@ var _ = Describe("Manager", Ordered, func() { }, }, }) - Expect(err).NotTo(HaveOccurred(), "Failed to initialize MCP client") - Expect(initResponse).NotTo(BeNil()) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to initialize MCP client") + gomega.Expect(initResponse).NotTo(gomega.BeNil()) - By("listing available tools from the MCP server") + ginkgo.By("listing available tools from the MCP server") toolsResponse, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) - Expect(err).NotTo(HaveOccurred(), "Failed to list tools") - Expect(toolsResponse).NotTo(BeNil()) - Expect(toolsResponse.Tools).NotTo(BeEmpty(), "Expected at least one tool to be available") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to list tools") + gomega.Expect(toolsResponse).NotTo(gomega.BeNil()) + gomega.Expect(toolsResponse.Tools).NotTo(gomega.BeEmpty(), "Expected at least one tool to be available") // Log the available tools for debugging for _, tool := range toolsResponse.Tools { - _, _ = fmt.Fprintf(GinkgoWriter, "Available tool: %s - %s\n", tool.Name, tool.Description) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "Available tool: %s - %s\n", tool.Name, tool.Description) } - By("cleaning up port-forward") + ginkgo.By("cleaning up port-forward") if portForwardCmd != nil && portForwardCmd.Process != nil { err = portForwardCmd.Process.Kill() - Expect(err).NotTo(HaveOccurred(), "Failed to kill port-forward process") + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to kill port-forward process") } - By("cleaning up the MCPServer") + ginkgo.By("cleaning up the MCPServer") cmd = exec.Command("kubectl", "delete", "mcpserver", mcpServerName, "-n", namespace) _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) }) }) @@ -315,14 +360,6 @@ func getService(name, namespace string) *corev1.Service { return &service } -func mcpServerToYAML(mcpServer interface{}) string { - yamlBytes, err := yaml.Marshal(mcpServer) - if err != nil { - return "" - } - return string(yamlBytes) -} - // getImageRepository extracts the repository part from a full image name // e.g., "example.com/kmcp:v0.0.1" -> "example.com/kmcp" func getImageRepository(image string) string { diff --git a/test/e2e/run_tests.sh b/test/e2e/run_tests.sh new file mode 100644 index 0000000..fa9d10b --- /dev/null +++ b/test/e2e/run_tests.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# E2E Test Runner for kmcp +# This script runs the end-to-end tests using the Go test library + +set -e + +echo "Starting kmcp e2e test suite..." + +# Ensure we're in the right directory +cd "$(dirname "$0")/../.." + +# Check if kind cluster exists +if ! kind get clusters | grep -q "kind"; then + echo "Kind cluster not found. Please run setup-kind-demo.sh first." + exit 1 +fi + +# Check if kubectl is available +if ! command -v kubectl &> /dev/null; then + echo "kubectl is not installed or not in PATH" + exit 1 +fi + +# Check if helm is available +if ! command -v helm &> /dev/null; then + echo "helm is not installed or not in PATH" + exit 1 +fi + +# Run the tests +echo "Running e2e tests..." +go test -v ./test/e2e -timeout=30m + +echo "E2E test suite completed." \ No newline at end of file