Skip to content

Commit 3c4045e

Browse files
committed
wip: deploy; fixing container
1 parent 43cd75e commit 3c4045e

File tree

4 files changed

+429
-103
lines changed

4 files changed

+429
-103
lines changed

cmd/kmcp/cmd/deploy.go

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"kagent.dev/kmcp/api/v1alpha1"
12+
"kagent.dev/kmcp/pkg/manifest"
13+
"sigs.k8s.io/yaml"
14+
)
15+
16+
var deployCmd = &cobra.Command{
17+
Use: "deploy [name]",
18+
Short: "Deploy MCP server to Kubernetes",
19+
Long: `Deploy an MCP server to Kubernetes by generating MCPServer CRDs.
20+
21+
This command generates MCPServer Custom Resource Definitions (CRDs) based on:
22+
- Project configuration from kmcp.yaml
23+
- Docker image built with 'kmcp build --docker'
24+
- Deployment configuration options
25+
26+
The generated MCPServer will include:
27+
- Docker image reference from the build
28+
- Transport configuration (stdio/http)
29+
- Port and command configuration
30+
- Environment variables and secrets
31+
32+
Examples:
33+
kmcp deploy # Deploy with project name
34+
kmcp deploy my-server # Deploy with custom name
35+
kmcp deploy --namespace staging # Deploy to staging namespace
36+
kmcp deploy --apply # Generate and apply to cluster
37+
kmcp deploy --image custom:tag # Use custom image
38+
kmcp deploy --transport http # Use HTTP transport
39+
kmcp deploy --output deploy.yaml # Save to file`,
40+
Args: cobra.MaximumNArgs(1),
41+
RunE: runDeploy,
42+
}
43+
44+
var (
45+
deployNamespace string
46+
deployApply bool
47+
deployOutput string
48+
deployImage string
49+
deployTransport string
50+
deployPort int
51+
deployTargetPort int
52+
deployCommand string
53+
deployArgs []string
54+
deployEnv []string
55+
deployForce bool
56+
deployDryRun bool
57+
)
58+
59+
func init() {
60+
rootCmd.AddCommand(deployCmd)
61+
62+
deployCmd.Flags().StringVarP(&deployNamespace, "namespace", "n", "default", "Kubernetes namespace")
63+
deployCmd.Flags().BoolVar(&deployApply, "apply", false, "Apply the generated resources to the cluster")
64+
deployCmd.Flags().StringVarP(&deployOutput, "output", "o", "", "Output file for the generated YAML")
65+
deployCmd.Flags().StringVar(&deployImage, "image", "", "Docker image to deploy (overrides build image)")
66+
deployCmd.Flags().StringVar(&deployTransport, "transport", "", "Transport type (stdio, http)")
67+
deployCmd.Flags().IntVar(&deployPort, "port", 0, "Container port (default: from project config)")
68+
deployCmd.Flags().IntVar(&deployTargetPort, "target-port", 0, "Target port for HTTP transport")
69+
deployCmd.Flags().StringVar(&deployCommand, "command", "", "Command to run (overrides project config)")
70+
deployCmd.Flags().StringSliceVar(&deployArgs, "args", []string{}, "Command arguments")
71+
deployCmd.Flags().StringSliceVar(&deployEnv, "env", []string{}, "Environment variables (KEY=VALUE)")
72+
deployCmd.Flags().BoolVar(&deployForce, "force", false, "Force deployment even if validation fails")
73+
}
74+
75+
func runDeploy(cmd *cobra.Command, args []string) error {
76+
// Get current working directory
77+
cwd, err := os.Getwd()
78+
if err != nil {
79+
return fmt.Errorf("failed to get current directory: %w", err)
80+
}
81+
82+
// Load project manifest
83+
manifestManager := manifest.NewManager(cwd)
84+
if !manifestManager.Exists() {
85+
return fmt.Errorf("kmcp.yaml not found. Run 'kmcp init' first")
86+
}
87+
88+
projectManifest, err := manifestManager.Load()
89+
if err != nil {
90+
return fmt.Errorf("failed to load project manifest: %w", err)
91+
}
92+
93+
// Determine deployment name
94+
deploymentName := projectManifest.Name
95+
if len(args) > 0 {
96+
deploymentName = args[0]
97+
}
98+
99+
// Generate MCPServer resource
100+
mcpServer, err := generateMCPServer(projectManifest, deploymentName)
101+
if err != nil {
102+
return fmt.Errorf("failed to generate MCPServer: %w", err)
103+
}
104+
105+
// Set namespace
106+
mcpServer.Namespace = deployNamespace
107+
108+
if verbose {
109+
fmt.Printf("Generated MCPServer: %s/%s\n", mcpServer.Namespace, mcpServer.Name)
110+
}
111+
112+
// Convert to YAML
113+
yamlData, err := yaml.Marshal(mcpServer)
114+
if err != nil {
115+
return fmt.Errorf("failed to marshal MCPServer to YAML: %w", err)
116+
}
117+
118+
// Add YAML document separator and standard header
119+
yamlContent := fmt.Sprintf("---\n# MCPServer deployment generated by kmcp deploy\n# Project: %s\n# Framework: %s\n%s",
120+
projectManifest.Name, projectManifest.Framework, string(yamlData))
121+
122+
// Handle output
123+
if deployOutput != "" {
124+
// Write to file
125+
if err := os.WriteFile(deployOutput, []byte(yamlContent), 0644); err != nil {
126+
return fmt.Errorf("failed to write to file: %w", err)
127+
}
128+
fmt.Printf("✅ MCPServer manifest written to: %s\n", deployOutput)
129+
} else if !deployApply {
130+
// Print to stdout
131+
fmt.Print(yamlContent)
132+
}
133+
134+
// Apply to cluster if requested
135+
if deployApply {
136+
137+
if err := applyToCluster(yamlContent, deploymentName); err != nil {
138+
return fmt.Errorf("failed to apply to cluster: %w", err)
139+
}
140+
}
141+
142+
return nil
143+
}
144+
145+
func generateMCPServer(projectManifest *manifest.ProjectManifest, deploymentName string) (*v1alpha1.MCPServer, error) {
146+
// Determine image name
147+
imageName := deployImage
148+
if imageName == "" {
149+
// Use image from build config or generate default
150+
if projectManifest.Build.Docker.Image != "" {
151+
imageName = projectManifest.Build.Docker.Image
152+
} else {
153+
// Generate default image name
154+
imageName = fmt.Sprintf("%s:latest", strings.ToLower(strings.ReplaceAll(projectManifest.Name, "_", "-")))
155+
}
156+
}
157+
158+
// Determine transport type
159+
transportType := v1alpha1.TransportTypeStdio
160+
if deployTransport != "" {
161+
if deployTransport == "http" {
162+
transportType = v1alpha1.TransportTypeHTTP
163+
} else if deployTransport == "stdio" {
164+
transportType = v1alpha1.TransportTypeStdio
165+
} else {
166+
return nil, fmt.Errorf("invalid transport type: %s (must be 'stdio' or 'http')", deployTransport)
167+
}
168+
}
169+
170+
// Determine port
171+
port := deployPort
172+
if port == 0 {
173+
if projectManifest.Build.Docker.Port != 0 {
174+
port = projectManifest.Build.Docker.Port
175+
} else {
176+
port = 3000 // Default port
177+
}
178+
}
179+
180+
// Determine command and args
181+
command := deployCommand
182+
args := deployArgs
183+
if command == "" {
184+
// Set default command based on framework
185+
command = getDefaultCommand(projectManifest.Framework)
186+
if len(args) == 0 {
187+
args = getDefaultArgs(projectManifest.Framework)
188+
}
189+
}
190+
191+
// Parse environment variables
192+
envVars := parseEnvVars(deployEnv)
193+
194+
// Add framework-specific environment variables
195+
for k, v := range projectManifest.Build.Docker.Environment {
196+
if envVars[k] == "" { // Don't override user-provided values
197+
envVars[k] = v
198+
}
199+
}
200+
201+
// Create MCPServer spec
202+
mcpServer := &v1alpha1.MCPServer{
203+
TypeMeta: metav1.TypeMeta{
204+
APIVersion: "kagent.dev/v1alpha1",
205+
Kind: "MCPServer",
206+
},
207+
ObjectMeta: metav1.ObjectMeta{
208+
Name: deploymentName,
209+
Labels: map[string]string{
210+
"app.kubernetes.io/name": deploymentName,
211+
"app.kubernetes.io/instance": deploymentName,
212+
"app.kubernetes.io/component": "mcp-server",
213+
"app.kubernetes.io/part-of": "kmcp",
214+
"app.kubernetes.io/managed-by": "kmcp",
215+
"kmcp.dev/framework": projectManifest.Framework,
216+
"kmcp.dev/version": projectManifest.Version,
217+
},
218+
Annotations: map[string]string{
219+
"kmcp.dev/project-name": projectManifest.Name,
220+
"kmcp.dev/description": projectManifest.Description,
221+
},
222+
},
223+
Spec: v1alpha1.MCPServerSpec{
224+
Deployment: v1alpha1.MCPServerDeployment{
225+
Image: imageName,
226+
Port: uint16(port),
227+
Cmd: command,
228+
Args: args,
229+
Env: envVars,
230+
},
231+
TransportType: transportType,
232+
},
233+
}
234+
235+
// Configure transport-specific settings
236+
if transportType == v1alpha1.TransportTypeHTTP {
237+
targetPort := deployTargetPort
238+
if targetPort == 0 {
239+
targetPort = port
240+
}
241+
mcpServer.Spec.HTTPTransport = &v1alpha1.HTTPTransport{
242+
TargetPort: uint32(targetPort),
243+
TargetPath: "/mcp",
244+
}
245+
} else {
246+
mcpServer.Spec.StdioTransport = &v1alpha1.StdioTransport{}
247+
}
248+
249+
return mcpServer, nil
250+
}
251+
252+
func getDefaultCommand(framework string) string {
253+
switch framework {
254+
case manifest.FrameworkFastMCPPython, manifest.FrameworkOfficialPython:
255+
return "python"
256+
case manifest.FrameworkFastMCPTypeScript, manifest.FrameworkEasyMCPTypeScript, manifest.FrameworkOfficialTypeScript:
257+
return "node"
258+
default:
259+
return "python"
260+
}
261+
}
262+
263+
func getDefaultArgs(framework string) []string {
264+
switch framework {
265+
case manifest.FrameworkFastMCPPython, manifest.FrameworkOfficialPython:
266+
return []string{"-m", "src.main"}
267+
case manifest.FrameworkFastMCPTypeScript, manifest.FrameworkEasyMCPTypeScript, manifest.FrameworkOfficialTypeScript:
268+
return []string{"dist/index.js"}
269+
default:
270+
return []string{"-m", "src.main"}
271+
}
272+
}
273+
274+
func parseEnvVars(envVars []string) map[string]string {
275+
result := make(map[string]string)
276+
for _, env := range envVars {
277+
parts := strings.SplitN(env, "=", 2)
278+
if len(parts) == 2 {
279+
result[parts[0]] = parts[1]
280+
}
281+
}
282+
return result
283+
}
284+
285+
func applyToCluster(yamlContent, deploymentName string) error {
286+
fmt.Printf("🚀 Applying MCPServer to cluster...\n")
287+
288+
// Create temporary file for kubectl apply
289+
tmpFile, err := os.CreateTemp("", "mcpserver-*.yaml")
290+
if err != nil {
291+
return fmt.Errorf("failed to create temp file: %w", err)
292+
}
293+
defer os.Remove(tmpFile.Name())
294+
295+
// Write YAML content to temp file
296+
if _, err := tmpFile.Write([]byte(yamlContent)); err != nil {
297+
return fmt.Errorf("failed to write to temp file: %w", err)
298+
}
299+
tmpFile.Close()
300+
301+
// Apply using kubectl
302+
if err := runKubectl("apply", "-f", tmpFile.Name()); err != nil {
303+
return fmt.Errorf("kubectl apply failed: %w", err)
304+
}
305+
306+
fmt.Printf("✅ MCPServer '%s' applied successfully\n", deploymentName)
307+
fmt.Printf("💡 Check status with: kubectl get mcpserver %s -n %s\n", deploymentName, deployNamespace)
308+
fmt.Printf("💡 View logs with: kubectl logs -l app.kubernetes.io/name=%s -n %s\n", deploymentName, deployNamespace)
309+
310+
return nil
311+
}
312+
313+
func runKubectl(args ...string) error {
314+
if verbose {
315+
fmt.Printf("Running: kubectl %s\n", strings.Join(args, " "))
316+
}
317+
318+
cmd := exec.Command("kubectl", args...)
319+
cmd.Stdout = os.Stdout
320+
cmd.Stderr = os.Stderr
321+
322+
return cmd.Run()
323+
}

