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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +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).
- 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
20 changes: 20 additions & 0 deletions docs/mcp-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ Clients read the token from their process environment. Launch the client from th

## 3. Choose a client

### One-command registration (current checkout)

After starting MCP and loading the token in your client terminal, run:

```sh
jevwise mcp install codex
jevwise mcp install claude
# Match a custom server port:
jevwise mcp install codex --listen 127.0.0.1:9090
# Claude-only scopes:
jevwise mcp install claude --scope local # Current project, private settings
jevwise mcp install claude --scope project # Shared .mcp.json, requires client approval
```

- 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.
- 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`.

### Codex

Register the HTTP endpoint, storing the **environment variable name**, not its value:
Expand Down
1 change: 1 addition & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ 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 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`. |
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,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), newMCPTokenPath(&runtimeDir))
cmd.AddCommand(newMCPManagement("status", &runtimeDir), newMCPManagement("stop", &runtimeDir), newMCPTokenPath(&runtimeDir), newMCPInstall(runAgentCommand))
return cmd
}

Expand Down
85 changes: 85 additions & 0 deletions internal/cli/mcp_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cli

import (
"context"
"encoding/json"
"errors"
"io"
"time"

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

func newMCPInstall(run agentCommand) *cobra.Command {
var address, scope string
noArgs := func(_ *cobra.Command, args []string) error {
if len(args) != 0 {
return errors.New("MCP install accepts only codex or claude, without positional arguments")
}
return nil
}
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(&scope, "scope", "user", "Client settings scope: user; Claude also supports local or project")
for _, client := range []string{"codex", "claude"} {
cmd.AddCommand(&cobra.Command{Use: client, Short: "Register Jevwise with " + client, Args: noArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := cmd.Context().Err(); err != nil {
return err
}
canonical, err := mcpserver.ValidateAddress(address)
if err != nil {
return err
}
if scope != "user" && (client != "claude" || scope != "local" && scope != "project") {
return errors.New("invalid MCP install scope; Codex supports user only")
}
duration, err := time.ParseDuration(cmd.Flags().Lookup("timeout").Value.String())
if err != nil || duration <= 0 {
return errors.New("MCP installation timeout must be a positive duration")
}
ctx, cancel := context.WithTimeout(cmd.Context(), duration)
defer cancel()
endpoint := "http://" + canonical + "/mcp"
debuglog.Event(ctx, "mcp.install.register")
if client == "codex" {
data, inspectErr := run(ctx, client, "mcp", "list", "--json")
var servers []struct{ Name string }
if inspectErr != nil || len(data) > agentOutputLimit || json.Unmarshal(data, &servers) != nil || servers == nil {
return &decisionError{"cannot inspect Codex MCP settings; check installed client and configuration", inspectErr}
}
for _, server := range servers {
if server.Name == "" {
return errors.New("invalid Codex MCP inventory")
}
if server.Name == "jevwise" {
return errors.New("Codex MCP entry jevwise already exists; inspect it before changing configuration")
}
}
if err := ctx.Err(); err != nil {
return err
}
_, err = run(ctx, client, "mcp", "add", "jevwise", "--url", endpoint, "--bearer-token-env-var", "JEVWISE_MCP_TOKEN")
} else {
data, _ := json.Marshal(map[string]any{"type": "http", "url": endpoint, "headers": map[string]string{"Authorization": "Bearer ${JEVWISE_MCP_TOKEN}"}})
_, err = run(ctx, client, "--bare", "mcp", "add-json", "--scope", scope, "jevwise", string(data))
}
if err != nil {
return &decisionError{"cannot register MCP client; check client installation, settings and duplicate entry (diagnostics withheld)", err}
}
line := "Registered Jevwise MCP for " + client + ". Load JEVWISE_MCP_TOKEN in the client environment before launch.\n"
n, err := io.WriteString(cmd.OutOrStdout(), line)
if err == nil && n != len(line) {
err = io.ErrShortWrite
}
if err != nil {
return &decisionError{"MCP client registered but confirmation could not be written", err}
}
return ctx.Err()
}})
}
return cmd
}
138 changes: 138 additions & 0 deletions internal/cli/mcp_install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cli

import (
"context"
"encoding/json"
"errors"
"strings"
"testing"

"github.com/bitbrew-dev/jevwise/internal/config"
"github.com/bitbrew-dev/jevwise/internal/service"
"github.com/spf13/cobra"
)

func installerRoot(t *testing.T, run agentCommand) *cobra.Command {
t.Helper()
root := NewRootWithFactory(func(config.Config) (service.DecisionService, func(), error) {
t.Fatal("installation initialized upstream service")
return nil, nil, nil
})
mcp, _, _ := root.Find([]string{"mcp"})
for _, child := range mcp.Commands() {
if child.Name() == "install" {
mcp.RemoveCommand(child)
}
}
child := newMCPInstall(run)
mcp.AddCommand(child)
instrumentCommands(child)
return root
}

