Skip to content

Commit 04047e3

Browse files
authored
KMCP CLI - Run (#9)
Adds the `kmcp run local` command to spin up a fastmcp python server and the modelcontextprotocol inspector for local development. --------- Signed-off-by: JM Huibonhoa <jm.huibonhoa@solo.io>
1 parent 4d5befe commit 04047e3

10 files changed

Lines changed: 347 additions & 33 deletions

File tree

.github/workflows/tag.yaml

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ jobs:
9191
steps:
9292
- name: Checkout
9393
uses: actions/checkout@v4
94-
- name: Build
94+
- name: Set up Go
95+
uses: actions/setup-go@v4
96+
with:
97+
go-version: '1.21'
98+
- name: Build for multiple platforms
9599
run: |
96100
# if workflow_dispatch is used, use the version input
97101
if [ -n "${{ github.event.inputs.version }}" ]; then
@@ -100,10 +104,28 @@ jobs:
100104
export VERSION=$(echo "$GITHUB_REF" | cut -c11-)
101105
fi
102106
echo "Building release artifacts with version: ${VERSION}"
103-
make build-cli VERSION=${VERSION}
107+
108+
# Build for Linux amd64
109+
GOOS=linux GOARCH=amd64 make build-cli VERSION=${VERSION}
110+
mv dist/kmcp dist/kmcp-linux-amd64
111+
112+
# Build for Linux arm64
113+
GOOS=linux GOARCH=arm64 make build-cli VERSION=${VERSION}
114+
mv dist/kmcp dist/kmcp-linux-arm64
115+
116+
# Build for macOS amd64
117+
GOOS=darwin GOARCH=amd64 make build-cli VERSION=${VERSION}
118+
mv dist/kmcp dist/kmcp-darwin-amd64
119+
120+
# Build for macOS arm64
121+
GOOS=darwin GOARCH=arm64 make build-cli VERSION=${VERSION}
122+
mv dist/kmcp dist/kmcp-darwin-arm64
104123
- name: Release
105124
uses: softprops/action-gh-release@v2
106125
if: startsWith(github.ref, 'refs/tags/')
107126
with:
108127
files: |
109-
dist/kmcp
128+
dist/kmcp-linux-amd64
129+
dist/kmcp-linux-arm64
130+
dist/kmcp-darwin-amd64
131+
dist/kmcp-darwin-arm64

cmd/kmcp/cmd/build.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ MCP server package or Docker image.
1919
2020
Examples:
2121
kmcp build # Build from current directory
22-
kmcp build --dir /path/to/project # Build from specific directory
23-
kmcp build --docker --dir ./my-project # Build Docker image from specific directory`,
22+
kmcp build --project-dir /path/to/project # Build from specific directory
23+
kmcp build --docker --project-dir ./my-project # Build Docker image from specific directory`,
2424
RunE: runBuild,
2525
}
2626

@@ -39,7 +39,7 @@ func init() {
3939
buildCmd.Flags().StringVarP(&buildOutput, "output", "o", "", "Output directory or image name")
4040
buildCmd.Flags().StringVarP(&buildTag, "tag", "t", "", "Docker image tag")
4141
buildCmd.Flags().StringVar(&buildPlatform, "platform", "", "Target platform (e.g., linux/amd64,linux/arm64)")
42-
buildCmd.Flags().StringVarP(&buildDir, "dir", "d", "", "Build directory (default: current directory)")
42+
buildCmd.Flags().StringVarP(&buildDir, "project-dir", "d", "", "Build directory (default: current directory)")
4343
}
4444

4545
func runBuild(_ *cobra.Command, _ []string) error {

cmd/kmcp/cmd/init.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ func runInitFramework(
119119
return fmt.Errorf("failed to generate project: %w", err)
120120
}
121121

122+
fmt.Printf(" To run the server locally:\n")
123+
fmt.Printf(" kmcp run local --project-dir %s\n", projectPath)
124+
122125
return manifest.NewManager(projectPath).Save(projectManifest)
123126
}
124127

