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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Supply credentials through a secured environment or protected files, not secret-
| Default environment variable | Purpose |
| --- | --- |
| `JEV_API_KEY` | Upstream Jev API access for decisions and the MCP server's backend |
| `JEVWISE_MCP_TOKEN` | Agent authentication to the local MCP server; not an upstream API key |
| `JEVWISE_MCP_TOKEN` | Optional override for local MCP authentication; otherwise generated and privately stored |

### Upstream API key

Expand All @@ -76,7 +76,9 @@ api_key = "$ABCD_TOKEN"
### Local MCP authentication

- Use a separate bearer token: **never reuse the upstream API key or give it to the agent**.
- Set `JEVWISE_MCP_TOKEN`, or use `jevwise mcp --token-file /path/to/protected/agent-token`. The protected file overrides environment tokens.
- 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.
- 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
14 changes: 7 additions & 7 deletions docs/mcp-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,15 @@ The commands below use the renamed checkout CLI, `jevwise`; published releases t
Prerequisites:

- Install/build `jevwise` and configure the Jev provider using the [installation guide](../README.md#installation) and [configuration guide](configuration.md).
- Prepare a random, separate agent bearer token in a protected current-user-only file. Follow the [token and runtime permissions](mcp.md#credentials-state-and-safety), including Windows ACL requirements.
- Replace the example token-file path below with your own existing protected file. Do not reuse your upstream API key or the runtime management key.
- The current checkout creates/reuses a separate protected `agent.token` when no environment token or `--token-file` override is supplied. Follow the [token and runtime permissions](mcp.md#credentials-state-and-safety). Never reuse your API key or management key.

```sh
# macOS/Linux shell; use jevwise.exe on Windows.
jevwise mcp --background --token-file /absolute/path/to/protected/agent-token \
--listen 127.0.0.1:8080
jevwise mcp --background --listen 127.0.0.1:8080
jevwise mcp status
```

Foreground mode omits `--background`. Keep its terminal open; Ctrl+C stops it. If you set `--runtime-dir`, use the same directory for start, status and stop.
Foreground mode omits `--background`. Keep its terminal open; Ctrl+C stops it. If you set `--runtime-dir`, use the same directory for start, status, stop and `token-path`.

## 2. Load only the agent token in a separate client terminal

Expand All @@ -30,18 +28,20 @@ The agent needs the agent bearer token, **not** the Jev API key or management ke
macOS/Linux Bash or Zsh (remove the supported final LF/CRLF):

```sh
export JEVWISE_MCP_TOKEN_FILE=/absolute/path/to/protected/agent-token
export JEVWISE_MCP_TOKEN_FILE="$(jevwise mcp token-path)"
JEVWISE_MCP_TOKEN="$(cat "$JEVWISE_MCP_TOKEN_FILE")"
export JEVWISE_MCP_TOKEN="${JEVWISE_MCP_TOKEN%$'\r'}"
```

Windows PowerShell, using your protected token file:

```powershell
$env:JEVWISE_MCP_TOKEN_FILE = 'C:\private\agent-token'
$env:JEVWISE_MCP_TOKEN_FILE = (jevwise mcp token-path)
$env:JEVWISE_MCP_TOKEN = (Get-Content -Raw -LiteralPath $env:JEVWISE_MCP_TOKEN_FILE) -replace '\r?\n$', ''
```

These examples use the automatic file after successful startup. With an explicit token override, load that token instead: `token-path` always reports the automatic path, not the active override.

Clients read the token from their process environment. Launch the client from this terminal; an already-running desktop app may not inherit it. Reopen the client after token changes. If an existing client server is already named `jevwise`, inspect it before adding or replacing configuration.

## 3. Choose a client
Expand Down
16 changes: 10 additions & 6 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ The examples below describe the current checkout, using synthetic paths and addr

### Start and manage

Configure upstream credentials as for `jevwise decide`. Separately prepare a random agent bearer token in a protected, current-user-only file. Do not reuse your API key.
Configure upstream credentials as for `jevwise decide`. With no token override, the current checkout generates and privately stores a separate random agent bearer token. Do not reuse your API key.

```sh
# Synthetic examples: supply your own protected token file before running.
jevwise mcp --token-file /private/example/agent-token --listen 127.0.0.1:8080
jevwise mcp --background --token-file /private/example/agent-token
# Requires your upstream API key; the local token is managed automatically.
jevwise mcp --listen 127.0.0.1:8080
jevwise mcp --background
jevwise mcp token-path
jevwise mcp status
jevwise mcp stop
```
Expand All @@ -29,12 +30,15 @@ jevwise mcp stop
| `jevwise mcp` | Foreground server; prints the public `/mcp` endpoint after startup. Ctrl+C stops it. |
| `--background` | Starts a terminal-independent child after authenticated readiness and ownership checks. |
| `jevwise mcp status` | Authenticates the exact instance and reports its startup/running state. v1.2.0 and newer also show the recorded PID when available. |
| `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`. |
| `--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. |

- Without `--token-file`, startup reads `JEVWISE_MCP_TOKEN`, then compatibility aliases `JEVWISE_MCP_AUTH_TOKEN` and `JEV_MCP_TOKEN`, skipping only empty values. An invalid nonempty higher-priority value fails without fallback. Tokens must be nonempty printable ASCII without whitespace; a file may end in one LF or CRLF. No secret-valued token flag exists.
- Without `--token-file`, startup reads `JEVWISE_MCP_TOKEN`, then compatibility aliases `JEVWISE_MCP_AUTH_TOKEN` and `JEV_MCP_TOKEN`, skipping only empty values. When every environment name is empty, startup creates/reuses `<runtime-dir>/agent.token`. An invalid nonempty higher-priority value fails without fallback. Tokens must be nonempty printable ASCII without whitespace; a file may end in one LF or CRLF. No secret-valued token flag exists.
- The automatic file contains 256 random bits encoded as 64 hexadecimal characters. Unix storage uses a private `0700` directory and `0600` file; Windows uses protected current-user-only DACLs. Unsafe existing directories/files and links are refused, never repaired or replaced. Stale initialization locks/staging files block first token creation; a valid saved token can be reused while a background instance holds its lock. Publication requires hard-link support; concurrent first-start attempts may need retrying.
- Stopping MCP preserves the automatic token. Cache/runtime deletion removes it. To rotate it, stop all servers using that runtime directory, remove only `agent.token`, restart, and reload the new token in clients. Use the same `--runtime-dir` with `token-path`; custom runtime parents must already exist and be trusted. Stop an existing server before switching between automatic and explicit token modes.
- Keep credentials out of command arguments and shell history. Use protected files or appropriately secured environment configuration instead of `--api-key`.
- `status` and `stop` do not load decision configuration, API keys, the decision service or the agent token file. They use the private runtime management key, with no upstream API request.
- Background mode does not install a login item, reboot service or automatic restart. On Windows, a restrictive parent Job Object can prevent detachment or terminate the child when its parent exits.
Expand All @@ -61,7 +65,7 @@ Connect an HTTP-capable local agent to `http://127.0.0.1:8080/mcp`, supplying `A
| Credential | Purpose | Where it belongs |
| --- | --- | --- |
| Upstream API key | Server-to-Jev decisions | Existing decision configuration; never give it to the agent. |
| Agent bearer token | Agent-to-local `/mcp` access | `JEVWISE_MCP_TOKEN` or an explicit protected token file. |
| Agent bearer token | Agent-to-local `/mcp` access | Automatic private `agent.token`, `JEVWISE_MCP_TOKEN`, or an explicit protected token file. |
| Management key | Status/stop and instance verification | Separate private runtime file; never give it to the agent or transmit it as a bearer token. |

- Background bootstrap passes secrets through owned anonymous pipes, not child arguments, public state or logs. State contains bounded instance metadata, never credentials; the post-v1.1.0 checkout also records an inspection-only PID. Agent and API credentials must differ; the management key is separately generated.
Expand Down
116 changes: 116 additions & 0 deletions internal/cli/managed_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package cli

import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/bitbrew-dev/jevwise/internal/config"
"github.com/bitbrew-dev/jevwise/internal/daemon"
"github.com/bitbrew-dev/jevwise/internal/mcpserver"
"github.com/bitbrew-dev/jevwise/internal/service"
)

func TestMCPAutomaticTokenForegroundAndReadOnlyPath(t *testing.T) {
decisionEnvironment(t)
dir := filepath.Join(t.TempDir(), "runtime")
factory := func(config.Config) (service.DecisionService, func(), error) { return mcpFixtureService(t), nil, nil }
path := filepath.Join(dir, daemon.AgentTokenName)
out, stderr, err := execute(mcpRoot(factory, nil), "mcp", "token-path", "--runtime-dir", dir, "--config", "/missing", "--token-file", "/missing")
if err != nil || stderr != "" || out != path+"\n" {
t.Fatal("token-path loaded credentials", err)
}
if _, err := os.Lstat(dir); !os.IsNotExist(err) {
t.Fatal("token-path created storage")
}
var first string
run := func(_ context.Context, _ service.DecisionService, _ time.Duration, options mcpserver.RunOptions) error {
if first == "" {
first = options.Token
}
if options.Token != first || len(first) != 64 {
t.Error("automatic token changed")
}
return nil
}
for range 2 {
root := mcpRoot(factory, run)
mcp, _, _ := root.Find([]string{"mcp"})
instrumentCommands(mcp)
out, stderr, err := execute(root, "mcp", "--runtime-dir", dir, "--api-key", "private-upstream", "--debug")
if err != nil || out != "" || !strings.Contains(stderr, "mcp.token.auto_resolve") || strings.Contains(stderr, first) || strings.Contains(stderr, "private-upstream") {
t.Fatal("automatic startup failed or leaked credentials", err)
}
}
if token, err := daemon.ReadAgentToken(path); err != nil || token != first {
t.Fatal("server did not use persisted token", err)
}
}

func TestAutomaticTokenOverridesAndLazyCommandsHaveNoStorageEffects(t *testing.T) {
decisionEnvironment(t)
dir := filepath.Join(t.TempDir(), "must-stay-absent")
factory := func(config.Config) (service.DecisionService, func(), error) { return mcpFixtureService(t), nil, nil }
run := func(context.Context, service.DecisionService, time.Duration, mcpserver.RunOptions) error { return nil }
for _, args := range [][]string{
{"mcp", "--help"}, {"mcp"}, {"mcp", "--api-key", "private-key", "--token-file", "/missing"},
{"mcp", "--api-key", "private-key", "--provider", "codex"},
} {
_, _, _ = execute(mcpRoot(factory, run), append(args, "--runtime-dir", dir)...)
}
for _, value := range []string{"valid-explicit-token", "invalid private token"} {
t.Setenv("JEVWISE_MCP_TOKEN", value)
_, _, _ = execute(mcpRoot(factory, run), "mcp", "--api-key", "private-key", "--runtime-dir", dir)
}
if _, err := os.Lstat(dir); !os.IsNotExist(err) {
t.Fatal("override, lazy or invalid startup created managed token storage")
}
}

func TestAutomaticTokenCreatesMissingDefaultCacheParent(t *testing.T) {
decisionEnvironment(t)
home := t.TempDir()
for _, name := range []string{"HOME", "USERPROFILE"} {
t.Setenv(name, home)
}
for _, name := range []string{"XDG_CACHE_HOME", "LOCALAPPDATA"} {
t.Setenv(name, filepath.Join(home, "new-cache"))
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
factory := func(config.Config) (service.DecisionService, func(), error) { return mcpFixtureService(t), nil, nil }
run := func(context.Context, service.DecisionService, time.Duration, mcpserver.RunOptions) error { return nil }
if _, _, err := execute(mcpRoot(factory, run), "mcp", "--api-key", "private-upstream"); err != nil {
t.Fatal("fresh default cache startup failed", err)
}
if _, err := daemon.ReadAgentToken(filepath.Join(cache, "jevwise-mcp", daemon.AgentTokenName)); err != nil {
t.Fatal("fresh default cache token was not saved privately", err)
}
}

func TestMCPTokenPathFailuresStaySafe(t *testing.T) {
decisionEnvironment(t)
for _, args := range [][]string{{"mcp", "token-path", "--runtime-dir", ""}, {"mcp", "token-path", "private-argument"}} {
if _, _, err := execute(NewRoot(), args...); err == nil || strings.Contains(err.Error(), "private") {
t.Fatal("invalid token-path accepted or echoed arguments", err)
}
}
root := NewRoot()
root.SetArgs([]string{"mcp", "token-path"})
root.SetOut(managementWriter(func(data []byte) (int, error) { return len(data) - 1, nil }))
if err := root.ExecuteContext(context.Background()); err == nil || err.Error() != "cannot write MCP token path" {
t.Fatal("token-path short write was not safely reported", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
root = NewRoot()
root.SetArgs([]string{"mcp", "token-path"})
if err := root.ExecuteContext(ctx); err != context.Canceled {
t.Fatal("token-path ignored cancellation", err)
}
}
34 changes: 31 additions & 3 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,41 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte
return err
}
}
if err := mcpserver.ValidateToken(token); err != nil {
if err := mcpserver.ValidateToken(token); err != nil && (token != "" || cmd.Flags().Changed("token-file")) {
return errors.New("MCP bearer token is missing or invalid: set JEVWISE_MCP_TOKEN or use --token-file")
}
cfg, err := LoadConfig(cmd)
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if token == "" {
if cfg.Provider != "jev" {
return initializationFailure(ctx, "provider is not implemented", service.ErrNotImplemented)
}
if cfg.APIKey == "" {
return initializationFailure(ctx, "cannot initialize MCP decision service", &initializationError{kind: "api_key_missing"})
}
path, err := managedTokenPath(runtimeDir)
if err != nil {
return err
}
// The default cache parent may not exist on a fresh user account.
// Custom runtime paths retain the existing caller-prepared-parent rule.
if !cmd.Flags().Changed("runtime-dir") {
if err := os.MkdirAll(filepath.Dir(filepath.Dir(path)), 0700); err != nil {
return &decisionError{"cannot prepare MCP cache directory", err}
}
}
debuglog.Event(ctx, "mcp.token.auto_resolve")
token, err = daemon.EnsureAgentToken(filepath.Dir(path))
if err != nil {
debuglog.Event(ctx, "mcp.token.auto_failed")
return err
}
}
if cfg.APIKey != "" && token == cfg.APIKey {
return errors.New("MCP bearer token must differ from the upstream API key")
}
Expand Down Expand Up @@ -98,15 +126,15 @@ 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; token from JEVWISE_MCP_TOKEN")
cmd.Flags().StringVar(&address, "listen", "127.0.0.1:8080", "Literal loopback IP:port for /mcp")
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()
if cache != "" {
runtimeDir = filepath.Join(cache, "jevwise-mcp")
}
cmd.PersistentFlags().StringVar(&runtimeDir, "runtime-dir", runtimeDir, "Private per-user MCP background runtime directory")
cmd.AddCommand(newMCPManagement("status", &runtimeDir), newMCPManagement("stop", &runtimeDir))
cmd.AddCommand(newMCPManagement("status", &runtimeDir), newMCPManagement("stop", &runtimeDir), newMCPTokenPath(&runtimeDir))
return cmd
}

Expand Down
10 changes: 7 additions & 3 deletions internal/cli/mcp_process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,14 +243,18 @@ func TestMCPActualBackgroundCLIStartStatusDuplicateAndStop(t *testing.T) {
marker := filepath.Join(t.TempDir(), "background-cli-exited")
processEnvironment(t, marker)
decisionEnvironment(t)
t.Setenv("JEVWISE_MCP_TOKEN", fixture.AgentToken)
t.Setenv("JEV_API_KEY", fixture.Config.APIKey)
configPath := filepath.Join(t.TempDir(), "empty.toml")
if err := os.WriteFile(configPath, nil, 0600); err != nil {
t.Fatal(err)
}
var captured daemon.Bootstrap
var started, parentCreated int
runtimeStopped := func() bool {
entries, err := os.ReadDir(fixture.RuntimeDir)
token, tokenErr := daemon.ReadAgentToken(filepath.Join(fixture.RuntimeDir, daemon.AgentTokenName))
return err == nil && tokenErr == nil && len(entries) == 1 && entries[0].Name() == daemon.AgentTokenName && token == captured.AgentToken
}
factory := func(config.Config) (service.DecisionService, func(), error) {
parentCreated++
return nil, nil, errors.New("parent must not initialize a background service")
Expand All @@ -272,7 +276,7 @@ func TestMCPActualBackgroundCLIStartStatusDuplicateAndStop(t *testing.T) {
defer cancel()
_, _ = controller.Stop(ctx)
}
waitProcessCondition(t, func() bool { return processExited(marker) && runtimeEmpty(fixture.RuntimeDir) }, "background CLI child survived cleanup")
waitProcessCondition(t, func() bool { return processExited(marker) && runtimeStopped() }, "background CLI child survived cleanup")
})
execute := func(args ...string) (string, error) {
root := NewRootWithFactory(factory)
Expand Down Expand Up @@ -319,5 +323,5 @@ func TestMCPActualBackgroundCLIStartStatusDuplicateAndStop(t *testing.T) {
if err != nil || output != "MCP background stop acknowledged\n" || parentCreated != 0 {
t.Fatal("stop loaded service config or initialized a parent service", err)
}
waitProcessCondition(t, func() bool { return processExited(marker) && runtimeEmpty(fixture.RuntimeDir) }, "background CLI stop did not release runtime ownership")
waitProcessCondition(t, func() bool { return processExited(marker) && runtimeStopped() }, "background CLI stop did not release runtime ownership")
}
Loading
Loading