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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<runtime-dir>/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.
Expand Down
3 changes: 3 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/decide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
}
}
Expand Down
20 changes: 18 additions & 2 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
5 changes: 2 additions & 3 deletions internal/cli/mcp_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"time"

"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/bitbrew-dev/jevwise/internal/mcpserver"
"github.com/spf13/cobra"
)

Expand All @@ -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"}
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/mcp_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions internal/cli/mcp_listen_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
12 changes: 11 additions & 1 deletion internal/config/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand Down
93 changes: 93 additions & 0 deletions internal/config/mcp.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading