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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,22 @@ rootCmd.AddCommand(ophis.Command(config))

The command tree, editor config (`enable`/`disable`), and internal filters all use the configured name automatically.

### Default Environment Variables

Editors launch MCP server subprocesses with a minimal environment. On macOS this means a PATH of just `/usr/bin:/bin:/usr/sbin:/sbin`, so tools like `helm`, `kubectl`, or `docker` installed via mise/homebrew/nix won't be found. Use `DefaultEnv` to capture the current PATH (or any other variables) at `enable` time:

```go
config := &ophis.Config{
DefaultEnv: map[string]string{
"PATH": os.Getenv("PATH"),
},
}

rootCmd.AddCommand(ophis.Command(config))
```

These are merged into the editor config written by `enable`. User-provided `--env` values take precedence on conflict.

See [docs/config.md](docs/config.md) for detailed configuration options.

## How It Works
Expand Down
17 changes: 17 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ type Config struct {
// If nil or empty, defaults to exposing all commands with all flags.
Selectors []Selector

// DefaultEnv specifies environment variables that are automatically
// included when `enable` writes a server config for any editor.
// These are merged with user-provided --env values; user values
// take precedence on conflict.
//
// A common use is to capture PATH so the MCP server subprocess can
// find executables that live outside the system PATH:
//
// &ophis.Config{
// DefaultEnv: map[string]string{
// "PATH": os.Getenv("PATH"),
// },
// }
//
// If nil, no default environment variables are added (current behavior).
DefaultEnv map[string]string

// ToolNamePrefix replaces the root command name in tool names.
// This is useful for shortening tool names to comply with API limits (e.g., Claude's 64 char limit).
// For example, if root command is "omnistrate-ctl" and ToolNamePrefix is "omctl",
Expand Down
57 changes: 50 additions & 7 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Selectors control which commands and flags become MCP tools. Ophis evaluates sel
### Default Behavior

- If `Config.Selectors` is nil/empty, all commands and flags are exposed
- If `Config.DefaultEnv` is nil, no default environment variables are added to editor configs
- If `CmdSelector` is nil, the selector matches all commands
- If `LocalFlagSelector` or `InheritedFlagSelector` is nil, all flags are included

Expand All @@ -18,6 +19,48 @@ Always filtered regardless of configuration:
- Non-runnable commands (no Run/RunE)
- Built-in commands (the ophis command, help, completion). The ophis command name defaults to "mcp" but can be changed via Config.CommandName

## DefaultEnv

Editors like Claude Desktop, VSCode, and Cursor launch MCP server subprocesses with a minimal environment. On macOS this typically means a PATH of just `/usr/bin:/bin:/usr/sbin:/sbin`, which cannot find executables managed by mise, asdf, homebrew, nix, or installed to non-standard paths.

`DefaultEnv` specifies environment variables that are automatically included when `enable` writes a server config for any editor. These are merged with user-provided `--env` values; user values take precedence on conflict.

### Capture PATH

The most common use is to capture the current shell's PATH so the MCP server subprocess can find tools like `helm`, `kubectl`, `docker`, `terraform`, etc.:

```go
config := &ophis.Config{
DefaultEnv: map[string]string{
"PATH": os.Getenv("PATH"),
},
}
```

### Multiple Variables

```go
config := &ophis.Config{
DefaultEnv: map[string]string{
"PATH": os.Getenv("PATH"),
"KUBECONFIG": os.Getenv("KUBECONFIG"),
"HOME": os.Getenv("HOME"),
},
}
```

### User Override

User-provided `--env` values always take precedence over `DefaultEnv`:

```bash
# Uses DefaultEnv PATH
./my-cli mcp claude enable

# Overrides DefaultEnv PATH with user value, keeps other DefaultEnv vars
./my-cli mcp claude enable --env PATH=/custom/path
```

## Examples

### Expose Specific Commands
Expand Down Expand Up @@ -139,13 +182,13 @@ Set MCP [tool annotations](https://modelcontextprotocol.io/specification/2025-06

### Available Annotation Keys

| Key | Type | Description |
|-----|------|-------------|
| `ophis.AnnotationTitle` (`"title"`) | string | Human-readable title for the tool |
| `ophis.AnnotationReadOnly` (`"readOnlyHint"`) | bool | Tool does not modify its environment |
| `ophis.AnnotationDestructive` (`"destructiveHint"`) | bool | Tool may perform destructive updates |
| `ophis.AnnotationIdempotent` (`"idempotentHint"`) | bool | Repeated calls have no additional effect |
| `ophis.AnnotationOpenWorld` (`"openWorldHint"`) | bool | Tool may interact with external entities |
| Key | Type | Description |
| --------------------------------------------------- | ------ | ---------------------------------------- |
| `ophis.AnnotationTitle` (`"title"`) | string | Human-readable title for the tool |
| `ophis.AnnotationReadOnly` (`"readOnlyHint"`) | bool | Tool does not modify its environment |
| `ophis.AnnotationDestructive` (`"destructiveHint"`) | bool | Tool may perform destructive updates |
| `ophis.AnnotationIdempotent` (`"idempotentHint"`) | bool | Repeated calls have no additional effect |
| `ophis.AnnotationOpenWorld` (`"openWorldHint"`) | bool | Tool may interact with external entities |

Boolean values are parsed with `strconv.ParseBool` (`"true"`, `"1"`, `"t"`, `"false"`, `"0"`, `"f"`, etc.). Invalid values are skipped with a warning.

Expand Down
2 changes: 1 addition & 1 deletion examples/make/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.24.6
replace github.com/njayp/ophis => ../../

require (
github.com/njayp/ophis v1.1.2
github.com/njayp/ophis v1.1.3
github.com/spf13/cobra v1.10.2
)

Expand Down
20 changes: 15 additions & 5 deletions internal/cfgmgr/cmd/claude/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ import (

type enableFlags struct {
commandName string
defaultEnv map[string]string
configPath string
logLevel string
serverName string
env map[string]string
}

// enableCommand creates a Cobra command for adding an MCP server to Claude Desktop.
func enableCommand(commandName string) *cobra.Command {
f := &enableFlags{commandName: commandName}
// defaultEnv is merged into the server env; user-provided --env values take precedence.
func enableCommand(commandName string, defaultEnv map[string]string) *cobra.Command {
f := &enableFlags{commandName: commandName, defaultEnv: defaultEnv}
cmd := &cobra.Command{
Use: "enable",
Short: "Add server to Claude config",
Expand Down Expand Up @@ -61,9 +63,17 @@ func (f *enableFlags) run(cmd *cobra.Command) error {
server.Args = append(server.Args, "--log-level", f.logLevel)
}

// Add environment variables if specified
if len(f.env) > 0 {
server.Env = f.env
// Merge default env with user-provided env.
// User values take precedence on conflict.
env := make(map[string]string)
for k, v := range f.defaultEnv {
env[k] = v
}
for k, v := range f.env {
env[k] = v
}
if len(env) > 0 {
server.Env = env
}

if f.serverName == "" {
Expand Down
5 changes: 3 additions & 2 deletions internal/cfgmgr/cmd/claude/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import (
// Command creates a new Cobra command for managing Claude Desktop MCP servers.
// commandName is the name of the ophis root command (e.g. "mcp" or "agent"),
// used by enable to build the correct command path for editor config files.
func Command(commandName string) *cobra.Command {
// defaultEnv is merged into the server env on enable; user --env values take precedence.
func Command(commandName string, defaultEnv map[string]string) *cobra.Command {
cmd := &cobra.Command{
Use: "claude",
Short: "Manage Claude Desktop MCP servers",
Long: "Manage MCP server configuration for Claude Desktop",
}

// Add subcommands
cmd.AddCommand(enableCommand(commandName), disableCommand(), listCommand())
cmd.AddCommand(enableCommand(commandName, defaultEnv), disableCommand(), listCommand())
return cmd
}
20 changes: 15 additions & 5 deletions internal/cfgmgr/cmd/cursor/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

type enableFlags struct {
commandName string
defaultEnv map[string]string
configPath string
logLevel string
serverName string
Expand All @@ -20,8 +21,9 @@ type enableFlags struct {

// enableCommand creates a Cobra command for adding an MCP server to Cursor.
// commandName is the name of the ophis root command in the Cobra tree.
func enableCommand(commandName string) *cobra.Command {
f := &enableFlags{commandName: commandName}
// defaultEnv is merged into the server env; user-provided --env values take precedence.
func enableCommand(commandName string, defaultEnv map[string]string) *cobra.Command {
f := &enableFlags{commandName: commandName, defaultEnv: defaultEnv}
cmd := &cobra.Command{
Use: "enable",
Short: "Add server to Cursor config",
Expand Down Expand Up @@ -66,9 +68,17 @@ func (f *enableFlags) run(cmd *cobra.Command) error {
server.Args = append(server.Args, "--log-level", f.logLevel)
}

// Add environment variables if specified
if len(f.env) > 0 {
server.Env = f.env
// Merge default env with user-provided env.
// User values take precedence on conflict.
env := make(map[string]string)
for k, v := range f.defaultEnv {
env[k] = v
}
for k, v := range f.env {
env[k] = v
}
if len(env) > 0 {
server.Env = env
}

if f.serverName == "" {
Expand Down
5 changes: 3 additions & 2 deletions internal/cfgmgr/cmd/cursor/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import (
// Command creates a new Cobra command for managing Cursor MCP servers.
// commandName is the Use name of the ophis root command (e.g. "mcp" or "agent"),
// threaded through to enableCommand so that GetCmdPath can locate it.
func Command(commandName string) *cobra.Command {
// defaultEnv is merged into the server env on enable; user --env values take precedence.
func Command(commandName string, defaultEnv map[string]string) *cobra.Command {
cmd := &cobra.Command{
Use: "cursor",
Short: "Manage Cursor MCP servers",
Long: "Manage MCP server configuration for Cursor",
}

// Add subcommands
cmd.AddCommand(enableCommand(commandName), disableCommand(), listCommand())
cmd.AddCommand(enableCommand(commandName, defaultEnv), disableCommand(), listCommand())
return cmd
}
20 changes: 15 additions & 5 deletions internal/cfgmgr/cmd/vscode/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

type enableFlags struct {
commandName string
defaultEnv map[string]string
configPath string
logLevel string
serverName string
Expand All @@ -20,8 +21,9 @@ type enableFlags struct {

// enableCommand creates a Cobra command for adding an MCP server to VSCode.
// commandName is the Use name of the ophis root command (e.g. "mcp" or "agent").
func enableCommand(commandName string) *cobra.Command {
f := &enableFlags{commandName: commandName}
// defaultEnv is merged into the server env; user-provided --env values take precedence.
func enableCommand(commandName string, defaultEnv map[string]string) *cobra.Command {
f := &enableFlags{commandName: commandName, defaultEnv: defaultEnv}
cmd := &cobra.Command{
Use: "enable",
Short: "Add server to VSCode config",
Expand Down Expand Up @@ -66,9 +68,17 @@ func (f *enableFlags) run(cmd *cobra.Command) error {
server.Args = append(server.Args, "--log-level", f.logLevel)
}

// Add environment variables if specified
if len(f.env) > 0 {
server.Env = f.env
// Merge default env with user-provided env.
// User values take precedence on conflict.
env := make(map[string]string)
for k, v := range f.defaultEnv {
env[k] = v
}
for k, v := range f.env {
env[k] = v
}
if len(env) > 0 {
server.Env = env
}

if f.serverName == "" {
Expand Down
5 changes: 3 additions & 2 deletions internal/cfgmgr/cmd/vscode/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import (
// Command creates a new Cobra command for managing VSCode MCP servers.
// commandName is the name of the ophis command in the Cobra tree (e.g. "mcp" or "agent"),
// used by enable to build the correct command path for editor config files.
func Command(commandName string) *cobra.Command {
// defaultEnv is merged into the server env on enable; user --env values take precedence.
func Command(commandName string, defaultEnv map[string]string) *cobra.Command {
cmd := &cobra.Command{
Use: "vscode",
Short: "Manage VSCode MCP servers",
Long: "Manage MCP server configuration for Visual Studio Code",
}

// Add subcommands
cmd.AddCommand(enableCommand(commandName), disableCommand(), listCommand())
cmd.AddCommand(enableCommand(commandName, defaultEnv), disableCommand(), listCommand())
return cmd
}
12 changes: 9 additions & 3 deletions root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import (
// Pass nil for default configuration or provide a Config for customization.
func Command(config *Config) *cobra.Command {
name := config.commandName()

var defaultEnv map[string]string
if config != nil {
defaultEnv = config.DefaultEnv
}

cmd := &cobra.Command{
Use: name,
Short: "MCP server management",
Expand All @@ -22,9 +28,9 @@ func Command(config *Config) *cobra.Command {
startCommand(config),
toolCommand(config),
streamCommand(config),
claude.Command(name),
vscode.Command(name),
cursor.Command(name),
claude.Command(name, defaultEnv),
vscode.Command(name, defaultEnv),
cursor.Command(name, defaultEnv),
)
return cmd
}
Loading
Loading