From 3850ec01012f6dbfb6eb0acd0233cf7bba5d0b8f Mon Sep 17 00:00:00 2001 From: benbenbang Date: Sun, 11 Oct 2026 12:24:53 +0800 Subject: [PATCH] feat(mcp): auto-generate and reuse local agent token Previously `jevwise mcp` required an explicit agent bearer token via `JEVWISE_MCP_TOKEN` or `--token-file`. Now, when no token override is supplied, the server generates a random 256-bit token stored privately in `/agent.token` and reuses it across restarts, so only the upstream API key needs manual setup. **New capability:** * `jevwise mcp` ensures an automatic token when no environment value or `--token-file` override is present, validating the provider and API key before creating it, and preparing the default cache parent on fresh accounts. * Added `jevwise mcp token-path` to print only the automatic token file path without reading secrets, creating files, or reporting explicit overrides. * Override precedence is preserved: `--token-file` > `JEVWISE_MCP_TOKEN` (and aliases) > automatic token, and explicit values are never copied into the automatic file. **Tests:** * Added `managed_token_test.go` covering automatic token resolution. * Updated background process tests to assert the runtime retains only `agent.token` after stop rather than being empty. **Docs:** * Updated README, `docs/mcp.md`, and `docs/mcp-agents.md` to describe automatic token generation, storage permissions, rotation, and loading the token via `token-path`. --- README.md | 6 +- docs/mcp-agents.md | 14 ++-- docs/mcp.md | 16 ++-- internal/cli/managed_token_test.go | 116 +++++++++++++++++++++++++++++ internal/cli/mcp.go | 34 ++++++++- internal/cli/mcp_process_test.go | 10 ++- internal/cli/mcp_token_path.go | 49 ++++++++++++ 7 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 internal/cli/managed_token_test.go create mode 100644 internal/cli/mcp_token_path.go diff --git a/README.md b/README.md index 69c66ad..05aa63a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `/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. diff --git a/docs/mcp-agents.md b/docs/mcp-agents.md index 4a3880a..9871676 100644 --- a/docs/mcp-agents.md +++ b/docs/mcp-agents.md @@ -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 @@ -30,7 +28,7 @@ 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'}" ``` @@ -38,10 +36,12 @@ 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 diff --git a/docs/mcp.md b/docs/mcp.md index 53ed807..4b716a6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 ``` @@ -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 `/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. @@ -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. diff --git a/internal/cli/managed_token_test.go b/internal/cli/managed_token_test.go new file mode 100644 index 0000000..818ddb6 --- /dev/null +++ b/internal/cli/managed_token_test.go @@ -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) + } +} diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index b1bfe5e..b815f7c 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -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") } @@ -98,7 +126,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; 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() @@ -106,7 +134,7 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte 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 } diff --git a/internal/cli/mcp_process_test.go b/internal/cli/mcp_process_test.go index 916fd05..8e95421 100644 --- a/internal/cli/mcp_process_test.go +++ b/internal/cli/mcp_process_test.go @@ -243,7 +243,6 @@ 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 { @@ -251,6 +250,11 @@ func TestMCPActualBackgroundCLIStartStatusDuplicateAndStop(t *testing.T) { } 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") @@ -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) @@ -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") } diff --git a/internal/cli/mcp_token_path.go b/internal/cli/mcp_token_path.go new file mode 100644 index 0000000..03136a3 --- /dev/null +++ b/internal/cli/mcp_token_path.go @@ -0,0 +1,49 @@ +package cli + +import ( + "errors" + "io" + "path/filepath" + + "github.com/bitbrew-dev/jevwise/internal/daemon" + "github.com/spf13/cobra" +) + +func managedTokenPath(directory string) (string, error) { + if directory == "" { + return "", errors.New("invalid MCP runtime directory") + } + path, err := filepath.Abs(directory) + if err != nil { + return "", errors.New("invalid MCP runtime directory") + } + return filepath.Join(path, daemon.AgentTokenName), nil +} + +func newMCPTokenPath(directory *string) *cobra.Command { + return &cobra.Command{Use: "token-path", Short: "Show the automatic token file path, never its contents", + Args: func(_ *cobra.Command, args []string) error { + if len(args) != 0 { + return errors.New("token-path does not accept positional arguments") + } + return nil + }, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cmd.Context().Err(); err != nil { + return err + } + path, err := managedTokenPath(*directory) + if err != nil { + return err + } + line := path + "\n" + n, err := io.WriteString(cmd.OutOrStdout(), line) + if err == nil && n != len(line) { + err = io.ErrShortWrite + } + if err != nil { + return &decisionError{"cannot write MCP token path", err} + } + return nil + }} +}