func TestMCPInstallClientArgumentsAndNoCredentialOutput(t *testing.T) {
for _, client := range []string{"codex", "claude"} {
calls := 0
run := func(ctx context.Context, name string, args ...string) ([]byte, error) {
calls++
if _, ok := ctx.Deadline(); !ok || name != client {
t.Error("installer lacks timeout or selected wrong client")
}
if client == "codex" && calls == 1 {
if strings.Join(args, " ") != "mcp list --json" {
t.Error("Codex preflight changed")
}
return []byte(`[{"name":"unrelated"}]`), nil
}
if client == "codex" {
if strings.Join(args, " ") != "mcp add jevwise --url http://127.0.0.1:9090/mcp --bearer-token-env-var JEVWISE_MCP_TOKEN" {
t.Error("Codex registration changed")
}
} else {
var entry struct {
Type, URL string
Headers map[string]string
}
if len(args) != 7 || strings.Join(args[:6], " ") != "--bare mcp add-json --scope user jevwise" || json.Unmarshal([]byte(args[6]), &entry) != nil || entry.Type != "http" || entry.URL != "http://127.0.0.1:9090/mcp" || entry.Headers["Authorization"] != "Bearer ${JEVWISE_MCP_TOKEN}" {
t.Error("Claude literal environment reference or scope changed")
}
}
return []byte("private-client-output"), nil
}
out, stderr, err := execute(installerRoot(t, run), "mcp", "install", client, "--listen", "127.0.0.1:9090", "--debug", "--config", "/missing", "--api-key", "private-key")
if err != nil || !strings.Contains(out, "Registered") || !strings.Contains(stderr, "mcp.install.register") || strings.Contains(out+stderr, "private") {
t.Fatal("installation failed or leaked client output", err)
}
}
}

func TestMCPInstallPreflightAndClientFailuresNeverOverwriteOrLeak(t *testing.T) {
cause := errors.New("private-client-error")
for _, data := range []string{`[{"name":"jevwise"}]`, `null`, `{}`, `[{}]`, `private-invalid`, strings.Repeat("x", agentOutputLimit+1)} {
calls := 0
run := func(context.Context, string, ...string) ([]byte, error) { calls++; return []byte(data), nil }
out, stderr, err := execute(installerRoot(t, run), "mcp", "install", "codex")
if err == nil || calls != 1 || out != "" || stderr != "" || strings.Contains(err.Error(), "private") {
t.Fatal("bad inventory overwrote settings or leaked", err)
}
}
for _, client := range []string{"codex", "claude"} {
run := func(_ context.Context, _ string, args ...string) ([]byte, error) {
if len(args) > 1 && args[1] == "list" {
return []byte(`[]`), nil
}
return []byte("private-output"), cause
}
out, stderr, err := execute(installerRoot(t, run), "mcp", "install", client)
if !errors.Is(err, cause) || out != "" || stderr != "" || strings.Contains(err.Error(), "private") {
t.Fatal("client failure not safely retained", err)
}
}
}

func TestMCPInstallLazyHelpAndInvalidInput(t *testing.T) {
run := func(context.Context, string, ...string) ([]byte, error) {
t.Fatal("lazy or invalid command ran client")
return nil, nil
}
for _, args := range [][]string{{"mcp", "install"}, {"mcp", "install", "--help"}, {"mcp", "install", "codex", "--help"}, {"mcp", "install", "claude", "--help"}} {
if _, _, err := execute(installerRoot(t, run), args...); err != nil {
t.Fatal("installer help not lazy", err)
}
}
for _, extra := range [][]string{{"--listen", "0.0.0.0:9090"}, {"--listen", "localhost:9090"}, {"--scope", "local"}, {"--timeout", "invalid"}, {"private-argument"}} {
if _, _, err := execute(installerRoot(t, run), append([]string{"mcp", "install", "codex"}, extra...)...); err == nil || strings.Contains(err.Error(), "private") {
t.Fatal("invalid input accepted or echoed", err)
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
root := installerRoot(t, run)
root.SetArgs([]string{"mcp", "install", "claude"})
if err := root.ExecuteContext(ctx); err != context.Canceled {
t.Fatal("pre-canceled installer executed client", err)
}
}

func TestMCPInstallClaudeScopesAndCanceledPreflight(t *testing.T) {
for _, scope := range []string{"local", "project"} {
run := func(_ context.Context, _ string, args ...string) ([]byte, error) {
if args[4] != scope || !strings.Contains(args[6], "http://[::1]:9090/mcp") {
t.Error("Claude scope or IPv6 endpoint changed")
}
return nil, nil
}
if _, _, err := execute(installerRoot(t, run), "mcp", "install", "claude", "--scope", scope, "--listen", "[::1]:9090"); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
run := func(context.Context, string, ...string) ([]byte, error) { calls++; cancel(); return []byte(`[]`), nil }
root := installerRoot(t, run)
root.SetArgs([]string{"mcp", "install", "codex"})
if err := root.ExecuteContext(ctx); err != context.Canceled || calls != 1 {
t.Fatal("canceled preflight still registered client", err)
}
}
Loading