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
10 changes: 10 additions & 0 deletions pkgs/mcpx/cmd/mcpx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"syscall"

"github.com/dezren39/mcpx/internal/cli"
"github.com/dezren39/mcpx/internal/config"
"github.com/dezren39/mcpx/internal/daemon"
)

Expand Down Expand Up @@ -93,6 +94,15 @@ func main() {
case strings.HasPrefix(args[0], "--config="):
app.ConfigPath = strings.TrimPrefix(args[0], "--config=")
args = args[1:]
case args[0] == "--paths-config" && len(args) > 1:
// A bootstrap flag like --config: it decides which files
// the settings are read from, so it is captured here,
// before any settings exist (issue #1842).
config.SetPathsConfigSeed(splitList(args[1]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward the bootstrap seed to auto-started daemons

When --paths-config adds a configuration file and a command needs to auto-start a daemon, the seed exists only in this process's flagSeed. Connect keys the expected socket from the expanded configuration, but Client.EnsureDaemon (internal/cli/client.go) forwards only --config, not this seed. Without an equivalent MCPX_PATHS_CONFIG environment variable, the child loads the default chain and listens on a different configuration fingerprint, so the client times out waiting for its socket. Propagate the bootstrap seed to the spawned process as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed forward in PR #5165 (issue #2507, commit 2a88cd5): config.PathsConfigSeedEnv() serializes the flag seed as MCPX_PATHS_CONFIG, and the daemon autostart path in internal/cli/client.go now sets it as the child daemon's environment, so an auto-started daemon resolves the same chain as the invoking process. Covered by TestPathsConfigSeedEnvForwardsFlagSeed (internal/config).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accumulate repeated config-search flags

For mcpx --paths-config /a --paths-config /b config, each call replaces flagSeed, so only /b participates in discovery. The setting is declared Repeatable: true in internal/settings/registry.go, and the existing binding implementation accumulates repeated occurrences. The new bootstrap interception bypasses that behavior and silently drops earlier configuration sources. Collect all occurrences before installing the seed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed forward in PR #5165 (issue #2507, commit 2a88cd5): main now calls the new config.AddPathsConfigSeed per --paths-config occurrence, accumulating like the Repeatable setting's normal binding; SetPathsConfigSeed remains the replace/reset form. Covered by TestPathsConfigFlagSeedAccumulates.

args = args[2:]
case strings.HasPrefix(args[0], "--paths-config="):
config.SetPathsConfigSeed(splitList(strings.TrimPrefix(args[0], "--paths-config=")))
args = args[1:]
case args[0] == "--version" || args[0] == "-v":
fmt.Println("mcpx", version)
return
Expand Down
11 changes: 11 additions & 0 deletions pkgs/mcpx/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ Lowest to highest:
what it displaced. "Why is this not what my config says" is the most common
configuration question there is, and the answer is always in that list.

The search path itself is decided before any of those layers exist, in two
passes. The discovery pass walks the default locations, seeded by
`MCPX_PATHS_CONFIG` / `--paths-config`, and reads only `paths.config`. The
load pass resolves that list and loads everything on it, nearest last: a
file's `paths.config` governs the rest of the chain from that point, a null
entry stands for the locations the default walk provided, and a list with
no null replaces the farther remainder. A `paths.config` found only in the
load pass is reported by `mcpx config --sources`, not applied, and a cycle
is refused naming the path that closed it. `MCPX_CONFIG` bypasses both
passes and loads exactly one file.

Two spellings of one setting at the same level is an error rather than a race:
`--log-level` and `--logging-level` together, or two variables that mean the
same thing with different values. The environment has no order, so there is
Expand Down
13 changes: 9 additions & 4 deletions pkgs/mcpx/docs/declared-but-unread.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,15 @@ describe a knob nobody wired.

### Left open

`paths.config` is circular by construction (#5): the config search path is what
produces the settings, so the settings cannot decide it. It is declared so that
`mcpx config --schema` names the thing, and `config.SearchPath` reads
`MCPX_PATHS_CONFIG` directly. It is the only entry on the allowlist.
`paths.config` was circular by construction (#5): the config search path is
what produces the settings, so the settings cannot decide it. It is no longer
left open. `config.ResolveChainFrom` resolves the chain in two passes
(issue #1842): a discovery pass over the default locations, seeded by
`MCPX_PATHS_CONFIG` / `--paths-config`, reads only `paths.config`; the load
pass then resolves that list, nearest last. A `paths.config` found in pass 2
that differs is reported by `mcpx config --sources`, not applied, and a cycle
is refused naming the path that closed it. `MCPX_CONFIG` bypasses both
passes.

### The inverse bug

Expand Down
51 changes: 36 additions & 15 deletions pkgs/mcpx/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,25 +208,24 @@ func (a *App) adoptSettings() {
a.warnPathsConfigFile(set)
}

// warnPathsConfigFile warns when a paths.config entry names a file.
// warnPathsConfigFile warns when a paths.config entry names nothing
// that exists.
//
// The entries are directories that are searched for configuration files,
// so pointing one at a file -- a natural reading of --paths-config -- is
// silently ignored and the default configuration loads instead (#278).
// Naming the file directly is what MCPX_CONFIG and --config are for, so
// say so rather than leaving the user to debug a configuration that was
// never read.
// Entries govern the config chain (issue #1842): a directory is
// searched with the usual spellings, and a file means exactly that
// file. An entry naming neither is the one case that silently does
// nothing, so say so rather than leaving the user to debug a
// configuration that was never read.
func (a *App) warnPathsConfigFile(set *settings.Set) {
if a.warnedPathsConfig {
return
}
for _, entry := range set.List("paths.config") {
if st, err := os.Stat(entry); err == nil && !st.IsDir() {
if _, err := os.Stat(entry); err != nil {
fmt.Fprintf(os.Stderr,
"mcpx: warning: paths.config entry %q is a file; entries are "+
"directories searched for config files, so it is ignored. "+
"To use that file directly, pass --config %s or set MCPX_CONFIG.\n",
entry, entry)
"mcpx: warning: paths.config entry %q does not exist; "+
"it contributes nothing to the config chain.\n",
entry)
a.warnedPathsConfig = true
return
}
Expand Down Expand Up @@ -580,10 +579,18 @@ func (a *App) configSources(cfg *config.Config) error {
From string `json:"from"`
}
out := struct {
Sources []string `json:"sources"`
Servers []row `json:"servers"`
Presets []presetRow `json:"presets,omitempty"`
Sources []string `json:"sources"`
Discovery []string `json:"discovery,omitempty"`
Load []string `json:"load,omitempty"`
Divergent []string `json:"divergent,omitempty"`
Servers []row `json:"servers"`
Presets []presetRow `json:"presets,omitempty"`
}{Sources: cfg.Sources}
if cfg.Chain != nil {
out.Discovery = cfg.Chain.Discovery
out.Load = cfg.Chain.Load
out.Divergent = cfg.Chain.Divergent
}
// A value a preset supplied is a source too, and the one least likely to
// be remembered: it came from a name on the command line, not a file.
for _, v := range a.Settings().All() {
Expand Down Expand Up @@ -611,6 +618,20 @@ func (a *App) configSources(cfg *config.Config) error {
}
return nil
}
if cfg.Chain != nil && !cfg.Chain.Bypass {
fmt.Println("Discovery pass (searched for paths.config), nearest first:")
for _, p := range out.Discovery {
fmt.Println(" " + p)
}
fmt.Println("Load pass (files the chain resolved to), nearest first:")
for _, p := range out.Load {
fmt.Println(" " + p)
}
for _, d := range out.Divergent {
fmt.Println(" ! " + d)
}
fmt.Println()
}
fmt.Println("Files, nearest first:")
for i, p := range out.Sources {
marker := " "
Expand Down
22 changes: 16 additions & 6 deletions pkgs/mcpx/internal/cli/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ func (a *App) Settings() *settings.Set {
set := settings.NewSet(sch)

// Configuration files, furthest first so the nearest ends up highest.
files := configFilesFarthestFirst(a.ConfigPath)
files, ferr := configFilesFarthestFirst(a.ConfigPath)
if ferr != nil {
a.settingsErr = ferr
return
}
for i, path := range files {
doc, rerr := readJSONFile(path)
if rerr != nil {
Expand Down Expand Up @@ -355,12 +359,18 @@ func ignoredKeysError(ignored []config.IgnoredKey) error {
return errors.New(b.String())
}

func configFilesFarthestFirst(explicit string) []string {
func configFilesFarthestFirst(explicit string) ([]string, error) {
if explicit != "" {
return []string{explicit}
return []string{explicit}, nil
}
// The settings layer reads the same chain Load merges: the two-pass
// resolution (issue #1842), so a file paths.config adds contributes
// its settings as well as its servers.
search, _, err := config.ResolveChain()
if err != nil {
return nil, err
}
search := config.SearchPath()
// SearchPath is nearest-first, because that is the order a lookup wants.
// The chain is nearest-first, because that is the order a lookup wants.
// Layering wants the opposite, so that the nearest file is applied last
// and therefore wins.
out := make([]string, 0, len(search))
Expand All @@ -369,7 +379,7 @@ func configFilesFarthestFirst(explicit string) []string {
out = append(out, search[i])
}
}
return out
return out, nil
}

func readJSONFile(path string) (map[string]any, error) {
Expand Down
Loading
Loading