From dcc9f78adb77eeb65e937e98f44e69315b6138e2 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 10 Nov 2026 17:58:32 -0500 Subject: [PATCH] feat(mcp): resolve listen address from mcp_base_url and mcp_port config - add mcp_base_url (literal loopback IP, default 127.0.0.1) and mcp_port (default 8080) - precedence: changed --listen > TS_JEV_MCP_BASE_URL/TS_JEV_MCP_PORT > TOML > defaults - jevwise mcp and mcp install read only these two keys for the address - show both keys in config init template and config view; update docs --- README.md | 2 +- config.example.toml | 3 ++ docs/configuration.md | 3 ++ docs/mcp-agents.md | 2 +- docs/mcp.md | 2 +- internal/cli/decide_test.go | 2 +- internal/cli/mcp.go | 20 ++++++- internal/cli/mcp_install.go | 5 +- internal/cli/mcp_install_test.go | 2 + internal/cli/mcp_listen_test.go | 78 +++++++++++++++++++++++++++ internal/config/files.go | 12 ++++- internal/config/mcp.go | 93 ++++++++++++++++++++++++++++++++ internal/config/mcp_test.go | 77 ++++++++++++++++++++++++++ internal/config/template.toml | 3 ++ jevwise.toml | 3 ++ 15 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 internal/cli/mcp_listen_test.go create mode 100644 internal/config/mcp.go create mode 100644 internal/config/mcp_test.go diff --git a/README.md b/README.md index e25193d..4875c62 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ api_key = "$ABCD_TOKEN" - Use a separate bearer token: **never reuse the upstream API key or give it to the agent**. - With no token override, `jevwise mcp` generates a random token in `/agent.token` and reuses it across restarts. Only your upstream API key needs manual setup. - `jevwise mcp token-path` prints the automatic file path, never the secret; see [agent onboarding](docs/mcp-agents.md) to load it into the client environment. -- Register the installed client with `jevwise mcp install codex` or `jevwise mcp install claude`. Both default to user-wide settings; custom ports use `--listen IP:PORT`. Registration stores only an environment reference, not the token. See [agent onboarding](docs/mcp-agents.md#3-choose-a-client). +- Register the installed client with `jevwise mcp install codex` or `jevwise mcp install claude`. Both default to user-wide settings; custom ports use `--listen IP:PORT` or `mcp_port` in [configuration](docs/configuration.md). Registration stores only an environment reference, not the token. See [agent onboarding](docs/mcp-agents.md#3-choose-a-client). - Overrides take precedence: `--token-file /path/to/protected/agent-token` > `JEVWISE_MCP_TOKEN` (including aliases) > automatic token. Explicit values are never copied into the automatic file. - MCP fallback names, in order: `JEVWISE_MCP_AUTH_TOKEN`, `JEV_MCP_TOKEN`. Invalid nonempty tokens fail without fallback; tokens must be printable ASCII without whitespace. - Keep secrets out of shell history, logs and stored agent configuration. Follow [local MCP setup](docs/mcp.md) and [agent onboarding](docs/mcp-agents.md) for protected files and client environment references. diff --git a/config.example.toml b/config.example.toml index 2bd6957..3a45e5b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -6,3 +6,6 @@ model = "jev-latest" provider = "jev" timeout = "10s" editor = "vim" +# Local MCP server: literal loopback IP (no scheme) and port. +mcp_base_url = "127.0.0.1" +mcp_port = 8080 diff --git a/docs/configuration.md b/docs/configuration.md index 04d1dc8..4bd158d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,7 +13,10 @@ Precedence: **changed flags > nonblank environment > TOML > defaults**. These na | Model | `--model` | `TS_JEV_MODEL`, then `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | | Provider | `--provider` | `TS_JEV_PROVIDER` | `jev` | | Operation timeout | `--timeout` | `TS_JEV_TIMEOUT` | `10s` | +| MCP host | `--listen` (with port) | `TS_JEV_MCP_BASE_URL` | `127.0.0.1` | +| MCP port | `--listen` (with host) | `TS_JEV_MCP_PORT` | `8080` | +- `mcp_base_url` is a literal loopback IP without scheme or port (`127.0.0.1`, `::1`); `http://`, `localhost` and non-loopback hosts fail. `mcp_port` is an integer from 1 to 65535 (`mcp_port = 8080` or `"8080"`). `jevwise mcp` and `jevwise mcp install` read only these two keys for the address; a changed `--listen` overrides both, so `jevwise mcp --listen 127.0.0.1:9090` equals `jevwise mcp` with `mcp_port = 9090`. - Explicit file: `--config /path/to/config.toml`. Missing or invalid explicit files fail. - Otherwise: current-directory `.jevwise.toml`, then `$XDG_CONFIG_HOME/jevwise/jevwise.toml` or `$HOME/.config/jevwise/jevwise.toml`. Only a missing global file is allowed. The selected local file replaces rather than merges with global settings; no ancestor-directory search or visible-name fallback. - On Windows, the default global path is `%USERPROFILE%\.config\jevwise\jevwise.toml` when `XDG_CONFIG_HOME` is unset. diff --git a/docs/mcp-agents.md b/docs/mcp-agents.md index 4e10d85..0b674ee 100644 --- a/docs/mcp-agents.md +++ b/docs/mcp-agents.md @@ -62,7 +62,7 @@ jevwise mcp install claude --scope project # Shared .mcp.json, requires client a - Both default to user-wide settings. Requires the selected `codex` or `claude` executable on PATH; Claude Code must support `--bare`. Standard client configuration overrides (`CODEX_HOME`, `CLAUDE_CONFIG_DIR`) are honored. - Uses native registration commands, preserving unrelated settings. An existing `jevwise` entry is refused; inspect it before intentionally removing/updating it with the client's own commands. Configuration assumes trusted clients and cooperative writers; do not run competing installers/settings edits concurrently. -- Stores `JEVWISE_MCP_TOKEN` as an environment reference, never its value. The installer does not export the token into your shell, read Jev configuration, start MCP, verify connectivity or make a paid decision. Use section 2 to load the token before launching your client. `--listen` must match the running server; it does not change the server port. +- Stores `JEVWISE_MCP_TOKEN` as an environment reference, never its value. The installer does not export the token into your shell, read Jev configuration other than `mcp_base_url` and `mcp_port`, start MCP, verify connectivity or make a paid decision. Use section 2 to load the token before launching your client. The endpoint comes from `--listen`, else `mcp_base_url` and `mcp_port`; it must match the running server and does not change the server port. - Child processes do not inherit API keys or MCP tokens. Client output is bounded and withheld, including diagnostics; failures may have partially registered an entry, so inspect settings before retrying. `--timeout` bounds registration (default `10s`), not a server/model request. Only the direct process is managed, not wrapper descendants. - Manual setup remains available below. Installed registration is not proof of a live authenticated connection; verify via `/mcp` without calling `decide`. diff --git a/docs/mcp.md b/docs/mcp.md index 4e285f6..56f92d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -33,7 +33,7 @@ jevwise mcp stop | `jevwise mcp install codex` / `claude` | Register the HTTP endpoint using an installed client CLI; no server/model startup or token export. | | `jevwise mcp token-path` | Prints only the automatic token path; does not read credentials, create files or report explicit overrides. | | `jevwise mcp stop` | Authenticates the exact instance and requests shutdown; an acknowledgement is not a promise that cleanup has already finished. | -| `--listen` | Literal loopback IP and nonzero port; default `127.0.0.1:8080`. | +| `--listen` | Literal loopback IP and nonzero port; overrides `mcp_base_url` and `mcp_port` from [configuration](configuration.md); default `127.0.0.1:8080`. | | `--runtime-dir` | Private background state directory; default `os.UserCacheDir()/jevwise-mcp`. Use the same override for start, status and stop. | | `--token-file` | Explicit protected agent-token file, overriding `JEVWISE_MCP_TOKEN`; unreadable or unsafe files fail without fallback. | diff --git a/internal/cli/decide_test.go b/internal/cli/decide_test.go index 49bf868..78ca39e 100644 --- a/internal/cli/decide_test.go +++ b/internal/cli/decide_test.go @@ -26,7 +26,7 @@ func (f decideFunc) Decide(ctx context.Context, req service.Request) (service.Re func decisionEnvironment(t *testing.T) { t.Helper() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - for _, key := range []string{"JEV_API_KEY", "JEVWISE_API_KEY", "JEVWISE_MCP_TOKEN", "JEVWISE_MCP_AUTH_TOKEN", "JEV_MCP_TOKEN", "TS_JEV_API_KEY", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_BASE_URL", "TS_JEV_TIMEOUT", "TYPESAFE_API_KEY", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_BASE_URL"} { + for _, key := range []string{"JEV_API_KEY", "JEVWISE_API_KEY", "JEVWISE_MCP_TOKEN", "JEVWISE_MCP_AUTH_TOKEN", "JEV_MCP_TOKEN", "TS_JEV_API_KEY", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_BASE_URL", "TS_JEV_TIMEOUT", "TS_JEV_MCP_BASE_URL", "TS_JEV_MCP_PORT", "TYPESAFE_API_KEY", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_BASE_URL"} { t.Setenv(key, "") } } diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 17bae28..45b055b 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + "github.com/bitbrew-dev/jevwise/internal/config" "github.com/bitbrew-dev/jevwise/internal/daemon" "github.com/bitbrew-dev/jevwise/internal/debuglog" "github.com/bitbrew-dev/jevwise/internal/mcpserver" @@ -36,7 +37,7 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte if err := ctx.Err(); err != nil { return err } - canonical, err := mcpserver.ValidateAddress(address) + canonical, err := mcpListenAddress(cmd, address) if err != nil { return err } @@ -126,7 +127,7 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte } return nil }} - cmd.Flags().StringVar(&address, "listen", "127.0.0.1:8080", "Literal loopback IP:port for /mcp") + cmd.Flags().StringVar(&address, "listen", "127.0.0.1:8080", "Literal loopback IP:port for /mcp; overrides mcp_base_url and mcp_port") cmd.Flags().BoolVar(&background, "background", false, "Start an independent MCP background process") cmd.PersistentFlags().StringVar(&tokenFile, "token-file", "", "Read an owner-private bearer token file instead of JEVWISE_MCP_TOKEN") cache, _ := os.UserCacheDir() @@ -148,3 +149,18 @@ func mcpEnvironmentToken() string { } return "" } + +// mcpListenAddress keeps a changed --listen ahead of the mcp_base_url and +// mcp_port settings, which are resolved without loading any other setting. +func mcpListenAddress(cmd *cobra.Command, listen string) (string, error) { + if !cmd.Flags().Changed("listen") { + path, err := cmd.Flags().GetString("config") + if err != nil { + return "", errors.New("configuration flag is unavailable") + } + if listen, err = config.MCPAddress(path); err != nil { + return "", err + } + } + return mcpserver.ValidateAddress(listen) +} diff --git a/internal/cli/mcp_install.go b/internal/cli/mcp_install.go index 660bfb8..8a583e8 100644 --- a/internal/cli/mcp_install.go +++ b/internal/cli/mcp_install.go @@ -9,7 +9,6 @@ import ( "time" "github.com/bitbrew-dev/jevwise/internal/debuglog" - "github.com/bitbrew-dev/jevwise/internal/mcpserver" "github.com/spf13/cobra" ) @@ -23,7 +22,7 @@ func newMCPInstall(run agentCommand) *cobra.Command { } cmd := &cobra.Command{Use: "install", Short: "Register Jevwise with an installed MCP client", Args: noArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }} - cmd.PersistentFlags().StringVar(&address, "listen", "127.0.0.1:8080", "Server's literal loopback IP:port (same as mcp --listen)") + cmd.PersistentFlags().StringVar(&address, "listen", "127.0.0.1:8080", "Server's literal loopback IP:port (same as mcp --listen); overrides mcp_base_url and mcp_port") cmd.PersistentFlags().StringVar(&scope, "scope", "user", "Client settings scope: user; Claude also supports local or project") _ = cmd.RegisterFlagCompletionFunc("scope", func(cmd *cobra.Command, _ []string, prefix string) ([]string, cobra.ShellCompDirective) { choices := []string{"user"} @@ -44,7 +43,7 @@ func newMCPInstall(run agentCommand) *cobra.Command { if err := cmd.Context().Err(); err != nil { return err } - canonical, err := mcpserver.ValidateAddress(address) + canonical, err := mcpListenAddress(cmd, address) if err != nil { return err } diff --git a/internal/cli/mcp_install_test.go b/internal/cli/mcp_install_test.go index 1843c70..5c4d8f1 100644 --- a/internal/cli/mcp_install_test.go +++ b/internal/cli/mcp_install_test.go @@ -14,6 +14,8 @@ import ( func installerRoot(t *testing.T, run agentCommand) *cobra.Command { t.Helper() + decisionEnvironment(t) + t.Chdir(t.TempDir()) root := NewRootWithFactory(func(config.Config) (service.DecisionService, func(), error) { t.Fatal("installation initialized upstream service") return nil, nil, nil diff --git a/internal/cli/mcp_listen_test.go b/internal/cli/mcp_listen_test.go new file mode 100644 index 0000000..6e40546 --- /dev/null +++ b/internal/cli/mcp_listen_test.go @@ -0,0 +1,78 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bitbrew-dev/jevwise/internal/config" + "github.com/bitbrew-dev/jevwise/internal/mcpserver" + "github.com/bitbrew-dev/jevwise/internal/service" +) + +func mcpListenConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestMCPListenFromConfigMatchesFlag(t *testing.T) { + decisionEnvironment(t) + t.Setenv("JEV_MCP_TOKEN", "fixture-agent-token") + factory := func(config.Config) (service.DecisionService, func(), error) { return mcpFixtureService(t), nil, nil } + path := mcpListenConfig(t, "api_key = 'fixture-upstream-key'\nmcp_base_url = '127.0.0.1'\nmcp_port = 9090\n") + for _, args := range [][]string{ + {"mcp", "--config", path}, + {"mcp", "--config", path, "--listen", "127.0.0.1:9090"}, + {"mcp", "--config", mcpListenConfig(t, "api_key = 'fixture-upstream-key'\nmcp_port = 7070\n"), "--listen", "127.0.0.1:9090"}, + } { + address := "" + run := func(ctx context.Context, _ service.DecisionService, _ time.Duration, options mcpserver.RunOptions) error { + address = options.Address + return nil + } + if _, _, err := execute(mcpRoot(factory, run), args...); err != nil || address != "127.0.0.1:9090" { + t.Fatal("listen address not resolved from config or flag", args, address, err) + } + } + run := func(context.Context, service.DecisionService, time.Duration, mcpserver.RunOptions) error { + t.Fatal("invalid MCP setting started runner") + return nil + } + for _, content := range []string{"mcp_base_url = 'http://127.0.0.1'\n", "mcp_port = 0\n"} { + if _, _, err := execute(mcpRoot(factory, run), "mcp", "--config", mcpListenConfig(t, "api_key = 'k'\n"+content)); err == nil { + t.Fatal("invalid MCP setting accepted", content) + } + } +} + +func TestMCPInstallListenFromConfig(t *testing.T) { + for _, tc := range []struct { + content string + args []string + want string + }{ + {"mcp_port = 9090\n", nil, "http://127.0.0.1:9090/mcp"}, + {"mcp_port = 9090\ntimeout = 'invalid'\n", []string{"--listen", "127.0.0.1:7070"}, "http://127.0.0.1:7070/mcp"}, + } { + url := "" + run := func(_ context.Context, _ string, args ...string) ([]byte, error) { + url = args[len(args)-1] + return nil, nil + } + root := installerRoot(t, run) + args := append([]string{"mcp", "install", "claude", "--config", mcpListenConfig(t, tc.content)}, tc.args...) + if _, _, err := execute(root, args...); err != nil || !strings.Contains(url, `"url":"`+tc.want+`"`) { + t.Fatal("install endpoint not resolved from config or flag", url, err) + } + } + if _, _, err := execute(installerRoot(t, nil), "mcp", "install", "claude", "--config", mcpListenConfig(t, "mcp_base_url = 'localhost'\n")); err == nil { + t.Fatal("install accepted non-literal MCP host") + } +} diff --git a/internal/config/files.go b/internal/config/files.go index f50ea19..c7367c5 100644 --- a/internal/config/files.go +++ b/internal/config/files.go @@ -94,6 +94,13 @@ func fileValues(path string) (map[string]string, error) { } values[key] = strings.TrimSpace(value) } + mcp, err := mcpValues(v, false) + if err != nil { + return nil, err + } + for key, value := range mcp { + values[key] = value + } return values, nil } @@ -110,8 +117,11 @@ func View(path string) (string, error) { } values["api_key"] = "[redacted]" var out strings.Builder - for _, key := range []string{"api_key", "base_url", "model", "provider", "timeout", "editor"} { + for _, key := range []string{"api_key", "base_url", "model", "provider", "timeout", "editor", "mcp_base_url", "mcp_port"} { value, _ := json.Marshal(values[key]) + if key == "mcp_port" { + value = []byte(values[key]) + } out.WriteString(key + " = " + string(value) + "\n") } return out.String(), nil diff --git a/internal/config/mcp.go b/internal/config/mcp.go new file mode 100644 index 0000000..6bfe60f --- /dev/null +++ b/internal/config/mcp.go @@ -0,0 +1,93 @@ +package config + +import ( + "bytes" + "errors" + "net/netip" + "os" + "strconv" + "strings" + + "github.com/spf13/viper" +) + +// Default MCP listen settings. The server binds only literal loopback IPs. +const ( + DefaultMCPBaseURL = "127.0.0.1" + DefaultMCPPort = 8080 +) + +var errMCPPort = errors.New("mcp_port must be an integer from 1 to 65535") + +// MCPAddress resolves mcp_base_url and mcp_port (nonblank environment > TOML > +// defaults) into a canonical loopback IP:port. It reads no other settings, so only a +// missing file falls back to defaults; unsafe files, invalid TOML or values fail. +func MCPAddress(path string) (string, error) { + v := viper.New() + v.SetConfigType("toml") + resolved, _, err := ResolvePath(path) + if err != nil { + return "", err + } + data, err := ReadFile(resolved) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", errors.New("cannot read configuration: file unreadable or unsafe") + } + if err == nil && v.ReadConfig(bytes.NewReader(data)) != nil { + return "", errors.New("cannot read configuration: invalid TOML") + } + values, err := mcpValues(v, true) + if err != nil { + return "", err + } + return mcpAddress(values["mcp_base_url"], values["mcp_port"]) +} + +// mcpValues reads both keys, accepting a TOML integer or string port, and +// validates the result. Environment overrides apply only when env is true. +func mcpValues(v *viper.Viper, env bool) (map[string]string, error) { + values := map[string]string{"mcp_base_url": DefaultMCPBaseURL, "mcp_port": strconv.Itoa(DefaultMCPPort)} + for key := range values { + switch raw := v.Get(key).(type) { + case nil: + case string: + values[key] = strings.TrimSpace(raw) + case int64: + if key != "mcp_port" { + return nil, errors.New("mcp_base_url must be a string") + } + values[key] = strconv.FormatInt(raw, 10) + case int: + if key != "mcp_port" { + return nil, errors.New("mcp_base_url must be a string") + } + values[key] = strconv.Itoa(raw) + default: + if key == "mcp_port" { + return nil, errMCPPort + } + return nil, errors.New("mcp_base_url must be a string") + } + if env { + if value := strings.TrimSpace(os.Getenv("TS_JEV_" + strings.ToUpper(key))); value != "" { + values[key] = value + } + } + } + if _, err := mcpAddress(values["mcp_base_url"], values["mcp_port"]); err != nil { + return nil, err + } + return values, nil +} + +func mcpAddress(host, port string) (string, error) { + addr, err := netip.ParseAddr(host) + if err != nil || !addr.IsLoopback() || addr.Zone() != "" { + return "", errors.New("mcp_base_url must be a literal loopback IP without scheme or port, such as 127.0.0.1") + } + n, err := strconv.ParseUint(port, 10, 16) + if err != nil || n == 0 { + return "", errMCPPort + } + return netip.AddrPortFrom(addr.Unmap(), uint16(n)).String(), nil +} diff --git a/internal/config/mcp_test.go b/internal/config/mcp_test.go new file mode 100644 index 0000000..1b79ab9 --- /dev/null +++ b/internal/config/mcp_test.go @@ -0,0 +1,77 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func mcpEnv(t *testing.T) { + t.Helper() + cleanEnv(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("TS_JEV_MCP_BASE_URL", "") + t.Setenv("TS_JEV_MCP_PORT", "") +} + +func TestMCPAddressPrecedence(t *testing.T) { + mcpEnv(t) + dir := t.TempDir() + if got, err := MCPAddress(filepath.Join(dir, "missing.toml")); err != nil || got != "127.0.0.1:8080" { + t.Fatal("missing file did not fall back to defaults", got, err) + } + for content, want := range map[string]string{ + "mcp_port = 9090\n": "127.0.0.1:9090", + "mcp_port = \"9091\"\n": "127.0.0.1:9091", + "mcp_base_url = \"::1\"\nmcp_port = 9092\n": "[::1]:9092", + "mcp_base_url = \" 127.0.0.2 \"\n": "127.0.0.2:8080", + "model = 'unrelated'\ntimeout = 'invalid'\n": "127.0.0.1:8080", + } { + path := writeConfig(t, filepath.Join(dir, "config.toml"), content) + if got, err := MCPAddress(path); err != nil || got != want { + t.Fatal("wrong address", content, got, err) + } + } + if err := os.Symlink(writeConfig(t, filepath.Join(dir, "target.toml"), "mcp_port = 9090\n"), LocalName); err == nil { + if _, err := MCPAddress(""); err == nil { + t.Fatal("linked local configuration accepted") + } + } + path := writeConfig(t, filepath.Join(dir, "config.toml"), "mcp_port = 9090\n") + t.Setenv("TS_JEV_MCP_PORT", "9093") + if got, err := MCPAddress(path); err != nil || got != "127.0.0.1:9093" { + t.Fatal("environment did not override TOML", got, err) + } +} + +func TestMCPAddressRejectsInvalidValues(t *testing.T) { + mcpEnv(t) + dir := t.TempDir() + for _, content := range []string{ + "mcp_base_url = 'http://127.0.0.1'\n", "mcp_base_url = 'localhost'\n", "mcp_base_url = '0.0.0.0'\n", + "mcp_base_url = '127.0.0.1:9090'\n", "mcp_base_url = ''\n", "mcp_base_url = 1\n", + "mcp_port = 0\n", "mcp_port = 65536\n", "mcp_port = -1\n", "mcp_port = 'http'\n", "mcp_port = true\n", "mcp_port = [", + } { + path := writeConfig(t, filepath.Join(dir, "config.toml"), content) + if got, err := MCPAddress(path); err == nil || got != "" { + t.Fatal("invalid MCP setting accepted", content, got) + } + } +} + +func TestViewAndTemplateIncludeMCPSettings(t *testing.T) { + mcpEnv(t) + path := writeConfig(t, filepath.Join(t.TempDir(), "config.toml"), string(template)) + text, err := View(path) + if err != nil || !strings.Contains(text, "mcp_base_url = \"127.0.0.1\"\n") || !strings.Contains(text, "mcp_port = 8080\n") { + t.Fatal("view or template lacks MCP settings", err, text) + } + if got, err := MCPAddress(path); err != nil || got != "127.0.0.1:8080" { + t.Fatal("template MCP settings invalid", got, err) + } + writeConfig(t, path, "mcp_port = 0\n") + if _, err := View(path); err == nil { + t.Fatal("view accepted invalid MCP port") + } +} diff --git a/internal/config/template.toml b/internal/config/template.toml index 92a7cf9..06cc876 100644 --- a/internal/config/template.toml +++ b/internal/config/template.toml @@ -5,3 +5,6 @@ model = "jev-latest" provider = "jev" timeout = "10s" editor = "vim" +# Local MCP server: literal loopback IP (no scheme) and port. +mcp_base_url = "127.0.0.1" +mcp_port = 8080 diff --git a/jevwise.toml b/jevwise.toml index 176efad..4c94f2d 100644 --- a/jevwise.toml +++ b/jevwise.toml @@ -5,3 +5,6 @@ model = "jev-latest" provider = "jev" timeout = "10s" editor = "vim" +# Local MCP server: literal loopback IP (no scheme) and port. +mcp_base_url = "127.0.0.1" +mcp_port = 8080