Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov

# Dependency directories (remove the comment below to include it)
# vendor/

# Go workspace file
go.work
go.work.sum

# env file
.env

# Editor/IDE
.idea/
.vscode/

# Binary
mcpd

# Project config files
.mcpd.toml

# Log files
*.log
46 changes: 46 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
.PHONY: build install clean uninstall

MODULE_PATH := github.com/mozilla-ai/mcpd-cli/v2

# /usr/local/bin is a common default for user-installed binaries
INSTALL_DIR := /usr/local/bin

# Get the version string dynamically
# This will be:
# - e.g., "v1.0.0" if on a tag
# - e.g., "v0.1.0-2-gabcdef123" if 2 commits past tag v0.1.0 (with hash abcdef123)
# - e.g., "abcdef123-dirty" if on a commit and dirty
# - e.g., "dev" if git is not available or no commits yet
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")

# Linker flags for injecting version
# The path is MODULE_PATH/package.variableName
LDFLAGS := -X '$(MODULE_PATH)/cmd.version=$(VERSION)'

test:
go test ./...

benchmark:
go test -bench=. -benchmem ./...

build:
@echo "building mcpd (with flags: ${LDFLAGS})..."
@go build -o mcpd -ldflags="${LDFLAGS}" .

install: build
# Copy the executable to the install directory
# Requires sudo if INSTALL_DIR is a system path like /usr/local/bin
@cp mcpd $(INSTALL_DIR)/mcpd
@echo "mcpd installed to $(INSTALL_DIR)/mcpd"

clean:
# Remove the built executable and any temporary files
@rm -f mcpd # The executable itself
# Add any other build artifacts here if they accumulate (e.g., cache files)
@echo "Build artifacts cleaned."

uninstall:
# Remove the installed executable from the system
# Requires sudo if INSTALL_DIR is a system path
@rm -f $(INSTALL_DIR)/mcpd
@echo "mcpd uninstalled from $(INSTALL_DIR)/mcpd"
18 changes: 18 additions & 0 deletions cmd/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package config

import (
"github.com/spf13/cobra"
)

var Cmd = &cobra.Command{
Use: "config",
Short: "Manages MCP server configuration.",
Long: "Manages MCP server configuration values and environment variable export.",
}

func init() {
// TODO: Re-add subcommands.
// Add subcommands to the config command.
// Cmd.AddCommand(exportEnvCmd)
// Cmd.AddCommand(setCmd)
}
73 changes: 73 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cmd

import (
"fmt"
"os"

"github.com/hashicorp/go-hclog"
"github.com/spf13/cobra"

"github.com/mozilla-ai/mcpd-cli/v2/internal/cmd"
"github.com/mozilla-ai/mcpd-cli/v2/internal/flags"
)

type InitCmd struct {
*cmd.BaseCmd
}

func NewInitCmd(log hclog.Logger) *cobra.Command {
c := &InitCmd{
BaseCmd: &cmd.BaseCmd{Logger: log},
}

cobraCommand := &cobra.Command{
Use: "init",
Short: "Initializes the current directory as an mcpd project.",
Long: c.longDescription(),
RunE: c.run,
}

return cobraCommand
}

func (c *InitCmd) longDescription() string {
return fmt.Sprintf(
"Initializes the current directory as an mcpd project, creating an %s configuration file. "+
"This command sets up the basic structure required for an mcpd project.", flags.ConfigFile)
}

func (c *InitCmd) run(_ *cobra.Command, _ []string) error {
fmt.Fprintln(os.Stdout, "Initializing mcpd project in current directory...")

cwd, err := os.Getwd()
if err != nil {
c.Logger.Error("Failed to get working directory", "error", err)
return fmt.Errorf("error getting current directory: %w", err)
}

if err := initializeProject(cwd); err != nil {
c.Logger.Error("Project initialization failed", "error", err)
return fmt.Errorf("error initializing mcpd project: %w", err)
}

fmt.Fprintf(os.Stdout, "%s created successfully.\n", flags.ConfigFile)

return nil
}

func initializeProject(path string) error {
if _, err := os.Stat(flags.ConfigFile); err == nil {
return fmt.Errorf("%s already exists", flags.ConfigFile)
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to stat %s: %w", flags.ConfigFile, err)
}

// TODO: Look at off-loading the data structure to the internal/config package
content := `servers = []`

if err := os.WriteFile(flags.ConfigFile, []byte(content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", flags.ConfigFile, err)
}

return nil
}
107 changes: 107 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package cmd

import (
"fmt"
"io"
"os"
"strings"

"github.com/hashicorp/go-hclog"
"github.com/spf13/cobra"

"github.com/mozilla-ai/mcpd-cli/v2/cmd/config"
"github.com/mozilla-ai/mcpd-cli/v2/cmd/server"
"github.com/mozilla-ai/mcpd-cli/v2/internal/cmd"
"github.com/mozilla-ai/mcpd-cli/v2/internal/flags"
)

var version = "dev" // Set at build time using -ldflags

type RootCmd struct {
*cmd.BaseCmd
}

func Execute() {
logger, err := configureLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "error executing root command: %s", err)
os.Exit(1)
}