cmd/kmcp/cmd/run.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
10+
"github.com/spf13/cobra"
11+
"kagent.dev/kmcp/pkg/manifest"
12+
)
13+
14+
var runCmd = &cobra.Command{
15+
Use: "run",
16+
Short: "Run MCP server locally",
17+
Long: `Run an MCP server locally using the Model Context Protocol inspector.
18+
19+
This command will:
20+
1. Load the kmcp.yaml configuration from the project directory
21+
2. Determine the framework type and create appropriate configuration
22+
3. Run the MCP server using the Model Context Protocol inspector
23+
24+
Supported frameworks:
25+
- fastmcp-python: Requires uv to be installed
26+
- mcp-go: Requires Go to be installed
27+
28+
Examples:
29+
kmcp run --project-dir ./my-project # Run from specific directory`,
30+
RunE: executeRun,
31+
}
32+
33+
var (
34+
projectDir string
35+
)
36+
37+
func init() {
38+
rootCmd.AddCommand(runCmd)
39+
40+
runCmd.Flags().StringVarP(
41+
&projectDir,
42+
"project-dir",
43+
"d",
44+
"",
45+
"Project directory to use (default: current directory)",
46+
)
47+
}
48+
49+
func executeRun(_ *cobra.Command, _ []string) error {
50+
projectDir, err := getProjectDir()
51+
if err != nil {
52+
return err
53+
}
54+
55+
manifest, err := getProjectManifest(projectDir)
56+
if err != nil {
57+
return err
58+
}
59+
60+
// Check if npx is installed
61+
if err := checkNpxInstalled(); err != nil {
62+
return err
63+
}
64+
65+
// Determine framework and create configuration
66+
switch manifest.Framework {
67+
case "fastmcp-python":
68+
return runFastMCPPython(projectDir, manifest)
69+
case "mcp-go":
70+
return runMCPGo(projectDir, manifest)
71+
default:
72+
return fmt.Errorf("unsupported framework: %s", manifest.Framework)
73+
}
74+
}
75+
76+
func checkNpxInstalled() error {
77+
cmd := exec.Command("npx", "--version")
78+
if err := cmd.Run(); err != nil {
79+
return fmt.Errorf("npx is required to run the modelcontextinstaller. Please install Node.js and npm to get npx")
80+
}
81+
return nil
82+
}
83+
84+
// createMCPInspectorConfig creates an MCP inspector configuration file
85+
func createMCPInspectorConfig(serverName string, serverConfig map[string]interface{}, configPath string) error {
86+
config := map[string]interface{}{
87+
"mcpServers": map[string]interface{}{
88+
serverName: serverConfig,
89+
},
90+
}
91+
92+
configData, err := json.MarshalIndent(config, "", " ")
93+
if err != nil {
94+
return fmt.Errorf("failed to marshal config: %w", err)
95+
}
96+
97+
if err := os.WriteFile(configPath, configData, 0644); err != nil {
98+
return fmt.Errorf("failed to write mcp-server-config.json: %w", err)
99+
}
100+
101+
if verbose {
102+
fmt.Printf("Created mcp-server-config.json: %s\n", configPath)
103+
}
104+
105+
return nil
106+
}
107+
108+
// runMCPInspector runs the MCP inspector with the given configuration
109+
func runMCPInspector(configPath, serverName string, workingDir string) error {
110+
args := []string{
111+
"@modelcontextprotocol/inspector",
112+
"--config", configPath,
113+
"--server", serverName,
114+
}
115+
116+
if verbose {
117+
fmt.Printf("Running: npx %s\n", args)
118+
}
119+
120+
cmd := exec.Command("npx", args...)
121+
if workingDir != "" {
122+
cmd.Dir = workingDir
123+
}
124+
cmd.Stdout = os.Stdout
125+
cmd.Stderr = os.Stderr
126+
127+
// Run synchronously
128+
return cmd.Run()
129+
}
130+
131+
func runFastMCPPython(projectDir string, manifest *manifest.ProjectManifest) error {
132+
// Check if uv is available
133+
if _, err := exec.LookPath("uv"); err != nil {
134+
uvInstallURL := "https://docs.astral.sh/uv/getting-started/installation/"
135+
return fmt.Errorf(
136+
"uv is required for this command to run fastmcp-python projects locally. Please install uv: %s", uvInstallURL,
137+
)
138+
}
139+
140+
// Run uv sync first
141+
if verbose {
142+
fmt.Printf("Running uv sync in: %s\n", projectDir)
143+
}
144+
syncCmd := exec.Command("uv", "sync")
145+
syncCmd.Dir = projectDir
146+
syncCmd.Stdout = os.Stdout
147+
syncCmd.Stderr = os.Stderr
148+
if err := syncCmd.Run(); err != nil {
149+
return fmt.Errorf("failed to run uv sync: %w", err)
150+
}
151+
152+
// Create server configuration for local execution
153+
serverConfig := map[string]interface{}{
154+
"command": "uv",
155+
"args": []string{"run", "python", "src/main.py"},
156+
}
157+
158+
// Create MCP inspector config
159+
configPath := filepath.Join(projectDir, "mcp-server-config.json")
160+
if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath); err != nil {
161+
return err
162+
}
163+
164+
// Run the inspector
165+
return runMCPInspector(configPath, manifest.Name, projectDir)
166+
}
167+
168+
func runMCPGo(projectDir string, manifest *manifest.ProjectManifest) error {
169+
// Check if go is available
170+
if _, err := exec.LookPath("go"); err != nil {
171+
goInstallURL := "https://golang.org/doc/install"
172+
return fmt.Errorf("go is required to run mcp-go projects locally. Please install Go: %s", goInstallURL)
173+
}
174+
175+
// Run go mod tidy first to ensure dependencies are up to date
176+
if verbose {
177+
fmt.Printf("Running go mod tidy in: %s\n", projectDir)
178+
}
179+
tidyCmd := exec.Command("go", "mod", "tidy")
180+
tidyCmd.Dir = projectDir
181+
tidyCmd.Stdout = os.Stdout
182+
tidyCmd.Stderr = os.Stderr
183+
if err := tidyCmd.Run(); err != nil {
184+
return fmt.Errorf("failed to run go mod tidy: %w", err)
185+
}
186+
187+
// Create server configuration for local execution
188+
serverConfig := map[string]interface{}{
189+
"command": "go",
190+
"args": []string{"run", "main.go"},
191+
}
192+
193+
// Create MCP inspector config
194+
configPath := filepath.Join(projectDir, "mcp-server-config.json")
195+
if err := createMCPInspectorConfig(manifest.Name, serverConfig, configPath); err != nil {
196+
return err
197+
}
198+
199+
// Run the inspector
200+
return runMCPInspector(configPath, manifest.Name, projectDir)
201+
}
202+
203+
func getProjectDir() (string, error) {
204+
// Determine project directory
205+
dir := projectDir
206+
if dir == "" {
207+
// Use current working directory
208+
var err error
209+
dir, err = os.Getwd()
210+
if err != nil {
211+
return "", fmt.Errorf("failed to get current directory: %w", err)
212+
}
213+
} else {
214+
// Convert relative path to absolute path
215+
if !filepath.IsAbs(dir) {
216+
cwd, err := os.Getwd()
217+
if err != nil {
218+
return "", fmt.Errorf("failed to get current directory: %w", err)
219+
}
220+
dir = filepath.Join(cwd, dir)
221+
}
222+
}
223+
224+
if verbose {
225+
fmt.Printf("Using project directory: %s\n", dir)
226+
}
227+
228+
return dir, nil
229+
}
230+
231+
func getProjectManifest(projectDir string) (*manifest.ProjectManifest, error) {
232+
// Check if kmcp.yaml exists
233+
manager := manifest.NewManager(projectDir)
234+
if !manager.Exists() {
235+
return nil, fmt.Errorf("this directory is not an mcp-server directory: kmcp.yaml not found")
236+
}
237+
238+
// Load the manifest
239+
manifest, err := manager.Load()
240+
if err != nil {
241+
return nil, fmt.Errorf("failed to load kmcp.yaml: %w", err)
242+
}
243+
244+
return manifest, nil
245+
}