cmd/kmcp/cmd/init.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ func runInit(cmd *cobra.Command, args []string) error {
167167

168168
fmt.Printf("\nTo build a Docker image:\n")
169169
fmt.Printf(" kmcp build --docker\n")
170+
fmt.Printf("\nTo develop using Docker only (no local Python/uv required):\n")
171+
fmt.Printf(" kmcp build --docker --verbose # Build and test\n")
172+
fmt.Printf(" kmcp deploy --apply # Deploy to Kubernetes\n")
170173

171174
fmt.Printf("\nTo manage secrets:\n")
172175
fmt.Printf(" kmcp secrets add-secret API_KEY --environment local\n")

pkg/agentgateway/agentgateway_translator.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment(
7878
// copy the binary into the container when running with stdio
7979
template = corev1.PodSpec{
8080
InitContainers: []corev1.Container{{
81-
Name: "copy-binary",
82-
Image: agentGatewayContainerImage,
83-
Command: []string{"sh"},
81+
Name: "copy-binary",
82+
Image: agentGatewayContainerImage,
83+
ImagePullPolicy: corev1.PullIfNotPresent,
84+
Command: []string{"sh"},
8485
Args: []string{
8586
"-c",
8687
"cp /usr/bin/agentgateway /agentbin/agentgateway",
@@ -92,8 +93,9 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment(
9293
SecurityContext: getSecurityContext(),
9394
}},
9495
Containers: []corev1.Container{{
95-
Name: "mcp-server",
96-
Image: image,
96+
Name: "mcp-server",
97+
Image: image,
98+
ImagePullPolicy: corev1.PullIfNotPresent,
9799
Command: []string{
98100
"sh",
99101
},
@@ -141,9 +143,10 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment(
141143
template = corev1.PodSpec{
142144
Containers: []corev1.Container{
143145
{
144-
Name: "agent-gateway",
145-
Image: agentGatewayContainerImage,
146-
Command: []string{"sh"},
146+
Name: "agent-gateway",
147+
Image: agentGatewayContainerImage,
148+
ImagePullPolicy: corev1.PullIfNotPresent,
149+
Command: []string{"sh"},
147150
Args: []string{
148151
"-c",
149152
"/agentbin/agentgateway -f /config/local.yaml",
@@ -157,6 +160,7 @@ func (t *agentGatewayTranslator) translateAgentGatewayDeployment(
157160
{
158161
Name: "mcp-server",
159162
Image: image,
163+
ImagePullPolicy: corev1.PullIfNotPresent,
160164
Command: cmd,
161165
Args: server.Spec.Deployment.Args,
162166
Env: convertEnvVars(server.Spec.Deployment.Env),

0 commit comments

Comments
 (0)