rootCmd := NewRootCmd(logger)

if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

func NewRootCmd(logger hclog.Logger) *cobra.Command {
c := &RootCmd{
BaseCmd: &cmd.BaseCmd{Logger: logger},
}

rootCmd := &cobra.Command{
Use: "mcpd <command> [args]",
Short: "'mcpd' CLI is the primary interface for developers to interact with mcpd.",
Long: c.longDescription(),
SilenceUsage: true,
Version: version,
}

// Global flags
flags.InitFlags(rootCmd.PersistentFlags())

// Add top-level commands that are NOT part of a resource group
rootCmd.AddCommand(NewInitCmd(logger))
// TODO: Re-add commands:
// rootCmd.AddCommand(listToolsCmd)
// rootCmd.AddCommand(loginCmd)

// Add commands from specific resource/service packages, they remain top-level commands in the CLI's usage.
// TODO: Re-add daemon
// rootCmd.AddCommand(server.NewDaemonCmd(logger))
rootCmd.AddCommand(server.NewAddCmd(logger))
rootCmd.AddCommand(server.NewRemoveCmd(logger))
// TODO: Update to add: NewConfigCmd etc.
rootCmd.AddCommand(config.Cmd)

return rootCmd
}

func (c *RootCmd) longDescription() string {
return `The 'mcpd' CLI is the primary interface for developers to interact with the
mcpd Control Plane, define their agent projects, and manage MCP server dependencies.`
}

func configureLogger() (hclog.Logger, error) {
logPath := strings.TrimSpace(os.Getenv("MCPD_LOG_PATH"))

// If MCPD_LOG_PATH is not set, don't log anywhere.
logOutput := io.Discard

if logPath != "" {
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("failed to open log file (%s): %w", logPath, err)
}
logOutput = f
}

logger := hclog.New(&hclog.LoggerOptions{
Name: "mcpd",
Level: hclog.LevelFromString(getLogLevel()),
Output: logOutput,
})

return logger, nil
}

func getLogLevel() string {
lvl := strings.ToLower(os.Getenv("LOG_LEVEL"))
switch lvl {
case "trace", "debug", "info", "warn", "error", "off":
return lvl
default:
return "info"
}
}
91 changes: 91 additions & 0 deletions cmd/server/add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package server

import (
"fmt"
"strings"

"github.com/hashicorp/go-hclog"
"github.com/spf13/cobra"

"github.com/mozilla-ai/mcpd-cli/v2/internal/cmd"
"github.com/mozilla-ai/mcpd-cli/v2/internal/config"
)

// AddCmd should be used to represent the 'add' command.
type AddCmd struct {
*cmd.BaseCmd
Version string
Tools []string
}

// NewAddCmd creates a newly configured (Cobra) command.
func NewAddCmd(logger hclog.Logger) *cobra.Command {
c := &AddCmd{
BaseCmd: &cmd.BaseCmd{Logger: logger},
}

cobraCommand := &cobra.Command{
Use: "add <server_name>",
Short: "Adds an MCP server dependency to the project.",
Long: c.longDescription(),
RunE: c.run,
}

cobraCommand.Flags().StringVar(
&c.Version,
"version",
"latest",
"Specify the version of the server package",
)
cobraCommand.Flags().StringArrayVar(
&c.Tools,
"tool",
nil,
"Optional, when specified limits the available tools on the server (can be repeated)",
)

return cobraCommand
}

// longDescription returns the long version of the command description.
func (c *AddCmd) longDescription() string {
return `Adds an MCP server dependency to the project.
mcpd will search the registry for the server and attempt to return information on the version specified,
or 'latest' if no version specified.`
}

// run is configured (via NewAddCmd) to be called by the Cobra framework when the command is executed.
// It may return an error (or nil, when there is no error).
func (c *AddCmd) run(cmd *cobra.Command, args []string) error {
if len(args) == 0 || strings.TrimSpace(args[0]) == "" {
return fmt.Errorf("server name is required and cannot be empty")
}

name := strings.TrimSpace(args[0])
if name == "" {
return fmt.Errorf("server name cannot be empty")
}

// TODO: Make an actual call to the mcpd registry to get information here.
// Currently, we just fake the response here so we can deal with the config file.
pkg := fmt.Sprintf("modelcontextprotocol/%s@%s", name, c.Version)

entry := config.ServerEntry{
Name: name,
Package: pkg,
Tools: c.Tools,
}

if err := config.AddServer(entry); err != nil {
return err
}

// User-friendly output + logging
fmt.Fprintf(cmd.OutOrStdout(), "✓ Added server '%s' (version: %s)\n", name, c.Version)
if len(c.Tools) > 0 {
fmt.Fprintf(cmd.OutOrStdout(), " Tools: %s\n", strings.Join(c.Tools, ", "))
}
c.Logger.Debug("Server added", "name", name, "version", c.Version, "tools", c.Tools)

return nil
}
Loading