cmd/kmcp/cmd/secrets.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
corev1 "k8s.io/api/core/v1"
1313
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1414
"k8s.io/client-go/kubernetes"
15-
_ "k8s.io/client-go/plugin/pkg/client/auth"
1615
"sigs.k8s.io/controller-runtime/pkg/client/config"
1716
"sigs.k8s.io/yaml"
1817
)
@@ -50,7 +49,7 @@ Examples:
5049
kmcp secrets sync staging --from-file .env.staging
5150
5251
# Sync secrets from a specific project directory
53-
kmcp secrets sync staging --dir ./my-project
52+
kmcp secrets sync staging --project-dir ./my-project
5453
5554
# Perform a dry run to see the generated secret without applying it
5655
kmcp secrets sync production --dry-run
@@ -68,10 +67,10 @@ func init() {
6867
// create-k8s-secret-from-env flags
6968
syncCmd.Flags().StringVar(&secretSourceFile, "from-file", ".env", "Source .env file to sync from")
7069
syncCmd.Flags().BoolVar(&secretDryRun, "dry-run", false, "Output the generated secret YAML instead of applying it")
71-
syncCmd.Flags().StringVarP(&secretDir, "dir", "d", "", "Project directory (default: current directory)")
70+
syncCmd.Flags().StringVarP(&secretDir, "project-dir", "d", "", "Project directory (default: current directory)")
7271
}
7372

74-
func runSync(cmd *cobra.Command, args []string) error {
73+
func runSync(_ *cobra.Command, args []string) error {
7574
environment := args[0]
7675

7776
// Determine project root

pkg/frameworks/golang/templates/tools/echo.go.tmpl

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@ func Echo() MCPTool[EchoParams, EchoResult] {
2222
Name: "echo",
2323
Description: "Echoes a message back to the user.",
2424
Handler: func(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[EchoParams]) (*mcp.CallToolResultFor[EchoResult], error) {
25-
return &mcp.CallToolResultFor[EchoResult]{
26-
StructuredContent: EchoResult{
27-
Echo: "Echo: " + params.Arguments.Message,
25+
echoMessage := "Echo: " + params.Arguments.Message
26+
result := &mcp.CallToolResultFor[EchoResult]{
27+
Content: []mcp.Content{
28+
&mcp.TextContent{
29+
Text: echoMessage,
30+
},
2831
},
29-
}, nil
32+
}
33+
return result, nil
3034
},
3135
}
3236
}

pkg/frameworks/golang/templates/tools/tool.go.tmpl

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ func init() {
1010
Name: "{{.ToolName}}",
1111
Description: "{{.Description}}",
1212
Handler: func(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[{{.ClassName}}Params]) (*mcp.CallToolResultFor[{{.ClassName}}Result], error) {
13+
result := run{{.ClassName}}(params.Arguments)
1314
return &mcp.CallToolResultFor[{{.ClassName}}Result]{
14-
StructuredContent: run{{.ClassName}}(params.Arguments),
15+
Content: []mcp.Content{
16+
&mcp.TextContent{
17+
Text: result.Result,
18+
},
19+
},
1520
}, nil
1621
},
1722
})

0 commit comments

Comments
 (0)